SIGN IN SIGN UP

feat(capsuledb)!: implement Storage on one declarative CapsuleDB capsule (#88)

## Summary

`@domainkit/capsuledb` implements DomainKit 0.9's `Storage.Service` on
PostgreSQL as one declarative
CapsuleDB capsule: six tables, no host foreign keys, every row scoped by
`Principal.Service`.

## Usage

```ts
import { Cloudflare, Custody, DomainKit, Vercel } from "domainkit";
import { PgStorage } from "@domainkit/capsuledb";

const DomainKitLive = DomainKit.layer({
  providers: [Cloudflare.provider({ oauth }), Vercel.provider()],
}).pipe(Layer.provide([PgStorage.layer(), Custody.layerConfig()]));
```

`PgStorage.layer()` runs the capsule through `Registry.layer` with the
Postgres profile: it creates
the ledger, applies pending migrations, then provides `Storage.Service`.
It requires only the host's
`SqlClient` — credentials arrive sealed, so Storage never needs
`Custody.Service`.

A host that owns its migration pipeline emits the SQL instead and
asserts at boot:

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

```ts
PgStorage.layer({ mode: "assert" }); // applies nothing; fails unless the database already matches
```

## Tables

| Table | Key | Holds |
| --- | --- | --- |
| `domainkit_authorizations` | `id` | provider grant, capabilities,
revocation state, sealed credential |
| `domainkit_connections` | `id` | the principal-facing handle over one
authorization |
| `domainkit_attachments` | `id`, unique `(owner_id, domain)` | domain,
zone, provider target |
| `domainkit_continuations` | `id` | interactive-flow state with a TTL |
| `domainkit_attempts` | `id` | plan, approval, receipt, rejection,
status, lease, failure |
| `domainkit_readiness` | `(owner_id, domain)` | latest observation,
per-requirement evidence, backoff |

Readiness is keyed by domain rather than by attachment, so a host
observing public DNS alone gets the
same row; `attachment_id` is a nullable link that `attachments.remove`
clears rather than deleting
what was observed about the domain.

The prefix defaults to `domainkit` and is part of the physical layout,
so it is immutable after the
first deploy. Rows are stored as their core schema's encoded form spread
over columns, which keeps
`packages/domainkit/src/Storage.ts` the only description of a row's
shape — the reason the D39–D41
rename touched no value module here.

## Concurrency

```ts
// A session lock on a reserved connection, not a transaction lock: the guarded effect refreshes a
// credential against the provider, and no transaction should stay open across an HTTP call.
const connection = yield* sql.reserve;
yield* Effect.acquireRelease(
  connection.execute("SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS acquired", [scoped]),
  () => connection.execute("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [scoped]),
);
```

Aggregate transitions (`approve`, `reject`, `claim`, `complete`, `fail`,
`promoteCapabilities`, CAS
`upsert`) run in one transaction over a `FOR UPDATE` row. Uniqueness
decides duplicates through
`ON CONFLICT ... DO NOTHING`, and `continuations.consume` claims by
`DELETE ... RETURNING` while
`continuations.get` reads without spending.

Revocation stays two-phase — mark `pending`, call the provider outside
the transaction, then delete
— and the delete carries the ciphertext it revoked:

```sql
DELETE FROM domainkit_authorizations
WHERE id = $1 AND owner_id = $2 AND credential_ciphertext = $3
```

A refresh that rotates the credential mid-revoke therefore leaves the
row `pending` for
`recoverRevocations` instead of dropping a row whose newly issued
credential is still live at the
provider. That covers the one ordering core's own refresh guard cannot:
there `rotate` succeeds, so
no `NotFound` surfaces for it to compensate on.

## Errors

Every failure is a `DomainKit.Error` carrying one `Reason`. SQL errors
become
`Reason.StorageFailed`; invariants raise `Reason.NotFound`,
`Reason.InvalidInput`, `Reason.Busy`,
`Reason.Stale`, and `Reason.Expired` so a host matches the same reasons
it would against the
in-memory implementation.

## Validation

| Gate | Result |
| --- | --- |
| CI `validate` | green |
| `bun run --filter @domainkit/capsuledb test` | 15 tests: 10
conformance, 3 lifecycle, 2 emit |
| `bun run --filter @domainkit/capsuledb release:check` | pass, plus 2
packed-artifact tests |
| root `bun run release:check` | pass |

Tests run against one Postgres testcontainer per suite, on CI as well as
locally. The emit test runs
`capsuledb emit`, applies the SQL with the container's client, then
boots `mode: "assert"` and
expects Ready.

## Stack note

`@domainkit/react` and the docs app are rewritten in their own lanes and
are not touched here; the
root release gate covers the core and this package.

## Not in this PR

- Version and changelog stay with release automation: Tegami produces
both from its version PR, and
  the release issue owns aligning every package on 0.9.
- SQLite and D1 remain unimplemented. A research note records how
locking, two-phase revocation, and
D1's batch bound would work, and which conformance cases would need
changing.
S
Saatvik Arya committed
90b10f903760f03bb077ac627d4e1adb51d2608d
Parent: 605eb99
Committed by GitHub <noreply@github.com> on 9/3/2026, 6:54:03 PM