Skip to content
DomainKit
Esc
↑↓navigate↵open⌘Jpreview
On this page

Persistence and custody

Choose a Storage implementation, seal credentials, and decide whether DomainKit, your pipeline, or your deploy runs the migrations.

This guide covers where DomainKit keeps its rows, who runs the migrations, and how provider credentials are sealed. Storage is the durable seam every lifecycle operation goes through: authorizations with sealed credentials, connections, attachments, interactive-flow continuations, attempts carrying a plan, approval and receipt, and observed readiness. Connect seals provider secret material through Custody before writing an authorization credential to Storage.

Choose an implementation

Implementation Use it for
@domainkit/capsuledb An Effect host on PostgreSQL
Storage.layerMemory Tests and playgrounds
Storage.layerFromAsync Your own database, behind a Promise-shaped implementation

All of them are held to Testing.conformance.storage, so a host can swap one for another without a behaviour change.

PostgreSQL

/**
 * `PgStorage.layer()` prepares at boot: it creates CapsuleDB's ledger, applies pending migrations,
 * and only then provides `Storage`. Nothing can observe a database whose tables are missing.
 */
export const DomainKitLive = DomainKit.layer({
  providers: [
    Cloudflare.provider({
      oauth: {
        clientId: Config.String("CF_CLIENT_ID"),
        clientSecret: Config.Redacted("CF_CLIENT_SECRET"),
      },
    }),
    Vercel.provider(),
  ],
}).pipe(
  // `provideMerge`, not `provide`: the route handlers read attempts and receipts straight from
  // Storage, so the layer they are given has to still carry it.
  Layer.provideMerge(Layer.mergeAll(PgStorage.layer(), Custody.layerConfig())),
  Layer.provide(PgClient.layerConfig({ url: Config.Redacted("DATABASE_URL") })),
);

PgStorage.layer() prepares at boot: it creates CapsuleDB’s ledger, applies pending migrations, and only then provides Storage, so a service can never observe a database whose tables are missing. The layer needs only your SqlClient, and it never opens, replaces, or closes it.

The eight tables and their keys are on the @domainkit/capsuledb reference. Every one carries owner_id, and every query filters by the request’s Principal, so a row belonging to another tenant reads as absent rather than forbidden. continuations.header is the single exception, described below.

Or run the migrations yourself

capsuledb emit \
  --module ./node_modules/@domainkit/capsuledb/dist/index.mjs \
  --export capsule \
  --dialect postgres \
  --out ./drizzle

Apply that SQL with your own pipeline, add whatever foreign keys, partitioning, or row-level security you want, then boot in assert mode.

/**
 * When migrations are yours to run, apply the `capsuledb emit` output with your own pipeline and
 * boot in assert mode: it changes no schema and fails unless the database already matches.
 */
export const Asserted = PgStorage.layer({ mode: "assert" });

Assert mode applies nothing and fails unless the database already matches the capsule, so a missed migration is a boot failure instead of a runtime surprise. capsuledb check compares an emitted folder against the current capsule in CI.

Prepare at deploy time on a serverless host

A function should not migrate a database while it cold-starts, and several instances should not race to. Prepare the database in the deploy instead, with capsuledb/alchemy. PgStorage.manifest() yields the manifest of exactly the capsule PgStorage.layer() installs, so the stack prepares the schema the function will assert.

/**
 * Deploy time: prepare the database with the manifest of exactly the capsule the runtime asserts.
 * Return the fingerprint so the stack can hand it to the function as a version-scoped environment
 * value, which publishes the new code only after the migration it depends on.
 */
export const prepareAtDeploy = (url: Redacted.Redacted<string>) =>
  Effect.gen(function* () {
    const manifest = yield* PgStorage.manifest();
    const registry = yield* CapsuleDB.Registry("domainkit-storage", {
      url,
      provider: "Postgres",
      manifest,
    });
    return registry.fingerprint;
  });

/** Register next to the stack's other providers. */
export const deployProviders = CapsuleDB.providers;

Pass the returned fingerprint into the function’s environment as a value scoped to the code version. The deploy then finishes the migration before the new code publishes, and a version never runs against a schema older than the one it was built for. CapsuleDB.providers registers the resource beside the stack’s other providers, and the database url stays a Redacted value.

Boot the function with mode: "assert" and readiness: "first-use".

/**
 * Run time: the layer builds without a statement, and the first Storage query checks the database
 * once. A cold start that never reaches Storage does no registry work. A failed check surfaces as a
 * `SqlError` on that query and is retried on the next.
 */
export const StorageAtRuntime = PgStorage.layer({ mode: "assert", readiness: "first-use" });

