@domainkit/capsuledb 0.1.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,113 @@
1
1
  # `@domainkit/capsuledb`
2
2
 
3
- Optional PostgreSQL persistence for DomainKit's Effect-first managed-DNS authorization lifecycle.
3
+ DomainKit's `Storage` on PostgreSQL, as one declarative [CapsuleDB](https://github.com/aryasaatvik/CapsuleDB)
4
+ capsule. Install it under `DomainKit.layer` and the whole durable lifecycle — provider
5
+ authorizations, connections, attachments, interactive-flow continuations, plan/approval/receipt
6
+ attempts, and observed readiness — lives in your database, scoped to your tenants.
4
7
 
5
- The host owns and supplies the exact `SqlClient`, credential encryption, identity and tenant
6
- policy, stable owner/domain bindings, audit, routes, and consent. The package exposes only semantic
7
- Effect services. It does not export tables, rows, queries, an ORM adapter, or a Promise-first API.
8
+ The host owns the `SqlClient` and its lifetime. This package owns its own tables and never exposes
9
+ rows, queries, or a raw client.
8
10
 
9
- This package is the published PostgreSQL persistence implementation for DomainKit. Its development
10
- dependency pins the reviewed CapsuleDB Git SHA; that pin is excluded from the packed runtime
11
- dependency graph.
11
+ ## Install
12
+
13
+ ```sh
14
+ bun add @domainkit/capsuledb capsuledb @effect/sql-pg
15
+ ```
16
+
17
+ ## Wire it up
18
+
19
+ ```ts
20
+ import { Config, Layer } from "effect";
21
+ import { PgClient } from "@effect/sql-pg";
22
+ import { Cloudflare, Custody, DomainKit, Vercel } from "domainkit";
23
+ import { PgStorage } from "@domainkit/capsuledb";
24
+
25
+ export const DomainKitLive = DomainKit.layer({
26
+ providers: [
27
+ Cloudflare.provider({
28
+ oauth: {
29
+ clientId: Config.string("CF_CLIENT_ID"),
30
+ clientSecret: Config.redacted("CF_CLIENT_SECRET"),
31
+ },
32
+ }),
33
+ Vercel.provider(),
34
+ ],
35
+ }).pipe(
36
+ // `provideMerge`, not `provide`: `domainkit/server`'s handlers read attempts and receipts
37
+ // straight from Storage, so the layer they are given has to still carry it.
38
+ Layer.provideMerge(Layer.mergeAll(PgStorage.layer(), Custody.layerConfig())),
39
+ Layer.provide(PgClient.layerConfig({ url: Config.redacted("DATABASE_URL") })),
40
+ );
41
+ ```
42
+
43
+ `PgStorage.layer()` prepares at boot: it creates CapsuleDB's ledger, applies pending migrations, and
44
+ only then provides `Storage`. A capsule service can never observe a database whose tables are
45
+ missing.
46
+
47
+ `Custody.layerConfig()` reads `DOMAINKIT_CUSTODY_KEY` and seals every provider credential before it
48
+ reaches a row. Swap it for a KMS with `Custody.layerFromAsync`; there is no plaintext mode, and this
49
+ package never sees one.
50
+
51
+ ## Or emit the SQL and assert at boot
52
+
53
+ If your migrations are yours to run, take the SQL instead:
54
+
55
+ ```sh
56
+ capsuledb emit \
57
+ --module ./node_modules/@domainkit/capsuledb/dist/index.mjs \
58
+ --export capsule \
59
+ --dialect postgres \
60
+ --out ./drizzle
61
+ ```
62
+
63
+ That writes CapsuleDB's ledger DDL, the capsule's migration, and a readiness row, plus a
64
+ `capsuledb.emit.json` index naming the files it owns. Apply them with your own pipeline, then boot
65
+ in assert mode:
66
+
67
+ ```ts
68
+ PgStorage.layer({ mode: "assert" });
69
+ ```
70
+
71
+ Assert mode applies nothing and fails unless the database already matches the capsule, so a missed
72
+ migration is a boot failure rather than a runtime surprise. `capsuledb check` compares an emitted
73
+ folder against the current capsule in CI.
74
+
75
+ ## Tables
76
+
77
+ | Table | Key | Holds |
78
+ | -------------------------- | --------------------------------- | ----------------------------------------------------------------- |
79
+ | `domainkit_authorizations` | `id` | provider grant, capabilities, revocation state, sealed credential |
80
+ | `domainkit_connections` | `id` | the principal-facing handle over one authorization |
81
+ | `domainkit_attachments` | `id`, unique `(owner_id, domain)` | domain, zone, provider target |
82
+ | `domainkit_continuations` | `id` | interactive-flow state with a TTL |
83
+ | `domainkit_attempts` | `id` | plan, approval, receipt, status, lease, failure |
84
+ | `domainkit_readiness` | `(owner_id, domain)` | latest observation, per-requirement evidence, backoff |
85
+
86
+ Readiness is keyed by domain rather than by attachment, so a host observing public DNS alone gets
87
+ the same row; `attachment_id` links the attachment when one exists and is cleared when it is
88
+ removed.
89
+
90
+ Every table carries `owner_id`, and every query filters by the `Principal.Service` your host provides, so a
91
+ row belonging to another tenant reads as absent. No foreign keys are declared, to your tables or
92
+ between these; add the ones you want in the emitted SQL.
93
+
94
+ `PgStorage.layer({ prefix })` renames the tables. The prefix is part of the physical layout: it
95
+ changes the rendered DDL and the migration checksum, so fix it before the first deploy and never
96
+ change it after. `registryPrefix` does the same for CapsuleDB's own ledger tables and must match
97
+ `capsuledb emit --prefix`.
98
+
99
+ ## What it guarantees
100
+
101
+ - Aggregate transitions — approve, claim, complete, fail, capability promotion, credential
102
+ replacement — run in one transaction over a `FOR UPDATE` row.
103
+ - A continuation is consumed by `DELETE ... RETURNING`, so a replayed OAuth callback fails
104
+ `NotFound` instead of connecting twice.
105
+ - Revocation is two-phase: mark `pending`, call the provider outside the transaction, then delete
106
+ the row only while it still holds the credential that was revoked. A crash in between, or a
107
+ refresh that rotated the credential mid-revoke, leaves a row `recoverRevocations` finishes later,
108
+ so a newly issued credential is never orphaned at the provider.
109
+ - `withLock` takes a session advisory lock on a reserved connection and fails `Busy` rather than
110
+ waiting, so a credential refresh single-flights without holding a transaction across an HTTP call.
111
+
112
+ The package passes `Testing.conformance.storage` from `domainkit/testing`, the same suite the
113
+ in-memory implementation passes.
package/dist/index.d.mts CHANGED
@@ -1,60 +1,29 @@
1
- import { Context, Effect, Schema } from "effect";
2
- import { ManagedDnsConnections } from "domainkit";
1
+ import { Capsule, Registry, Schema } from "capsuledb";
2
+ import { Storage } from "domainkit";
3
3
  import * as SqlClient from "effect/unstable/sql/SqlClient";
