Skip to content
DomainKit
Esc
navigateopen⌘Jpreview
On this page

Persistence and custody

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

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. Custody seals a credential before Storage sees it.

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

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.

/**
 * 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.

Custody

There is no plaintext mode. Connect seals every credential through Custody before it reaches a row and opens it after reading one, so a Storage implementation only ever stores ciphertext.

/** `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, 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.

Was this page helpful?