The layer builds without sending a statement. The first Storage query runs one cached check, so a cold start that never reaches Storage does no registry work. If that check fails, the query fails with a SqlError and the next query retries it, which lets an instance that started before the deploy finished recover on its own. readiness defaults to boot, which checks while the layer builds.

If you rename the tables, pass the same prefix and registryPrefix to manifest and to layer; the resource takes the registry prefix as prefix.

/**
 * A host that renames the tables passes the same options to `manifest` and to `layer`, so the stack
 * prepares the layout the function asserts. `registryPrefix` reaches the resource as `prefix`.
 */
export const prepareRenamed = (url: Redacted.Redacted<string>) =>
  Effect.gen(function* () {
    const manifest = yield* PgStorage.manifest({
      prefix: "acme_dns",
      registryPrefix: "acme_capsules",
    });
    return yield* CapsuleDB.Registry("domainkit-storage", {
      url,
      provider: "Postgres",
      manifest,
      prefix: "acme_capsules",
    });
  });
/**
 * The table prefix is part of the physical layout: it changes the rendered DDL and the migration
 * checksum, so fix it before the first deploy. `registryPrefix` does the same for CapsuleDB's own
 * ledger and must match `capsuledb emit --prefix`.
 */
export const Prefixed = PgStorage.layer({ prefix: "acme_dns", registryPrefix: "acme_capsules" });

The prefix is part of the physical layout: it changes the rendered DDL and the migration checksum. Fix it before the first deploy and never change it after.

The one read without a principal

Storage.continuations.header(id) answers with a flow’s ownerId, actorId, provider, and expiresAt, and takes no Principal. Writing your own implementation means writing this one without an owner filter, on purpose: a provider callback arrives as a top-level navigation carrying a state and a session cookie, so domainkit/server has to learn whose flow it is finishing before it can name a principal to scope by. It then holds the host’s answer against that record.

Two properties make the exception safe, and an implementation has to keep both. It returns those four fields and never payload, which holds the PKCE codeVerifier and stays behind the owner-scoped get. And it enforces the TTL exactly as get does, so a stale id never reaches a host. It is a read: the row survives for consume to spend once the provider exchange succeeds. get and consume keep their owner filters regardless, so a host that resolves the wrong principal still fails closed.

Custody

There is no plaintext mode for authorization credentials. Connect seals provider secret material through Custody before it reaches a credential row and opens it after reading one. OAuth continuation payloads follow a separate path: they include PKCE verifiers and CapsuleDB stores them as unsealed JSON. Protect database access and backups, and sweep expired continuation rows.

/** `layerConfig` reads a 32-byte key from `DOMAINKIT_CUSTODY_KEY`. There is no plaintext mode. */
export const CustodyLive = Custody.layerConfig();

/** Hand sealing to a KMS instead; the rest of the lifecycle does not change. */
export const CustodyKms = Custody.layerFromAsync({
  seal: (plaintext) => kms.encrypt(plaintext),
  open: (ciphertext) => kms.decrypt(ciphertext),
});

/** A fresh key in the accepted encoding, for a first deploy or a local playground. */
export const newKey = Redacted.make(Custody.generateKey());

The default is AES-256-GCM over Web Crypto from one 32-byte key, read from DOMAINKIT_CUSTODY_KEY as base64 or hex. The envelope is v1.<iv>.<ciphertext>. Rotating the key means re-sealing stored credentials; until then open fails with CryptoFailed.

Testing against the seam

/** Tests and playgrounds take the in-memory pair through one layer. */
export const Playground = DomainKit.layerMemory({ providers: [Vercel.provider()] });

Testing.conformance.storage checks tenant isolation and the unscoped header read, apply leases, exactly-once continuations, revocation recovery, and lock semantics against any implementation.

/**
 * Register every `Storage` invariant with your test runner: tenant isolation, apply leases,
 * exactly-once continuations, revocation recovery, and lock semantics. Both shipped
 * implementations pass this suite, so a host can swap them without a behaviour change.
 */
export const registerStorageCases = (
  layer: Layer.Layer<Storage.Service, unknown>,
  it: (name: string, run: () => Promise<void>) => void,
) => Testing.conformance.storage(layer, { it });

What the invariants buy you

  • Aggregate transitions run in one transaction over a locked row, so approve, claim, complete, and fail cannot interleave.
  • A continuation is consumed by a delete that returns the row, so a replayed OAuth callback fails NotFound instead of connecting twice.
  • Revocation is two-phase, so a crash between marking and deleting leaves a row that recovery finishes later rather than a credential still live at the provider.
  • The single-flight guard fails Busy instead of waiting, so a credential refresh never holds a transaction open across an HTTP call.

One gap stays open on purpose: Storage records no per-write progress, so a crash between two writes of one apply leaves those records without a receipt until the host re-plans. Re-planning turns them into no-ops.

Last updated on

Was this page helpful?