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.

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:

MemberSignatureRole
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() => booleanWhether 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:

FieldTypePurpose
providerProviderThe owning provider (used by save() as a fallback).
recordTypeRecordTypeThe record's type.
encryptedUint8ArrayThe sealed ciphertext — required for a locked record.
valueTThe decoded value. Supplying it marks the record unlocked.
passwordstringThe password the record was opened with.
decrypt(ciphertext: Uint8Array, password: string) => Uint8ArrayVerify/decrypt on unlock(pwd).
persist(value: T, password: string) => Promise<void>How save() writes changes back.
requireLiveSession() => voidThrows 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>.

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.
A record that exists but cannot be opened is a locked 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:

MemberDefaultOverride 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.

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:

in-memory-provider.ts
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" });

Last updated on