domainkit 0.15.0 → 0.16.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 +9 -2
- package/dist/Provision.mjs +209 -19
- package/dist/Provision.mjs.map +1 -1
- package/dist/Reason.mjs +22 -2
- package/dist/Reason.mjs.map +1 -1
- package/dist/Server.mjs +86 -4
- package/dist/Server.mjs.map +1 -1
- package/dist/Storage.mjs +100 -2
- package/dist/Storage.mjs.map +1 -1
- package/dist/internal/attempts.mjs +14 -4
- package/dist/internal/attempts.mjs.map +1 -1
- package/dist/internal/conformance/storage.mjs +183 -0
- package/dist/internal/conformance/storage.mjs.map +1 -1
- package/dist/internal/error.mjs +2 -0
- package/dist/internal/error.mjs.map +1 -1
- package/dist/internal/storage-memory.mjs +185 -2
- package/dist/internal/storage-memory.mjs.map +1 -1
- package/dist/package.mjs +1 -1
- package/dist/types/Provision.d.ts +124 -3
- package/dist/types/Reason.d.ts +18 -2
- package/dist/types/Server.d.ts +3619 -202
- package/dist/types/Storage.d.ts +176 -0
- package/dist/types/internal/attempts.d.ts +1 -0
- package/dist/types/internal/error.d.ts +1 -1
- package/package.json +1 -1
package/dist/Storage.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Storage.mjs","names":["Plan.PlanId","Plan.Kind","Plan.Model","Approval.Model","Receipt.Model","Receipt.ReceiptId","Plan.OperationId","DnsRecord.Model","Errors.isDomainKitError","Errors.DomainKitError","Reason.StorageFailed","Principal.Service","Errors.fail","Reason.Busy"],"sources":["../src/Storage.ts"],"sourcesContent":["/**\n * Everything DomainKit persists, behind one service the host installs once. Every method requires\n * `Principal` and is scoped by it. `@domainkit/capsuledb` ships the Postgres implementation;\n * `domainkit/testing` ships the memory one; both pass `Testing.conformance.storage`.\n *\n * Storage never sees plaintext credentials: `Connect` seals secrets through `Custody` before\n * writing a `Credential` row and opens them after reading one.\n *\n * Grouped by noun so the 25-odd methods stay navigable. Implementations must satisfy the\n * invariants documented per group; the conformance suite checks them.\n */\nimport { Context, DateTime, Effect, Layer, Option, Schema } from \"effect\";\n\nimport * as Approval from \"./Approval.ts\";\nimport * as DnsRecord from \"./DnsRecord.ts\";\nimport * as Errors from \"./internal/error.ts\";\nimport * as Reason from \"./Reason.ts\";\nimport { makeMemory } from \"./internal/storage-memory.ts\";\nimport * as Plan from \"./Plan.ts\";\nimport * as Principal from \"./Principal.ts\";\nimport * as Receipt from \"./Receipt.ts\";\n\nexport type Fx<A> = Effect.Effect<A, Errors.DomainKitError, Principal.Service>;\n\n// ---------------------------------------------------------------------------------------------\n// Rows\n// ---------------------------------------------------------------------------------------------\n\nexport const Capability = Schema.Literals([\"dns:read\", \"dns:write\"]);\nexport type Capability = typeof Capability.Type;\n\nexport const AuthMethod = Schema.Literals([\"token\", \"oauth\", \"integration\"]);\nexport type AuthMethod = typeof AuthMethod.Type;\n\n/** Sealed provider credential plus the metadata needed without unsealing it. */\nexport class Credential extends Schema.Class<Credential>(\"@domainkit/Storage/Credential\")({\n ciphertext: Schema.String,\n expiresAt: Schema.NullOr(Schema.DateTimeUtcFromString),\n rotatedAt: Schema.DateTimeUtcFromString,\n}) {}\n\n/** One provider authorization (an OAuth grant or a token) owned by a principal. */\nexport class Authorization extends Schema.Class<Authorization>(\"@domainkit/Storage/Authorization\")({\n id: Schema.String,\n ownerId: Schema.String,\n provider: Schema.String,\n method: AuthMethod,\n capabilities: Schema.Array(Capability),\n /** Provider-specific account context (account id, team id, ...) decoded by the provider's `context` schema. */\n context: Schema.Unknown,\n /**\n * What the provider called the account when the credential was issued, so a UI names the\n * connection without a provider call. `null` when the provider names no single account.\n */\n label: Schema.NullOr(Schema.String),\n revocation: Schema.Literals([\"active\", \"pending\", \"revoked\"]),\n createdBy: Schema.String,\n createdAt: Schema.DateTimeUtcFromString,\n}) {}\n\n/** A connection is the principal-facing handle over an authorization; one authorization, many domains. */\nexport class Connection extends Schema.Class<Connection>(\"@domainkit/Storage/Connection\")({\n id: Schema.String,\n ownerId: Schema.String,\n authorizationId: Schema.String,\n createdAt: Schema.DateTimeUtcFromString,\n}) {}\n\nexport class Attachment extends Schema.Class<Attachment>(\"@domainkit/Storage/Attachment\")({\n id: Schema.String,\n ownerId: Schema.String,\n connectionId: Schema.String,\n domain: Schema.String,\n zone: Schema.String,\n /** The target's label as the provider gave it at attach time, so a UI names the account without a provider call. */\n label: Schema.String,\n /** Provider zone identity (zone id, account id) decoded by the provider's `context` schema. */\n target: Schema.Unknown,\n createdAt: Schema.DateTimeUtcFromString,\n}) {}\n\n/** Interactive-flow state between `Connect.start` and `Connect.complete`. */\nexport class Continuation extends Schema.Class<Continuation>(\"@domainkit/Storage/Continuation\")({\n id: Schema.String,\n ownerId: Schema.String,\n actorId: Schema.String,\n provider: Schema.String,\n payload: Schema.Unknown,\n returnTo: Schema.NullOr(Schema.String),\n expiresAt: Schema.DateTimeUtcFromString,\n}) {}\n\n/**\n * Who a continuation belongs to, without the continuation itself. `payload` has no place here: it\n * carries the PKCE `codeVerifier`, which only the owner-scoped read may reach.\n */\nexport class ContinuationHeader extends Schema.Class<ContinuationHeader>(\n \"@domainkit/Storage/ContinuationHeader\",\n)({\n ownerId: Schema.String,\n actorId: Schema.String,\n provider: Schema.String,\n expiresAt: Schema.DateTimeUtcFromString,\n}) {}\n\nexport const AttemptStatus = Schema.Literals([\n \"planned\",\n \"approved\",\n \"applying\",\n \"complete\",\n \"partial\",\n \"failed\",\n \"expired\",\n \"rejected\",\n]);\nexport type AttemptStatus = typeof AttemptStatus.Type;\n\n/** Who declined the plan, why, and when. Terminal: the domain needs a new plan. */\nexport const Rejection = Schema.Struct({\n actorId: Schema.String,\n reason: Schema.NullOr(Schema.String),\n at: Schema.DateTimeUtcFromString,\n});\nexport type Rejection = typeof Rejection.Type;\n\n/** One durable plan -> approval -> receipt lifecycle. */\nexport class Attempt extends Schema.Class<Attempt>(\"@domainkit/Storage/Attempt\")({\n id: Plan.PlanId,\n ownerId: Schema.String,\n attachmentId: Schema.String,\n kind: Plan.Kind,\n status: AttemptStatus,\n plan: Plan.Model,\n approval: Schema.NullOr(Approval.Model),\n receipt: Schema.NullOr(Receipt.Model),\n rejection: Schema.NullOr(Rejection),\n /** Cleanup attempts point at the provisioning receipt they undo. */\n sourceReceiptId: Schema.NullOr(Receipt.ReceiptId),\n leaseExpiresAt: Schema.NullOr(Schema.DateTimeUtcFromString),\n /** Why the last apply stopped before any write; cleared on the next claim. */\n failure: Schema.NullOr(Schema.String),\n updatedAt: Schema.DateTimeUtcFromString,\n}) {}\n\nexport const RequirementStatus = Schema.Literals([\"satisfied\", \"missing\", \"mismatch\", \"unknown\"]);\nexport type RequirementStatus = typeof RequirementStatus.Type;\n\nexport const Overall = Schema.Literals([\"ready\", \"pending\", \"failed\"]);\nexport type Overall = typeof Overall.Type;\n\n/**\n * Latest observed readiness for one domain, written by `Verify.observe`. Keyed by domain so\n * observe-only hosts (no attachment, public DNS alone) get the same row; `attachmentId` links the\n * attachment when one exists.\n */\nexport class Readiness extends Schema.Class<Readiness>(\"@domainkit/Storage/Readiness\")({\n domain: Schema.String,\n attachmentId: Schema.NullOr(Schema.String),\n ownerId: Schema.String,\n overall: Overall,\n requirements: Schema.Array(\n Schema.Struct({\n operationId: Schema.NullOr(Plan.OperationId),\n record: DnsRecord.Model,\n status: RequirementStatus,\n /** Encoded `Verify.Evidence` values. */\n evidence: Schema.Array(Schema.Unknown),\n }),\n ),\n /** Encoded `Verify.HostEvidence` values, keyed by `source` on merge. */\n host: Schema.Array(Schema.Unknown),\n /** When the current pending streak began; drives the backoff ladder. */\n pendingSince: Schema.NullOr(Schema.DateTimeUtcFromString),\n checkedAt: Schema.DateTimeUtcFromString,\n nextCheckAt: Schema.NullOr(Schema.DateTimeUtcFromString),\n}) {}\n\n// ---------------------------------------------------------------------------------------------\n// Service\n// ---------------------------------------------------------------------------------------------\n\nexport interface Interface {\n readonly authorizations: {\n /**\n * Insert, or CAS-replace when `expectedId` is set: the row must exist under this owner with an\n * active revocation state, else `NotFound` / `Busy`.\n */\n readonly upsert: (input: {\n readonly authorization: Authorization;\n readonly credential: Credential;\n readonly expectedId?: string;\n }) => Fx<Authorization>;\n readonly get: (id: string) => Fx<Authorization>;\n readonly credential: (id: string) => Fx<Credential>;\n readonly rotate: (id: string, credential: Credential) => Fx<void>;\n readonly promoteCapabilities: (id: string, capabilities: ReadonlyArray<Capability>) => Fx<void>;\n /**\n * Two-phase: mark `pending`, run `revoke` outside any transaction, then delete. A failed\n * `revoke` leaves the row pending; `recoverRevocations` finishes it later.\n */\n readonly revoke: <E, R>(\n id: string,\n revoke: Effect.Effect<void, E, R>,\n ) => Effect.Effect<void, Errors.DomainKitError | E, Principal.Service | R>;\n readonly recoverRevocations: <E, R>(\n revoke: (authorization: Authorization) => Effect.Effect<void, E, R>,\n ) => Effect.Effect<number, Errors.DomainKitError | E, Principal.Service | R>;\n };\n readonly connections: {\n readonly create: (authorizationId: string) => Fx<Connection>;\n readonly get: (id: string) => Fx<Connection>;\n readonly list: (filter?: { readonly provider?: string }) => Fx<ReadonlyArray<Connection>>;\n /** Fails `InvalidInput` while attachments still reference the connection. */\n readonly remove: (id: string) => Fx<void>;\n };\n readonly attachments: {\n /** One attachment per (owner, domain); a duplicate fails `InvalidInput`. */\n readonly create: (input: {\n readonly connectionId: string;\n readonly domain: string;\n readonly zone: string;\n readonly label: string;\n readonly target: unknown;\n }) => Fx<Attachment>;\n readonly get: (id: string) => Fx<Attachment>;\n readonly byDomain: (domain: string) => Fx<Option.Option<Attachment>>;\n readonly list: (connectionId: string) => Fx<ReadonlyArray<Attachment>>;\n readonly remove: (id: string) => Fx<void>;\n };\n readonly continuations: {\n readonly put: (continuation: Continuation) => Fx<void>;\n /** Read without spending; a late read fails `Expired`. */\n readonly get: (id: string) => Fx<Continuation>;\n /**\n * The flow's owner, actor, provider, and expiry, without a principal and without spending the\n * row. The one read in this interface that is not tenant-scoped, because `domainkit/server`\n * has to learn whose flow a provider callback finishes before it can resolve the principal to\n * scope by; the callback carries a continuation id and a session cookie and nothing else.\n *\n * A missing row fails `NotFound` and a stale one fails `Expired`, exactly as `get` does, so an\n * expired id never reaches a host. `payload` is out of reach here by construction.\n */\n readonly header: (id: string) => Effect.Effect<ContinuationHeader, Errors.DomainKitError>;\n /** Exactly-once: the second consume of the same id fails `NotFound`; a late one fails `Expired`. */\n readonly consume: (id: string) => Fx<Continuation>;\n };\n readonly attempts: {\n readonly create: (attempt: Attempt) => Fx<Attempt>;\n readonly get: (id: Plan.PlanId) => Fx<Attempt>;\n readonly byApproval: (id: Approval.ApprovalId) => Fx<Attempt>;\n readonly byReceipt: (id: Receipt.ReceiptId) => Fx<Attempt>;\n readonly latest: (attachmentId: string, kind: Plan.Kind) => Fx<Option.Option<Attempt>>;\n /** `planned` -> `approved`; approving again with the same approval id is a no-op, anything else fails `Stale`. */\n readonly approve: (id: Plan.PlanId, approval: Approval.Model) => Fx<Attempt>;\n /**\n * `planned` -> `rejected` (terminal). Rejecting again returns the row unchanged; any other\n * status fails `Stale`, `expired` fails `Expired`, and a digest mismatch fails `Stale`.\n */\n readonly reject: (\n id: Plan.PlanId,\n input: {\n readonly digest: Plan.Digest;\n readonly actorId: string;\n readonly reason: string | null;\n },\n ) => Fx<Attempt>;\n /**\n * Atomic transition to `applying` with a lease from `approved`, `failed`, or an expired\n * `applying`; fails `Busy` while a lease is live and `Stale` from any other status.\n */\n readonly claim: (id: Plan.PlanId, lease: DateTime.Utc) => Fx<Attempt>;\n readonly complete: (id: Plan.PlanId, receipt: Receipt.Model) => Fx<Attempt>;\n readonly fail: (id: Plan.PlanId, message: string) => Fx<Attempt>;\n };\n readonly readiness: {\n /** One row per (owner, domain); `attachmentId`, when set, must exist for the owner. */\n readonly put: (readiness: Readiness) => Fx<void>;\n readonly get: (domain: string) => Fx<Option.Option<Readiness>>;\n };\n /** Single-flight guard keyed by string (credential refresh, apply). Fails `Busy` rather than waiting. */\n readonly withLock: <A, E, R>(\n key: string,\n effect: Effect.Effect<A, E, R>,\n ) => Effect.Effect<A, E | Errors.DomainKitError, R | Principal.Service>;\n}\n\nexport class Service extends Context.Service<Service, Interface>()(\"@domainkit/Storage\") {}\n\nexport interface MemoryOptions {\n /** Fault injection: runs before every mutation commits; failing here leaves state untouched. */\n readonly beforeCommit?: (operation: string) => Effect.Effect<void, Errors.DomainKitError>;\n}\n\n/** In-memory implementation for tests and local development. Not for production. */\nexport const layerMemory: Layer.Layer<Service> = Layer.sync(Service)(() => makeMemory());\n\n/** `layerMemory` with fault injection for storage-failure tests. */\nexport const layerMemoryWith = (options: MemoryOptions): Layer.Layer<Service> =>\n Layer.sync(Service)(() => makeMemory(options));\n\nexport { makeMemory };\n\n// ---------------------------------------------------------------------------------------------\n// Async adapter\n// ---------------------------------------------------------------------------------------------\n\n/**\n * A Promise-shaped implementation for hosts that persist without Effect. Every method takes the\n * principal explicitly. Rejections become `StorageFailed`; reject with a `DomainKit.Error` to keep\n * a typed reason. Two-phase revocation and locking are split into plain steps so no callback has\n * to run an Effect.\n */\nexport interface AsyncInterface {\n readonly authorizations: {\n readonly upsert: (\n principal: Principal.Interface,\n input: {\n readonly authorization: Authorization;\n readonly credential: Credential;\n readonly expectedId?: string;\n },\n ) => Promise<Authorization>;\n readonly get: (principal: Principal.Interface, id: string) => Promise<Authorization>;\n readonly credential: (principal: Principal.Interface, id: string) => Promise<Credential>;\n readonly rotate: (\n principal: Principal.Interface,\n id: string,\n credential: Credential,\n ) => Promise<void>;\n readonly promoteCapabilities: (\n principal: Principal.Interface,\n id: string,\n capabilities: ReadonlyArray<Capability>,\n ) => Promise<void>;\n /** Mark `pending` durably; must be idempotent. */\n readonly prepareRevocation: (\n principal: Principal.Interface,\n id: string,\n ) => Promise<Authorization>;\n /** Delete the authorization after the provider confirmed revocation. */\n readonly completeRevocation: (principal: Principal.Interface, id: string) => Promise<void>;\n readonly pendingRevocations: (\n principal: Principal.Interface,\n ) => Promise<ReadonlyArray<Authorization>>;\n };\n readonly connections: {\n readonly create: (\n principal: Principal.Interface,\n authorizationId: string,\n ) => Promise<Connection>;\n readonly get: (principal: Principal.Interface, id: string) => Promise<Connection>;\n readonly list: (\n principal: Principal.Interface,\n filter?: { readonly provider?: string },\n ) => Promise<ReadonlyArray<Connection>>;\n readonly remove: (principal: Principal.Interface, id: string) => Promise<void>;\n };\n readonly attachments: {\n readonly create: (\n principal: Principal.Interface,\n input: {\n readonly connectionId: string;\n readonly domain: string;\n readonly zone: string;\n readonly label: string;\n readonly target: unknown;\n },\n ) => Promise<Attachment>;\n readonly get: (principal: Principal.Interface, id: string) => Promise<Attachment>;\n readonly byDomain: (\n principal: Principal.Interface,\n domain: string,\n ) => Promise<Attachment | null>;\n readonly list: (\n principal: Principal.Interface,\n connectionId: string,\n ) => Promise<ReadonlyArray<Attachment>>;\n readonly remove: (principal: Principal.Interface, id: string) => Promise<void>;\n };\n readonly continuations: {\n readonly put: (principal: Principal.Interface, continuation: Continuation) => Promise<void>;\n readonly get: (principal: Principal.Interface, id: string) => Promise<Continuation>;\n /** Takes no principal: see `Interface.continuations.header`. Never returns `payload`. */\n readonly header: (id: string) => Promise<ContinuationHeader>;\n readonly consume: (principal: Principal.Interface, id: string) => Promise<Continuation>;\n };\n readonly attempts: {\n readonly create: (principal: Principal.Interface, attempt: Attempt) => Promise<Attempt>;\n readonly get: (principal: Principal.Interface, id: Plan.PlanId) => Promise<Attempt>;\n readonly byApproval: (\n principal: Principal.Interface,\n id: Approval.ApprovalId,\n ) => Promise<Attempt>;\n readonly byReceipt: (principal: Principal.Interface, id: Receipt.ReceiptId) => Promise<Attempt>;\n readonly latest: (\n principal: Principal.Interface,\n attachmentId: string,\n kind: Plan.Kind,\n ) => Promise<Attempt | null>;\n readonly approve: (\n principal: Principal.Interface,\n id: Plan.PlanId,\n approval: Approval.Model,\n ) => Promise<Attempt>;\n readonly reject: (\n principal: Principal.Interface,\n id: Plan.PlanId,\n input: {\n readonly digest: Plan.Digest;\n readonly actorId: string;\n readonly reason: string | null;\n },\n ) => Promise<Attempt>;\n readonly claim: (\n principal: Principal.Interface,\n id: Plan.PlanId,\n lease: DateTime.Utc,\n ) => Promise<Attempt>;\n readonly complete: (\n principal: Principal.Interface,\n id: Plan.PlanId,\n receipt: Receipt.Model,\n ) => Promise<Attempt>;\n readonly fail: (\n principal: Principal.Interface,\n id: Plan.PlanId,\n message: string,\n ) => Promise<Attempt>;\n };\n readonly readiness: {\n readonly put: (principal: Principal.Interface, readiness: Readiness) => Promise<void>;\n readonly get: (principal: Principal.Interface, domain: string) => Promise<Readiness | null>;\n };\n /** Return `false` when another holder has the key. */\n readonly acquireLock: (principal: Principal.Interface, key: string) => Promise<boolean>;\n readonly releaseLock: (principal: Principal.Interface, key: string) => Promise<void>;\n}\n\nconst storageFailed = (operation: string) => (cause: unknown) =>\n Errors.isDomainKitError(cause)\n ? cause\n : new Errors.DomainKitError({\n reason: new Reason.StorageFailed({\n operation,\n message: cause instanceof Error ? cause.message : String(cause),\n }),\n });\n\n/** Wraps a Promise-shaped implementation. Rejections become `StorageFailed`. */\nexport const fromAsync = (service: AsyncInterface): Interface => {\n const call = <A>(operation: string, run: (principal: Principal.Interface) => Promise<A>): Fx<A> =>\n Effect.flatMap(Principal.Service, (principal) =>\n Effect.tryPromise({ try: () => run(principal), catch: storageFailed(operation) }),\n );\n const option = <A>(\n operation: string,\n run: (principal: Principal.Interface) => Promise<A | null>,\n ) => call(operation, run).pipe(Effect.map(Option.fromNullishOr));\n return {\n authorizations: {\n upsert: (input) =>\n call(\"authorizations.upsert\", (p) => service.authorizations.upsert(p, input)),\n get: (id) => call(\"authorizations.get\", (p) => service.authorizations.get(p, id)),\n credential: (id) =>\n call(\"authorizations.credential\", (p) => service.authorizations.credential(p, id)),\n rotate: (id, credential) =>\n call(\"authorizations.rotate\", (p) => service.authorizations.rotate(p, id, credential)),\n promoteCapabilities: (id, capabilities) =>\n call(\"authorizations.promoteCapabilities\", (p) =>\n service.authorizations.promoteCapabilities(p, id, capabilities),\n ),\n revoke: (id, revoke) =>\n call(\"authorizations.prepareRevocation\", (p) =>\n service.authorizations.prepareRevocation(p, id),\n ).pipe(\n Effect.flatMap(() => revoke),\n Effect.flatMap(() =>\n call(\"authorizations.completeRevocation\", (p) =>\n service.authorizations.completeRevocation(p, id),\n ),\n ),\n ),\n recoverRevocations: (revoke) =>\n call(\"authorizations.pendingRevocations\", (p) =>\n service.authorizations.pendingRevocations(p),\n ).pipe(\n Effect.flatMap((pending) =>\n Effect.forEach(pending, (authorization) =>\n revoke(authorization).pipe(\n Effect.flatMap(() =>\n call(\"authorizations.completeRevocation\", (p) =>\n service.authorizations.completeRevocation(p, authorization.id),\n ),\n ),\n ),\n ),\n ),\n Effect.map((completed) => completed.length),\n ),\n },\n connections: {\n create: (authorizationId) =>\n call(\"connections.create\", (p) => service.connections.create(p, authorizationId)),\n get: (id) => call(\"connections.get\", (p) => service.connections.get(p, id)),\n list: (filter) => call(\"connections.list\", (p) => service.connections.list(p, filter)),\n remove: (id) => call(\"connections.remove\", (p) => service.connections.remove(p, id)),\n },\n attachments: {\n create: (input) => call(\"attachments.create\", (p) => service.attachments.create(p, input)),\n get: (id) => call(\"attachments.get\", (p) => service.attachments.get(p, id)),\n byDomain: (domain) =>\n option(\"attachments.byDomain\", (p) => service.attachments.byDomain(p, domain)),\n list: (connectionId) =>\n call(\"attachments.list\", (p) => service.attachments.list(p, connectionId)),\n remove: (id) => call(\"attachments.remove\", (p) => service.attachments.remove(p, id)),\n },\n continuations: {\n put: (continuation) =>\n call(\"continuations.put\", (p) => service.continuations.put(p, continuation)),\n get: (id) => call(\"continuations.get\", (p) => service.continuations.get(p, id)),\n header: (id) =>\n Effect.tryPromise({\n try: () => service.continuations.header(id),\n catch: storageFailed(\"continuations.header\"),\n }),\n consume: (id) => call(\"continuations.consume\", (p) => service.continuations.consume(p, id)),\n },\n attempts: {\n create: (attempt) => call(\"attempts.create\", (p) => service.attempts.create(p, attempt)),\n get: (id) => call(\"attempts.get\", (p) => service.attempts.get(p, id)),\n byApproval: (id) => call(\"attempts.byApproval\", (p) => service.attempts.byApproval(p, id)),\n byReceipt: (id) => call(\"attempts.byReceipt\", (p) => service.attempts.byReceipt(p, id)),\n latest: (attachmentId, kind) =>\n option(\"attempts.latest\", (p) => service.attempts.latest(p, attachmentId, kind)),\n approve: (id, approval) =>\n call(\"attempts.approve\", (p) => service.attempts.approve(p, id, approval)),\n reject: (id, input) => call(\"attempts.reject\", (p) => service.attempts.reject(p, id, input)),\n claim: (id, lease) => call(\"attempts.claim\", (p) => service.attempts.claim(p, id, lease)),\n complete: (id, receipt) =>\n call(\"attempts.complete\", (p) => service.attempts.complete(p, id, receipt)),\n fail: (id, message) => call(\"attempts.fail\", (p) => service.attempts.fail(p, id, message)),\n },\n readiness: {\n put: (readiness) => call(\"readiness.put\", (p) => service.readiness.put(p, readiness)),\n get: (domain) => option(\"readiness.get\", (p) => service.readiness.get(p, domain)),\n },\n withLock: (key, effect) =>\n Effect.acquireRelease(\n call(\"acquireLock\", (p) => service.acquireLock(p, key)).pipe(\n Effect.flatMap((acquired) =>\n acquired ? Effect.void : Errors.fail(new Reason.Busy({ key })),\n ),\n ),\n () => call(\"releaseLock\", (p) => service.releaseLock(p, key)).pipe(Effect.ignore),\n ).pipe(\n Effect.flatMap(() => effect),\n Effect.scoped,\n ),\n };\n};\n\nexport const layerFromAsync = (service: AsyncInterface): Layer.Layer<Service> =>\n Layer.succeed(Service)(fromAsync(service));\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"Storage.mjs","names":["Plan.PlanId","Plan.Kind","Plan.Model","Approval.Model","Receipt.Model","Receipt.ReceiptId","Plan.Digest","Plan.OperationId","DnsRecord.Model","Errors.isDomainKitError","Errors.DomainKitError","Reason.StorageFailed","Principal.Service","Errors.fail","Reason.Busy"],"sources":["../src/Storage.ts"],"sourcesContent":["/**\n * Everything DomainKit persists, behind one service the host installs once. Every method requires\n * `Principal` and is scoped by it. `@domainkit/capsuledb` ships the Postgres implementation;\n * `domainkit/testing` ships the memory one; both pass `Testing.conformance.storage`.\n *\n * Storage never sees plaintext credentials: `Connect` seals secrets through `Custody` before\n * writing a `Credential` row and opens them after reading one.\n *\n * Grouped by noun so the 25-odd methods stay navigable. Implementations must satisfy the\n * invariants documented per group; the conformance suite checks them.\n */\nimport { Context, DateTime, Effect, Layer, Option, Schema } from \"effect\";\n\nimport * as Approval from \"./Approval.ts\";\nimport * as DnsRecord from \"./DnsRecord.ts\";\nimport * as Errors from \"./internal/error.ts\";\nimport * as Reason from \"./Reason.ts\";\nimport { makeMemory } from \"./internal/storage-memory.ts\";\nimport * as Plan from \"./Plan.ts\";\nimport * as Principal from \"./Principal.ts\";\nimport * as Receipt from \"./Receipt.ts\";\n\nexport type Fx<A> = Effect.Effect<A, Errors.DomainKitError, Principal.Service>;\n\n// ---------------------------------------------------------------------------------------------\n// Rows\n// ---------------------------------------------------------------------------------------------\n\nexport const Capability = Schema.Literals([\"dns:read\", \"dns:write\"]);\nexport type Capability = typeof Capability.Type;\n\nexport const AuthMethod = Schema.Literals([\"token\", \"oauth\", \"integration\"]);\nexport type AuthMethod = typeof AuthMethod.Type;\n\n/** Sealed provider credential plus the metadata needed without unsealing it. */\nexport class Credential extends Schema.Class<Credential>(\"@domainkit/Storage/Credential\")({\n ciphertext: Schema.String,\n expiresAt: Schema.NullOr(Schema.DateTimeUtcFromString),\n rotatedAt: Schema.DateTimeUtcFromString,\n}) {}\n\n/** One provider authorization (an OAuth grant or a token) owned by a principal. */\nexport class Authorization extends Schema.Class<Authorization>(\"@domainkit/Storage/Authorization\")({\n id: Schema.String,\n ownerId: Schema.String,\n provider: Schema.String,\n method: AuthMethod,\n capabilities: Schema.Array(Capability),\n /** Provider-specific account context (account id, team id, ...) decoded by the provider's `context` schema. */\n context: Schema.Unknown,\n /**\n * What the provider called the account when the credential was issued, so a UI names the\n * connection without a provider call. `null` when the provider names no single account.\n */\n label: Schema.NullOr(Schema.String),\n revocation: Schema.Literals([\"active\", \"pending\", \"revoked\"]),\n createdBy: Schema.String,\n createdAt: Schema.DateTimeUtcFromString,\n}) {}\n\n/** A connection is the principal-facing handle over an authorization; one authorization, many domains. */\nexport class Connection extends Schema.Class<Connection>(\"@domainkit/Storage/Connection\")({\n id: Schema.String,\n ownerId: Schema.String,\n authorizationId: Schema.String,\n createdAt: Schema.DateTimeUtcFromString,\n}) {}\n\nexport class Attachment extends Schema.Class<Attachment>(\"@domainkit/Storage/Attachment\")({\n id: Schema.String,\n ownerId: Schema.String,\n connectionId: Schema.String,\n domain: Schema.String,\n zone: Schema.String,\n /** The target's label as the provider gave it at attach time, so a UI names the account without a provider call. */\n label: Schema.String,\n /** Provider zone identity (zone id, account id) decoded by the provider's `context` schema. */\n target: Schema.Unknown,\n createdAt: Schema.DateTimeUtcFromString,\n}) {}\n\n/** Interactive-flow state between `Connect.start` and `Connect.complete`. */\nexport class Continuation extends Schema.Class<Continuation>(\"@domainkit/Storage/Continuation\")({\n id: Schema.String,\n ownerId: Schema.String,\n actorId: Schema.String,\n provider: Schema.String,\n payload: Schema.Unknown,\n returnTo: Schema.NullOr(Schema.String),\n expiresAt: Schema.DateTimeUtcFromString,\n}) {}\n\n/**\n * Who a continuation belongs to, without the continuation itself. `payload` has no place here: it\n * carries the PKCE `codeVerifier`, which only the owner-scoped read may reach.\n */\nexport class ContinuationHeader extends Schema.Class<ContinuationHeader>(\n \"@domainkit/Storage/ContinuationHeader\",\n)({\n ownerId: Schema.String,\n actorId: Schema.String,\n provider: Schema.String,\n expiresAt: Schema.DateTimeUtcFromString,\n}) {}\n\nexport const AttemptStatus = Schema.Literals([\n \"planned\",\n \"approved\",\n \"applying\",\n \"complete\",\n \"partial\",\n \"failed\",\n \"expired\",\n \"rejected\",\n]);\nexport type AttemptStatus = typeof AttemptStatus.Type;\n\n/** Who declined the plan, why, and when. Terminal: the domain needs a new plan. */\nexport const Rejection = Schema.Struct({\n actorId: Schema.String,\n reason: Schema.NullOr(Schema.String),\n at: Schema.DateTimeUtcFromString,\n});\nexport type Rejection = typeof Rejection.Type;\n\n/** One durable plan -> approval -> receipt lifecycle. */\nexport class Attempt extends Schema.Class<Attempt>(\"@domainkit/Storage/Attempt\")({\n id: Plan.PlanId,\n ownerId: Schema.String,\n attachmentId: Schema.String,\n kind: Plan.Kind,\n status: AttemptStatus,\n plan: Plan.Model,\n approval: Schema.NullOr(Approval.Model),\n receipt: Schema.NullOr(Receipt.Model),\n rejection: Schema.NullOr(Rejection),\n /** Cleanup attempts point at the provisioning receipt they undo. */\n sourceReceiptId: Schema.NullOr(Receipt.ReceiptId),\n leaseExpiresAt: Schema.NullOr(Schema.DateTimeUtcFromString),\n /** Why the last apply stopped before any write; cleared on the next claim. */\n failure: Schema.NullOr(Schema.String),\n updatedAt: Schema.DateTimeUtcFromString,\n}) {}\n\nexport const BatchId = Schema.String.pipe(Schema.brand(\"@domainkit/BatchId\"));\nexport type BatchId = typeof BatchId.Type;\n\n/**\n * Where a batch stands, recomputed from its items' attempts after every transition.\n *\n * `planning` while any item is still without a plan, `planned` once every item carries one,\n * `approved` once the principal bound the batch digest, then the apply states. `partial` and\n * `failed` are resumable; `complete` and `rejected` are terminal and leave the unfinished index.\n */\nexport const BatchStatus = Schema.Literals([\n \"planning\",\n \"planned\",\n \"approved\",\n \"applying\",\n \"complete\",\n \"partial\",\n \"failed\",\n \"rejected\",\n]);\nexport type BatchStatus = typeof BatchStatus.Type;\n\n/** Who bound the batch to its digest, and when. The digest itself is `Batch.digest`. */\nexport const BatchApproval = Schema.Struct({\n actorId: Schema.String,\n at: Schema.DateTimeUtcFromString,\n});\nexport type BatchApproval = typeof BatchApproval.Type;\n\n/**\n * Many domains planned together, approved once, and applied with bounded concurrency.\n *\n * The batch owns the aggregate's own state only: its digest, who approved or declined it, and the\n * status recomputed from its items. Every plan, approval, receipt, lease, and failure stays on the\n * attempt an item points at, so nothing is stored twice.\n */\nexport class Batch extends Schema.Class<Batch>(\"@domainkit/Storage/Batch\")({\n id: BatchId,\n ownerId: Schema.String,\n kind: Plan.Kind,\n status: BatchStatus,\n /** SHA-256 over the items' `attachmentId:planDigest` pairs, bound at approval. */\n digest: Schema.NullOr(Plan.Digest),\n approval: Schema.NullOr(BatchApproval),\n rejection: Schema.NullOr(Rejection),\n /** Unique per owner: a replayed create returns the batch this key already made. */\n idempotencyKey: Schema.String,\n createdBy: Schema.String,\n createdAt: Schema.DateTimeUtcFromString,\n updatedAt: Schema.DateTimeUtcFromString,\n /** When the batch reached `complete` or `rejected`; null while it still owes a move. */\n completedAt: Schema.NullOr(Schema.DateTimeUtcFromString),\n}) {}\n\n/**\n * One domain's place in a batch: a pointer to the attachment and to the attempt carrying its plan.\n *\n * `attemptId` is null until the item is planned, and `planFailure` carries why the last planning\n * pass stopped, cleared the moment a plan lands.\n */\nexport class BatchItem extends Schema.Class<BatchItem>(\"@domainkit/Storage/BatchItem\")({\n batchId: BatchId,\n attachmentId: Schema.String,\n /** The item's place in the batch, as `create` received it. */\n position: Schema.Number,\n attemptId: Schema.NullOr(Plan.PlanId),\n planFailure: Schema.NullOr(Schema.String),\n}) {}\n\n/**\n * The status a batch holds, given whether it was approved or declined and where each item's\n * attempt stands. `null` names an item that has no plan yet.\n *\n * Both `Storage` implementations recompute the stored status through this after every transition,\n * so the owner-scoped unfinished index never disagrees with the attempts it summarizes.\n */\nexport const batchStatusOf = (input: {\n readonly approved: boolean;\n readonly rejected: boolean;\n readonly items: ReadonlyArray<AttemptStatus | null>;\n}): BatchStatus => {\n if (input.rejected) return \"rejected\";\n const items = input.items;\n if (items.length === 0 || items.some((status) => status === null)) return \"planning\";\n const statuses = items as ReadonlyArray<AttemptStatus>;\n const every = (status: AttemptStatus) => statuses.every((item) => item === status);\n if (every(\"planned\")) return \"planned\";\n // An approval the batch never took means an item moved on its own; it is not approvable, and\n // planning is the state a host resumes from.\n if (!input.approved) return \"planning\";\n if (every(\"approved\")) return \"approved\";\n const settled = statuses.every(\n (status) => status !== \"planned\" && status !== \"approved\" && status !== \"applying\",\n );\n if (!settled) return \"applying\";\n if (every(\"complete\")) return \"complete\";\n return statuses.some((status) => status === \"partial\") ? \"partial\" : \"failed\";\n};\n\n/** A batch with its items, in `position` order. Every batch read returns one. */\nexport interface BatchAggregate {\n readonly batch: Batch;\n readonly items: ReadonlyArray<BatchItem>;\n}\n\n/** One item's attempt approval, as `batches.approve` writes it beside the batch's own. */\nexport interface BatchItemApproval {\n readonly attachmentId: string;\n readonly approval: Approval.Model;\n}\n\nexport const RequirementStatus = Schema.Literals([\"satisfied\", \"missing\", \"mismatch\", \"unknown\"]);\nexport type RequirementStatus = typeof RequirementStatus.Type;\n\nexport const Overall = Schema.Literals([\"ready\", \"pending\", \"failed\"]);\nexport type Overall = typeof Overall.Type;\n\n/**\n * Latest observed readiness for one domain, written by `Verify.observe`. Keyed by domain so\n * observe-only hosts (no attachment, public DNS alone) get the same row; `attachmentId` links the\n * attachment when one exists.\n */\nexport class Readiness extends Schema.Class<Readiness>(\"@domainkit/Storage/Readiness\")({\n domain: Schema.String,\n attachmentId: Schema.NullOr(Schema.String),\n ownerId: Schema.String,\n overall: Overall,\n requirements: Schema.Array(\n Schema.Struct({\n operationId: Schema.NullOr(Plan.OperationId),\n record: DnsRecord.Model,\n status: RequirementStatus,\n /** Encoded `Verify.Evidence` values. */\n evidence: Schema.Array(Schema.Unknown),\n }),\n ),\n /** Encoded `Verify.HostEvidence` values, keyed by `source` on merge. */\n host: Schema.Array(Schema.Unknown),\n /** When the current pending streak began; drives the backoff ladder. */\n pendingSince: Schema.NullOr(Schema.DateTimeUtcFromString),\n checkedAt: Schema.DateTimeUtcFromString,\n nextCheckAt: Schema.NullOr(Schema.DateTimeUtcFromString),\n}) {}\n\n// ---------------------------------------------------------------------------------------------\n// Service\n// ---------------------------------------------------------------------------------------------\n\nexport interface Interface {\n readonly authorizations: {\n /**\n * Insert, or CAS-replace when `expectedId` is set: the row must exist under this owner with an\n * active revocation state, else `NotFound` / `Busy`.\n */\n readonly upsert: (input: {\n readonly authorization: Authorization;\n readonly credential: Credential;\n readonly expectedId?: string;\n }) => Fx<Authorization>;\n readonly get: (id: string) => Fx<Authorization>;\n readonly credential: (id: string) => Fx<Credential>;\n readonly rotate: (id: string, credential: Credential) => Fx<void>;\n readonly promoteCapabilities: (id: string, capabilities: ReadonlyArray<Capability>) => Fx<void>;\n /**\n * Two-phase: mark `pending`, run `revoke` outside any transaction, then delete. A failed\n * `revoke` leaves the row pending; `recoverRevocations` finishes it later.\n */\n readonly revoke: <E, R>(\n id: string,\n revoke: Effect.Effect<void, E, R>,\n ) => Effect.Effect<void, Errors.DomainKitError | E, Principal.Service | R>;\n readonly recoverRevocations: <E, R>(\n revoke: (authorization: Authorization) => Effect.Effect<void, E, R>,\n ) => Effect.Effect<number, Errors.DomainKitError | E, Principal.Service | R>;\n };\n readonly connections: {\n readonly create: (authorizationId: string) => Fx<Connection>;\n readonly get: (id: string) => Fx<Connection>;\n readonly list: (filter?: { readonly provider?: string }) => Fx<ReadonlyArray<Connection>>;\n /** Fails `InvalidInput` while attachments still reference the connection. */\n readonly remove: (id: string) => Fx<void>;\n };\n readonly attachments: {\n /** One attachment per (owner, domain); a duplicate fails `InvalidInput`. */\n readonly create: (input: {\n readonly connectionId: string;\n readonly domain: string;\n readonly zone: string;\n readonly label: string;\n readonly target: unknown;\n }) => Fx<Attachment>;\n readonly get: (id: string) => Fx<Attachment>;\n readonly byDomain: (domain: string) => Fx<Option.Option<Attachment>>;\n readonly list: (connectionId: string) => Fx<ReadonlyArray<Attachment>>;\n readonly remove: (id: string) => Fx<void>;\n };\n readonly continuations: {\n readonly put: (continuation: Continuation) => Fx<void>;\n /** Read without spending; a late read fails `Expired`. */\n readonly get: (id: string) => Fx<Continuation>;\n /**\n * The flow's owner, actor, provider, and expiry, without a principal and without spending the\n * row. The one read in this interface that is not tenant-scoped, because `domainkit/server`\n * has to learn whose flow a provider callback finishes before it can resolve the principal to\n * scope by; the callback carries a continuation id and a session cookie and nothing else.\n *\n * A missing row fails `NotFound` and a stale one fails `Expired`, exactly as `get` does, so an\n * expired id never reaches a host. `payload` is out of reach here by construction.\n */\n readonly header: (id: string) => Effect.Effect<ContinuationHeader, Errors.DomainKitError>;\n /** Exactly-once: the second consume of the same id fails `NotFound`; a late one fails `Expired`. */\n readonly consume: (id: string) => Fx<Continuation>;\n };\n readonly attempts: {\n readonly create: (attempt: Attempt) => Fx<Attempt>;\n readonly get: (id: Plan.PlanId) => Fx<Attempt>;\n readonly byApproval: (id: Approval.ApprovalId) => Fx<Attempt>;\n readonly byReceipt: (id: Receipt.ReceiptId) => Fx<Attempt>;\n readonly latest: (attachmentId: string, kind: Plan.Kind) => Fx<Option.Option<Attempt>>;\n /** `planned` -> `approved`; approving again with the same approval id is a no-op, anything else fails `Stale`. */\n readonly approve: (id: Plan.PlanId, approval: Approval.Model) => Fx<Attempt>;\n /**\n * `planned` -> `rejected` (terminal). Rejecting again returns the row unchanged; any other\n * status fails `Stale`, `expired` fails `Expired`, and a digest mismatch fails `Stale`.\n */\n readonly reject: (\n id: Plan.PlanId,\n input: {\n readonly digest: Plan.Digest;\n readonly actorId: string;\n readonly reason: string | null;\n },\n ) => Fx<Attempt>;\n /**\n * Atomic transition to `applying` with a lease from `approved`, `failed`, or an expired\n * `applying`; fails `Busy` while a lease is live and `Stale` from any other status.\n */\n readonly claim: (id: Plan.PlanId, lease: DateTime.Utc) => Fx<Attempt>;\n readonly complete: (id: Plan.PlanId, receipt: Receipt.Model) => Fx<Attempt>;\n readonly fail: (id: Plan.PlanId, message: string) => Fx<Attempt>;\n };\n /**\n * The batch aggregate: many attempts planned together and approved once.\n *\n * Every transition runs in one transaction over the locked batch row and leaves `status`\n * recomputed from the items' attempts, so the owner-scoped unfinished index never disagrees\n * with the attempts it summarizes.\n */\n readonly batches: {\n /**\n * Insert the batch and one item per attachment, or return the batch this owner already\n * created under `idempotencyKey`. A replay ignores `attachmentIds` and returns what is\n * stored, which is what makes a retried create safe.\n */\n readonly create: (input: {\n readonly kind: Plan.Kind;\n readonly idempotencyKey: string;\n /** One item per attachment, in order; empty or duplicated fails `InvalidInput`. */\n readonly attachmentIds: ReadonlyArray<string>;\n }) => Fx<BatchAggregate>;\n readonly get: (id: BatchId) => Fx<BatchAggregate>;\n /**\n * The batch this owner created under `idempotencyKey`, if any.\n *\n * What a caller reads before it validates a create's payload, so a retried request answers\n * with the batch the first one made whatever the retry now says.\n */\n readonly byIdempotencyKey: (key: string) => Fx<Option.Option<BatchAggregate>>;\n /** Every batch that has not reached `complete` or `rejected`, most recently touched first. */\n readonly listUnfinished: () => Fx<ReadonlyArray<BatchAggregate>>;\n /**\n * Point an item at the attempt that now carries its plan and clear its plan failure.\n *\n * Fails `Stale` unless the batch is still `planning` or `planned`. That check is the fence a\n * planner still in flight hits when the principal declines the batch underneath it, so plan\n * state never lands on a rejected aggregate.\n */\n readonly recordItemPlan: (\n id: BatchId,\n attachmentId: string,\n attemptId: Plan.PlanId,\n ) => Fx<BatchAggregate>;\n /** Record why an item could not be planned; the batch stays `planning`. Same fence. */\n readonly recordItemPlanFailure: (\n id: BatchId,\n attachmentId: string,\n message: string,\n ) => Fx<BatchAggregate>;\n /**\n * Bind the batch to `digest` and approve every item's attempt in the same transaction, so a\n * batch is never approved without the per-attempt approvals `attempts.apply` needs.\n *\n * Fails `Stale` unless the batch is `planned` and every item still points at the attempt its\n * approval names. Approving an approved batch under the same digest returns it unchanged; a\n * different digest fails `Stale`.\n */\n readonly approve: (\n id: BatchId,\n input: {\n readonly digest: Plan.Digest;\n readonly actorId: string;\n /** One per item, covering every item in the batch. */\n readonly approvals: ReadonlyArray<BatchItemApproval>;\n },\n ) => Fx<BatchAggregate>;\n /**\n * Decline the batch and every planned attempt under it, in one transaction. Terminal.\n *\n * Rejecting again returns the batch unchanged; a batch that reached `approved` or later\n * fails `Stale`.\n */\n readonly reject: (\n id: BatchId,\n input: { readonly actorId: string; readonly reason: string | null },\n ) => Fx<BatchAggregate>;\n /** Recompute the stored status from the items' attempts, after applying moved them. */\n readonly refresh: (id: BatchId) => Fx<BatchAggregate>;\n };\n readonly readiness: {\n /** One row per (owner, domain); `attachmentId`, when set, must exist for the owner. */\n readonly put: (readiness: Readiness) => Fx<void>;\n readonly get: (domain: string) => Fx<Option.Option<Readiness>>;\n };\n /** Single-flight guard keyed by string (credential refresh, apply). Fails `Busy` rather than waiting. */\n readonly withLock: <A, E, R>(\n key: string,\n effect: Effect.Effect<A, E, R>,\n ) => Effect.Effect<A, E | Errors.DomainKitError, R | Principal.Service>;\n}\n\nexport class Service extends Context.Service<Service, Interface>()(\"@domainkit/Storage\") {}\n\nexport interface MemoryOptions {\n /** Fault injection: runs before every mutation commits; failing here leaves state untouched. */\n readonly beforeCommit?: (operation: string) => Effect.Effect<void, Errors.DomainKitError>;\n}\n\n/** In-memory implementation for tests and local development. Not for production. */\nexport const layerMemory: Layer.Layer<Service> = Layer.sync(Service)(() => makeMemory());\n\n/** `layerMemory` with fault injection for storage-failure tests. */\nexport const layerMemoryWith = (options: MemoryOptions): Layer.Layer<Service> =>\n Layer.sync(Service)(() => makeMemory(options));\n\nexport { makeMemory };\n\n// ---------------------------------------------------------------------------------------------\n// Async adapter\n// ---------------------------------------------------------------------------------------------\n\n/**\n * A Promise-shaped implementation for hosts that persist without Effect. Every method takes the\n * principal explicitly. Rejections become `StorageFailed`; reject with a `DomainKit.Error` to keep\n * a typed reason. Two-phase revocation and locking are split into plain steps so no callback has\n * to run an Effect.\n */\nexport interface AsyncInterface {\n readonly authorizations: {\n readonly upsert: (\n principal: Principal.Interface,\n input: {\n readonly authorization: Authorization;\n readonly credential: Credential;\n readonly expectedId?: string;\n },\n ) => Promise<Authorization>;\n readonly get: (principal: Principal.Interface, id: string) => Promise<Authorization>;\n readonly credential: (principal: Principal.Interface, id: string) => Promise<Credential>;\n readonly rotate: (\n principal: Principal.Interface,\n id: string,\n credential: Credential,\n ) => Promise<void>;\n readonly promoteCapabilities: (\n principal: Principal.Interface,\n id: string,\n capabilities: ReadonlyArray<Capability>,\n ) => Promise<void>;\n /** Mark `pending` durably; must be idempotent. */\n readonly prepareRevocation: (\n principal: Principal.Interface,\n id: string,\n ) => Promise<Authorization>;\n /** Delete the authorization after the provider confirmed revocation. */\n readonly completeRevocation: (principal: Principal.Interface, id: string) => Promise<void>;\n readonly pendingRevocations: (\n principal: Principal.Interface,\n ) => Promise<ReadonlyArray<Authorization>>;\n };\n readonly connections: {\n readonly create: (\n principal: Principal.Interface,\n authorizationId: string,\n ) => Promise<Connection>;\n readonly get: (principal: Principal.Interface, id: string) => Promise<Connection>;\n readonly list: (\n principal: Principal.Interface,\n filter?: { readonly provider?: string },\n ) => Promise<ReadonlyArray<Connection>>;\n readonly remove: (principal: Principal.Interface, id: string) => Promise<void>;\n };\n readonly attachments: {\n readonly create: (\n principal: Principal.Interface,\n input: {\n readonly connectionId: string;\n readonly domain: string;\n readonly zone: string;\n readonly label: string;\n readonly target: unknown;\n },\n ) => Promise<Attachment>;\n readonly get: (principal: Principal.Interface, id: string) => Promise<Attachment>;\n readonly byDomain: (\n principal: Principal.Interface,\n domain: string,\n ) => Promise<Attachment | null>;\n readonly list: (\n principal: Principal.Interface,\n connectionId: string,\n ) => Promise<ReadonlyArray<Attachment>>;\n readonly remove: (principal: Principal.Interface, id: string) => Promise<void>;\n };\n readonly continuations: {\n readonly put: (principal: Principal.Interface, continuation: Continuation) => Promise<void>;\n readonly get: (principal: Principal.Interface, id: string) => Promise<Continuation>;\n /** Takes no principal: see `Interface.continuations.header`. Never returns `payload`. */\n readonly header: (id: string) => Promise<ContinuationHeader>;\n readonly consume: (principal: Principal.Interface, id: string) => Promise<Continuation>;\n };\n readonly attempts: {\n readonly create: (principal: Principal.Interface, attempt: Attempt) => Promise<Attempt>;\n readonly get: (principal: Principal.Interface, id: Plan.PlanId) => Promise<Attempt>;\n readonly byApproval: (\n principal: Principal.Interface,\n id: Approval.ApprovalId,\n ) => Promise<Attempt>;\n readonly byReceipt: (principal: Principal.Interface, id: Receipt.ReceiptId) => Promise<Attempt>;\n readonly latest: (\n principal: Principal.Interface,\n attachmentId: string,\n kind: Plan.Kind,\n ) => Promise<Attempt | null>;\n readonly approve: (\n principal: Principal.Interface,\n id: Plan.PlanId,\n approval: Approval.Model,\n ) => Promise<Attempt>;\n readonly reject: (\n principal: Principal.Interface,\n id: Plan.PlanId,\n input: {\n readonly digest: Plan.Digest;\n readonly actorId: string;\n readonly reason: string | null;\n },\n ) => Promise<Attempt>;\n readonly claim: (\n principal: Principal.Interface,\n id: Plan.PlanId,\n lease: DateTime.Utc,\n ) => Promise<Attempt>;\n readonly complete: (\n principal: Principal.Interface,\n id: Plan.PlanId,\n receipt: Receipt.Model,\n ) => Promise<Attempt>;\n readonly fail: (\n principal: Principal.Interface,\n id: Plan.PlanId,\n message: string,\n ) => Promise<Attempt>;\n };\n readonly batches: {\n readonly create: (\n principal: Principal.Interface,\n input: {\n readonly kind: Plan.Kind;\n readonly idempotencyKey: string;\n readonly attachmentIds: ReadonlyArray<string>;\n },\n ) => Promise<BatchAggregate>;\n readonly get: (principal: Principal.Interface, id: BatchId) => Promise<BatchAggregate>;\n readonly byIdempotencyKey: (\n principal: Principal.Interface,\n key: string,\n ) => Promise<BatchAggregate | null>;\n readonly listUnfinished: (\n principal: Principal.Interface,\n ) => Promise<ReadonlyArray<BatchAggregate>>;\n readonly recordItemPlan: (\n principal: Principal.Interface,\n id: BatchId,\n attachmentId: string,\n attemptId: Plan.PlanId,\n ) => Promise<BatchAggregate>;\n readonly recordItemPlanFailure: (\n principal: Principal.Interface,\n id: BatchId,\n attachmentId: string,\n message: string,\n ) => Promise<BatchAggregate>;\n readonly approve: (\n principal: Principal.Interface,\n id: BatchId,\n input: {\n readonly digest: Plan.Digest;\n readonly actorId: string;\n readonly approvals: ReadonlyArray<BatchItemApproval>;\n },\n ) => Promise<BatchAggregate>;\n readonly reject: (\n principal: Principal.Interface,\n id: BatchId,\n input: { readonly actorId: string; readonly reason: string | null },\n ) => Promise<BatchAggregate>;\n readonly refresh: (principal: Principal.Interface, id: BatchId) => Promise<BatchAggregate>;\n };\n readonly readiness: {\n readonly put: (principal: Principal.Interface, readiness: Readiness) => Promise<void>;\n readonly get: (principal: Principal.Interface, domain: string) => Promise<Readiness | null>;\n };\n /** Return `false` when another holder has the key. */\n readonly acquireLock: (principal: Principal.Interface, key: string) => Promise<boolean>;\n readonly releaseLock: (principal: Principal.Interface, key: string) => Promise<void>;\n}\n\nconst storageFailed = (operation: string) => (cause: unknown) =>\n Errors.isDomainKitError(cause)\n ? cause\n : new Errors.DomainKitError({\n reason: new Reason.StorageFailed({\n operation,\n message: cause instanceof Error ? cause.message : String(cause),\n }),\n });\n\n/** Wraps a Promise-shaped implementation. Rejections become `StorageFailed`. */\nexport const fromAsync = (service: AsyncInterface): Interface => {\n const call = <A>(operation: string, run: (principal: Principal.Interface) => Promise<A>): Fx<A> =>\n Effect.flatMap(Principal.Service, (principal) =>\n Effect.tryPromise({ try: () => run(principal), catch: storageFailed(operation) }),\n );\n const option = <A>(\n operation: string,\n run: (principal: Principal.Interface) => Promise<A | null>,\n ) => call(operation, run).pipe(Effect.map(Option.fromNullishOr));\n return {\n authorizations: {\n upsert: (input) =>\n call(\"authorizations.upsert\", (p) => service.authorizations.upsert(p, input)),\n get: (id) => call(\"authorizations.get\", (p) => service.authorizations.get(p, id)),\n credential: (id) =>\n call(\"authorizations.credential\", (p) => service.authorizations.credential(p, id)),\n rotate: (id, credential) =>\n call(\"authorizations.rotate\", (p) => service.authorizations.rotate(p, id, credential)),\n promoteCapabilities: (id, capabilities) =>\n call(\"authorizations.promoteCapabilities\", (p) =>\n service.authorizations.promoteCapabilities(p, id, capabilities),\n ),\n revoke: (id, revoke) =>\n call(\"authorizations.prepareRevocation\", (p) =>\n service.authorizations.prepareRevocation(p, id),\n ).pipe(\n Effect.flatMap(() => revoke),\n Effect.flatMap(() =>\n call(\"authorizations.completeRevocation\", (p) =>\n service.authorizations.completeRevocation(p, id),\n ),\n ),\n ),\n recoverRevocations: (revoke) =>\n call(\"authorizations.pendingRevocations\", (p) =>\n service.authorizations.pendingRevocations(p),\n ).pipe(\n Effect.flatMap((pending) =>\n Effect.forEach(pending, (authorization) =>\n revoke(authorization).pipe(\n Effect.flatMap(() =>\n call(\"authorizations.completeRevocation\", (p) =>\n service.authorizations.completeRevocation(p, authorization.id),\n ),\n ),\n ),\n ),\n ),\n Effect.map((completed) => completed.length),\n ),\n },\n connections: {\n create: (authorizationId) =>\n call(\"connections.create\", (p) => service.connections.create(p, authorizationId)),\n get: (id) => call(\"connections.get\", (p) => service.connections.get(p, id)),\n list: (filter) => call(\"connections.list\", (p) => service.connections.list(p, filter)),\n remove: (id) => call(\"connections.remove\", (p) => service.connections.remove(p, id)),\n },\n attachments: {\n create: (input) => call(\"attachments.create\", (p) => service.attachments.create(p, input)),\n get: (id) => call(\"attachments.get\", (p) => service.attachments.get(p, id)),\n byDomain: (domain) =>\n option(\"attachments.byDomain\", (p) => service.attachments.byDomain(p, domain)),\n list: (connectionId) =>\n call(\"attachments.list\", (p) => service.attachments.list(p, connectionId)),\n remove: (id) => call(\"attachments.remove\", (p) => service.attachments.remove(p, id)),\n },\n continuations: {\n put: (continuation) =>\n call(\"continuations.put\", (p) => service.continuations.put(p, continuation)),\n get: (id) => call(\"continuations.get\", (p) => service.continuations.get(p, id)),\n header: (id) =>\n Effect.tryPromise({\n try: () => service.continuations.header(id),\n catch: storageFailed(\"continuations.header\"),\n }),\n consume: (id) => call(\"continuations.consume\", (p) => service.continuations.consume(p, id)),\n },\n attempts: {\n create: (attempt) => call(\"attempts.create\", (p) => service.attempts.create(p, attempt)),\n get: (id) => call(\"attempts.get\", (p) => service.attempts.get(p, id)),\n byApproval: (id) => call(\"attempts.byApproval\", (p) => service.attempts.byApproval(p, id)),\n byReceipt: (id) => call(\"attempts.byReceipt\", (p) => service.attempts.byReceipt(p, id)),\n latest: (attachmentId, kind) =>\n option(\"attempts.latest\", (p) => service.attempts.latest(p, attachmentId, kind)),\n approve: (id, approval) =>\n call(\"attempts.approve\", (p) => service.attempts.approve(p, id, approval)),\n reject: (id, input) => call(\"attempts.reject\", (p) => service.attempts.reject(p, id, input)),\n claim: (id, lease) => call(\"attempts.claim\", (p) => service.attempts.claim(p, id, lease)),\n complete: (id, receipt) =>\n call(\"attempts.complete\", (p) => service.attempts.complete(p, id, receipt)),\n fail: (id, message) => call(\"attempts.fail\", (p) => service.attempts.fail(p, id, message)),\n },\n batches: {\n create: (input) => call(\"batches.create\", (p) => service.batches.create(p, input)),\n get: (id) => call(\"batches.get\", (p) => service.batches.get(p, id)),\n byIdempotencyKey: (key) =>\n option(\"batches.byIdempotencyKey\", (p) => service.batches.byIdempotencyKey(p, key)),\n listUnfinished: () =>\n call(\"batches.listUnfinished\", (p) => service.batches.listUnfinished(p)),\n recordItemPlan: (id, attachmentId, attemptId) =>\n call(\"batches.recordItemPlan\", (p) =>\n service.batches.recordItemPlan(p, id, attachmentId, attemptId),\n ),\n recordItemPlanFailure: (id, attachmentId, message) =>\n call(\"batches.recordItemPlanFailure\", (p) =>\n service.batches.recordItemPlanFailure(p, id, attachmentId, message),\n ),\n approve: (id, input) => call(\"batches.approve\", (p) => service.batches.approve(p, id, input)),\n reject: (id, input) => call(\"batches.reject\", (p) => service.batches.reject(p, id, input)),\n refresh: (id) => call(\"batches.refresh\", (p) => service.batches.refresh(p, id)),\n },\n readiness: {\n put: (readiness) => call(\"readiness.put\", (p) => service.readiness.put(p, readiness)),\n get: (domain) => option(\"readiness.get\", (p) => service.readiness.get(p, domain)),\n },\n withLock: (key, effect) =>\n Effect.acquireRelease(\n call(\"acquireLock\", (p) => service.acquireLock(p, key)).pipe(\n Effect.flatMap((acquired) =>\n acquired ? Effect.void : Errors.fail(new Reason.Busy({ key })),\n ),\n ),\n () => call(\"releaseLock\", (p) => service.releaseLock(p, key)).pipe(Effect.ignore),\n ).pipe(\n Effect.flatMap(() => effect),\n Effect.scoped,\n ),\n };\n};\n\nexport const layerFromAsync = (service: AsyncInterface): Layer.Layer<Service> =>\n Layer.succeed(Service)(fromAsync(service));\n"],"mappings":"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"}
|
|
@@ -81,9 +81,13 @@ const make = (storage, connect, kind) => {
|
|
|
81
81
|
if (typeof plan !== "string" && plan.digest !== attempt.plan.digest) return yield* stale(plan);
|
|
82
82
|
return attempt;
|
|
83
83
|
});
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
/**
|
|
85
|
+
* The approval a planned attempt would record, built and checked but not written.
|
|
86
|
+
*
|
|
87
|
+
* Split out so an aggregate can collect one approval per attempt and write them all in a single
|
|
88
|
+
* transaction beside its own.
|
|
89
|
+
*/
|
|
90
|
+
const draftApproval = (attempt, options = {}) => Effect.gen(function* () {
|
|
87
91
|
const principal = yield* Service;
|
|
88
92
|
const now = yield* DateTime.now;
|
|
89
93
|
if (past(attempt.plan.expiresAt, now)) return yield* expired("plan", attempt.plan.id);
|
|
@@ -106,7 +110,7 @@ const make = (storage, connect, kind) => {
|
|
|
106
110
|
field: "operationIds"
|
|
107
111
|
}));
|
|
108
112
|
}
|
|
109
|
-
|
|
113
|
+
return new Model$1({
|
|
110
114
|
id: ApprovalId.make(yield* fresh("apr")),
|
|
111
115
|
version: "domainkit.approval.v2",
|
|
112
116
|
kind,
|
|
@@ -117,6 +121,11 @@ const make = (storage, connect, kind) => {
|
|
|
117
121
|
approvedAt: now,
|
|
118
122
|
expiresAt: attempt.plan.expiresAt
|
|
119
123
|
});
|
|
124
|
+
});
|
|
125
|
+
const approve = (plan, options = {}) => Effect.gen(function* () {
|
|
126
|
+
const attempt = yield* attemptOf(plan);
|
|
127
|
+
if (attempt.approval !== null) return attempt.approval;
|
|
128
|
+
const approval = yield* draftApproval(attempt, options);
|
|
120
129
|
return (yield* storage.attempts.approve(attempt.id, approval)).approval ?? approval;
|
|
121
130
|
});
|
|
122
131
|
/** Decline a planned attempt on behalf of the principal's actor. */
|
|
@@ -253,6 +262,7 @@ const make = (storage, connect, kind) => {
|
|
|
253
262
|
return {
|
|
254
263
|
record,
|
|
255
264
|
approve,
|
|
265
|
+
draftApproval,
|
|
256
266
|
reject,
|
|
257
267
|
apply,
|
|
258
268
|
attemptOf
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"attempts.mjs","names":["Errors.fail","Reason.Stale","Reason.Expired","DomainName.isWithin","Reason.InvalidInput","Principal.Service","Plan.Model","Planner.digest","Storage.Attempt","Reason.NotFound","writes","Plan.writes","conflicts","Plan.conflicts","Reason.Conflict","Approval.Model","Receipt.Model","Receipt.Skipped","Receipt.Applied","Receipt.Failed","Planner.reconcile","Planner.requirements","Plan.Conflict","DnsRecord.equals","Plan.Delete"],"sources":["../../src/internal/attempts.ts"],"sourcesContent":["import { DateTime, Duration, Effect } from \"effect\";\n\nimport * as Approval from \"../Approval.ts\";\nimport * as Connect from \"../Connect.ts\";\nimport * as DnsRecord from \"../DnsRecord.ts\";\nimport * as Errors from \"./error.ts\";\nimport * as Reason from \"../Reason.ts\";\nimport * as DomainName from \"../DomainName.ts\";\nimport * as Plan from \"../Plan.ts\";\nimport * as Principal from \"../Principal.ts\";\nimport type * as Provider from \"../Provider.ts\";\nimport * as Receipt from \"../Receipt.ts\";\nimport * as Storage from \"../Storage.ts\";\nimport { fresh } from \"./ids.ts\";\nimport * as Planner from \"./planner.ts\";\n\ntype Fx<A> = Effect.Effect<A, Errors.DomainKitError, Principal.Service>;\n\nexport interface ApproveOptions {\n readonly operationIds?: ReadonlyArray<Plan.OperationId>;\n readonly allowPartial?: boolean;\n}\n\nexport interface PolicyShape {\n readonly planTtlMs: number;\n readonly applyLeaseMs: number;\n}\n\nconst stale = (plan: Plan.Model) =>\n Errors.fail(new Reason.Stale({ planId: plan.id, digest: plan.digest }));\n\nconst expired = (entity: \"plan\" | \"approval\", id: string) =>\n Errors.fail(new Reason.Expired({ entity, id }));\n\nconst past = (at: DateTime.Utc, now: DateTime.Utc) =>\n DateTime.toEpochMillis(at) <= DateTime.toEpochMillis(now);\n\n/** Every requirement must sit at or below the attached domain; other tenants share the zone. */\nexport const assertWithin = (\n attachment: Storage.Attachment,\n records: ReadonlyArray<DnsRecord.Model>,\n): Effect.Effect<void, Errors.DomainKitError> => {\n const outside = records.filter((record) => !DomainName.isWithin(record.name, attachment.domain));\n return outside.length === 0\n ? Effect.void\n : Errors.fail(\n new Reason.InvalidInput({\n message: `${outside.map(({ name }) => name).join(\", \")} is outside the attached domain ${attachment.domain}`,\n field: \"requirements\",\n }),\n );\n};\n\n/** Shared plan -> approve -> apply machinery for provisioning and cleanup attempts. */\nexport const make = (storage: Storage.Interface, connect: Connect.Interface, kind: Plan.Kind) => {\n const providerOf = (attachment: Storage.Attachment) =>\n Effect.gen(function* () {\n const connection = yield* storage.connections.get(attachment.connectionId);\n const authorization = yield* storage.authorizations.get(connection.authorizationId);\n return authorization.provider;\n });\n\n /** Persist a freshly built plan as a `planned` attempt. */\n const record = (input: {\n readonly attachment: Storage.Attachment;\n readonly target: Provider.Target;\n readonly operations: ReadonlyArray<Plan.Operation>;\n readonly ttlMs: number;\n readonly sourceReceiptId: Receipt.ReceiptId | null;\n }): Fx<Plan.Model> =>\n Effect.gen(function* () {\n const principal = yield* Principal.Service;\n const provider = yield* providerOf(input.attachment);\n const now = yield* DateTime.now;\n const unsigned: Planner.Unsigned = {\n version: \"domainkit.plan.v2\",\n kind,\n domain: input.attachment.domain,\n zone: input.target.zone,\n provider,\n attachmentId: input.attachment.id,\n operations: input.operations,\n };\n const plan = new Plan.Model({\n ...unsigned,\n id: Plan.PlanId.make(yield* fresh(\"plan\")),\n digest: yield* Planner.digest(unsigned),\n createdAt: now,\n expiresAt: DateTime.addDuration(now, Duration.millis(input.ttlMs)),\n });\n yield* storage.attempts.create(\n new Storage.Attempt({\n id: plan.id,\n ownerId: principal.ownerId,\n attachmentId: input.attachment.id,\n kind,\n status: \"planned\",\n plan,\n approval: null,\n receipt: null,\n rejection: null,\n sourceReceiptId: input.sourceReceiptId,\n leaseExpiresAt: null,\n failure: null,\n updatedAt: now,\n }),\n );\n return plan;\n });\n\n const attemptOf = (plan: Plan.Model | Plan.PlanId): Fx<Storage.Attempt> =>\n Effect.gen(function* () {\n const attempt = yield* storage.attempts.get(typeof plan === \"string\" ? plan : plan.id);\n if (attempt.kind !== kind) {\n return yield* Errors.fail(new Reason.NotFound({ entity: \"plan\", id: attempt.id }));\n }\n if (typeof plan !== \"string\" && plan.digest !== attempt.plan.digest)\n return yield* stale(plan);\n return attempt;\n });\n\n const approve = (\n plan: Plan.Model | Plan.PlanId,\n options: ApproveOptions = {},\n ): Fx<Approval.Model> =>\n Effect.gen(function* () {\n const attempt = yield* attemptOf(plan);\n if (attempt.approval !== null) return attempt.approval;\n const principal = yield* Principal.Service;\n const now = yield* DateTime.now;\n if (past(attempt.plan.expiresAt, now)) return yield* expired(\"plan\", attempt.plan.id);\n const writes = Plan.writes(attempt.plan);\n const writeIds = new Set(writes.map(({ id }) => id));\n const selected = [...new Set(options.operationIds ?? writes.map(({ id }) => id))].sort();\n const unknown = selected.filter((id) => !writeIds.has(id));\n if (unknown.length > 0) {\n return yield* Errors.fail(\n new Reason.InvalidInput({\n message: `Operations ${unknown.join(\", \")} are not writable in plan ${attempt.plan.id}`,\n field: \"operationIds\",\n }),\n );\n }\n const conflicts = Plan.conflicts(attempt.plan);\n if (options.allowPartial !== true) {\n if (conflicts.length > 0) {\n return yield* Errors.fail(\n new Reason.Conflict({ planId: attempt.plan.id, operations: conflicts }),\n );\n }\n if (selected.length !== writes.length) {\n return yield* Errors.fail(\n new Reason.InvalidInput({\n message: \"Approving a subset of the plan requires allowPartial\",\n field: \"operationIds\",\n }),\n );\n }\n }\n const approval = new Approval.Model({\n id: Approval.ApprovalId.make(yield* fresh(\"apr\")),\n version: \"domainkit.approval.v2\",\n kind,\n planId: attempt.plan.id,\n digest: attempt.plan.digest,\n operationIds: selected,\n approvedBy: principal.actorId,\n approvedAt: now,\n expiresAt: attempt.plan.expiresAt,\n });\n const stored = yield* storage.attempts.approve(attempt.id, approval);\n return stored.approval ?? approval;\n });\n\n /** Decline a planned attempt on behalf of the principal's actor. */\n const reject = (\n plan: Plan.Model | Plan.PlanId,\n options: { readonly reason?: string } = {},\n ): Fx<Storage.Attempt> =>\n Effect.gen(function* () {\n const attempt = yield* attemptOf(plan);\n const principal = yield* Principal.Service;\n return yield* storage.attempts.reject(attempt.id, {\n digest: attempt.plan.digest,\n actorId: principal.actorId,\n reason: options.reason ?? null,\n });\n });\n\n const apply = (\n approval: Approval.Model | Approval.ApprovalId,\n policy: PolicyShape,\n ): Fx<Receipt.Model> =>\n Effect.gen(function* () {\n const attempt =\n typeof approval === \"string\"\n ? yield* storage.attempts.byApproval(approval)\n : yield* attemptOf(approval.planId);\n if (attempt.kind !== kind) {\n return yield* Errors.fail(\n new Reason.NotFound({\n entity: \"approval\",\n id: typeof approval === \"string\" ? approval : approval.id,\n }),\n );\n }\n const stored = attempt.approval;\n if (stored === null || (typeof approval !== \"string\" && stored.id !== approval.id)) {\n return yield* Errors.fail(\n new Reason.NotFound({\n entity: \"approval\",\n id: typeof approval === \"string\" ? approval : approval.id,\n }),\n );\n }\n if (attempt.receipt !== null) return attempt.receipt;\n const now = yield* DateTime.now;\n if (past(stored.expiresAt, now)) return yield* expired(\"approval\", stored.id);\n const claimed = yield* storage.attempts.claim(\n attempt.id,\n DateTime.addDuration(now, Duration.millis(policy.applyLeaseMs)),\n );\n return yield* run(claimed, stored).pipe(\n Effect.tapError((error) =>\n storage.attempts.fail(claimed.id, error.message).pipe(Effect.ignore),\n ),\n );\n });\n\n /** Writes under a claimed lease; a failure before any write releases the attempt as `failed`. */\n const run = (attempt: Storage.Attempt, approval: Approval.Model): Fx<Receipt.Model> =>\n Effect.gen(function* () {\n const plan = attempt.plan;\n const attachment = yield* storage.attachments.get(attempt.attachmentId);\n const { session, target } = yield* connect.session(attachment.id);\n const dns = session.dns(target);\n yield* revalidate(plan, dns, target.zone);\n const approved = new Set(approval.operationIds);\n const outcomes: Array<Receipt.Outcome> = [];\n const applied: Array<Receipt.Applied> = [];\n let stopped = false;\n const finish = (status: \"complete\" | \"partial\") =>\n Effect.gen(function* () {\n const receipt = new Receipt.Model({\n id: Receipt.ReceiptId.make(yield* fresh(\"rcpt\")),\n version: \"domainkit.receipt.v2\",\n kind,\n planId: plan.id,\n approvalId: approval.id,\n digest: plan.digest,\n provider: plan.provider,\n zone: plan.zone,\n status,\n outcomes: [...outcomes],\n appliedAt: yield* DateTime.now,\n });\n yield* storage.attempts.complete(attempt.id, receipt);\n return receipt;\n });\n const writes = Effect.gen(function* () {\n for (const operation of plan.operations) {\n if (operation._tag === \"Noop\") {\n outcomes.push(new Receipt.Skipped({ operationId: operation.id, reason: \"noop\" }));\n continue;\n }\n if (operation._tag === \"Conflict\" || !approved.has(operation.id)) {\n outcomes.push(\n new Receipt.Skipped({ operationId: operation.id, reason: \"not-approved\" }),\n );\n continue;\n }\n if (stopped) {\n outcomes.push(\n new Receipt.Skipped({ operationId: operation.id, reason: \"not-attempted\" }),\n );\n continue;\n }\n const result = yield* write(plan, operation, dns, target.zone).pipe(Effect.result);\n if (result._tag === \"Success\") {\n const outcome = new Receipt.Applied({\n operationId: operation.id,\n providerRecordId: result.success,\n });\n outcomes.push(outcome);\n applied.push(outcome);\n continue;\n }\n if (applied.length === 0) return yield* Effect.fail(result.failure);\n stopped = true;\n outcomes.push(\n new Receipt.Failed({ operationId: operation.id, message: result.failure.message }),\n );\n }\n return yield* finish(stopped ? \"partial\" : \"complete\");\n });\n return yield* writes.pipe(\n Effect.onInterrupt(() =>\n applied.length === 0\n ? Effect.void\n : Effect.gen(function* () {\n for (const operation of plan.operations) {\n if (outcomes.some((outcome) => outcome.operationId === operation.id)) continue;\n outcomes.push(\n new Receipt.Skipped({ operationId: operation.id, reason: \"not-attempted\" }),\n );\n }\n yield* finish(\"partial\");\n }).pipe(Effect.ignore),\n ),\n );\n });\n\n /** Re-plan the zone and fail `Stale` when the digest moved since the plan was built. */\n const revalidate = (plan: Plan.Model, dns: Provider.Dns, zone: string) =>\n Effect.gen(function* () {\n const current = yield* currentOperations(plan, dns, zone);\n const digest = yield* Planner.digest({\n version: plan.version,\n kind: plan.kind,\n domain: plan.domain,\n zone: plan.zone,\n provider: plan.provider,\n attachmentId: plan.attachmentId,\n operations: current,\n });\n if (digest !== plan.digest) return yield* stale(plan);\n });\n\n const currentOperations = (\n plan: Plan.Model,\n dns: Provider.Dns,\n zone: string,\n ): Effect.Effect<ReadonlyArray<Plan.Operation>, Errors.DomainKitError> =>\n kind === \"provisioning\"\n ? Effect.flatMap(dns.list(zone), (observed) =>\n Planner.reconcile(\n Planner.requirements(plan),\n observed.map(({ record: observedRecord }) => observedRecord),\n ),\n )\n : Effect.forEach(\n plan.operations,\n (operation): Effect.Effect<Plan.Operation, Errors.DomainKitError> =>\n operation._tag === \"Delete\"\n ? cleanupOperation(\n dns,\n zone,\n operation.id,\n operation.record,\n operation.providerRecordId,\n )\n : Effect.succeed(operation),\n );\n\n const write = (\n plan: Plan.Model,\n operation: Plan.Create | Plan.Delete,\n dns: Provider.Dns,\n zone: string,\n ): Effect.Effect<string, Errors.DomainKitError> =>\n operation._tag === \"Create\"\n ? Effect.gen(function* () {\n const observed = yield* dns.list(zone);\n const [check] = yield* Planner.reconcile(\n [operation.record],\n observed.map(({ record: observedRecord }) => observedRecord),\n );\n if (check?._tag !== \"Create\") return yield* stale(plan);\n const created = yield* dns.create(zone, operation.record);\n return created.providerRecordId;\n })\n : Effect.gen(function* () {\n const check = yield* cleanupOperation(\n dns,\n zone,\n operation.id,\n operation.record,\n operation.providerRecordId,\n );\n if (check._tag !== \"Delete\") return yield* stale(plan);\n yield* dns.delete(zone, operation.providerRecordId);\n return operation.providerRecordId;\n });\n\n return { record, approve, reject, apply, attemptOf };\n};\n\n/** Read one receipt-proven record back; anything but an exact match is a conflict. */\nexport const cleanupOperation = (\n dns: Provider.Dns,\n zone: string,\n id: Plan.OperationId,\n record: DnsRecord.Model,\n providerRecordId: string,\n): Effect.Effect<Plan.Delete | Plan.Conflict, Errors.DomainKitError> =>\n Effect.map(dns.get(zone, providerRecordId), (observed) => {\n if (observed === null)\n return new Plan.Conflict({ id, record, existing: [], reason: \"missing\" });\n if (!DnsRecord.equals(observed, record)) {\n return new Plan.Conflict({ id, record, existing: [observed], reason: \"value-mismatch\" });\n }\n return new Plan.Delete({ id, record, providerRecordId });\n });\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"attempts.mjs","names":["Errors.fail","Reason.Stale","Reason.Expired","DomainName.isWithin","Reason.InvalidInput","Principal.Service","Plan.Model","Planner.digest","Storage.Attempt","Reason.NotFound","writes","Plan.writes","conflicts","Plan.conflicts","Reason.Conflict","Approval.Model","Receipt.Model","Receipt.Skipped","Receipt.Applied","Receipt.Failed","Planner.reconcile","Planner.requirements","Plan.Conflict","DnsRecord.equals","Plan.Delete"],"sources":["../../src/internal/attempts.ts"],"sourcesContent":["import { DateTime, Duration, Effect } from \"effect\";\n\nimport * as Approval from \"../Approval.ts\";\nimport * as Connect from \"../Connect.ts\";\nimport * as DnsRecord from \"../DnsRecord.ts\";\nimport * as Errors from \"./error.ts\";\nimport * as Reason from \"../Reason.ts\";\nimport * as DomainName from \"../DomainName.ts\";\nimport * as Plan from \"../Plan.ts\";\nimport * as Principal from \"../Principal.ts\";\nimport type * as Provider from \"../Provider.ts\";\nimport * as Receipt from \"../Receipt.ts\";\nimport * as Storage from \"../Storage.ts\";\nimport { fresh } from \"./ids.ts\";\nimport * as Planner from \"./planner.ts\";\n\ntype Fx<A> = Effect.Effect<A, Errors.DomainKitError, Principal.Service>;\n\nexport interface ApproveOptions {\n readonly operationIds?: ReadonlyArray<Plan.OperationId>;\n readonly allowPartial?: boolean;\n}\n\nexport interface PolicyShape {\n readonly planTtlMs: number;\n readonly applyLeaseMs: number;\n}\n\nconst stale = (plan: Plan.Model) =>\n Errors.fail(new Reason.Stale({ planId: plan.id, digest: plan.digest }));\n\nconst expired = (entity: \"plan\" | \"approval\", id: string) =>\n Errors.fail(new Reason.Expired({ entity, id }));\n\nconst past = (at: DateTime.Utc, now: DateTime.Utc) =>\n DateTime.toEpochMillis(at) <= DateTime.toEpochMillis(now);\n\n/** Every requirement must sit at or below the attached domain; other tenants share the zone. */\nexport const assertWithin = (\n attachment: Storage.Attachment,\n records: ReadonlyArray<DnsRecord.Model>,\n): Effect.Effect<void, Errors.DomainKitError> => {\n const outside = records.filter((record) => !DomainName.isWithin(record.name, attachment.domain));\n return outside.length === 0\n ? Effect.void\n : Errors.fail(\n new Reason.InvalidInput({\n message: `${outside.map(({ name }) => name).join(\", \")} is outside the attached domain ${attachment.domain}`,\n field: \"requirements\",\n }),\n );\n};\n\n/** Shared plan -> approve -> apply machinery for provisioning and cleanup attempts. */\nexport const make = (storage: Storage.Interface, connect: Connect.Interface, kind: Plan.Kind) => {\n const providerOf = (attachment: Storage.Attachment) =>\n Effect.gen(function* () {\n const connection = yield* storage.connections.get(attachment.connectionId);\n const authorization = yield* storage.authorizations.get(connection.authorizationId);\n return authorization.provider;\n });\n\n /** Persist a freshly built plan as a `planned` attempt. */\n const record = (input: {\n readonly attachment: Storage.Attachment;\n readonly target: Provider.Target;\n readonly operations: ReadonlyArray<Plan.Operation>;\n readonly ttlMs: number;\n readonly sourceReceiptId: Receipt.ReceiptId | null;\n }): Fx<Plan.Model> =>\n Effect.gen(function* () {\n const principal = yield* Principal.Service;\n const provider = yield* providerOf(input.attachment);\n const now = yield* DateTime.now;\n const unsigned: Planner.Unsigned = {\n version: \"domainkit.plan.v2\",\n kind,\n domain: input.attachment.domain,\n zone: input.target.zone,\n provider,\n attachmentId: input.attachment.id,\n operations: input.operations,\n };\n const plan = new Plan.Model({\n ...unsigned,\n id: Plan.PlanId.make(yield* fresh(\"plan\")),\n digest: yield* Planner.digest(unsigned),\n createdAt: now,\n expiresAt: DateTime.addDuration(now, Duration.millis(input.ttlMs)),\n });\n yield* storage.attempts.create(\n new Storage.Attempt({\n id: plan.id,\n ownerId: principal.ownerId,\n attachmentId: input.attachment.id,\n kind,\n status: \"planned\",\n plan,\n approval: null,\n receipt: null,\n rejection: null,\n sourceReceiptId: input.sourceReceiptId,\n leaseExpiresAt: null,\n failure: null,\n updatedAt: now,\n }),\n );\n return plan;\n });\n\n const attemptOf = (plan: Plan.Model | Plan.PlanId): Fx<Storage.Attempt> =>\n Effect.gen(function* () {\n const attempt = yield* storage.attempts.get(typeof plan === \"string\" ? plan : plan.id);\n if (attempt.kind !== kind) {\n return yield* Errors.fail(new Reason.NotFound({ entity: \"plan\", id: attempt.id }));\n }\n if (typeof plan !== \"string\" && plan.digest !== attempt.plan.digest)\n return yield* stale(plan);\n return attempt;\n });\n\n /**\n * The approval a planned attempt would record, built and checked but not written.\n *\n * Split out so an aggregate can collect one approval per attempt and write them all in a single\n * transaction beside its own.\n */\n const draftApproval = (\n attempt: Storage.Attempt,\n options: ApproveOptions = {},\n ): Fx<Approval.Model> =>\n Effect.gen(function* () {\n const principal = yield* Principal.Service;\n const now = yield* DateTime.now;\n if (past(attempt.plan.expiresAt, now)) return yield* expired(\"plan\", attempt.plan.id);\n const writes = Plan.writes(attempt.plan);\n const writeIds = new Set(writes.map(({ id }) => id));\n const selected = [...new Set(options.operationIds ?? writes.map(({ id }) => id))].sort();\n const unknown = selected.filter((id) => !writeIds.has(id));\n if (unknown.length > 0) {\n return yield* Errors.fail(\n new Reason.InvalidInput({\n message: `Operations ${unknown.join(\", \")} are not writable in plan ${attempt.plan.id}`,\n field: \"operationIds\",\n }),\n );\n }\n const conflicts = Plan.conflicts(attempt.plan);\n if (options.allowPartial !== true) {\n if (conflicts.length > 0) {\n return yield* Errors.fail(\n new Reason.Conflict({ planId: attempt.plan.id, operations: conflicts }),\n );\n }\n if (selected.length !== writes.length) {\n return yield* Errors.fail(\n new Reason.InvalidInput({\n message: \"Approving a subset of the plan requires allowPartial\",\n field: \"operationIds\",\n }),\n );\n }\n }\n return new Approval.Model({\n id: Approval.ApprovalId.make(yield* fresh(\"apr\")),\n version: \"domainkit.approval.v2\",\n kind,\n planId: attempt.plan.id,\n digest: attempt.plan.digest,\n operationIds: selected,\n approvedBy: principal.actorId,\n approvedAt: now,\n expiresAt: attempt.plan.expiresAt,\n });\n });\n\n const approve = (\n plan: Plan.Model | Plan.PlanId,\n options: ApproveOptions = {},\n ): Fx<Approval.Model> =>\n Effect.gen(function* () {\n const attempt = yield* attemptOf(plan);\n if (attempt.approval !== null) return attempt.approval;\n const approval = yield* draftApproval(attempt, options);\n const stored = yield* storage.attempts.approve(attempt.id, approval);\n return stored.approval ?? approval;\n });\n\n /** Decline a planned attempt on behalf of the principal's actor. */\n const reject = (\n plan: Plan.Model | Plan.PlanId,\n options: { readonly reason?: string } = {},\n ): Fx<Storage.Attempt> =>\n Effect.gen(function* () {\n const attempt = yield* attemptOf(plan);\n const principal = yield* Principal.Service;\n return yield* storage.attempts.reject(attempt.id, {\n digest: attempt.plan.digest,\n actorId: principal.actorId,\n reason: options.reason ?? null,\n });\n });\n\n const apply = (\n approval: Approval.Model | Approval.ApprovalId,\n policy: PolicyShape,\n ): Fx<Receipt.Model> =>\n Effect.gen(function* () {\n const attempt =\n typeof approval === \"string\"\n ? yield* storage.attempts.byApproval(approval)\n : yield* attemptOf(approval.planId);\n if (attempt.kind !== kind) {\n return yield* Errors.fail(\n new Reason.NotFound({\n entity: \"approval\",\n id: typeof approval === \"string\" ? approval : approval.id,\n }),\n );\n }\n const stored = attempt.approval;\n if (stored === null || (typeof approval !== \"string\" && stored.id !== approval.id)) {\n return yield* Errors.fail(\n new Reason.NotFound({\n entity: \"approval\",\n id: typeof approval === \"string\" ? approval : approval.id,\n }),\n );\n }\n if (attempt.receipt !== null) return attempt.receipt;\n const now = yield* DateTime.now;\n if (past(stored.expiresAt, now)) return yield* expired(\"approval\", stored.id);\n const claimed = yield* storage.attempts.claim(\n attempt.id,\n DateTime.addDuration(now, Duration.millis(policy.applyLeaseMs)),\n );\n return yield* run(claimed, stored).pipe(\n Effect.tapError((error) =>\n storage.attempts.fail(claimed.id, error.message).pipe(Effect.ignore),\n ),\n );\n });\n\n /** Writes under a claimed lease; a failure before any write releases the attempt as `failed`. */\n const run = (attempt: Storage.Attempt, approval: Approval.Model): Fx<Receipt.Model> =>\n Effect.gen(function* () {\n const plan = attempt.plan;\n const attachment = yield* storage.attachments.get(attempt.attachmentId);\n const { session, target } = yield* connect.session(attachment.id);\n const dns = session.dns(target);\n yield* revalidate(plan, dns, target.zone);\n const approved = new Set(approval.operationIds);\n const outcomes: Array<Receipt.Outcome> = [];\n const applied: Array<Receipt.Applied> = [];\n let stopped = false;\n const finish = (status: \"complete\" | \"partial\") =>\n Effect.gen(function* () {\n const receipt = new Receipt.Model({\n id: Receipt.ReceiptId.make(yield* fresh(\"rcpt\")),\n version: \"domainkit.receipt.v2\",\n kind,\n planId: plan.id,\n approvalId: approval.id,\n digest: plan.digest,\n provider: plan.provider,\n zone: plan.zone,\n status,\n outcomes: [...outcomes],\n appliedAt: yield* DateTime.now,\n });\n yield* storage.attempts.complete(attempt.id, receipt);\n return receipt;\n });\n const writes = Effect.gen(function* () {\n for (const operation of plan.operations) {\n if (operation._tag === \"Noop\") {\n outcomes.push(new Receipt.Skipped({ operationId: operation.id, reason: \"noop\" }));\n continue;\n }\n if (operation._tag === \"Conflict\" || !approved.has(operation.id)) {\n outcomes.push(\n new Receipt.Skipped({ operationId: operation.id, reason: \"not-approved\" }),\n );\n continue;\n }\n if (stopped) {\n outcomes.push(\n new Receipt.Skipped({ operationId: operation.id, reason: \"not-attempted\" }),\n );\n continue;\n }\n const result = yield* write(plan, operation, dns, target.zone).pipe(Effect.result);\n if (result._tag === \"Success\") {\n const outcome = new Receipt.Applied({\n operationId: operation.id,\n providerRecordId: result.success,\n });\n outcomes.push(outcome);\n applied.push(outcome);\n continue;\n }\n if (applied.length === 0) return yield* Effect.fail(result.failure);\n stopped = true;\n outcomes.push(\n new Receipt.Failed({ operationId: operation.id, message: result.failure.message }),\n );\n }\n return yield* finish(stopped ? \"partial\" : \"complete\");\n });\n return yield* writes.pipe(\n Effect.onInterrupt(() =>\n applied.length === 0\n ? Effect.void\n : Effect.gen(function* () {\n for (const operation of plan.operations) {\n if (outcomes.some((outcome) => outcome.operationId === operation.id)) continue;\n outcomes.push(\n new Receipt.Skipped({ operationId: operation.id, reason: \"not-attempted\" }),\n );\n }\n yield* finish(\"partial\");\n }).pipe(Effect.ignore),\n ),\n );\n });\n\n /** Re-plan the zone and fail `Stale` when the digest moved since the plan was built. */\n const revalidate = (plan: Plan.Model, dns: Provider.Dns, zone: string) =>\n Effect.gen(function* () {\n const current = yield* currentOperations(plan, dns, zone);\n const digest = yield* Planner.digest({\n version: plan.version,\n kind: plan.kind,\n domain: plan.domain,\n zone: plan.zone,\n provider: plan.provider,\n attachmentId: plan.attachmentId,\n operations: current,\n });\n if (digest !== plan.digest) return yield* stale(plan);\n });\n\n const currentOperations = (\n plan: Plan.Model,\n dns: Provider.Dns,\n zone: string,\n ): Effect.Effect<ReadonlyArray<Plan.Operation>, Errors.DomainKitError> =>\n kind === \"provisioning\"\n ? Effect.flatMap(dns.list(zone), (observed) =>\n Planner.reconcile(\n Planner.requirements(plan),\n observed.map(({ record: observedRecord }) => observedRecord),\n ),\n )\n : Effect.forEach(\n plan.operations,\n (operation): Effect.Effect<Plan.Operation, Errors.DomainKitError> =>\n operation._tag === \"Delete\"\n ? cleanupOperation(\n dns,\n zone,\n operation.id,\n operation.record,\n operation.providerRecordId,\n )\n : Effect.succeed(operation),\n );\n\n const write = (\n plan: Plan.Model,\n operation: Plan.Create | Plan.Delete,\n dns: Provider.Dns,\n zone: string,\n ): Effect.Effect<string, Errors.DomainKitError> =>\n operation._tag === \"Create\"\n ? Effect.gen(function* () {\n const observed = yield* dns.list(zone);\n const [check] = yield* Planner.reconcile(\n [operation.record],\n observed.map(({ record: observedRecord }) => observedRecord),\n );\n if (check?._tag !== \"Create\") return yield* stale(plan);\n const created = yield* dns.create(zone, operation.record);\n return created.providerRecordId;\n })\n : Effect.gen(function* () {\n const check = yield* cleanupOperation(\n dns,\n zone,\n operation.id,\n operation.record,\n operation.providerRecordId,\n );\n if (check._tag !== \"Delete\") return yield* stale(plan);\n yield* dns.delete(zone, operation.providerRecordId);\n return operation.providerRecordId;\n });\n\n return { record, approve, draftApproval, reject, apply, attemptOf };\n};\n\n/** Read one receipt-proven record back; anything but an exact match is a conflict. */\nexport const cleanupOperation = (\n dns: Provider.Dns,\n zone: string,\n id: Plan.OperationId,\n record: DnsRecord.Model,\n providerRecordId: string,\n): Effect.Effect<Plan.Delete | Plan.Conflict, Errors.DomainKitError> =>\n Effect.map(dns.get(zone, providerRecordId), (observed) => {\n if (observed === null)\n return new Plan.Conflict({ id, record, existing: [], reason: \"missing\" });\n if (!DnsRecord.equals(observed, record)) {\n return new Plan.Conflict({ id, record, existing: [observed], reason: \"value-mismatch\" });\n }\n return new Plan.Delete({ id, record, providerRecordId });\n });\n"],"mappings":"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"}
|
|
@@ -420,6 +420,189 @@ const cases = (layer) => {
|
|
|
420
420
|
yield* expect(afterFailure === "free", "lock was not released after a failure");
|
|
421
421
|
}).pipe(Effect.provideService(Service, owner)))
|
|
422
422
|
},
|
|
423
|
+
{
|
|
424
|
+
name: "creates a batch once per idempotency key and keeps it inside its owner",
|
|
425
|
+
run: run(Effect.gen(function* () {
|
|
426
|
+
const first = yield* connect(owner, "auth-batch-key");
|
|
427
|
+
const second = yield* connect(owner, "auth-batch-key-2");
|
|
428
|
+
const storage = first.storage;
|
|
429
|
+
const attachmentIds = [first.attachment.id, second.attachment.id];
|
|
430
|
+
const created = yield* storage.batches.create({
|
|
431
|
+
kind: "provisioning",
|
|
432
|
+
idempotencyKey: "batch-key-create",
|
|
433
|
+
attachmentIds
|
|
434
|
+
});
|
|
435
|
+
yield* expect(created.batch.status === "planning", "a new batch is not planning");
|
|
436
|
+
yield* expect(created.items.map((item) => item.position).join(",") === "0,1", "batch items did not keep the order they were created in");
|
|
437
|
+
yield* expect(created.items.every((item) => item.attemptId === null && item.planFailure === null), "a new batch item already points at an attempt");
|
|
438
|
+
const replay = yield* storage.batches.create({
|
|
439
|
+
kind: "provisioning",
|
|
440
|
+
idempotencyKey: "batch-key-create",
|
|
441
|
+
attachmentIds: [first.attachment.id]
|
|
442
|
+
});
|
|
443
|
+
yield* expect(replay.batch.id === created.batch.id && replay.items.length === 2, "a replayed idempotency key did not return the stored batch");
|
|
444
|
+
yield* expectReason(storage.batches.create({
|
|
445
|
+
kind: "provisioning",
|
|
446
|
+
idempotencyKey: "batch-key-empty",
|
|
447
|
+
attachmentIds: []
|
|
448
|
+
}), "InvalidInput", "batches.create with no attachments");
|
|
449
|
+
yield* expectReason(storage.batches.create({
|
|
450
|
+
kind: "provisioning",
|
|
451
|
+
idempotencyKey: "batch-key-duplicate",
|
|
452
|
+
attachmentIds: [first.attachment.id, first.attachment.id]
|
|
453
|
+
}), "InvalidInput", "batches.create with a repeated attachment");
|
|
454
|
+
const found = yield* storage.batches.byIdempotencyKey("batch-key-create");
|
|
455
|
+
yield* expect(Option.isSome(found) && found.value.batch.id === created.batch.id, "batches.byIdempotencyKey did not find the batch the key made");
|
|
456
|
+
const unknown = yield* storage.batches.byIdempotencyKey("batch-key-never-used");
|
|
457
|
+
yield* expect(Option.isNone(unknown), "batches.byIdempotencyKey invented a batch for an unused key");
|
|
458
|
+
const asOther = (effect) => effect.pipe(Effect.provideService(Service, other));
|
|
459
|
+
yield* expectReason(asOther(storage.batches.get(created.batch.id)), "NotFound", "batches.get");
|
|
460
|
+
const foreignKey = yield* asOther(storage.batches.byIdempotencyKey("batch-key-create"));
|
|
461
|
+
yield* expect(Option.isNone(foreignKey), "batches.byIdempotencyKey leaked across owners");
|
|
462
|
+
const foreign = yield* asOther(storage.batches.listUnfinished());
|
|
463
|
+
yield* expect(!foreign.some(({ batch }) => batch.id === created.batch.id), "batches.listUnfinished leaked across owners");
|
|
464
|
+
const unfinished = yield* storage.batches.listUnfinished();
|
|
465
|
+
yield* expect(unfinished.some(({ batch }) => batch.id === created.batch.id), "a planning batch is missing from the unfinished index");
|
|
466
|
+
}).pipe(Effect.provideService(Service, owner)))
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
name: "recomputes batch status from its items and approves every attempt at once",
|
|
470
|
+
run: run(Effect.gen(function* () {
|
|
471
|
+
const first = yield* connect(owner, "auth-batch-plan");
|
|
472
|
+
const second = yield* connect(owner, "auth-batch-plan-2");
|
|
473
|
+
const storage = first.storage;
|
|
474
|
+
const now = yield* DateTime.now;
|
|
475
|
+
const planA = planRow(first.attachment.id, now, "batch-a");
|
|
476
|
+
const planB = planRow(second.attachment.id, now, "batch-b");
|
|
477
|
+
yield* storage.attempts.create(attemptRow(owner, planA));
|
|
478
|
+
yield* storage.attempts.create(attemptRow(owner, planB));
|
|
479
|
+
const { batch } = yield* storage.batches.create({
|
|
480
|
+
kind: "provisioning",
|
|
481
|
+
idempotencyKey: "batch-key-plan",
|
|
482
|
+
attachmentIds: [first.attachment.id, second.attachment.id]
|
|
483
|
+
});
|
|
484
|
+
const approvalA = approvalRow(planA, "batch-a");
|
|
485
|
+
const approvalB = approvalRow(planB, "batch-b");
|
|
486
|
+
const approvals = [{
|
|
487
|
+
attachmentId: first.attachment.id,
|
|
488
|
+
approval: approvalA
|
|
489
|
+
}, {
|
|
490
|
+
attachmentId: second.attachment.id,
|
|
491
|
+
approval: approvalB
|
|
492
|
+
}];
|
|
493
|
+
const digest = Digest.make("batch-digest-1");
|
|
494
|
+
yield* expectReason(storage.batches.approve(batch.id, {
|
|
495
|
+
digest,
|
|
496
|
+
actorId: owner.actorId,
|
|
497
|
+
approvals
|
|
498
|
+
}), "BatchStale", "batches.approve before every item is planned");
|
|
499
|
+
const failed = yield* storage.batches.recordItemPlanFailure(batch.id, first.attachment.id, "the provider was unavailable");
|
|
500
|
+
yield* expect(failed.batch.status === "planning" && failed.items[0]?.planFailure === "the provider was unavailable", "a plan failure did not leave the batch planning");
|
|
501
|
+
const partiallyPlanned = yield* storage.batches.recordItemPlan(batch.id, first.attachment.id, planA.id);
|
|
502
|
+
yield* expect(partiallyPlanned.batch.status === "planning" && partiallyPlanned.items[0]?.planFailure === null, "a landed plan did not clear the item's failure");
|
|
503
|
+
const planned = yield* storage.batches.recordItemPlan(batch.id, second.attachment.id, planB.id);
|
|
504
|
+
yield* expect(planned.batch.status === "planned", "a batch whose items are all planned is not planned");
|
|
505
|
+
const approved = yield* storage.batches.approve(batch.id, {
|
|
506
|
+
digest,
|
|
507
|
+
actorId: owner.actorId,
|
|
508
|
+
approvals
|
|
509
|
+
});
|
|
510
|
+
yield* expect(approved.batch.status === "approved" && approved.batch.digest === digest, "batches.approve did not bind the digest");
|
|
511
|
+
const attemptA = yield* storage.attempts.get(planA.id);
|
|
512
|
+
const attemptB = yield* storage.attempts.get(planB.id);
|
|
513
|
+
yield* expect(attemptA.approval?.id === approvalA.id && attemptB.approval?.id === approvalB.id, "batches.approve did not approve every item's attempt");
|
|
514
|
+
const again = yield* storage.batches.approve(batch.id, {
|
|
515
|
+
digest,
|
|
516
|
+
actorId: owner.actorId,
|
|
517
|
+
approvals
|
|
518
|
+
});
|
|
519
|
+
yield* expect(again.batch.status === "approved", "approving an approved batch under the same digest is not idempotent");
|
|
520
|
+
yield* expectReason(storage.batches.approve(batch.id, {
|
|
521
|
+
digest: Digest.make("batch-digest-moved"),
|
|
522
|
+
actorId: owner.actorId,
|
|
523
|
+
approvals
|
|
524
|
+
}), "BatchStale", "batches.approve with a digest the batch does not hold");
|
|
525
|
+
yield* expectReason(storage.batches.reject(batch.id, {
|
|
526
|
+
actorId: owner.actorId,
|
|
527
|
+
reason: null
|
|
528
|
+
}), "BatchStale", "batches.reject after approval");
|
|
529
|
+
const lease = DateTime.add(now, { minutes: 2 });
|
|
530
|
+
yield* storage.attempts.claim(planA.id, lease);
|
|
531
|
+
yield* storage.attempts.complete(planA.id, receiptRow(planA, approvalA, "batch-a"));
|
|
532
|
+
const applying = yield* storage.batches.refresh(batch.id);
|
|
533
|
+
yield* expect(applying.batch.status === "applying", "a batch with one item left is not applying");
|
|
534
|
+
yield* storage.attempts.claim(planB.id, lease);
|
|
535
|
+
yield* storage.attempts.complete(planB.id, receiptRow(planB, approvalB, "batch-b"));
|
|
536
|
+
const complete = yield* storage.batches.refresh(batch.id);
|
|
537
|
+
yield* expect(complete.batch.status === "complete" && complete.batch.completedAt !== null, "a batch whose items all completed is not complete");
|
|
538
|
+
const unfinished = yield* storage.batches.listUnfinished();
|
|
539
|
+
yield* expect(!unfinished.some((entry) => entry.batch.id === batch.id), "a complete batch is still in the unfinished index");
|
|
540
|
+
}).pipe(Effect.provideService(Service, owner)))
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
name: "refuses a batch approval for an attempt that was declined on its own",
|
|
544
|
+
run: run(Effect.gen(function* () {
|
|
545
|
+
const { storage, attachment } = yield* connect(owner, "auth-batch-declined");
|
|
546
|
+
const now = yield* DateTime.now;
|
|
547
|
+
const plan = planRow(attachment.id, now, "batch-declined");
|
|
548
|
+
yield* storage.attempts.create(attemptRow(owner, plan));
|
|
549
|
+
const { batch } = yield* storage.batches.create({
|
|
550
|
+
kind: "provisioning",
|
|
551
|
+
idempotencyKey: "batch-key-declined",
|
|
552
|
+
attachmentIds: [attachment.id]
|
|
553
|
+
});
|
|
554
|
+
const planned = yield* storage.batches.recordItemPlan(batch.id, attachment.id, plan.id);
|
|
555
|
+
yield* expect(planned.batch.status === "planned", "the batch is not planned");
|
|
556
|
+
yield* storage.attempts.reject(plan.id, {
|
|
557
|
+
digest: plan.digest,
|
|
558
|
+
actorId: owner.actorId,
|
|
559
|
+
reason: null
|
|
560
|
+
});
|
|
561
|
+
yield* expectReason(storage.batches.approve(batch.id, {
|
|
562
|
+
digest: Digest.make("batch-digest-declined"),
|
|
563
|
+
actorId: owner.actorId,
|
|
564
|
+
approvals: [{
|
|
565
|
+
attachmentId: attachment.id,
|
|
566
|
+
approval: approvalRow(plan, "batch-declined")
|
|
567
|
+
}]
|
|
568
|
+
}), "Stale", "batches.approve over a declined attempt");
|
|
569
|
+
const attempt = yield* storage.attempts.get(plan.id);
|
|
570
|
+
yield* expect(attempt.status === "rejected" && attempt.approval === null, "batches.approve reopened a declined attempt");
|
|
571
|
+
}).pipe(Effect.provideService(Service, owner)))
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
name: "declines a batch with its planned attempts and fences a late planner",
|
|
575
|
+
run: run(Effect.gen(function* () {
|
|
576
|
+
const { storage, attachment } = yield* connect(owner, "auth-batch-reject");
|
|
577
|
+
const now = yield* DateTime.now;
|
|
578
|
+
const plan = planRow(attachment.id, now, "batch-reject");
|
|
579
|
+
const late = planRow(attachment.id, now, "batch-reject-late");
|
|
580
|
+
yield* storage.attempts.create(attemptRow(owner, plan));
|
|
581
|
+
yield* storage.attempts.create(attemptRow(owner, late));
|
|
582
|
+
const { batch } = yield* storage.batches.create({
|
|
583
|
+
kind: "provisioning",
|
|
584
|
+
idempotencyKey: "batch-key-reject",
|
|
585
|
+
attachmentIds: [attachment.id]
|
|
586
|
+
});
|
|
587
|
+
yield* storage.batches.recordItemPlan(batch.id, attachment.id, plan.id);
|
|
588
|
+
const rejected = yield* storage.batches.reject(batch.id, {
|
|
589
|
+
actorId: owner.actorId,
|
|
590
|
+
reason: "the customer changed their mind"
|
|
591
|
+
});
|
|
592
|
+
yield* expect(rejected.batch.status === "rejected" && rejected.batch.completedAt !== null, "batches.reject is not terminal");
|
|
593
|
+
const attempt = yield* storage.attempts.get(plan.id);
|
|
594
|
+
yield* expect(attempt.status === "rejected", "batches.reject left a planned attempt open");
|
|
595
|
+
const twice = yield* storage.batches.reject(batch.id, {
|
|
596
|
+
actorId: owner.actorId,
|
|
597
|
+
reason: null
|
|
598
|
+
});
|
|
599
|
+
yield* expect(twice.batch.rejection?.reason === "the customer changed their mind", "a second rejection overwrote the first");
|
|
600
|
+
yield* expectReason(storage.batches.recordItemPlan(batch.id, attachment.id, late.id), "BatchStale", "batches.recordItemPlan on a rejected batch");
|
|
601
|
+
yield* expectReason(storage.batches.recordItemPlanFailure(batch.id, attachment.id, "too late"), "BatchStale", "batches.recordItemPlanFailure on a rejected batch");
|
|
602
|
+
const unfinished = yield* storage.batches.listUnfinished();
|
|
603
|
+
yield* expect(!unfinished.some((entry) => entry.batch.id === batch.id), "a rejected batch is still in the unfinished index");
|
|
604
|
+
}).pipe(Effect.provideService(Service, owner)))
|
|
605
|
+
},
|
|
423
606
|
{
|
|
424
607
|
name: "stores readiness per domain, with or without an attachment",
|
|
425
608
|
run: run(Effect.gen(function* () {
|