4
- declare namespace custody_d_exports {
5
- export { Credential, Error$1 as Error, Interface$1 as Interface, Service$1 as Service };
4
+ import * as Layer from "effect/Layer";
5
+ declare namespace PgStorage_d_exports {
6
+ export { Options, layer };
6
7
  }
7
- /** Secret token material; credential expiry remains ordinary lifecycle metadata. */
8
- type Credential = Omit<ManagedDnsConnections.StoredCredential, "expiresAt">;
9
- declare const Error_base$1: Schema.Class<Error$1, Schema.TaggedStruct<"CredentialCustodyError", {
10
- readonly message: Schema.String;
11
- readonly operation: Schema.String;
12
- readonly retry: Schema.Literals<readonly ["never", "after-user-action", "safe", "unknown"]>;
13
- }>, import("effect/Cause").YieldableError>;
14
- declare class Error$1 extends Error_base$1 {}
15
- interface Interface$1 {
16
- readonly open: (ciphertext: string) => Effect.Effect<Credential, Error$1>;
17
- readonly seal: (credential: Credential) => Effect.Effect<string, Error$1>;
8
+ interface Options {
9
+ /** Table prefix. Default `domainkit`. Part of the physical layout; immutable after first deploy. */
10
+ readonly prefix?: string;
11
+ /** `prepare` (default) migrates at boot; `assert` expects the host applied `capsuledb emit` output. */
12
+ readonly mode?: "prepare" | "assert";
13
+ /**
14
+ * Prefix for CapsuleDB's own ledger tables; default `capsuledb`. Set it only to match a prefix a
15
+ * host already uses for other capsules, and pass the same value to `capsuledb emit --prefix`.
16
+ */
17
+ readonly registryPrefix?: string;
18
18
  }
19
- declare const Service_base$1: Context.ServiceClass<Service$1, "@domainkit/capsuledb/CredentialCustody", Interface$1>;
20
- /** Host-owned encryption boundary. DomainKit never receives keys or stores plaintext. */
21
- declare class Service$1 extends Service_base$1 {}
22
- declare namespace host_bindings_d_exports {
23
- export { DomainReference, Error, Interface, OwnerReference, Service };
24
- }
25
- declare const Error_base: Schema.Class<Error, Schema.TaggedStruct<"HostBindingError", {
26
- readonly message: Schema.String;
27
- readonly operation: Schema.String;
28
- readonly retry: Schema.Literals<readonly ["never", "after-user-action", "safe", "unknown"]>;
29
- }>, import("effect/Cause").YieldableError>;
30
- declare class Error extends Error_base {}
31
- interface OwnerReference {
32
- readonly ownerReference: string;
33
- }
34
- interface DomainReference extends OwnerReference {
35
- readonly domainReference: string;
36
- }
37
- interface Interface {
38
- /** Resolve a durable host owner reference without capturing request identity. */
39
- readonly owner: (ownerId: string) => Effect.Effect<OwnerReference, Error>;
40
- /** Resolve a host domain reference inside the caller's active transaction context. */
41
- readonly domain: (input: {
42
- readonly domain: string;
43
- readonly ownerId: string;
44
- }) => Effect.Effect<DomainReference, Error>;
45
- /** Project an opaque stored owner reference back to DomainKit's semantic owner id. */
46
- readonly ownerId: (ownerReference: string) => Effect.Effect<string, Error>;
47
- }
48
- declare const Service_base: Context.ServiceClass<Service, "@domainkit/capsuledb/HostBindings", Interface>;
49
- /** Stateless host mapping for tenant/domain foreign-key references. */
50
- declare class Service extends Service_base {}
19
+ declare const layer: (options?: Options) => Layer.Layer<Storage.Service, Registry.RegistryRuntimeError, SqlClient.SqlClient>;
51
20
  //#endregion
52
- //#region src/persistence.d.ts
53
- /** PostgreSQL-only authorization lifecycle capsule. The host must prepare its registry at startup. */
54
- declare const capsule: Effect.Effect<import("capsuledb").Capsule<ManagedDnsConnections.Service, never, Service$1 | Service | SqlClient.SqlClient>, import("capsuledb").InvalidDefinition | import("capsuledb").CapsuleDefinitionError, never>;
21
+ //#region src/capsule.d.ts
22
+ /** The capsule under the default `domainkit` prefix. `capsuledb emit --export capsule` reads this. */
23
+ declare const capsule: Capsule.Capsule<Storage.Service, never, SqlClient.SqlClient>;
55
24
  //#endregion
56
25
  //#region src/index.d.ts
57
26
  declare const VERSION: string;
58
27
  //#endregion
59
- export { custody_d_exports as CredentialCustody, host_bindings_d_exports as HostBindings, VERSION, capsule };
28
+ export { PgStorage_d_exports as PgStorage, VERSION, capsule };
60
29
  //# sourceMappingURL=index.d.mts.map