@domainkit/capsuledb 0.1.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -7
- package/dist/index.d.mts +20 -51
- package/dist/index.mjs +776 -467
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
package/dist/index.mjs
CHANGED
|
@@ -1,493 +1,802 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { Capsule, Migration, Pg, Registry, Schema } from "capsuledb";
|
|
3
|
+
import { DomainKit, Principal, Reason, Storage } from "domainkit";
|
|
4
|
+
import { DateTime, Effect, Layer, Option, Schema as Schema$1 } from "effect";
|
|
5
5
|
import * as SqlClient from "effect/unstable/sql/SqlClient";
|
|
6
|
-
import * as SqlError from "effect/unstable/sql/SqlError";
|
|
7
6
|
//#region package.json
|
|
8
|
-
var version = "0.
|
|
7
|
+
var version = "0.9.0";
|
|
9
8
|
//#endregion
|
|
10
|
-
//#region src/
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
var host_bindings_exports = /* @__PURE__ */ __exportAll({
|
|
30
|
-
Error: () => Error$1,
|
|
31
|
-
Service: () => Service
|
|
32
|
-
});
|
|
33
|
-
var Error$1 = class extends Schema.TaggedError()("HostBindingError", {
|
|
34
|
-
message: Schema.String,
|
|
35
|
-
operation: Schema.String,
|
|
36
|
-
retry: Schema.Literals([
|
|
37
|
-
"never",
|
|
38
|
-
"after-user-action",
|
|
39
|
-
"safe",
|
|
40
|
-
"unknown"
|
|
41
|
-
])
|
|
42
|
-
}) {};
|
|
43
|
-
/** Stateless host mapping for tenant/domain foreign-key references. */
|
|
44
|
-
var Service = class extends Context.Service()("@domainkit/capsuledb/HostBindings") {};
|
|
45
|
-
//#endregion
|
|
46
|
-
//#region src/persistence.ts
|
|
47
|
-
const AttachmentContext = Schema.Struct({
|
|
48
|
-
version: Schema.Literal("domainkit.attachment.v1"),
|
|
49
|
-
value: Schema.Struct({
|
|
50
|
-
accountKind: Schema.NullOr(Schema.Literals([
|
|
51
|
-
"account",
|
|
52
|
-
"personal",
|
|
53
|
-
"team"
|
|
54
|
-
])),
|
|
55
|
-
domain: Schema.String,
|
|
56
|
-
evidence: Schema.optionalKey(Connection.ProviderTargetEvidence)
|
|
57
|
-
})
|
|
58
|
-
});
|
|
59
|
-
const storageError = (operation, message, retry = "unknown") => new ManagedDnsConnections.Error({
|
|
60
|
-
category: "storage",
|
|
9
|
+
//#region src/internal/storage.ts
|
|
10
|
+
/**
|
|
11
|
+
* `Storage.Interface` on PostgreSQL.
|
|
12
|
+
*
|
|
13
|
+
* Every method reads `Principal` and filters by `owner_id`, so a row belonging to another tenant
|
|
14
|
+
* is indistinguishable from a row that does not exist. Aggregate transitions run inside one
|
|
15
|
+
* transaction over a `FOR UPDATE` row, single-flight guards use a session-level advisory lock on a
|
|
16
|
+
* reserved connection, and revocation is two-phase: mark `pending`, call the provider outside any
|
|
17
|
+
* transaction, then delete.
|
|
18
|
+
*
|
|
19
|
+
* Storage never sees plaintext. `Connect` seals a credential through `Custody` before it reaches
|
|
20
|
+
* `upsert` or `rotate`, so this module only ever moves a ciphertext string.
|
|
21
|
+
*/
|
|
22
|
+
const fail = (reason) => Effect.fail(new DomainKit.Error({ reason }));
|
|
23
|
+
const notFound = (entity, id) => fail(new Reason.NotFound({
|
|
24
|
+
entity,
|
|
25
|
+
id
|
|
26
|
+
}));
|
|
27
|
+
const invalid = (message, field) => fail(new Reason.InvalidInput({
|
|
61
28
|
message,
|
|
29
|
+
...field === void 0 ? {} : { field }
|
|
30
|
+
}));
|
|
31
|
+
const busy = (key) => fail(new Reason.Busy({ key }));
|
|
32
|
+
const storageFailed = (operation, message) => new DomainKit.Error({ reason: new Reason.StorageFailed({
|
|
62
33
|
operation,
|
|
63
|
-
|
|
64
|
-
});
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
34
|
+
message
|
|
35
|
+
}) });
|
|
36
|
+
const sqlMessage = (error) => error.reason.cause instanceof Error ? `${error.reason._tag}: ${error.reason.cause.message}` : `${error.reason._tag}: ${String(error.reason.cause)}`;
|
|
37
|
+
/**
|
|
38
|
+
* Collapse the SQL error channel into `DomainKit.Error`.
|
|
39
|
+
*
|
|
40
|
+
* A `DomainKit.Error` raised by the body is the deliberate outcome of an invariant and passes
|
|
41
|
+
* through unchanged; anything else is a driver failure and becomes `StorageFailed`.
|
|
42
|
+
*/
|
|
43
|
+
const guard = (operation) => (effect) => Effect.catch(effect, (error) => Effect.fail(DomainKit.isError(error) ? error : storageFailed(operation, sqlMessage(error))));
|
|
44
|
+
/**
|
|
45
|
+
* A row is stored as its schema's encoded form spread over columns, so the core schema stays the
|
|
46
|
+
* only description of a row's shape. Reading reassembles the encoded object and decodes it.
|
|
47
|
+
*/
|
|
48
|
+
const codec = (schema, subject) => {
|
|
49
|
+
const decode = Schema$1.decodeUnknownEffect(schema);
|
|
50
|
+
const encode = Schema$1.encodeUnknownEffect(schema);
|
|
79
51
|
return {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
capabilityEvidence: row.capability_evidence,
|
|
83
|
-
createdAt: row.created_at.toISOString(),
|
|
84
|
-
id: row.id,
|
|
85
|
-
method: row.kind,
|
|
86
|
-
providerContext: row.provider_context,
|
|
87
|
-
providerId: row.provider_id,
|
|
88
|
-
requiredCapabilities: row.capabilities,
|
|
89
|
-
revocation: row.revocation,
|
|
90
|
-
scopes: row.scopes
|
|
91
|
-
}).pipe(Effect.mapError((error) => missing("decodeAuthorization", `Authorization ${row.id} is invalid: ${error.message}`))),
|
|
92
|
-
credentialCiphertext: row.credential_ciphertext,
|
|
93
|
-
credentialExpiresAt: row.expires_at
|
|
52
|
+
read: (input) => decode(input).pipe(Effect.mapError((error) => storageFailed(`${subject}.decode`, `stored ${subject} is invalid: ${error.message}`))),
|
|
53
|
+
write: (value) => encode(value).pipe(Effect.mapError((error) => storageFailed(`${subject}.encode`, `${subject} cannot be stored: ${error.message}`)))
|
|
94
54
|
};
|
|
55
|
+
};
|
|
56
|
+
const authorizationCodec = codec(Storage.Authorization, "authorization");
|
|
57
|
+
const connectionCodec = codec(Storage.Connection, "connection");
|
|
58
|
+
const attachmentCodec = codec(Storage.Attachment, "attachment");
|
|
59
|
+
const continuationCodec = codec(Storage.Continuation, "continuation");
|
|
60
|
+
const attemptCodec = codec(Storage.Attempt, "attempt");
|
|
61
|
+
const readinessCodec = codec(Storage.Readiness, "readiness");
|
|
62
|
+
const credentialCodec = codec(Storage.Credential, "credential");
|
|
63
|
+
/** A `TIMESTAMPTZ` column as the ISO string every DomainKit timestamp schema encodes to. */
|
|
64
|
+
const iso = (value) => value instanceof Date ? value.toISOString() : String(value);
|
|
65
|
+
const isoOrNull = (value) => value === null ? null : iso(value);
|
|
66
|
+
/** An ISO string from a schema's encoded form as the `Date` the driver binds to `TIMESTAMPTZ`. */
|
|
67
|
+
const at = (value) => new Date(value);
|
|
68
|
+
const atOrNull = (value) => value === null ? null : at(value);
|
|
69
|
+
const now = Effect.map(DateTime.now, DateTime.toDateUtc);
|
|
70
|
+
/**
|
|
71
|
+
* A `JSONB` column as a plain value.
|
|
72
|
+
*
|
|
73
|
+
* Drivers disagree about whether they parse JSON columns, so accept both and normalize here
|
|
74
|
+
* rather than depending on a host's client configuration.
|
|
75
|
+
*/
|
|
76
|
+
const fromJson = (value) => typeof value === "string" ? JSON.parse(value) : value;
|
|
77
|
+
const fromJsonOrNull = (value) => value === null ? null : fromJson(value);
|
|
78
|
+
/** Bind a value to a `JSONB` column as text; PostgreSQL coerces the untyped parameter. */
|
|
79
|
+
const toJson = (value) => JSON.stringify(value ?? null);
|
|
80
|
+
const toJsonOrNull = (value) => value === null || value === void 0 ? null : toJson(value);
|
|
81
|
+
const authorizationOf = (row) => authorizationCodec.read({
|
|
82
|
+
id: row.id,
|
|
83
|
+
ownerId: row.owner_id,
|
|
84
|
+
provider: row.provider,
|
|
85
|
+
method: row.method,
|
|
86
|
+
capabilities: fromJson(row.capabilities),
|
|
87
|
+
context: fromJson(row.context),
|
|
88
|
+
revocation: row.revocation,
|
|
89
|
+
createdBy: row.created_by,
|
|
90
|
+
createdAt: iso(row.created_at)
|
|
95
91
|
});
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
createdAt: row.created_at.toISOString(),
|
|
101
|
-
domain: context.value.domain,
|
|
102
|
-
id: row.id,
|
|
103
|
-
target: {
|
|
104
|
-
accountId: row.provider_account_id,
|
|
105
|
-
accountKind: context.value.accountKind,
|
|
106
|
-
...context.value.evidence === void 0 ? {} : { evidence: context.value.evidence },
|
|
107
|
-
zoneId: row.provider_zone_id,
|
|
108
|
-
zoneName: row.provider_zone_name
|
|
109
|
-
}
|
|
110
|
-
}).pipe(Effect.mapError((error) => missing("decodeAttachment", `Attachment ${row.id} is invalid: ${error.message}`)));
|
|
92
|
+
const credentialOf = (row) => credentialCodec.read({
|
|
93
|
+
ciphertext: row.credential_ciphertext,
|
|
94
|
+
expiresAt: isoOrNull(row.credential_expires_at),
|
|
95
|
+
rotatedAt: iso(row.credential_rotated_at)
|
|
111
96
|
});
|
|
112
|
-
const
|
|
113
|
-
|
|
97
|
+
const connectionOf = (row) => connectionCodec.read({
|
|
98
|
+
id: row.id,
|
|
99
|
+
ownerId: row.owner_id,
|
|
100
|
+
authorizationId: row.authorization_id,
|
|
101
|
+
createdAt: iso(row.created_at)
|
|
114
102
|
});
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
created_at FROM organization_domain_provider_connections
|
|
124
|
-
WHERE authorization_id = ${authorizationId} ORDER BY id`;
|
|
125
|
-
const connections = yield* Effect.forEach(connectionRows, (connection) => bindings.ownerId(connection.organization_id).pipe(Effect.mapError((error) => mapCapabilityError("loadAggregate", error)), Effect.map((ownerId) => ({
|
|
126
|
-
authorizationId: connection.authorization_id,
|
|
127
|
-
createdAt: connection.created_at,
|
|
128
|
-
id: connection.id,
|
|
129
|
-
method: decoded.authorization.method,
|
|
130
|
-
ownerId,
|
|
131
|
-
providerId: decoded.authorization.providerId
|
|
132
|
-
}))));
|
|
133
|
-
const attachmentRows = yield* sql`SELECT a.id, a.connection_id,
|
|
134
|
-
a.provider_account_id, a.provider_zone_id, a.provider_zone_name,
|
|
135
|
-
a.provider_target_context, a.created_at
|
|
136
|
-
FROM domain_provider_attachments a
|
|
137
|
-
JOIN organization_domain_provider_connections c ON c.id = a.connection_id
|
|
138
|
-
WHERE c.authorization_id = ${authorizationId} ORDER BY a.id`;
|
|
139
|
-
return {
|
|
140
|
-
attachments: yield* Effect.forEach(attachmentRows, decodeAttachment),
|
|
141
|
-
authorization: decoded.authorization,
|
|
142
|
-
connections,
|
|
143
|
-
credentialCiphertext: decoded.credentialCiphertext,
|
|
144
|
-
credentialExpiresAt: decoded.credentialExpiresAt
|
|
145
|
-
};
|
|
103
|
+
const attachmentOf = (row) => attachmentCodec.read({
|
|
104
|
+
id: row.id,
|
|
105
|
+
ownerId: row.owner_id,
|
|
106
|
+
connectionId: row.connection_id,
|
|
107
|
+
domain: row.domain,
|
|
108
|
+
zone: row.zone,
|
|
109
|
+
target: fromJson(row.target),
|
|
110
|
+
createdAt: iso(row.created_at)
|
|
146
111
|
});
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
112
|
+
const continuationOf = (row) => continuationCodec.read({
|
|
113
|
+
id: row.id,
|
|
114
|
+
ownerId: row.owner_id,
|
|
115
|
+
actorId: row.actor_id,
|
|
116
|
+
provider: row.provider,
|
|
117
|
+
payload: fromJson(row.payload),
|
|
118
|
+
returnTo: row.return_to,
|
|
119
|
+
expiresAt: iso(row.expires_at)
|
|
150
120
|
});
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
121
|
+
const attemptOf = (row) => attemptCodec.read({
|
|
122
|
+
id: row.id,
|
|
123
|
+
ownerId: row.owner_id,
|
|
124
|
+
attachmentId: row.attachment_id,
|
|
125
|
+
kind: row.kind,
|
|
126
|
+
status: row.status,
|
|
127
|
+
plan: fromJson(row.plan),
|
|
128
|
+
approval: fromJsonOrNull(row.approval),
|
|
129
|
+
receipt: fromJsonOrNull(row.receipt),
|
|
130
|
+
rejection: fromJsonOrNull(row.rejection),
|
|
131
|
+
sourceReceiptId: row.source_receipt_id,
|
|
132
|
+
leaseExpiresAt: isoOrNull(row.lease_expires_at),
|
|
133
|
+
failure: row.failure,
|
|
134
|
+
updatedAt: iso(row.updated_at)
|
|
156
135
|
});
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
};
|
|
168
|
-
});
|
|
169
|
-
const projectConnection = Effect.fn("CapsuleManagedDnsConnections.projectConnection")(function* (custody, aggregate, connection, authorization = aggregate.authorization) {
|
|
170
|
-
const opened = yield* openAggregate(custody, aggregate);
|
|
171
|
-
return Connection.project(connection, authorization, opened.credential);
|
|
172
|
-
});
|
|
173
|
-
const writeAuthorization = Effect.fn("CapsuleManagedDnsConnections.writeAuthorization")(function* (sql, authorization, credentialCiphertext, credentialExpiresAt) {
|
|
174
|
-
const encoded = yield* encodeAuthorization(authorization);
|
|
175
|
-
yield* sql`INSERT INTO domain_provider_authorizations (
|
|
176
|
-
id, provider_id, account_id, subject_id, kind, capabilities, capability_evidence, scopes,
|
|
177
|
-
provider_context, revocation, credential_ciphertext, expires_at, created_at, updated_at
|
|
178
|
-
) VALUES (
|
|
179
|
-
${authorization.id}, ${authorization.providerId}, NULL, ${authorization.authorizedById},
|
|
180
|
-
${authorization.method}, ${JSON.stringify(encoded.requiredCapabilities)}::jsonb,
|
|
181
|
-
${JSON.stringify(encoded.capabilityEvidence)}::jsonb,
|
|
182
|
-
${JSON.stringify(encoded.scopes)}::jsonb, ${JSON.stringify(encoded.providerContext)}::jsonb,
|
|
183
|
-
${JSON.stringify(encoded.revocation)}::jsonb,
|
|
184
|
-
${credentialCiphertext}, ${credentialExpiresAt}, ${authorization.createdAt}, NOW()
|
|
185
|
-
) ON CONFLICT (id) DO UPDATE SET
|
|
186
|
-
provider_id = EXCLUDED.provider_id,
|
|
187
|
-
subject_id = EXCLUDED.subject_id,
|
|
188
|
-
kind = EXCLUDED.kind,
|
|
189
|
-
capabilities = EXCLUDED.capabilities,
|
|
190
|
-
capability_evidence = EXCLUDED.capability_evidence,
|
|
191
|
-
scopes = EXCLUDED.scopes,
|
|
192
|
-
provider_context = EXCLUDED.provider_context,
|
|
193
|
-
revocation = EXCLUDED.revocation,
|
|
194
|
-
credential_ciphertext = EXCLUDED.credential_ciphertext,
|
|
195
|
-
expires_at = EXCLUDED.expires_at,
|
|
196
|
-
updated_at = NOW()`;
|
|
136
|
+
const readinessOf = (row) => readinessCodec.read({
|
|
137
|
+
domain: row.domain,
|
|
138
|
+
attachmentId: row.attachment_id,
|
|
139
|
+
ownerId: row.owner_id,
|
|
140
|
+
overall: row.overall,
|
|
141
|
+
requirements: fromJson(row.requirements),
|
|
142
|
+
host: fromJson(row.host),
|
|
143
|
+
pendingSince: isoOrNull(row.pending_since),
|
|
144
|
+
checkedAt: iso(row.checked_at),
|
|
145
|
+
nextCheckAt: isoOrNull(row.next_check_at)
|
|
197
146
|
});
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
const
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (!sameAttachment(existing, input.attachment)) return yield* Effect.fail(conflict("attach", "Domain attachment id is already in use"));
|
|
229
|
-
return {
|
|
230
|
-
attachment: existing,
|
|
231
|
-
connection: yield* projectConnection(custody, aggregate, connection)
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
const reference = yield* bindings.domain({
|
|
235
|
-
domain: input.attachment.domain,
|
|
236
|
-
ownerId: input.ownerId
|
|
237
|
-
}).pipe(Effect.mapError((error) => mapCapabilityError("attach", error)));
|
|
238
|
-
const context = {
|
|
239
|
-
version: "domainkit.attachment.v1",
|
|
240
|
-
value: {
|
|
241
|
-
accountKind: input.attachment.target.accountKind,
|
|
242
|
-
domain: input.attachment.domain,
|
|
243
|
-
...input.attachment.target.evidence === void 0 ? {} : { evidence: input.attachment.target.evidence }
|
|
244
|
-
}
|
|
245
|
-
};
|
|
246
|
-
yield* sql`INSERT INTO domain_provider_attachments (
|
|
247
|
-
id, organization_id, connection_id, domain_id, provider_account_id,
|
|
248
|
-
provider_zone_id, provider_zone_name, provider_target_context, created_at, updated_at
|
|
249
|
-
) VALUES (
|
|
250
|
-
${input.attachment.id}, ${reference.ownerReference}, ${input.connectionId},
|
|
251
|
-
${reference.domainReference}, ${input.attachment.target.accountId},
|
|
252
|
-
${input.attachment.target.zoneId}, ${input.attachment.target.zoneName},
|
|
253
|
-
${JSON.stringify(context)}::jsonb,
|
|
254
|
-
${input.attachment.createdAt}, NOW()
|
|
255
|
-
)`;
|
|
256
|
-
return {
|
|
257
|
-
attachment: input.attachment,
|
|
258
|
-
connection: yield* projectConnection(custody, aggregate, connection)
|
|
259
|
-
};
|
|
260
|
-
}))),
|
|
261
|
-
connect: (input) => custody.seal(input.credential).pipe(Effect.mapError((error) => mapCapabilityError("connect", error)), Effect.flatMap((credentialCiphertext) => mapSql("connect", sql.withTransaction(Effect.gen(function* () {
|
|
262
|
-
yield* sql`SELECT pg_advisory_xact_lock(hashtextextended(${input.authorization.id}, 0))`;
|
|
263
|
-
const current = yield* getStored(input.authorization.id, "update");
|
|
264
|
-
if (current?.authorization.revocation._tag === "Pending" || input.expectedAuthorizationId !== void 0 && current === null) return yield* Effect.fail(conflict("connect", current === null ? "Provider authorization no longer exists" : "Provider authorization is awaiting revocation recovery"));
|
|
265
|
-
if (input.authorization.id !== input.connection.authorizationId || input.authorization.providerId !== input.connection.providerId || input.authorization.method !== input.connection.method) return yield* Effect.fail(conflict("connect", "Connection does not match its authorization"));
|
|
266
|
-
const existingConnection = current?.connections.find(({ ownerId }) => ownerId === input.connection.ownerId);
|
|
267
|
-
if (input.expectedConnectionId !== void 0 && existingConnection?.id !== input.expectedConnectionId) return yield* Effect.fail(conflict("connect", "Organization connection changed during reconnect"));
|
|
268
|
-
yield* writeAuthorization(sql, input.authorization, credentialCiphertext, input.credential.expiresAt);
|
|
269
|
-
if (existingConnection === void 0) {
|
|
270
|
-
const reference = yield* bindings.owner(input.connection.ownerId).pipe(Effect.mapError((error) => mapCapabilityError("connect", error)));
|
|
271
|
-
yield* sql`INSERT INTO organization_domain_provider_connections (
|
|
272
|
-
id, organization_id, authorization_id, created_at, updated_at
|
|
273
|
-
) VALUES (
|
|
274
|
-
${input.connection.id}, ${reference.ownerReference},
|
|
275
|
-
${input.authorization.id}, ${input.connection.createdAt}, NOW()
|
|
276
|
-
)`;
|
|
277
|
-
}
|
|
278
|
-
return (yield* getStored(input.authorization.id, "update")) ?? (yield* Effect.fail(missing("connect", "Authorization write vanished")));
|
|
279
|
-
})))), Effect.flatMap((aggregate) => openAggregate(custody, aggregate))),
|
|
280
|
-
detach: ({ attachmentId, ownerId }) => mapSql("detach", sql.withTransaction(Effect.gen(function* () {
|
|
281
|
-
const authorizationId = yield* authorizationIdForAttachment(sql, attachmentId);
|
|
282
|
-
if (authorizationId === null) return yield* Effect.fail(missing("detach", "Domain attachment does not exist"));
|
|
283
|
-
const aggregate = yield* getStored(authorizationId, "update");
|
|
284
|
-
const attachment = aggregate?.attachments.find(({ id }) => id === attachmentId);
|
|
285
|
-
if (aggregate === null || attachment === void 0) return yield* Effect.fail(missing("detach", "Domain attachment does not exist"));
|
|
286
|
-
const connection = aggregate.connections.find(({ id, ownerId: candidate }) => id === attachment.connectionId && candidate === ownerId);
|
|
287
|
-
if (connection === void 0) return yield* Effect.fail(missing("detach", "Domain attachment does not belong to this owner"));
|
|
288
|
-
yield* sql`DELETE FROM domain_provider_attachments WHERE id = ${attachmentId}`;
|
|
289
|
-
return {
|
|
290
|
-
attachment,
|
|
291
|
-
connection: yield* projectConnection(custody, aggregate, connection),
|
|
292
|
-
remainingAttachments: aggregate.attachments.filter(({ connectionId, id }) => connectionId === connection.id && id !== attachmentId).length
|
|
293
|
-
};
|
|
294
|
-
}))),
|
|
295
|
-
disconnect: ({ connectionId, ownerId, revoke }) => mapSql("disconnect", sql.withTransaction(Effect.gen(function* () {
|
|
296
|
-
const authorizationId = yield* authorizationIdForConnection(sql, connectionId);
|
|
297
|
-
if (authorizationId === null) return yield* Effect.fail(missing("disconnect", "Provider connection does not exist"));
|
|
298
|
-
const aggregate = yield* getStored(authorizationId, "update");
|
|
299
|
-
const connection = aggregate?.connections.find(({ id, ownerId: candidate }) => id === connectionId && candidate === ownerId);
|
|
300
|
-
if (aggregate === null || connection === void 0) return yield* Effect.fail(missing("disconnect", "Provider connection does not belong to this owner"));
|
|
301
|
-
if (aggregate.attachments.some(({ connectionId: id }) => id === connectionId)) return yield* Effect.fail(conflict("disconnect", "Detach all organization domains before disconnecting the provider"));
|
|
302
|
-
if (aggregate.connections.length > 1) {
|
|
303
|
-
yield* sql`DELETE FROM organization_domain_provider_connections
|
|
304
|
-
WHERE id = ${connectionId}`;
|
|
305
|
-
return {
|
|
306
|
-
_tag: "Disconnected",
|
|
307
|
-
connection: yield* projectConnection(custody, aggregate, connection),
|
|
308
|
-
remainingConnections: aggregate.connections.length - 1
|
|
309
|
-
};
|
|
310
|
-
}
|
|
311
|
-
if (aggregate.authorization.revocation._tag === "Pending") return yield* Effect.fail(conflict("disconnect", "Provider authorization is awaiting revocation recovery"));
|
|
312
|
-
const pending = {
|
|
313
|
-
...aggregate.authorization,
|
|
314
|
-
revocation: {
|
|
315
|
-
_tag: "Pending",
|
|
316
|
-
requestedAt: /* @__PURE__ */ new Date()
|
|
317
|
-
}
|
|
318
|
-
};
|
|
319
|
-
yield* writeAuthorization(sql, pending, aggregate.credentialCiphertext, aggregate.credentialExpiresAt);
|
|
320
|
-
return {
|
|
321
|
-
_tag: "Prepared",
|
|
322
|
-
authorization: pending,
|
|
323
|
-
connection: yield* projectConnection(custody, aggregate, connection, pending)
|
|
324
|
-
};
|
|
325
|
-
})).pipe(Effect.flatMap((prepared) => {
|
|
326
|
-
if (prepared._tag === "Disconnected") {
|
|
327
|
-
const result = {
|
|
328
|
-
connection: prepared.connection,
|
|
329
|
-
remainingConnections: prepared.remainingConnections,
|
|
330
|
-
revokedAuthorization: false
|
|
331
|
-
};
|
|
332
|
-
return Effect.succeed(result);
|
|
333
|
-
}
|
|
334
|
-
const result = {
|
|
335
|
-
connection: prepared.connection,
|
|
336
|
-
remainingConnections: 0,
|
|
337
|
-
revokedAuthorization: true
|
|
338
|
-
};
|
|
339
|
-
return withRevocationLock("disconnect", prepared.authorization.id, getStored(prepared.authorization.id, "update").pipe(Effect.flatMap((current) => {
|
|
340
|
-
if (current?.authorization.revocation._tag !== "Pending") return Effect.fail(conflict("disconnect", "Revocation state changed before provider call"));
|
|
341
|
-
return revoke(current.authorization).pipe(Effect.andThen(Effect.gen(function* () {
|
|
342
|
-
if ((yield* getStored(prepared.authorization.id, "update"))?.authorization.revocation._tag !== "Pending") return yield* Effect.fail(conflict("disconnect", "Revocation state changed before completion"));
|
|
343
|
-
yield* sql`DELETE FROM domain_provider_attachments WHERE connection_id IN
|
|
344
|
-
(SELECT id FROM organization_domain_provider_connections
|
|
345
|
-
WHERE authorization_id = ${prepared.authorization.id})`;
|
|
346
|
-
yield* sql`DELETE FROM organization_domain_provider_connections
|
|
347
|
-
WHERE authorization_id = ${prepared.authorization.id}`;
|
|
348
|
-
yield* sql`DELETE FROM domain_provider_authorizations
|
|
349
|
-
WHERE id = ${prepared.authorization.id}`;
|
|
350
|
-
})), Effect.as(result));
|
|
351
|
-
})));
|
|
352
|
-
}))),
|
|
353
|
-
findConnection: (ownerId, authorizationId) => get(authorizationId).pipe(Effect.map((aggregate) => aggregate?.connections.find((connection) => connection.ownerId === ownerId) ?? null)),
|
|
354
|
-
get,
|
|
355
|
-
getAttachment: (attachmentId) => mapSql("getAttachment", sql.withTransaction(Effect.gen(function* () {
|
|
356
|
-
const authorizationId = yield* authorizationIdForAttachment(sql, attachmentId);
|
|
357
|
-
if (authorizationId === null) return null;
|
|
358
|
-
return (yield* getStored(authorizationId))?.attachments.find(({ id }) => id === attachmentId) ?? null;
|
|
359
|
-
}))),
|
|
360
|
-
getByConnectionId,
|
|
361
|
-
listAttachments: (connectionId, ownerId) => getByConnectionId(connectionId).pipe(Effect.flatMap((aggregate) => {
|
|
362
|
-
if (aggregate === null) return Effect.fail(missing("listAttachments", "Provider connection does not exist"));
|
|
363
|
-
if (!aggregate.connections.some((connection) => connection.id === connectionId && connection.ownerId === ownerId)) return Effect.fail(missing("listAttachments", "Provider connection does not belong to this owner"));
|
|
364
|
-
return Effect.succeed(aggregate.attachments.filter((attachment) => attachment.connectionId === connectionId));
|
|
365
|
-
})),
|
|
366
|
-
promoteEvidence: (authorizationId, evidence) => mapSql("promoteEvidence", sql.withTransaction(Effect.gen(function* () {
|
|
367
|
-
const aggregate = yield* getStored(authorizationId, "update");
|
|
368
|
-
if (aggregate === null) return yield* Effect.fail(missing("promoteEvidence", "Provider authorization does not exist"));
|
|
369
|
-
const byCapability = new Map(aggregate.authorization.capabilityEvidence.map((item) => [item.capability, item]));
|
|
370
|
-
for (const item of evidence) byCapability.set(item.capability, item);
|
|
371
|
-
yield* writeAuthorization(sql, {
|
|
372
|
-
...aggregate.authorization,
|
|
373
|
-
capabilityEvidence: [...byCapability.values()]
|
|
374
|
-
}, aggregate.credentialCiphertext, aggregate.credentialExpiresAt);
|
|
375
|
-
return (yield* getStored(authorizationId, "update")) ?? (yield* Effect.fail(missing("promoteEvidence", "Authorization write vanished")));
|
|
376
|
-
}))).pipe(Effect.flatMap((aggregate) => openAggregate(custody, aggregate))),
|
|
377
|
-
recover: ({ authorizationId, revoke }) => mapSql("recover", withRevocationLock("recover", authorizationId, getStored(authorizationId, "update").pipe(Effect.flatMap((aggregate) => Effect.gen(function* () {
|
|
378
|
-
if (aggregate === null) return yield* Effect.fail(missing("recover", "Provider authorization does not exist"));
|
|
379
|
-
if (aggregate.authorization.revocation._tag !== "Pending") return yield* Effect.fail(missing("recover", "Provider authorization is not awaiting revocation"));
|
|
380
|
-
const connection = aggregate.connections[0];
|
|
381
|
-
if (connection === void 0) return yield* Effect.fail(missing("recover", "Pending authorization has no connection"));
|
|
382
|
-
const projected = yield* projectConnection(custody, aggregate, connection);
|
|
383
|
-
return yield* revoke(aggregate.authorization).pipe(Effect.andThen(Effect.gen(function* () {
|
|
384
|
-
if ((yield* getStored(authorizationId, "update"))?.authorization.revocation._tag !== "Pending") return yield* Effect.fail(conflict("recover", "Revocation state changed before completion"));
|
|
385
|
-
yield* sql`DELETE FROM domain_provider_attachments WHERE connection_id IN
|
|
386
|
-
(SELECT id FROM organization_domain_provider_connections
|
|
387
|
-
WHERE authorization_id = ${authorizationId})`;
|
|
388
|
-
yield* sql`DELETE FROM organization_domain_provider_connections
|
|
389
|
-
WHERE authorization_id = ${authorizationId}`;
|
|
390
|
-
yield* sql`DELETE FROM domain_provider_authorizations WHERE id = ${authorizationId}`;
|
|
391
|
-
})), Effect.as({
|
|
392
|
-
connection: projected,
|
|
393
|
-
remainingConnections: 0,
|
|
394
|
-
revokedAuthorization: true
|
|
395
|
-
}));
|
|
396
|
-
}))))),
|
|
397
|
-
rotate: (authorizationId, credential) => custody.seal(credential).pipe(Effect.mapError((error) => mapCapabilityError("rotate", error)), Effect.flatMap((credentialCiphertext) => mapSql("rotate", sql.withTransaction(Effect.gen(function* () {
|
|
398
|
-
const aggregate = yield* getStored(authorizationId, "update");
|
|
399
|
-
if (aggregate === null) return yield* Effect.fail(missing("rotate", "Provider authorization does not exist"));
|
|
400
|
-
yield* writeAuthorization(sql, aggregate.authorization, credentialCiphertext, credential.expiresAt);
|
|
401
|
-
return (yield* getStored(authorizationId, "update")) ?? (yield* Effect.fail(missing("rotate", "Authorization write vanished")));
|
|
402
|
-
})))), Effect.flatMap((aggregate) => openAggregate(custody, aggregate)))
|
|
147
|
+
const stale = (attempt) => fail(new Reason.Stale({
|
|
148
|
+
planId: attempt.id,
|
|
149
|
+
digest: attempt.plan.digest
|
|
150
|
+
}));
|
|
151
|
+
/** `prefix_<uuid>`, the same identifier shape the memory implementation mints. */
|
|
152
|
+
const fresh = (prefix) => Effect.sync(() => `${prefix}_${crypto.randomUUID()}`);
|
|
153
|
+
const make$2 = (tables) => Effect.gen(function* () {
|
|
154
|
+
const sql = (yield* SqlClient.SqlClient).withoutTransforms();
|
|
155
|
+
const authorizations = sql(tables.authorizations.name);
|
|
156
|
+
const connections = sql(tables.connections.name);
|
|
157
|
+
const attachments = sql(tables.attachments.name);
|
|
158
|
+
const continuations = sql(tables.continuations.name);
|
|
159
|
+
const attempts = sql(tables.attempts.name);
|
|
160
|
+
const readiness = sql(tables.readiness.name);
|
|
161
|
+
/** The row this principal owns, locked for the rest of the transaction. */
|
|
162
|
+
const lockedAuthorization = (owner, id) => sql`
|
|
163
|
+
SELECT * FROM ${authorizations} WHERE id = ${id} AND owner_id = ${owner} FOR UPDATE
|
|
164
|
+
`.pipe(Effect.flatMap((rows) => rows[0] === void 0 ? notFound("authorization", id) : Effect.succeed(rows[0])));
|
|
165
|
+
const lockedAttempt = (owner, id) => sql`
|
|
166
|
+
SELECT * FROM ${attempts} WHERE id = ${id} AND owner_id = ${owner} FOR UPDATE
|
|
167
|
+
`.pipe(Effect.flatMap((rows) => rows[0] === void 0 ? notFound("plan", id) : Effect.succeed(rows[0])), Effect.flatMap(attemptOf));
|
|
168
|
+
const requireConnection = (owner, id) => sql`
|
|
169
|
+
SELECT * FROM ${connections} WHERE id = ${id} AND owner_id = ${owner}
|
|
170
|
+
`.pipe(Effect.flatMap((rows) => rows[0] === void 0 ? notFound("connection", id) : Effect.succeed(rows[0])));
|
|
171
|
+
const requireAttachment = (owner, id) => sql`
|
|
172
|
+
SELECT * FROM ${attachments} WHERE id = ${id} AND owner_id = ${owner}
|
|
173
|
+
`.pipe(Effect.flatMap((rows) => rows[0] === void 0 ? notFound("attachment", id) : Effect.succeed(rows[0])));
|
|
174
|
+
const writeAuthorization = (authorization, credential) => Effect.all({
|
|
175
|
+
row: authorizationCodec.write(authorization),
|
|
176
|
+
secret: credentialCodec.write(credential)
|
|
403
177
|
});
|
|
178
|
+
/**
|
|
179
|
+
* Phase one of revocation: mark the row `pending` so a crash between the provider call and the
|
|
180
|
+
* delete is recoverable. Idempotent, and it never widens the row's state.
|
|
181
|
+
*
|
|
182
|
+
* Returns the ciphertext the caller is about to have revoked, which phase two checks.
|
|
183
|
+
*/
|
|
184
|
+
const prepareRevocation = (owner, id) => sql.withTransaction(Effect.gen(function* () {
|
|
185
|
+
const row = yield* lockedAuthorization(owner, id);
|
|
186
|
+
yield* sql`
|
|
187
|
+
UPDATE ${authorizations} SET revocation = 'pending', updated_at = ${yield* now}
|
|
188
|
+
WHERE id = ${id} AND owner_id = ${owner}
|
|
189
|
+
`;
|
|
190
|
+
return row.credential_ciphertext;
|
|
191
|
+
}));
|
|
192
|
+
/**
|
|
193
|
+
* Phase two: delete the row, but only while it still holds the credential the provider was
|
|
194
|
+
* asked to revoke.
|
|
195
|
+
*
|
|
196
|
+
* A credential refresh runs concurrently with revocation and rotates the row after the provider
|
|
197
|
+
* call selected the old ciphertext. Deleting unconditionally would drop the row while the newly
|
|
198
|
+
* issued credential is still live at the provider, with no local state left to revoke it from.
|
|
199
|
+
* Leaving the row `pending` instead hands it to `recoverRevocations`, which revokes what the
|
|
200
|
+
* row now holds. Every ciphertext is AES-GCM with a fresh IV, so equality here is exact.
|
|
201
|
+
*/
|
|
202
|
+
const completeRevocation = (owner, id, ciphertext) => sql`
|
|
203
|
+
DELETE FROM ${authorizations}
|
|
204
|
+
WHERE id = ${id} AND owner_id = ${owner} AND credential_ciphertext = ${ciphertext}
|
|
205
|
+
`;
|
|
206
|
+
return {
|
|
207
|
+
authorizations: {
|
|
208
|
+
upsert: ({ authorization, credential, expectedId }) => Effect.flatMap(Principal.Service, ({ ownerId }) => Effect.gen(function* () {
|
|
209
|
+
if (authorization.ownerId !== ownerId) return yield* invalid("Authorization owner does not match the principal", "ownerId");
|
|
210
|
+
const { row, secret } = yield* writeAuthorization(authorization, credential);
|
|
211
|
+
const stamp = yield* now;
|
|
212
|
+
return yield* sql.withTransaction(Effect.gen(function* () {
|
|
213
|
+
if (expectedId !== void 0) {
|
|
214
|
+
if ((yield* lockedAuthorization(ownerId, expectedId)).revocation !== "active") return yield* busy(`authorization:${expectedId}`);
|
|
215
|
+
if (authorization.id !== expectedId) return yield* invalid("Authorization id must match expectedId", "id");
|
|
216
|
+
yield* sql`
|
|
217
|
+
UPDATE ${authorizations} SET
|
|
218
|
+
provider = ${row.provider},
|
|
219
|
+
method = ${row.method},
|
|
220
|
+
capabilities = ${toJson(row.capabilities)},
|
|
221
|
+
context = ${toJson(row.context)},
|
|
222
|
+
revocation = ${row.revocation},
|
|
223
|
+
created_by = ${row.createdBy},
|
|
224
|
+
created_at = ${at(row.createdAt)},
|
|
225
|
+
credential_ciphertext = ${secret.ciphertext},
|
|
226
|
+
credential_expires_at = ${atOrNull(secret.expiresAt)},
|
|
227
|
+
credential_rotated_at = ${at(secret.rotatedAt)},
|
|
228
|
+
updated_at = ${stamp}
|
|
229
|
+
WHERE id = ${expectedId} AND owner_id = ${ownerId}
|
|
230
|
+
`;
|
|
231
|
+
return authorization;
|
|
232
|
+
}
|
|
233
|
+
return (yield* sql`
|
|
234
|
+
INSERT INTO ${authorizations} (
|
|
235
|
+
id, owner_id, provider, method, capabilities, context, revocation,
|
|
236
|
+
created_by, created_at, credential_ciphertext, credential_expires_at,
|
|
237
|
+
credential_rotated_at, updated_at
|
|
238
|
+
) VALUES (
|
|
239
|
+
${row.id}, ${row.ownerId}, ${row.provider}, ${row.method},
|
|
240
|
+
${toJson(row.capabilities)}, ${toJson(row.context)}, ${row.revocation},
|
|
241
|
+
${row.createdBy}, ${at(row.createdAt)}, ${secret.ciphertext},
|
|
242
|
+
${atOrNull(secret.expiresAt)}, ${at(secret.rotatedAt)}, ${stamp}
|
|
243
|
+
) ON CONFLICT (id) DO NOTHING RETURNING id
|
|
244
|
+
`)[0] === void 0 ? yield* invalid(`Authorization ${authorization.id} already exists`, "id") : authorization;
|
|
245
|
+
}));
|
|
246
|
+
})).pipe(guard("authorizations.upsert")),
|
|
247
|
+
get: (id) => Effect.flatMap(Principal.Service, ({ ownerId }) => sql`
|
|
248
|
+
SELECT * FROM ${authorizations} WHERE id = ${id} AND owner_id = ${ownerId}
|
|
249
|
+
`.pipe(Effect.flatMap((rows) => rows[0] === void 0 ? notFound("authorization", id) : authorizationOf(rows[0])))).pipe(guard("authorizations.get")),
|
|
250
|
+
credential: (id) => Effect.flatMap(Principal.Service, ({ ownerId }) => sql`
|
|
251
|
+
SELECT * FROM ${authorizations} WHERE id = ${id} AND owner_id = ${ownerId}
|
|
252
|
+
`.pipe(Effect.flatMap((rows) => rows[0] === void 0 ? notFound("authorization", id) : credentialOf(rows[0])))).pipe(guard("authorizations.credential")),
|
|
253
|
+
rotate: (id, credential) => Effect.flatMap(Principal.Service, ({ ownerId }) => Effect.gen(function* () {
|
|
254
|
+
const secret = yield* credentialCodec.write(credential);
|
|
255
|
+
if ((yield* sql`
|
|
256
|
+
UPDATE ${authorizations} SET
|
|
257
|
+
credential_ciphertext = ${secret.ciphertext},
|
|
258
|
+
credential_expires_at = ${atOrNull(secret.expiresAt)},
|
|
259
|
+
credential_rotated_at = ${at(secret.rotatedAt)},
|
|
260
|
+
updated_at = ${yield* now}
|
|
261
|
+
WHERE id = ${id} AND owner_id = ${ownerId} RETURNING id
|
|
262
|
+
`)[0] === void 0) return yield* notFound("authorization", id);
|
|
263
|
+
})).pipe(guard("authorizations.rotate")),
|
|
264
|
+
promoteCapabilities: (id, capabilities) => Effect.flatMap(Principal.Service, ({ ownerId }) => sql.withTransaction(Effect.gen(function* () {
|
|
265
|
+
const current = yield* authorizationOf(yield* lockedAuthorization(ownerId, id));
|
|
266
|
+
const merged = [.../* @__PURE__ */ new Set([...current.capabilities, ...capabilities])];
|
|
267
|
+
yield* sql`
|
|
268
|
+
UPDATE ${authorizations}
|
|
269
|
+
SET capabilities = ${toJson(merged)}, updated_at = ${yield* now}
|
|
270
|
+
WHERE id = ${id} AND owner_id = ${ownerId}
|
|
271
|
+
`;
|
|
272
|
+
}))).pipe(guard("authorizations.promoteCapabilities")),
|
|
273
|
+
revoke: (id, revoke) => Effect.flatMap(Principal.Service, ({ ownerId }) => Effect.gen(function* () {
|
|
274
|
+
const ciphertext = yield* prepareRevocation(ownerId, id).pipe(guard("authorizations.revoke"));
|
|
275
|
+
yield* revoke;
|
|
276
|
+
yield* completeRevocation(ownerId, id, ciphertext).pipe(guard("authorizations.completeRevocation"));
|
|
277
|
+
})),
|
|
278
|
+
recoverRevocations: (revoke) => Effect.flatMap(Principal.Service, ({ ownerId }) => Effect.gen(function* () {
|
|
279
|
+
const rows = yield* sql`
|
|
280
|
+
SELECT * FROM ${authorizations}
|
|
281
|
+
WHERE owner_id = ${ownerId} AND revocation = 'pending'
|
|
282
|
+
ORDER BY created_at
|
|
283
|
+
`.pipe(guard("authorizations.pendingRevocations"));
|
|
284
|
+
yield* Effect.forEach(rows, (row) => Effect.gen(function* () {
|
|
285
|
+
yield* revoke(yield* authorizationOf(row));
|
|
286
|
+
yield* completeRevocation(ownerId, row.id, row.credential_ciphertext).pipe(guard("authorizations.completeRevocation"));
|
|
287
|
+
}));
|
|
288
|
+
return rows.length;
|
|
289
|
+
}))
|
|
290
|
+
},
|
|
291
|
+
connections: {
|
|
292
|
+
create: (authorizationId) => Effect.flatMap(Principal.Service, ({ ownerId }) => sql.withTransaction(Effect.gen(function* () {
|
|
293
|
+
yield* lockedAuthorization(ownerId, authorizationId);
|
|
294
|
+
const row = new Storage.Connection({
|
|
295
|
+
id: yield* fresh("conn"),
|
|
296
|
+
ownerId,
|
|
297
|
+
authorizationId,
|
|
298
|
+
createdAt: yield* DateTime.now
|
|
299
|
+
});
|
|
300
|
+
const encoded = yield* connectionCodec.write(row);
|
|
301
|
+
yield* sql`
|
|
302
|
+
INSERT INTO ${connections} (id, owner_id, authorization_id, created_at)
|
|
303
|
+
VALUES (${encoded.id}, ${encoded.ownerId}, ${encoded.authorizationId},
|
|
304
|
+
${at(encoded.createdAt)})
|
|
305
|
+
`;
|
|
306
|
+
return row;
|
|
307
|
+
}))).pipe(guard("connections.create")),
|
|
308
|
+
get: (id) => Effect.flatMap(Principal.Service, ({ ownerId }) => Effect.flatMap(requireConnection(ownerId, id), connectionOf)).pipe(guard("connections.get")),
|
|
309
|
+
list: (filter) => Effect.flatMap(Principal.Service, ({ ownerId }) => Effect.flatMap(filter?.provider === void 0 ? sql`
|
|
310
|
+
SELECT * FROM ${connections} WHERE owner_id = ${ownerId} ORDER BY created_at
|
|
311
|
+
` : sql`
|
|
312
|
+
SELECT c.* FROM ${connections} AS c
|
|
313
|
+
JOIN ${authorizations} AS a ON a.id = c.authorization_id
|
|
314
|
+
WHERE c.owner_id = ${ownerId} AND a.provider = ${filter.provider}
|
|
315
|
+
ORDER BY c.created_at
|
|
316
|
+
`, (rows) => Effect.forEach(rows, connectionOf))).pipe(guard("connections.list")),
|
|
317
|
+
remove: (id) => Effect.flatMap(Principal.Service, ({ ownerId }) => sql.withTransaction(Effect.gen(function* () {
|
|
318
|
+
if ((yield* sql`
|
|
319
|
+
SELECT * FROM ${connections}
|
|
320
|
+
WHERE id = ${id} AND owner_id = ${ownerId} FOR UPDATE
|
|
321
|
+
`)[0] === void 0) return yield* notFound("connection", id);
|
|
322
|
+
if ((yield* sql`
|
|
323
|
+
SELECT id FROM ${attachments}
|
|
324
|
+
WHERE connection_id = ${id} AND owner_id = ${ownerId} LIMIT 1
|
|
325
|
+
`)[0] !== void 0) return yield* invalid(`Connection ${id} still has attachments`, "connectionId");
|
|
326
|
+
yield* sql`DELETE FROM ${connections} WHERE id = ${id} AND owner_id = ${ownerId}`;
|
|
327
|
+
}))).pipe(guard("connections.remove"))
|
|
328
|
+
},
|
|
329
|
+
attachments: {
|
|
330
|
+
create: (input) => Effect.flatMap(Principal.Service, ({ ownerId }) => sql.withTransaction(Effect.gen(function* () {
|
|
331
|
+
yield* requireConnection(ownerId, input.connectionId);
|
|
332
|
+
const row = new Storage.Attachment({
|
|
333
|
+
id: yield* fresh("att"),
|
|
334
|
+
ownerId,
|
|
335
|
+
connectionId: input.connectionId,
|
|
336
|
+
domain: input.domain,
|
|
337
|
+
zone: input.zone,
|
|
338
|
+
target: input.target,
|
|
339
|
+
createdAt: yield* DateTime.now
|
|
340
|
+
});
|
|
341
|
+
const encoded = yield* attachmentCodec.write(row);
|
|
342
|
+
return (yield* sql`
|
|
343
|
+
INSERT INTO ${attachments} (
|
|
344
|
+
id, owner_id, connection_id, domain, zone, target, created_at
|
|
345
|
+
) VALUES (
|
|
346
|
+
${encoded.id}, ${encoded.ownerId}, ${encoded.connectionId}, ${encoded.domain},
|
|
347
|
+
${encoded.zone}, ${toJson(encoded.target)}, ${at(encoded.createdAt)}
|
|
348
|
+
) ON CONFLICT (owner_id, domain) DO NOTHING RETURNING id
|
|
349
|
+
`)[0] === void 0 ? yield* invalid(`${input.domain} is already attached`, "domain") : row;
|
|
350
|
+
}))).pipe(guard("attachments.create")),
|
|
351
|
+
get: (id) => Effect.flatMap(Principal.Service, ({ ownerId }) => Effect.flatMap(requireAttachment(ownerId, id), attachmentOf)).pipe(guard("attachments.get")),
|
|
352
|
+
byDomain: (domain) => Effect.flatMap(Principal.Service, ({ ownerId }) => sql`
|
|
353
|
+
SELECT * FROM ${attachments} WHERE owner_id = ${ownerId} AND domain = ${domain}
|
|
354
|
+
`.pipe(Effect.flatMap((rows) => rows[0] === void 0 ? Effect.succeedNone : Effect.map(attachmentOf(rows[0]), Option.some)))).pipe(guard("attachments.byDomain")),
|
|
355
|
+
list: (connectionId) => Effect.flatMap(Principal.Service, ({ ownerId }) => Effect.gen(function* () {
|
|
356
|
+
yield* requireConnection(ownerId, connectionId);
|
|
357
|
+
const rows = yield* sql`
|
|
358
|
+
SELECT * FROM ${attachments}
|
|
359
|
+
WHERE owner_id = ${ownerId} AND connection_id = ${connectionId}
|
|
360
|
+
ORDER BY created_at
|
|
361
|
+
`;
|
|
362
|
+
return yield* Effect.forEach(rows, attachmentOf);
|
|
363
|
+
})).pipe(guard("attachments.list")),
|
|
364
|
+
remove: (id) => Effect.flatMap(Principal.Service, ({ ownerId }) => sql.withTransaction(Effect.gen(function* () {
|
|
365
|
+
yield* requireAttachment(ownerId, id);
|
|
366
|
+
yield* sql`
|
|
367
|
+
UPDATE ${readiness} SET attachment_id = NULL
|
|
368
|
+
WHERE attachment_id = ${id} AND owner_id = ${ownerId}
|
|
369
|
+
`;
|
|
370
|
+
yield* sql`DELETE FROM ${attachments} WHERE id = ${id} AND owner_id = ${ownerId}`;
|
|
371
|
+
}))).pipe(guard("attachments.remove"))
|
|
372
|
+
},
|
|
373
|
+
continuations: {
|
|
374
|
+
put: (continuation) => Effect.flatMap(Principal.Service, ({ ownerId }) => Effect.gen(function* () {
|
|
375
|
+
if (continuation.ownerId !== ownerId) return yield* invalid("Continuation owner does not match the principal", "ownerId");
|
|
376
|
+
const encoded = yield* continuationCodec.write(continuation);
|
|
377
|
+
yield* sql`
|
|
378
|
+
INSERT INTO ${continuations} (
|
|
379
|
+
id, owner_id, actor_id, provider, payload, return_to, expires_at
|
|
380
|
+
) VALUES (
|
|
381
|
+
${encoded.id}, ${encoded.ownerId}, ${encoded.actorId}, ${encoded.provider},
|
|
382
|
+
${toJson(encoded.payload)}, ${encoded.returnTo}, ${at(encoded.expiresAt)}
|
|
383
|
+
) ON CONFLICT (id) DO UPDATE SET
|
|
384
|
+
owner_id = EXCLUDED.owner_id,
|
|
385
|
+
actor_id = EXCLUDED.actor_id,
|
|
386
|
+
provider = EXCLUDED.provider,
|
|
387
|
+
payload = EXCLUDED.payload,
|
|
388
|
+
return_to = EXCLUDED.return_to,
|
|
389
|
+
expires_at = EXCLUDED.expires_at
|
|
390
|
+
`;
|
|
391
|
+
})).pipe(guard("continuations.put")),
|
|
392
|
+
get: (id) => Effect.flatMap(Principal.Service, ({ ownerId }) => Effect.gen(function* () {
|
|
393
|
+
const rows = yield* sql`
|
|
394
|
+
SELECT * FROM ${continuations} WHERE id = ${id} AND owner_id = ${ownerId}
|
|
395
|
+
`;
|
|
396
|
+
if (rows[0] === void 0) return yield* notFound("continuation", id);
|
|
397
|
+
const continuation = yield* continuationOf(rows[0]);
|
|
398
|
+
const instant = yield* DateTime.now;
|
|
399
|
+
return DateTime.toEpochMillis(continuation.expiresAt) <= DateTime.toEpochMillis(instant) ? yield* fail(new Reason.Expired({
|
|
400
|
+
entity: "continuation",
|
|
401
|
+
id
|
|
402
|
+
})) : continuation;
|
|
403
|
+
})).pipe(guard("continuations.get")),
|
|
404
|
+
consume: (id) => Effect.flatMap(Principal.Service, ({ ownerId }) => Effect.gen(function* () {
|
|
405
|
+
const rows = yield* sql`
|
|
406
|
+
DELETE FROM ${continuations}
|
|
407
|
+
WHERE id = ${id} AND owner_id = ${ownerId} RETURNING *
|
|
408
|
+
`;
|
|
409
|
+
if (rows[0] === void 0) return yield* notFound("continuation", id);
|
|
410
|
+
const continuation = yield* continuationOf(rows[0]);
|
|
411
|
+
const instant = yield* DateTime.now;
|
|
412
|
+
return DateTime.toEpochMillis(continuation.expiresAt) <= DateTime.toEpochMillis(instant) ? yield* fail(new Reason.Expired({
|
|
413
|
+
entity: "continuation",
|
|
414
|
+
id
|
|
415
|
+
})) : continuation;
|
|
416
|
+
})).pipe(guard("continuations.consume"))
|
|
417
|
+
},
|
|
418
|
+
attempts: {
|
|
419
|
+
create: (attempt) => Effect.flatMap(Principal.Service, ({ ownerId }) => Effect.gen(function* () {
|
|
420
|
+
if (attempt.ownerId !== ownerId) return yield* invalid("Attempt owner does not match the principal", "ownerId");
|
|
421
|
+
const encoded = yield* attemptCodec.write(attempt);
|
|
422
|
+
return (yield* sql`
|
|
423
|
+
INSERT INTO ${attempts} (
|
|
424
|
+
id, owner_id, attachment_id, kind, status, plan, approval, approval_id,
|
|
425
|
+
receipt, receipt_id, rejection, source_receipt_id, lease_expires_at, failure,
|
|
426
|
+
plan_created_at, updated_at
|
|
427
|
+
) VALUES (
|
|
428
|
+
${encoded.id}, ${encoded.ownerId}, ${encoded.attachmentId}, ${encoded.kind},
|
|
429
|
+
${encoded.status}, ${toJson(encoded.plan)}, ${toJsonOrNull(encoded.approval)},
|
|
430
|
+
${attempt.approval?.id ?? null}, ${toJsonOrNull(encoded.receipt)},
|
|
431
|
+
${attempt.receipt?.id ?? null}, ${toJsonOrNull(encoded.rejection)},
|
|
432
|
+
${encoded.sourceReceiptId},
|
|
433
|
+
${atOrNull(encoded.leaseExpiresAt)}, ${encoded.failure},
|
|
434
|
+
${at(encoded.plan.createdAt)}, ${at(encoded.updatedAt)}
|
|
435
|
+
) ON CONFLICT (id) DO NOTHING RETURNING id
|
|
436
|
+
`)[0] === void 0 ? yield* invalid(`Plan ${attempt.id} already exists`, "id") : attempt;
|
|
437
|
+
})).pipe(guard("attempts.create")),
|
|
438
|
+
get: (id) => Effect.flatMap(Principal.Service, ({ ownerId }) => sql`
|
|
439
|
+
SELECT * FROM ${attempts} WHERE id = ${id} AND owner_id = ${ownerId}
|
|
440
|
+
`.pipe(Effect.flatMap((rows) => rows[0] === void 0 ? notFound("plan", id) : attemptOf(rows[0])))).pipe(guard("attempts.get")),
|
|
441
|
+
byApproval: (id) => Effect.flatMap(Principal.Service, ({ ownerId }) => sql`
|
|
442
|
+
SELECT * FROM ${attempts} WHERE approval_id = ${id} AND owner_id = ${ownerId}
|
|
443
|
+
`.pipe(Effect.flatMap((rows) => rows[0] === void 0 ? notFound("approval", id) : attemptOf(rows[0])))).pipe(guard("attempts.byApproval")),
|
|
444
|
+
byReceipt: (id) => Effect.flatMap(Principal.Service, ({ ownerId }) => sql`
|
|
445
|
+
SELECT * FROM ${attempts} WHERE receipt_id = ${id} AND owner_id = ${ownerId}
|
|
446
|
+
`.pipe(Effect.flatMap((rows) => rows[0] === void 0 ? notFound("receipt", id) : attemptOf(rows[0])))).pipe(guard("attempts.byReceipt")),
|
|
447
|
+
latest: (attachmentId, kind) => Effect.flatMap(Principal.Service, ({ ownerId }) => sql`
|
|
448
|
+
SELECT * FROM ${attempts}
|
|
449
|
+
WHERE owner_id = ${ownerId} AND attachment_id = ${attachmentId} AND kind = ${kind}
|
|
450
|
+
ORDER BY plan_created_at DESC, updated_at DESC LIMIT 1
|
|
451
|
+
`.pipe(Effect.flatMap((rows) => rows[0] === void 0 ? Effect.succeedNone : Effect.map(attemptOf(rows[0]), Option.some)))).pipe(guard("attempts.latest")),
|
|
452
|
+
approve: (id, approval) => Effect.flatMap(Principal.Service, ({ ownerId }) => sql.withTransaction(Effect.gen(function* () {
|
|
453
|
+
const current = yield* lockedAttempt(ownerId, id);
|
|
454
|
+
if (current.approval?.id === approval.id) return current;
|
|
455
|
+
if (current.status !== "planned") return yield* stale(current);
|
|
456
|
+
const next = new Storage.Attempt({
|
|
457
|
+
...current,
|
|
458
|
+
status: "approved",
|
|
459
|
+
approval,
|
|
460
|
+
updatedAt: yield* DateTime.now
|
|
461
|
+
});
|
|
462
|
+
const encoded = yield* attemptCodec.write(next);
|
|
463
|
+
yield* sql`
|
|
464
|
+
UPDATE ${attempts} SET
|
|
465
|
+
status = ${encoded.status},
|
|
466
|
+
approval = ${toJsonOrNull(encoded.approval)},
|
|
467
|
+
approval_id = ${approval.id},
|
|
468
|
+
updated_at = ${at(encoded.updatedAt)}
|
|
469
|
+
WHERE id = ${id} AND owner_id = ${ownerId}
|
|
470
|
+
`;
|
|
471
|
+
return next;
|
|
472
|
+
}))).pipe(guard("attempts.approve")),
|
|
473
|
+
reject: (id, input) => Effect.flatMap(Principal.Service, ({ ownerId }) => sql.withTransaction(Effect.gen(function* () {
|
|
474
|
+
const current = yield* lockedAttempt(ownerId, id);
|
|
475
|
+
if (current.status === "rejected") return current;
|
|
476
|
+
if (current.status === "expired") return yield* fail(new Reason.Expired({
|
|
477
|
+
entity: "plan",
|
|
478
|
+
id
|
|
479
|
+
}));
|
|
480
|
+
if (current.status !== "planned" || current.plan.digest !== input.digest) return yield* stale(current);
|
|
481
|
+
const instant = yield* DateTime.now;
|
|
482
|
+
const next = new Storage.Attempt({
|
|
483
|
+
...current,
|
|
484
|
+
status: "rejected",
|
|
485
|
+
rejection: {
|
|
486
|
+
actorId: input.actorId,
|
|
487
|
+
reason: input.reason,
|
|
488
|
+
at: instant
|
|
489
|
+
},
|
|
490
|
+
updatedAt: instant
|
|
491
|
+
});
|
|
492
|
+
const encoded = yield* attemptCodec.write(next);
|
|
493
|
+
yield* sql`
|
|
494
|
+
UPDATE ${attempts} SET
|
|
495
|
+
status = ${encoded.status},
|
|
496
|
+
rejection = ${toJsonOrNull(encoded.rejection)},
|
|
497
|
+
updated_at = ${at(encoded.updatedAt)}
|
|
498
|
+
WHERE id = ${id} AND owner_id = ${ownerId}
|
|
499
|
+
`;
|
|
500
|
+
return next;
|
|
501
|
+
}))).pipe(guard("attempts.reject")),
|
|
502
|
+
claim: (id, lease) => Effect.flatMap(Principal.Service, ({ ownerId }) => sql.withTransaction(Effect.gen(function* () {
|
|
503
|
+
const current = yield* lockedAttempt(ownerId, id);
|
|
504
|
+
const instant = yield* DateTime.now;
|
|
505
|
+
if (current.status === "applying") {
|
|
506
|
+
if (current.leaseExpiresAt !== null && DateTime.toEpochMillis(current.leaseExpiresAt) > DateTime.toEpochMillis(instant)) return yield* busy(`apply:${id}`);
|
|
507
|
+
} else if (current.status !== "approved" && current.status !== "failed") return yield* stale(current);
|
|
508
|
+
const next = new Storage.Attempt({
|
|
509
|
+
...current,
|
|
510
|
+
status: "applying",
|
|
511
|
+
leaseExpiresAt: lease,
|
|
512
|
+
failure: null,
|
|
513
|
+
updatedAt: instant
|
|
514
|
+
});
|
|
515
|
+
const encoded = yield* attemptCodec.write(next);
|
|
516
|
+
yield* sql`
|
|
517
|
+
UPDATE ${attempts} SET
|
|
518
|
+
status = ${encoded.status},
|
|
519
|
+
lease_expires_at = ${atOrNull(encoded.leaseExpiresAt)},
|
|
520
|
+
failure = NULL,
|
|
521
|
+
updated_at = ${at(encoded.updatedAt)}
|
|
522
|
+
WHERE id = ${id} AND owner_id = ${ownerId}
|
|
523
|
+
`;
|
|
524
|
+
return next;
|
|
525
|
+
}))).pipe(guard("attempts.claim")),
|
|
526
|
+
complete: (id, receipt) => Effect.flatMap(Principal.Service, ({ ownerId }) => sql.withTransaction(Effect.gen(function* () {
|
|
527
|
+
const current = yield* lockedAttempt(ownerId, id);
|
|
528
|
+
if (current.status !== "applying") return yield* stale(current);
|
|
529
|
+
const next = new Storage.Attempt({
|
|
530
|
+
...current,
|
|
531
|
+
status: receipt.status,
|
|
532
|
+
receipt,
|
|
533
|
+
leaseExpiresAt: null,
|
|
534
|
+
updatedAt: yield* DateTime.now
|
|
535
|
+
});
|
|
536
|
+
const encoded = yield* attemptCodec.write(next);
|
|
537
|
+
yield* sql`
|
|
538
|
+
UPDATE ${attempts} SET
|
|
539
|
+
status = ${encoded.status},
|
|
540
|
+
receipt = ${toJsonOrNull(encoded.receipt)},
|
|
541
|
+
receipt_id = ${receipt.id},
|
|
542
|
+
lease_expires_at = NULL,
|
|
543
|
+
updated_at = ${at(encoded.updatedAt)}
|
|
544
|
+
WHERE id = ${id} AND owner_id = ${ownerId}
|
|
545
|
+
`;
|
|
546
|
+
return next;
|
|
547
|
+
}))).pipe(guard("attempts.complete")),
|
|
548
|
+
fail: (id, message) => Effect.flatMap(Principal.Service, ({ ownerId }) => sql.withTransaction(Effect.gen(function* () {
|
|
549
|
+
const current = yield* lockedAttempt(ownerId, id);
|
|
550
|
+
if (current.status !== "applying") return yield* stale(current);
|
|
551
|
+
const next = new Storage.Attempt({
|
|
552
|
+
...current,
|
|
553
|
+
status: "failed",
|
|
554
|
+
failure: message,
|
|
555
|
+
leaseExpiresAt: null,
|
|
556
|
+
updatedAt: yield* DateTime.now
|
|
557
|
+
});
|
|
558
|
+
const encoded = yield* attemptCodec.write(next);
|
|
559
|
+
yield* sql`
|
|
560
|
+
UPDATE ${attempts} SET
|
|
561
|
+
status = ${encoded.status},
|
|
562
|
+
failure = ${message},
|
|
563
|
+
lease_expires_at = NULL,
|
|
564
|
+
updated_at = ${at(encoded.updatedAt)}
|
|
565
|
+
WHERE id = ${id} AND owner_id = ${ownerId}
|
|
566
|
+
`;
|
|
567
|
+
return next;
|
|
568
|
+
}))).pipe(guard("attempts.fail"))
|
|
569
|
+
},
|
|
570
|
+
readiness: {
|
|
571
|
+
put: (row) => Effect.flatMap(Principal.Service, ({ ownerId }) => Effect.gen(function* () {
|
|
572
|
+
if (row.ownerId !== ownerId) return yield* invalid("Readiness owner does not match the principal", "ownerId");
|
|
573
|
+
if (row.attachmentId !== null) yield* requireAttachment(ownerId, row.attachmentId);
|
|
574
|
+
const encoded = yield* readinessCodec.write(row);
|
|
575
|
+
yield* sql`
|
|
576
|
+
INSERT INTO ${readiness} (
|
|
577
|
+
owner_id, domain, attachment_id, overall, requirements, host, pending_since,
|
|
578
|
+
checked_at, next_check_at
|
|
579
|
+
) VALUES (
|
|
580
|
+
${encoded.ownerId}, ${encoded.domain}, ${encoded.attachmentId},
|
|
581
|
+
${encoded.overall}, ${toJson(encoded.requirements)}, ${toJson(encoded.host)},
|
|
582
|
+
${atOrNull(encoded.pendingSince)}, ${at(encoded.checkedAt)},
|
|
583
|
+
${atOrNull(encoded.nextCheckAt)}
|
|
584
|
+
) ON CONFLICT (owner_id, domain) DO UPDATE SET
|
|
585
|
+
attachment_id = EXCLUDED.attachment_id,
|
|
586
|
+
overall = EXCLUDED.overall,
|
|
587
|
+
requirements = EXCLUDED.requirements,
|
|
588
|
+
host = EXCLUDED.host,
|
|
589
|
+
pending_since = EXCLUDED.pending_since,
|
|
590
|
+
checked_at = EXCLUDED.checked_at,
|
|
591
|
+
next_check_at = EXCLUDED.next_check_at
|
|
592
|
+
`;
|
|
593
|
+
})).pipe(guard("readiness.put")),
|
|
594
|
+
get: (domain) => Effect.flatMap(Principal.Service, ({ ownerId }) => sql`
|
|
595
|
+
SELECT * FROM ${readiness}
|
|
596
|
+
WHERE owner_id = ${ownerId} AND domain = ${domain}
|
|
597
|
+
`.pipe(Effect.flatMap((rows) => rows[0] === void 0 ? Effect.succeedNone : Effect.map(readinessOf(rows[0]), Option.some)))).pipe(guard("readiness.get"))
|
|
598
|
+
},
|
|
599
|
+
withLock: (key, effect) => Effect.flatMap(Principal.Service, ({ ownerId }) => Effect.scoped(Effect.gen(function* () {
|
|
600
|
+
const connection = yield* Effect.mapError(sql.reserve, (error) => storageFailed("withLock", sqlMessage(error)));
|
|
601
|
+
const scoped = `${ownerId}:${key}`;
|
|
602
|
+
yield* Effect.acquireRelease(connection.execute("SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS acquired", [scoped], void 0).pipe(Effect.mapError((error) => storageFailed("withLock", sqlMessage(error))), Effect.flatMap((rows) => rows[0]?.acquired === true ? Effect.void : busy(key))), () => connection.execute("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [scoped], void 0).pipe(Effect.ignore));
|
|
603
|
+
return yield* effect;
|
|
604
|
+
})))
|
|
605
|
+
};
|
|
404
606
|
});
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
607
|
+
//#endregion
|
|
608
|
+
//#region src/internal/tables.ts
|
|
609
|
+
/**
|
|
610
|
+
* The six tables DomainKit owns, declared once and rendered per dialect by CapsuleDB.
|
|
611
|
+
*
|
|
612
|
+
* Every table carries `owner_id` and every query filters by it: tenancy is a column, not a schema.
|
|
613
|
+
* There are no foreign keys to host tables, and none between DomainKit's own tables either, so a
|
|
614
|
+
* host can add its own in the emitted migration without fighting a constraint the package owns.
|
|
615
|
+
*/
|
|
616
|
+
/** Default table prefix. Part of the physical layout, so it is immutable after the first deploy. */
|
|
617
|
+
const DEFAULT_PREFIX = "domainkit";
|
|
618
|
+
/**
|
|
619
|
+
* Declare the six tables under `prefix`.
|
|
620
|
+
*
|
|
621
|
+
* `Schema.table` validates every identifier it will quote, so an invalid prefix throws
|
|
622
|
+
* `CapsuleDefinitionError` here rather than reaching a renderer.
|
|
623
|
+
*/
|
|
624
|
+
const make$1 = (prefix) => ({
|
|
625
|
+
authorizations: Schema.table(`${prefix}_authorizations`, {
|
|
626
|
+
columns: {
|
|
627
|
+
id: Schema.text(),
|
|
628
|
+
owner_id: Schema.text(),
|
|
629
|
+
provider: Schema.text(),
|
|
630
|
+
method: Schema.text(),
|
|
631
|
+
capabilities: Schema.json(),
|
|
632
|
+
context: Schema.json(),
|
|
633
|
+
revocation: Schema.text(),
|
|
634
|
+
created_by: Schema.text(),
|
|
635
|
+
created_at: Schema.timestamp(),
|
|
636
|
+
credential_ciphertext: Schema.text(),
|
|
637
|
+
credential_expires_at: Schema.timestamp({ nullable: true }),
|
|
638
|
+
credential_rotated_at: Schema.timestamp(),
|
|
639
|
+
updated_at: Schema.timestamp()
|
|
640
|
+
},
|
|
641
|
+
primaryKey: ["id"],
|
|
642
|
+
indexes: [{ columns: ["owner_id", "revocation"] }, { columns: ["owner_id", "provider"] }]
|
|
643
|
+
}),
|
|
644
|
+
connections: Schema.table(`${prefix}_connections`, {
|
|
645
|
+
columns: {
|
|
646
|
+
id: Schema.text(),
|
|
647
|
+
owner_id: Schema.text(),
|
|
648
|
+
authorization_id: Schema.text(),
|
|
649
|
+
created_at: Schema.timestamp()
|
|
650
|
+
},
|
|
651
|
+
primaryKey: ["id"],
|
|
652
|
+
indexes: [{ columns: ["owner_id"] }, { columns: ["authorization_id"] }]
|
|
653
|
+
}),
|
|
654
|
+
attachments: Schema.table(`${prefix}_attachments`, {
|
|
655
|
+
columns: {
|
|
656
|
+
id: Schema.text(),
|
|
657
|
+
owner_id: Schema.text(),
|
|
658
|
+
connection_id: Schema.text(),
|
|
659
|
+
domain: Schema.text(),
|
|
660
|
+
zone: Schema.text(),
|
|
661
|
+
target: Schema.json(),
|
|
662
|
+
created_at: Schema.timestamp()
|
|
663
|
+
},
|
|
664
|
+
primaryKey: ["id"],
|
|
665
|
+
uniques: [["owner_id", "domain"]],
|
|
666
|
+
indexes: [{ columns: ["owner_id", "connection_id"] }]
|
|
667
|
+
}),
|
|
668
|
+
continuations: Schema.table(`${prefix}_continuations`, {
|
|
669
|
+
columns: {
|
|
670
|
+
id: Schema.text(),
|
|
671
|
+
owner_id: Schema.text(),
|
|
672
|
+
actor_id: Schema.text(),
|
|
673
|
+
provider: Schema.text(),
|
|
674
|
+
payload: Schema.json(),
|
|
675
|
+
return_to: Schema.text({ nullable: true }),
|
|
676
|
+
expires_at: Schema.timestamp()
|
|
677
|
+
},
|
|
678
|
+
primaryKey: ["id"],
|
|
679
|
+
indexes: [{ columns: ["expires_at"] }]
|
|
680
|
+
}),
|
|
681
|
+
attempts: Schema.table(`${prefix}_attempts`, {
|
|
682
|
+
columns: {
|
|
683
|
+
id: Schema.text(),
|
|
684
|
+
owner_id: Schema.text(),
|
|
685
|
+
attachment_id: Schema.text(),
|
|
686
|
+
kind: Schema.text(),
|
|
687
|
+
status: Schema.text(),
|
|
688
|
+
plan: Schema.json(),
|
|
689
|
+
approval: Schema.json({ nullable: true }),
|
|
690
|
+
/** Denormalized from `approval` so `byApproval` is an index lookup. */
|
|
691
|
+
approval_id: Schema.text({ nullable: true }),
|
|
692
|
+
receipt: Schema.json({ nullable: true }),
|
|
693
|
+
/** Denormalized from `receipt` so `byReceipt` is an index lookup. */
|
|
694
|
+
receipt_id: Schema.text({ nullable: true }),
|
|
695
|
+
/** Who declined the plan, why, and when; set when an attempt reaches `rejected`. */
|
|
696
|
+
rejection: Schema.json({ nullable: true }),
|
|
697
|
+
source_receipt_id: Schema.text({ nullable: true }),
|
|
698
|
+
lease_expires_at: Schema.timestamp({ nullable: true }),
|
|
699
|
+
failure: Schema.text({ nullable: true }),
|
|
700
|
+
/** The plan's own `createdAt`; `latest` orders by it. */
|
|
701
|
+
plan_created_at: Schema.timestamp(),
|
|
702
|
+
updated_at: Schema.timestamp()
|
|
703
|
+
},
|
|
704
|
+
primaryKey: ["id"],
|
|
705
|
+
indexes: [
|
|
706
|
+
{ columns: [
|
|
707
|
+
"owner_id",
|
|
708
|
+
"attachment_id",
|
|
709
|
+
"kind"
|
|
710
|
+
] },
|
|
711
|
+
{ columns: ["owner_id", "approval_id"] },
|
|
712
|
+
{ columns: ["owner_id", "receipt_id"] }
|
|
713
|
+
]
|
|
714
|
+
}),
|
|
715
|
+
readiness: Schema.table(`${prefix}_readiness`, {
|
|
716
|
+
columns: {
|
|
717
|
+
owner_id: Schema.text(),
|
|
718
|
+
domain: Schema.text(),
|
|
719
|
+
/** Set while an attachment covers the domain; observe-only readiness leaves it null. */
|
|
720
|
+
attachment_id: Schema.text({ nullable: true }),
|
|
721
|
+
overall: Schema.text(),
|
|
722
|
+
requirements: Schema.json(),
|
|
723
|
+
host: Schema.json(),
|
|
724
|
+
pending_since: Schema.timestamp({ nullable: true }),
|
|
725
|
+
checked_at: Schema.timestamp(),
|
|
726
|
+
next_check_at: Schema.timestamp({ nullable: true })
|
|
727
|
+
},
|
|
728
|
+
primaryKey: ["owner_id", "domain"],
|
|
729
|
+
indexes: [{ columns: ["owner_id", "next_check_at"] }, { columns: ["attachment_id"] }]
|
|
730
|
+
})
|
|
477
731
|
});
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
732
|
+
const list = (tables) => [
|
|
733
|
+
tables.authorizations,
|
|
734
|
+
tables.connections,
|
|
735
|
+
tables.attachments,
|
|
736
|
+
tables.continuations,
|
|
737
|
+
tables.attempts,
|
|
738
|
+
tables.readiness
|
|
739
|
+
];
|
|
740
|
+
//#endregion
|
|
741
|
+
//#region src/capsule.ts
|
|
742
|
+
/**
|
|
743
|
+
* DomainKit's CapsuleDB capsule: six tables and one `Storage.Service` implementation.
|
|
744
|
+
*
|
|
745
|
+
* The capsule is a plain value, so a host imports it, points `capsuledb emit` at it, or hands it
|
|
746
|
+
* to `Registry.layer` without running an Effect first.
|
|
747
|
+
*/
|
|
748
|
+
/**
|
|
749
|
+
* Build the capsule for a table prefix.
|
|
750
|
+
*
|
|
751
|
+
* The prefix is part of the physical layout: it changes the rendered DDL and therefore the
|
|
752
|
+
* migration checksums, so a deployed registry must keep the prefix it was created with.
|
|
753
|
+
*/
|
|
754
|
+
const make = (prefix = DEFAULT_PREFIX) => {
|
|
755
|
+
const tables = make$1(prefix);
|
|
756
|
+
return Capsule.make({
|
|
757
|
+
id: "domainkit.storage",
|
|
758
|
+
tables: list(tables),
|
|
759
|
+
migrations: [Migration.make({
|
|
760
|
+
id: 1,
|
|
761
|
+
name: "create-storage",
|
|
762
|
+
risk: "additive",
|
|
763
|
+
steps: list(tables).map(Migration.createTable)
|
|
764
|
+
})],
|
|
765
|
+
layer: Layer.effect(Storage.Service)(make$2(tables))
|
|
485
766
|
});
|
|
767
|
+
};
|
|
768
|
+
/** The capsule under the default `domainkit` prefix. `capsuledb emit --export capsule` reads this. */
|
|
769
|
+
const capsule = make();
|
|
770
|
+
//#endregion
|
|
771
|
+
//#region src/PgStorage.ts
|
|
772
|
+
/**
|
|
773
|
+
* The layer a host installs: DomainKit's `Storage` on the host's own PostgreSQL client.
|
|
774
|
+
*
|
|
775
|
+
* `layer()` runs the capsule through CapsuleDB's registry, which creates the ledger, applies
|
|
776
|
+
* pending migrations, and only then provides `Storage`. A host that owns its migration pipeline
|
|
777
|
+
* applies `capsuledb emit` output instead and boots with `mode: "assert"`, which touches no schema
|
|
778
|
+
* and fails unless the database already matches the capsule.
|
|
779
|
+
*
|
|
780
|
+
* The layer requires only `SqlClient`: credentials arrive sealed, so Storage never needs `Custody`.
|
|
781
|
+
*/
|
|
782
|
+
var PgStorage_exports = /* @__PURE__ */ __exportAll({ layer: () => layer });
|
|
783
|
+
const layer = (options = {}) => Registry.layer({
|
|
784
|
+
provider: Pg.profile,
|
|
785
|
+
capsules: [options.prefix === void 0 || options.prefix === "domainkit" ? capsule : make(options.prefix)],
|
|
786
|
+
...options.mode === void 0 ? {} : { mode: options.mode },
|
|
787
|
+
...options.registryPrefix === void 0 ? {} : { prefix: options.registryPrefix }
|
|
486
788
|
});
|
|
487
789
|
//#endregion
|
|
488
790
|
//#region src/index.ts
|
|
791
|
+
/**
|
|
792
|
+
* DomainKit's `Storage` on PostgreSQL, as one declarative CapsuleDB capsule.
|
|
793
|
+
*
|
|
794
|
+
* Hosts install `PgStorage.layer()` under `DomainKit.layer` and supply their own `SqlClient`. The
|
|
795
|
+
* `capsule` value is the same declaration `capsuledb emit` reads to write SQL for a host-owned
|
|
796
|
+
* migration pipeline.
|
|
797
|
+
*/
|
|
489
798
|
const VERSION = version;
|
|
490
799
|
//#endregion
|
|
491
|
-
export {
|
|
800
|
+
export { PgStorage_exports as PgStorage, VERSION, capsule };
|
|
492
801
|
|
|
493
802
|
//# sourceMappingURL=index.mjs.map
|