Taking Over the Record Layer: Provider
Extend Provider (rather than ContainerProvider) when the store has its own encryption or record model — implementing the container session and Record API with RecordInit/RecordType and the absent-vs-unreadable and occupiedAccountStems obligations.
Provider is the abstract root of the storage hierarchy. Extend it directly — rather than ContainerProvider — only when your store already owns its encryption or record model and should keep owning it end to end: a database with column-level encryption, a secrets manager, a store that frames records its own way. In exchange for that control you implement the container session and the whole Record API yourself, including the crypto.
If your store just holds opaque bytes, extend ContainerProvider instead — it supplies the envelope, framing and session, so you write six methods rather than a dozen. Reach for Provider only when the base envelope would be redundant or wrong for your store.
What you implement
Beyond the six storage primitives (_exist, _listItems, _read, _write, _remove, _ensureDir), a Provider subclass supplies the container session and the record API:
| Member | Signature | Role |
|---|---|---|
unlockContainer | (password: string) => Promise<boolean> | Start a session; return false for a wrong password, true on success. |
lockContainer | () => Promise<void> | End the session and drop in-memory secrets. |
isContainerUnlocked | () => boolean | Whether a session is live. |
loadRecord | <T>(type: RecordType, slug?: Slug) => Promise<Record<T>> | Load one record. |
loadRecords | <T>(type: RecordType) => Promise<ReadonlyArray<Record<T>>> | Load all records of a type. |
writeRecord | <T>(type: RecordType, slug: Slug, value: T) => Promise<void> | Seal and store a value. |
dropRecord | (type: RecordType, slug: Slug) => Promise<void> | Remove a record. |
close | () => Promise<void> | Release the store. |
Provider also ships a default initialize() that writes the four root records (CONFIG, NETWORK, ASSET, LOG) from bundled templates via your writeRecord — idempotent, so existing records are left untouched. Override it only if your backend needs non-record setup (allocating a schema, say).
RecordType
The five record kinds a container holds:
type RecordType = "CONFIG" | "ACCOUNTS" | "NETWORK" | "ASSET" | "LOG";CONFIG, NETWORK, ASSET and LOG are singletons (one record each); ACCOUNTS is a namespace of one record per account, addressed by slug (a branded string). The singletons ignore the slug argument.
Records and RecordInit
loadRecord hands back a Record<T> handle. You construct one with an optional RecordInit<T>; the fields you provide decide what the handle can do:
| Field | Type | Purpose |
|---|---|---|
provider | Provider | The owning provider (used by save() as a fallback). |
recordType | RecordType | The record's type. |
encrypted | Uint8Array | The sealed ciphertext — required for a locked record. |
value | T | The decoded value. Supplying it marks the record unlocked. |
password | string | The password the record was opened with. |
decrypt | (ciphertext: Uint8Array, password: string) => Uint8Array | Verify/decrypt on unlock(pwd). |
persist | (value: T, password: string) => Promise<void> | How save() writes changes back. |
requireLiveSession | () => void | Throws if the issuing session has ended; omit if your provider has no session concept. |
A Record<T> exposes readonly slug and readonly path, a get locked boolean, a get value (throws WativeError with code === "RECORD_LOCKED" while locked), an unlock(password): this credential check, and save(): Promise<void>.
Supply value for a record you have already decrypted (it opens unlocked); supply encrypted + decrypt without value for a record whose own password differs from the container's — the caller opens it with record.unlock(otherPassword).
Absent vs. unreadable — the load obligations
The account layer reads presence through your methods, so the difference between "not there" and "there but unreadable" is a contract, not a detail:
loadRecord for a record that is not there MUST throw WativeError("RECORD_NOT_FOUND"). Several guards read absence through this — initialize()'s re-seed decision and accountRecordExists among them — so signaling absence any other way (a locked Record, a plain Error, a resolved empty value) makes every name look occupied, and no account can ever be created.Record, not an error. Build it with encrypted and no value, so record.locked is true. From loadRecords this matters most: throwing there lets one damaged row deny access to every other account, and omitting it makes the row's name look free so the next same-name create overwrites a sealed mnemonic that was merely unreadable. Workspace handles the locked case for you — it skips the account, reserves the name, and reports the slug on workspace.damagedAccountSlugs.Name-collision hooks
Three members guard the account namespace against a second handle clobbering a record it cannot see. They have working defaults on Provider, so override them only when your store needs a more precise answer:
| Member | Default | Override when |
|---|---|---|
accountRecordExists(slug): Promise<boolean> | Asks through loadRecord: loaded → present; RECORD_NOT_FOUND → absent; any other error propagates (an I/O or permission failure must never read as "the name is free"). | Your store can answer "does this row exist" more cheaply or precisely than a full load. |
unreadableRecordSlugs(): ReadonlyArray<Slug> | [] — Workspace derives this from the locked records loadRecords returns. | You need to report rows that never reach loadRecords at all. |
occupiedAccountStems(): ReadonlyArray<string> | [] — correct for a store that collides only on exact string equality. | Your store folds names — a case-insensitive filesystem, or a utf8_general_ci column — where Alice-Desk and alice-desk are one key. Return the lowercased stems, or a create can be wrongly refused as a collision. |
The occupiedAccountStems default is not consequence-free on a folding store: with it empty, a damaged Alpha-Desk row leaves alpha-desk looking free, and create is then refused as a name collision instead of quietly taking a suffixed slug. Override it on any case-folding backend.
ContainerState and create-vs-open
Workspace.open() calls inspectContainer(): Promise<ContainerState> before unlocking to choose create-vs-open. Provider's default returns "workspace" — the never-refuse answer — so a minimal subclass always proceeds as open-or-create. Override it to return "empty" (safe to initialize) or "foreign" (non-empty, not a wative container — open() refuses) when your store can inspect itself cheaply.
type ContainerState = "empty" | "workspace" | "foreign";A full custom backend
Example 07 in the release consumer suite implements a complete Provider against an in-memory Map, with its own cipher standing in for a store that brings its own encryption:
import { Workspace, Provider, Record, WativeError } from "wative-core";
class InMemoryProvider extends Provider {
#store = new Map();
#password = null;
// …six storage primitives ( _exist / _read / _write / _remove /
// _ensureDir / _listItems ) over #store…
async unlockContainer(password) {
if (typeof password !== "string" || password.length === 0) return false;
// verify against the CONFIG record, then:
this.#password = password;
return true;
}
async lockContainer() { this.#password = null; }
isContainerUnlocked() { return this.#password !== null; }
async loadRecord(type, slug) {
if (this.#password === null) throw new WativeError("PROVIDER_IO", "container locked");
const path = this.#pathFor(type, slug);
if (!this.#store.has(path)) throw new WativeError("RECORD_NOT_FOUND", path);
const encrypted = this.#store.get(path);
const value = JSON.parse(new TextDecoder().decode(this.#decrypt(encrypted, this.#password)));
return new Record(slug, path, {
provider: this,
recordType: type,
encrypted,
value,
password: this.#password,
decrypt: (ct, pwd) => this.#decrypt(ct, pwd),
persist: async (val, pwd) => { this.#store.set(path, this.#encrypt(JSON.stringify(val), pwd)); },
});
}
async writeRecord(type, slug, value) {
if (this.#password === null) throw new WativeError("PROVIDER_IO", "container locked");
this.#store.set(this.#pathFor(type, slug), this.#encrypt(JSON.stringify(value), this.#password));
}
async dropRecord(type, slug) { this.#store.delete(this.#pathFor(type, slug)); }
async close() { this.#password = null; }
}
const provider = new InMemoryProvider("memory://desk-1");
const ws = await Workspace.open({ provider, password: "wsp-pwd" });The cipher in that example is a hand-rolled XOR, illustrative only — it is not a real cipher and must not be used to protect anything. When you extend Provider, plug in your store's actual encryption; if you do not have one, extend ContainerProvider, which brings a vetted AES-256-GCM envelope.