Writing a Backend: ContainerProvider

Extend ContainerProvider and implement six byte-moving methods (_exist/_read/_write/_remove/_ensureDir/_listItems) to store a workspace anywhere; the base class supplies encryption, record framing and the session.

ContainerProvider is the storage-agnostic half of a wative container. Everything that makes a container a container — the encrypted envelope, record framing, the key/path layout, the unlock/lock session, and portable export/import — lives in the base class. A concrete backend supplies only six methods that move bytes. That is why a browser backend is ~150 lines instead of ~750, and why every backend shares one implementation of the crypto: a container written by one is byte-compatible with one written by another.

Extend it when your store just holds opaque bytes (a Map, a key-value store, object storage, a database's blob column). If the store has its own encryption or record model, extend Provider instead.

The six methods

Implement these protected primitives against your store. Keys are opaque strings the base class hands you — root-relative for a key-value store (config, accounts/<slug>.db), or under an absolute root for a filesystem backend. Each may be synchronous or asynchronous: the return types are T | Promise<T>, so a Map-backed store returns values directly and a network store returns promises, from the same contract.

MethodSignatureContract
_exist(path) => boolean | Promise<boolean>true if a record sits at path, or if path is a prefix of one (a synthetic directory).
_read(path) => Uint8Array | Promise<Uint8Array>Return the stored bytes. If nothing is there, throw new WativeError("RECORD_NOT_FOUND", …).
_write(path, data) => void | Promise<void>Store data at path. Atomic where the store allows it.
_remove(path) => void | Promise<void>Delete the record at path.
_ensureDir(path) => void | Promise<void>Make sure a container/prefix exists. A no-op on a flat keyspace. Always called before a _write.
_listItems(path) => string[] | Promise<string[]>The immediate child names under the synthetic directory path, de-duplicated.

Your code never sees plaintext

The base class seals every value before it reaches _write and opens it after _read, so the bytes you store are already encrypted — an AES-256-GCM envelope over an Argon2id-derived key, with AAD bound to the record's identity. Your backend moves opaque blobs; it never has the password and never decrypts anything.

This is exactly what shipped example 19 asserts against a Map-backed backend:

encryption-is-free.ts
// After creating an account, the raw stored bytes contain neither the
// mnemonic nor the account name — the base class sealed them.
const everything = Buffer.concat(
  [...provider.dumpForTest().values()].map((b) => Buffer.from(b)),
).toString("latin1");

assert.ok(!everything.includes("abandon"));        // the mnemonic
assert.ok(!everything.includes("Secret Holder"));  // the account name

A whole backend, start to finish

Example 19 in the release consumer suite implements a complete backend — a Map of string keys to byte values — in about twenty lines. It runs a full workspace: create an account, derive wallets, sign, lock, reopen, and read the same keys back.

map-provider.ts
import { Workspace, ContainerProvider, WativeError } from "wative-core";

class MapProvider extends ContainerProvider {
  #store = new Map();
  #dirs = new Set([""]);

  _exist(path) {
    if (this.#store.has(path) || this.#dirs.has(path)) return true;
    const prefix = path.endsWith("/") ? path : path + "/";
    for (const k of this.#store.keys()) if (k.startsWith(prefix)) return true;
    return false;
  }
  _read(path) {
    const v = this.#store.get(path);
    if (!v) throw new WativeError("RECORD_NOT_FOUND", `nothing at ${path}`);
    return v;
  }
  _write(path, data) { this.#store.set(path, data); }
  _remove(path) { this.#store.delete(path); }
  _ensureDir(path) { this.#dirs.add(path); }
  _listItems(path) {
    const prefix = path.endsWith("/") ? path : path + "/";
    const names = new Set();
    for (const k of this.#store.keys()) {
      if (!k.startsWith(prefix)) continue;
      const head = k.slice(prefix.length).split("/")[0];
      if (head) names.add(head);
    }
    return [...names];
  }
}

const provider = new MapProvider("map://desk");
const ws = await Workspace.open({ provider, password: "wsp-pwd" });
const acc = await ws.accounts.create("Desk One", "wsp-pwd", MNEMONIC);
await acc.deriveWallets(2);
await ws.lock();

The argument passed to new MapProvider("map://desk") becomes the provider's rootDescriptor (a human-readable label for the store). Everything else — the account record layout, the accounts/<slug>.db key naming, unlock/lock, loadRecord/writeRecord/dropRecord — is inherited.

Portable backups: exportContainer / importContainer

Both are inherited from ContainerProvider, so every backend gets them for free.

exportContainer(): Promise<ReadonlyArray<ContainerEntry>> dumps every record as sealed bytes, copied verbatim — it never decrypts, so it does not require an unlocked container and never materializes a key in memory. The dump is only as readable as the password that sealed it. Because all backends share the framing, a dump taken by one imports into another unchanged.

A ContainerEntry is:

interface ContainerEntry {
  readonly key: string;      // root-relative, e.g. "config" or "accounts/desk.db"
  readonly data: Uint8Array; // the sealed record blob, exactly as stored
}

importContainer(entries, opts?: { overwrite?: boolean }): Promise<void> writes those sealed bytes back through unchanged — importing does not re-key anything. It refuses a target that already holds records unless you pass { overwrite: true }:

  • Without overwrite, a non-empty target throws WativeError (code === "PARAMETER_ERROR"), so a mistyped target cannot silently merge two workspaces into an unopenable hybrid.
  • With { overwrite: true }, the target is replaced, not merged: every key an export would carry is cleared before the dump is written (a replacing dump that carries no config record is refused, and the provider is locked afterward, because its old session described the container that was just replaced).
backup-round-trip.ts
const entries = await provider.exportContainer();     // sealed, no unlock needed
// … store `entries` somewhere durable …
await other.importContainer(entries);                 // into an empty target
await other.importContainer(entries, { overwrite: true }); // replace an existing one

Last updated on