@byok-sdk/cloud-dataplane 0.4.1 → 0.5.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.
@@ -0,0 +1,3358 @@
1
+ import pg from 'pg';
2
+ import { DEDUP_RING_CAPACITY, NONCE_TTL_MS, validateActivityAppend, projectTimelineEvents, ByokCloudError, validateApprovalTimelineAppend, ApprovalObservationSchema, AllowAllRateLimiter, TruthCommitError, TruthCommitResponseSchema, truthRecordMetadata, TRUTH_REQUEST_ID_MAX_LENGTH, parseTimelineEvents, activityCursor, parseApprovalObservations, approvalTimelineCursor } from '@byok-sdk/cloud';
3
+ import { ByokCoreError, assertCanonicalTimestamp, contentHash, isContentHash, tenantObjectKey, objectKeyPrefix, CoreConflictError, isLegalBoardTransition, checkSkillPackManifest, checkSkillPackEntry, SKILL_PACK_ENTRY_PATH, SKILL_PACK_MANIFEST_SCHEMA_ID } from '@byok-sdk/core';
4
+ import { AwsClient } from 'aws4fetch';
5
+ import { XMLParser } from 'fast-xml-parser';
6
+
7
+ // src/pool.ts
8
+ var defaultTypeParser = pg.types.getTypeParser;
9
+ var int8Parsers = {
10
+ getTypeParser(id, format) {
11
+ if (id === pg.types.builtins.INT8) return (value) => BigInt(value);
12
+ return defaultTypeParser(id, format);
13
+ }
14
+ };
15
+ function createByokPool(options) {
16
+ const { onPoolError, ...poolConfig } = options;
17
+ const pool = new pg.Pool({ ...poolConfig, types: int8Parsers });
18
+ pool.on("error", (err, client) => {
19
+ if (onPoolError) {
20
+ onPoolError(err, client);
21
+ return;
22
+ }
23
+ console.error("[byok-sdk] idle pg pool client error (handled, not fatal)", err);
24
+ });
25
+ return pool;
26
+ }
27
+ var DEFAULT_LIST_LIMIT = 100;
28
+ var MANIFEST_COLUMNS = "tenant_id, hash, byte_size, content_type, state, ref_count, created_at, updated_at, delete_pending_at";
29
+ function toEntry(row) {
30
+ return {
31
+ tenantId: row.tenant_id,
32
+ hash: row.hash,
33
+ byteSize: row.byte_size,
34
+ contentType: row.content_type,
35
+ state: row.state,
36
+ refCount: row.ref_count,
37
+ createdAt: row.created_at,
38
+ updatedAt: row.updated_at,
39
+ ...row.delete_pending_at === null ? {} : { deletePendingAt: row.delete_pending_at }
40
+ };
41
+ }
42
+ var PostgresObjectStore = class {
43
+ #pool;
44
+ #clock;
45
+ constructor(pool, clock) {
46
+ this.#pool = pool;
47
+ this.#clock = clock;
48
+ }
49
+ async putManifest(tenant, input) {
50
+ const now = this.#now();
51
+ const upserted = await this.#pool.query(
52
+ `INSERT INTO object_manifest (${MANIFEST_COLUMNS})
53
+ VALUES ($1, $2, $3::bigint, $4, 'pending', 0, $5, $5, NULL)
54
+ ON CONFLICT (tenant_id, hash) DO UPDATE
55
+ SET byte_size = EXCLUDED.byte_size,
56
+ content_type = EXCLUDED.content_type,
57
+ state = 'pending',
58
+ ref_count = 0,
59
+ created_at = EXCLUDED.created_at,
60
+ updated_at = EXCLUDED.updated_at,
61
+ delete_pending_at = NULL
62
+ WHERE object_manifest.state = 'deleted'
63
+ RETURNING ${MANIFEST_COLUMNS}`,
64
+ [tenant, input.hash, input.byteSize, input.contentType, now]
65
+ );
66
+ const row = upserted.rows[0];
67
+ if (row !== void 0) return toEntry(row);
68
+ return this.#require(tenant, input.hash);
69
+ }
70
+ async commit(tenant, input) {
71
+ const committed = await this.#pool.query(
72
+ `UPDATE object_manifest
73
+ SET state = 'committed', updated_at = $3
74
+ WHERE tenant_id = $1 AND hash = $2
75
+ AND state = 'pending'
76
+ AND byte_size = $4::bigint
77
+ AND content_type = $5
78
+ RETURNING ${MANIFEST_COLUMNS}`,
79
+ [tenant, input.hash, this.#now(), input.observedByteSize, input.observedContentType]
80
+ );
81
+ const row = committed.rows[0];
82
+ if (row !== void 0) return toEntry(row);
83
+ const current = await this.#require(tenant, input.hash);
84
+ if (current.byteSize !== input.observedByteSize || current.contentType !== input.observedContentType) {
85
+ throw new ByokCoreError(
86
+ "storage_integrity_mismatch",
87
+ `Observed object ${input.hash} (${String(input.observedByteSize)} bytes, ${input.observedContentType}) does not match the declared manifest.`
88
+ );
89
+ }
90
+ if (current.state === "committed") return current;
91
+ throw this.#stateInvalid(current.state, "committed");
92
+ }
93
+ async get(tenant, hash) {
94
+ const result = await this.#pool.query(
95
+ `SELECT ${MANIFEST_COLUMNS} FROM object_manifest WHERE tenant_id = $1 AND hash = $2`,
96
+ [tenant, hash]
97
+ );
98
+ const row = result.rows[0];
99
+ return row === void 0 ? void 0 : toEntry(row);
100
+ }
101
+ async list(tenant, query) {
102
+ if (query.deletePendingBefore !== void 0) {
103
+ assertCanonicalTimestamp(query.deletePendingBefore, "deletePendingBefore");
104
+ }
105
+ const result = await this.#pool.query(
106
+ `SELECT ${MANIFEST_COLUMNS} FROM object_manifest
107
+ WHERE tenant_id = $1
108
+ AND ($2::text IS NULL OR state = $2::text)
109
+ AND ($3::text IS NULL
110
+ OR (delete_pending_at IS NOT NULL AND delete_pending_at < $3::text))
111
+ ORDER BY hash COLLATE "C"
112
+ LIMIT $4`,
113
+ [
114
+ tenant,
115
+ query.state ?? null,
116
+ query.deletePendingBefore ?? null,
117
+ query.limit ?? DEFAULT_LIST_LIMIT
118
+ ]
119
+ );
120
+ return result.rows.map(toEntry);
121
+ }
122
+ async addReference(tenant, input) {
123
+ return this.#withManifestLocked(tenant, input.hash, async (client, current) => {
124
+ if (current.state !== "committed") {
125
+ return new ByokCoreError(
126
+ "object_state_invalid",
127
+ `Only committed objects can be referenced; ${input.hash} is ${current.state}.`
128
+ );
129
+ }
130
+ await client.query(
131
+ `INSERT INTO object_reference (tenant_id, hash, ref_kind, ref_id, created_at)
132
+ VALUES ($1, $2, $3, $4, $5)
133
+ ON CONFLICT (tenant_id, hash, ref_kind, ref_id) DO NOTHING`,
134
+ [tenant, input.hash, input.refKind, input.refId, this.#now()]
135
+ );
136
+ return this.#recount(client, tenant, input.hash);
137
+ });
138
+ }
139
+ async removeReference(tenant, input) {
140
+ return this.#withManifestLocked(tenant, input.hash, async (client) => {
141
+ await client.query(
142
+ `DELETE FROM object_reference
143
+ WHERE tenant_id = $1 AND hash = $2 AND ref_kind = $3 AND ref_id = $4`,
144
+ [tenant, input.hash, input.refKind, input.refId]
145
+ );
146
+ return this.#recount(client, tenant, input.hash);
147
+ });
148
+ }
149
+ async markDeletePending(tenant, hash) {
150
+ const marked = await this.#pool.query(
151
+ `UPDATE object_manifest
152
+ SET gc_accounted_bytes = CASE WHEN state = 'committed' THEN byte_size ELSE 0 END,
153
+ gc_accounted_object = (state = 'committed'),
154
+ state = 'delete_pending', delete_pending_at = $3, updated_at = $3
155
+ WHERE tenant_id = $1 AND hash = $2
156
+ AND ref_count = 0
157
+ AND state IN ('pending', 'committed')
158
+ RETURNING ${MANIFEST_COLUMNS}`,
159
+ [tenant, hash, this.#now()]
160
+ );
161
+ const row = marked.rows[0];
162
+ if (row !== void 0) return toEntry(row);
163
+ const current = await this.#require(tenant, hash);
164
+ if (current.refCount !== 0) {
165
+ throw new ByokCoreError(
166
+ "object_state_invalid",
167
+ `Object ${hash} still has ${current.refCount} reference(s).`
168
+ );
169
+ }
170
+ throw this.#stateInvalid(current.state, "delete_pending");
171
+ }
172
+ async markDeleted(tenant, hash) {
173
+ const deleted = await this.#pool.query(
174
+ `UPDATE object_manifest
175
+ SET state = 'deleted', updated_at = $3
176
+ WHERE tenant_id = $1 AND hash = $2 AND state = 'delete_pending'
177
+ RETURNING ${MANIFEST_COLUMNS}`,
178
+ [tenant, hash, this.#now()]
179
+ );
180
+ const row = deleted.rows[0];
181
+ if (row !== void 0) return toEntry(row);
182
+ const current = await this.#require(tenant, hash);
183
+ throw this.#stateInvalid(current.state, "deleted");
184
+ }
185
+ /**
186
+ * Runs a reference mutation with the manifest row held under `FOR UPDATE`.
187
+ *
188
+ * The lock is taken before anything is read, so the state the callback judges
189
+ * is the state no concurrent `markDeletePending` can move underneath it. A
190
+ * typed refusal is RETURNED rather than thrown, the way
191
+ * `PostgresQuotaStore.reserve` defers its rejection past `COMMIT`: it keeps
192
+ * the `catch` reserved for genuine faults, so a rollback that itself fails
193
+ * cannot replace this store's own answer.
194
+ */
195
+ async #withManifestLocked(tenant, hash, mutate) {
196
+ const client = await this.#pool.connect();
197
+ let settled;
198
+ try {
199
+ await client.query("BEGIN");
200
+ const locked = await client.query(
201
+ `SELECT ${MANIFEST_COLUMNS} FROM object_manifest
202
+ WHERE tenant_id = $1 AND hash = $2
203
+ FOR UPDATE`,
204
+ [tenant, hash]
205
+ );
206
+ const row = locked.rows[0];
207
+ settled = row === void 0 ? this.#notFound(hash) : await mutate(client, toEntry(row));
208
+ await client.query("COMMIT");
209
+ } catch (error) {
210
+ await client.query("ROLLBACK").catch(() => {
211
+ });
212
+ throw error;
213
+ } finally {
214
+ client.release();
215
+ }
216
+ if (settled instanceof ByokCoreError) throw settled;
217
+ return settled;
218
+ }
219
+ /** Sets `ref_count` to what the reference rows actually say. */
220
+ async #recount(client, tenant, hash) {
221
+ const result = await client.query(
222
+ `UPDATE object_manifest
223
+ SET ref_count = (SELECT count(*) FROM object_reference r
224
+ WHERE r.tenant_id = $1 AND r.hash = $2),
225
+ updated_at = $3
226
+ WHERE tenant_id = $1 AND hash = $2
227
+ RETURNING ${MANIFEST_COLUMNS}`,
228
+ [tenant, hash, this.#now()]
229
+ );
230
+ const row = result.rows[0];
231
+ if (row === void 0) throw this.#notFound(hash);
232
+ return toEntry(row);
233
+ }
234
+ async #require(tenant, hash) {
235
+ const entry = await this.get(tenant, hash);
236
+ if (entry === void 0) throw this.#notFound(hash);
237
+ return entry;
238
+ }
239
+ #notFound(hash) {
240
+ return new ByokCoreError(
241
+ "object_not_found",
242
+ `Object ${hash} has no manifest row in this tenant.`
243
+ );
244
+ }
245
+ #stateInvalid(from, to) {
246
+ return new ByokCoreError(
247
+ "object_state_invalid",
248
+ `${from} to ${to} is not a legal object manifest transition.`
249
+ );
250
+ }
251
+ #now() {
252
+ return this.#clock.now().toISOString();
253
+ }
254
+ };
255
+ var DEFAULT_PRESIGN_TTL_SECONDS = 15 * 60;
256
+ var MIN_PRESIGN_TTL_SECONDS = 1;
257
+ var MAX_PRESIGN_TTL_SECONDS = 604800;
258
+ var DEFAULT_MAX_ATTEMPTS = 3;
259
+ var DEFAULT_RETRY_DELAY_MS = 100;
260
+ var ObjectStoreRequestError = class extends Error {
261
+ status;
262
+ attempts;
263
+ constructor(message, attempts, status, options) {
264
+ super(message, options);
265
+ this.name = "ObjectStoreRequestError";
266
+ this.attempts = attempts;
267
+ this.status = status;
268
+ }
269
+ };
270
+ var R2_BLOB_ERROR_CODES = {
271
+ /**
272
+ * A tenant id that cannot be one safe path segment. Wire-relevant: it is the
273
+ * only signal a control plane gets that the id it issued cannot address
274
+ * object storage, and it is raised BEFORE any key is built.
275
+ */
276
+ storage_tenant_key_unsafe: "storage_tenant_key_unsafe",
277
+ /** A presign lifetime outside `[MIN_PRESIGN_TTL_SECONDS, MAX_PRESIGN_TTL_SECONDS]`. Construction-time only. */
278
+ storage_presign_ttl_invalid: "storage_presign_ttl_invalid",
279
+ /** ListObjectsV2 accepts 1..1000 keys per page. Maintenance input only. */
280
+ storage_list_limit_invalid: "storage_list_limit_invalid",
281
+ /** Continuation tokens are opaque but non-empty. Maintenance input only. */
282
+ storage_list_cursor_invalid: "storage_list_cursor_invalid"
283
+ };
284
+ var R2BlobStoreError = class extends Error {
285
+ code;
286
+ constructor(code, message, options) {
287
+ super(message, options);
288
+ this.name = "R2BlobStoreError";
289
+ this.code = code;
290
+ }
291
+ };
292
+ var R2CloudBlobStore = class {
293
+ #objects;
294
+ #signingClock;
295
+ #client;
296
+ #origin;
297
+ #bucket;
298
+ #keyPrefix;
299
+ #presignTtlSeconds;
300
+ #fetch;
301
+ #maxAttempts;
302
+ #retryDelayMs;
303
+ constructor(options) {
304
+ this.#objects = options.objects;
305
+ this.#signingClock = options.signingClock;
306
+ this.#client = new AwsClient({
307
+ accessKeyId: options.accessKeyId,
308
+ secretAccessKey: options.secretAccessKey,
309
+ service: "s3",
310
+ region: options.region,
311
+ // The client's own retry loop is bypassed: this store only ever calls
312
+ // `sign`, and drives its own bounded, jitter-free retries below so the
313
+ // transient-error dimension can assert an exact attempt sequence.
314
+ retries: 0
315
+ });
316
+ this.#origin = options.endpoint.replace(/\/+$/, "");
317
+ this.#bucket = options.bucket;
318
+ this.#keyPrefix = resolveKeyPrefix(options.keyPrefix);
319
+ this.#presignTtlSeconds = assertPresignTtl(options.presignTtlSeconds ?? DEFAULT_PRESIGN_TTL_SECONDS);
320
+ this.#fetch = options.fetch ?? ((request) => globalThis.fetch(request));
321
+ this.#maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
322
+ this.#retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
323
+ }
324
+ /**
325
+ * Reserve the manifest row, then hand back a PUT bound to this tenant, this
326
+ * key, this length, this type, and this expiry.
327
+ *
328
+ * `putManifest` is idempotent per (tenant, hash), so a device that declares
329
+ * the same content twice while it is still `pending` gets the same row and
330
+ * the same key — an interrupted upload is retried, not duplicated. It is
331
+ * idempotent per TENANT, which is the same reason the key embeds the tenant:
332
+ * two tenants holding identical bytes hold two independent objects, and
333
+ * neither can learn of the other's.
334
+ *
335
+ * Idempotence stops at `committed`, and that boundary is the point: a
336
+ * committed object is what a truth record is allowed to reference, so it has
337
+ * to be immutable, and re-issuing a write grant for one is the only way this
338
+ * adapter could make it otherwise.
339
+ */
340
+ async createUpload(tenant, reservation) {
341
+ this.#assertReservedObject(tenant, reservation);
342
+ const hash = contentHash(reservation.contentHash);
343
+ const byteSize = reservation.expectedBytes;
344
+ assertKeySegmentTenant(tenant);
345
+ const entry = await this.#objects.putManifest(tenant, {
346
+ hash,
347
+ byteSize,
348
+ contentType: reservation.contentType
349
+ });
350
+ if (entry.byteSize !== byteSize || entry.contentType !== reservation.contentType) {
351
+ throw new ByokCoreError(
352
+ "storage_integrity_mismatch",
353
+ `Object ${hash} is already declared as ${String(entry.byteSize)} bytes of ${entry.contentType}; this upload declares ${String(byteSize)} bytes of ${reservation.contentType}.`
354
+ );
355
+ }
356
+ if (entry.state !== "pending") {
357
+ throw new ByokCoreError(
358
+ "object_state_invalid",
359
+ `Object ${hash} is ${entry.state}; only a pending object can receive an upload grant.`
360
+ );
361
+ }
362
+ const url = this.#objectUrl(tenant, hash);
363
+ url.searchParams.set("X-Amz-Expires", String(this.#presignTtlSeconds));
364
+ const signed = await this.#client.sign(
365
+ new Request(url, {
366
+ method: "PUT",
367
+ headers: {
368
+ "content-length": String(byteSize),
369
+ "content-type": reservation.contentType
370
+ }
371
+ }),
372
+ {
373
+ // `allHeaders` is load-bearing: aws4fetch treats `content-length` and
374
+ // `content-type` as unsignable by default (they are per-hop headers for
375
+ // most services), and without this the grant would bind the key and the
376
+ // expiry but not the SHAPE of what may be written to it.
377
+ aws: { signQuery: true, allHeaders: true, datetime: this.#datetime() }
378
+ }
379
+ );
380
+ return { blobId: hash, uploadUrl: signed.url };
381
+ }
382
+ async observeUpload(tenant, blobId, reservation) {
383
+ if (!isContentHash(blobId) || reservation.tenantId !== tenant || reservation.kind !== "object" || reservation.contentHash !== blobId) {
384
+ return void 0;
385
+ }
386
+ const entry = await this.#objects.get(tenant, reservation.contentHash);
387
+ if (entry === void 0 || entry.state !== "pending" && entry.state !== "committed") {
388
+ return void 0;
389
+ }
390
+ const observed = await this.#head(tenant, reservation.contentHash);
391
+ if (!observed.present) return void 0;
392
+ return {
393
+ observedByteSize: observed.byteSize,
394
+ observedContentType: observed.contentType
395
+ };
396
+ }
397
+ /**
398
+ * A GET for a committed object this tenant owns; `undefined` otherwise.
399
+ *
400
+ * Every miss answers identically — unknown hash, another tenant's object, a
401
+ * malformed id, bytes that never landed, a tombstoned row. A caller cannot
402
+ * tell them apart, which is what keeps `getDownloadUrl` from being an
403
+ * existence oracle across tenants.
404
+ *
405
+ * This is a pure committed-manifest gate. Observation and commit belong to
406
+ * the explicit finalize route; a download must never decide accounting.
407
+ */
408
+ async getDownloadUrl(tenant, blobId) {
409
+ if (!isContentHash(blobId)) return void 0;
410
+ const hash = blobId;
411
+ const entry = await this.#objects.get(tenant, hash);
412
+ if (entry === void 0) return void 0;
413
+ if (entry.state !== "committed") {
414
+ return void 0;
415
+ }
416
+ const url = this.#objectUrl(tenant, hash);
417
+ url.searchParams.set("X-Amz-Expires", String(this.#presignTtlSeconds));
418
+ const signed = await this.#client.sign(new Request(url, { method: "GET" }), {
419
+ aws: { signQuery: true, datetime: this.#datetime() }
420
+ });
421
+ return signed.url;
422
+ }
423
+ /**
424
+ * The ONLY place an object key is built, and therefore the only place the
425
+ * key's two segments have to be safe.
426
+ *
427
+ * The hash half is closed by construction: `tenantObjectKey` is core's and it
428
+ * takes a `ContentHash` — a branded type with exactly one mint point that
429
+ * rejects anything but 64 lowercase hex — so a traversal segment, an absolute
430
+ * path, or an uppercase digest cannot be smuggled through a parameter that
431
+ * will not accept them.
432
+ *
433
+ * The tenant half is NOT, and that asymmetry is why the guard below exists.
434
+ * `tenantId()` fails closed on empty, padded, over-long, and `NUL`-bearing
435
+ * values, and deliberately normalizes nothing else — normalizing would make
436
+ * the SDK disagree with the control plane that issued the id about its
437
+ * canonical form (`@byok-sdk/core`'s `tenant.ts`). So `a/../b` is a legitimate
438
+ * tenant id, and the line below would otherwise hand it to `new URL()`, which
439
+ * resolves the traversal and returns tenant `b`'s key. That is a cross-tenant
440
+ * alias: `a/../b` could probe, overwrite, and read what `b` owns.
441
+ *
442
+ * Refused rather than encoded. Percent-encoding the segment would keep the
443
+ * key distinct, and would also give every tenant id two spellings — the one
444
+ * the control plane issued and the one at rest in the object store — which is
445
+ * the second source of truth core refused to create in the first place.
446
+ */
447
+ #objectUrl(tenant, hash) {
448
+ assertKeySegmentTenant(tenant);
449
+ return new URL(
450
+ `${this.#origin}/${this.#bucket}/${tenantObjectKey(tenant, hash, this.#keyPrefix)}`
451
+ );
452
+ }
453
+ /**
454
+ * `HEAD` the key, with bounded retries for transient faults.
455
+ *
456
+ * `HEAD` is idempotent by construction, so a retry can never double an
457
+ * effect — which is the whole reason the retry loop is allowed to exist here
458
+ * and not around anything that writes.
459
+ */
460
+ async #head(tenant, hash) {
461
+ const url = this.#objectUrl(tenant, hash);
462
+ const { response, attempts } = await this.#send(new Request(url, { method: "HEAD" }));
463
+ if (response.status === 404) {
464
+ return { present: false, byteSize: 0n, contentType: "" };
465
+ }
466
+ if (!response.ok) {
467
+ throw new ObjectStoreRequestError(
468
+ `HEAD on the object store answered ${response.status}.`,
469
+ attempts,
470
+ response.status
471
+ );
472
+ }
473
+ const length = response.headers.get("content-length");
474
+ const contentType = response.headers.get("content-type");
475
+ if (length === null || contentType === null) {
476
+ throw new ObjectStoreRequestError(
477
+ `HEAD on the object store omitted content-length or content-type, so ${hash} cannot be verified.`,
478
+ attempts,
479
+ response.status
480
+ );
481
+ }
482
+ return { present: true, byteSize: BigInt(length), contentType };
483
+ }
484
+ /**
485
+ * Signs, sends, and retries 5xx/429/network faults with a doubling delay.
486
+ *
487
+ * Returns the attempt count alongside the response because the caller is what
488
+ * decides the answer was a failure. A 4xx costs however many attempts the
489
+ * transient faults before it did, and `attempts` is the only field
490
+ * {@link ObjectStoreRequestError} carries that an operator can use to tell a
491
+ * flapping remote from a hard refusal — so it has to be counted here, where
492
+ * the loop is, rather than assumed to be 1 at the throw site.
493
+ */
494
+ async #send(request) {
495
+ const failures = [];
496
+ for (let attempt = 1; attempt <= this.#maxAttempts; attempt += 1) {
497
+ const signed = await this.#client.sign(request.clone(), {
498
+ aws: { datetime: this.#datetime() }
499
+ });
500
+ const outcome = await this.#attempt(signed);
501
+ if (outcome.response !== void 0) return { response: outcome.response, attempts: attempt };
502
+ failures.push(outcome.failure);
503
+ if (attempt < this.#maxAttempts) {
504
+ await this.#sleep(this.#retryDelayMs * 2 ** (attempt - 1));
505
+ }
506
+ }
507
+ throw new ObjectStoreRequestError(
508
+ `The object store failed ${this.#maxAttempts} attempt(s): ${failures.join(", ")}.`,
509
+ this.#maxAttempts
510
+ );
511
+ }
512
+ async #attempt(signed) {
513
+ try {
514
+ const response = await this.#fetch(signed);
515
+ if (response.status !== 429 && response.status < 500) return { response, failure: "" };
516
+ return { failure: `HTTP ${response.status}` };
517
+ } catch (cause) {
518
+ return { failure: cause instanceof Error ? cause.message : String(cause) };
519
+ }
520
+ }
521
+ #sleep(ms) {
522
+ return new Promise((resolve) => {
523
+ setTimeout(resolve, ms);
524
+ });
525
+ }
526
+ /** SigV4's `YYYYMMDDTHHmmssZ`, off the signing clock — see its doc for why that is a separate one. */
527
+ #datetime() {
528
+ return this.#signingClock.now().toISOString().replaceAll(/[:-]|\.\d{3}/g, "");
529
+ }
530
+ #assertReservedObject(tenant, reservation) {
531
+ if (reservation.tenantId === tenant && reservation.kind === "object" && reservation.state === "reserved" && reservation.expectedBytes >= 0n) {
532
+ return;
533
+ }
534
+ throw new ByokCoreError(
535
+ "storage_integrity_mismatch",
536
+ "An upload grant requires a reserved object reservation owned by this tenant."
537
+ );
538
+ }
539
+ };
540
+ var R2ObjectMaintenanceStore = class {
541
+ #signingClock;
542
+ #client;
543
+ #origin;
544
+ #bucket;
545
+ #keyPrefix;
546
+ #fetch;
547
+ #maxAttempts;
548
+ #retryDelayMs;
549
+ constructor(options) {
550
+ this.#signingClock = options.signingClock;
551
+ this.#client = new AwsClient({
552
+ accessKeyId: options.accessKeyId,
553
+ secretAccessKey: options.secretAccessKey,
554
+ service: "s3",
555
+ region: options.region,
556
+ retries: 0
557
+ });
558
+ this.#origin = options.endpoint.replace(/\/+$/, "");
559
+ this.#bucket = options.bucket;
560
+ this.#keyPrefix = resolveKeyPrefix(options.keyPrefix);
561
+ this.#fetch = options.fetch ?? ((request) => globalThis.fetch(request));
562
+ this.#maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
563
+ this.#retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
564
+ }
565
+ async inspectObject(tenant, hash) {
566
+ const observed = await this.#head(tenant, hash);
567
+ if (!observed.present) return void 0;
568
+ return {
569
+ observedByteSize: observed.byteSize,
570
+ observedContentType: observed.contentType
571
+ };
572
+ }
573
+ async deleteObject(tenant, hash) {
574
+ const url = this.#objectUrl(tenant, hash);
575
+ const { response, attempts } = await this.#send(new Request(url, { method: "DELETE" }));
576
+ if (response.status === 404) return "absent";
577
+ if (!response.ok) {
578
+ throw new ObjectStoreRequestError(
579
+ `DELETE on the object store answered ${response.status}.`,
580
+ attempts,
581
+ response.status
582
+ );
583
+ }
584
+ return "deleted";
585
+ }
586
+ async listTenantObjects(tenant, continuationToken, limit = 100) {
587
+ assertKeySegmentTenant(tenant);
588
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1e3) {
589
+ throw new R2BlobStoreError(
590
+ "storage_list_limit_invalid",
591
+ `A ListObjectsV2 page limit of ${String(limit)} is not a whole number in [1, 1000].`
592
+ );
593
+ }
594
+ const prefix = `${tenant}/sha256/`;
595
+ const url = new URL(`${this.#origin}/${this.#bucket}`);
596
+ url.searchParams.set("list-type", "2");
597
+ url.searchParams.set("prefix", prefix);
598
+ url.searchParams.set("max-keys", String(limit));
599
+ if (continuationToken !== void 0) {
600
+ if (continuationToken.length === 0) {
601
+ throw new R2BlobStoreError(
602
+ "storage_list_cursor_invalid",
603
+ "A ListObjectsV2 continuation token must not be empty."
604
+ );
605
+ }
606
+ url.searchParams.set("continuation-token", continuationToken);
607
+ }
608
+ const { response, attempts } = await this.#send(new Request(url, { method: "GET" }));
609
+ if (!response.ok) {
610
+ throw new ObjectStoreRequestError(
611
+ `ListObjectsV2 on the object store answered ${response.status}.`,
612
+ attempts,
613
+ response.status
614
+ );
615
+ }
616
+ return parseListObjectsV2(await response.text(), prefix, attempts);
617
+ }
618
+ async #head(tenant, hash) {
619
+ const { response, attempts } = await this.#send(
620
+ new Request(this.#objectUrl(tenant, hash), { method: "HEAD" })
621
+ );
622
+ if (response.status === 404) {
623
+ return { present: false, byteSize: 0n, contentType: "" };
624
+ }
625
+ if (!response.ok) {
626
+ throw new ObjectStoreRequestError(
627
+ `HEAD on the object store answered ${response.status}.`,
628
+ attempts,
629
+ response.status
630
+ );
631
+ }
632
+ const length = response.headers.get("content-length");
633
+ const contentType = response.headers.get("content-type");
634
+ if (length === null || contentType === null) {
635
+ throw new ObjectStoreRequestError(
636
+ `HEAD on the object store omitted content-length or content-type, so ${hash} cannot be observed.`,
637
+ attempts,
638
+ response.status
639
+ );
640
+ }
641
+ return { present: true, byteSize: BigInt(length), contentType };
642
+ }
643
+ #objectUrl(tenant, hash) {
644
+ assertKeySegmentTenant(tenant);
645
+ return new URL(
646
+ `${this.#origin}/${this.#bucket}/${tenantObjectKey(tenant, hash, this.#keyPrefix)}`
647
+ );
648
+ }
649
+ async #send(request) {
650
+ const failures = [];
651
+ for (let attempt = 1; attempt <= this.#maxAttempts; attempt += 1) {
652
+ const signed = await this.#client.sign(request.clone(), {
653
+ aws: { datetime: this.#datetime() }
654
+ });
655
+ try {
656
+ const response = await this.#fetch(signed);
657
+ if (response.status !== 429 && response.status < 500) {
658
+ return { response, attempts: attempt };
659
+ }
660
+ failures.push(`HTTP ${response.status}`);
661
+ } catch (cause) {
662
+ failures.push(cause instanceof Error ? cause.message : String(cause));
663
+ }
664
+ if (attempt < this.#maxAttempts) {
665
+ await new Promise((resolve) => {
666
+ setTimeout(resolve, this.#retryDelayMs * 2 ** (attempt - 1));
667
+ });
668
+ }
669
+ }
670
+ throw new ObjectStoreRequestError(
671
+ `The object store failed ${this.#maxAttempts} attempt(s): ${failures.join(", ")}.`,
672
+ this.#maxAttempts
673
+ );
674
+ }
675
+ #datetime() {
676
+ return this.#signingClock.now().toISOString().replaceAll(/[:-]|\.\d{3}/g, "");
677
+ }
678
+ };
679
+ var SAFE_TENANT_SEGMENT = /^[A-Za-z0-9._~-]+$/;
680
+ function assertKeySegmentTenant(tenant) {
681
+ if (SAFE_TENANT_SEGMENT.test(tenant) && !tenant.startsWith(".")) return;
682
+ throw new R2BlobStoreError(
683
+ "storage_tenant_key_unsafe",
684
+ `Tenant id ${JSON.stringify(tenant)} is not a single safe object-key segment, so no key can be built for it.`
685
+ );
686
+ }
687
+ function resolveKeyPrefix(keyPrefix) {
688
+ return keyPrefix === void 0 ? void 0 : objectKeyPrefix(keyPrefix);
689
+ }
690
+ function assertPresignTtl(seconds) {
691
+ if (Number.isInteger(seconds) && seconds >= MIN_PRESIGN_TTL_SECONDS && seconds <= MAX_PRESIGN_TTL_SECONDS) {
692
+ return seconds;
693
+ }
694
+ throw new R2BlobStoreError(
695
+ "storage_presign_ttl_invalid",
696
+ `A presign lifetime of ${String(seconds)} is not a whole number of seconds in [${String(MIN_PRESIGN_TTL_SECONDS)}, ${String(MAX_PRESIGN_TTL_SECONDS)}], which is the range R2 will honor.`
697
+ );
698
+ }
699
+ var LIST_OBJECTS_XML = new XMLParser({
700
+ ignoreAttributes: true,
701
+ parseTagValue: false,
702
+ trimValues: true,
703
+ processEntities: false
704
+ });
705
+ function parseListObjectsV2(xml, prefix, attempts) {
706
+ let parsed;
707
+ try {
708
+ parsed = LIST_OBJECTS_XML.parse(xml);
709
+ } catch (cause) {
710
+ throw new ObjectStoreRequestError(
711
+ "ListObjectsV2 returned malformed XML.",
712
+ attempts,
713
+ void 0,
714
+ { cause }
715
+ );
716
+ }
717
+ const document = asRecord(parsed);
718
+ const root = asRecord(document?.ListBucketResult);
719
+ if (root === void 0) {
720
+ throw new ObjectStoreRequestError(
721
+ "ListObjectsV2 XML omitted ListBucketResult.",
722
+ attempts
723
+ );
724
+ }
725
+ const isTruncated = requiredText(root, "IsTruncated", attempts);
726
+ if (isTruncated !== "true" && isTruncated !== "false") {
727
+ throw new ObjectStoreRequestError(
728
+ `ListObjectsV2 XML contained invalid IsTruncated=${JSON.stringify(isTruncated)}.`,
729
+ attempts
730
+ );
731
+ }
732
+ const rawContents = root.Contents === void 0 ? [] : Array.isArray(root.Contents) ? root.Contents : [root.Contents];
733
+ const objects = rawContents.map((raw, index) => {
734
+ const content = asRecord(raw);
735
+ if (content === void 0) {
736
+ throw new ObjectStoreRequestError(
737
+ `ListObjectsV2 XML contained a non-object Contents entry at index ${String(index)}.`,
738
+ attempts
739
+ );
740
+ }
741
+ const key = requiredText(content, "Key", attempts);
742
+ const sizeText = requiredText(content, "Size", attempts);
743
+ let byteSize;
744
+ try {
745
+ byteSize = BigInt(sizeText);
746
+ } catch (cause) {
747
+ throw new ObjectStoreRequestError(
748
+ `ListObjectsV2 XML contained invalid Size=${JSON.stringify(sizeText)}.`,
749
+ attempts,
750
+ void 0,
751
+ { cause }
752
+ );
753
+ }
754
+ if (byteSize < 0n) {
755
+ throw new ObjectStoreRequestError(
756
+ `ListObjectsV2 XML contained negative Size=${sizeText}.`,
757
+ attempts
758
+ );
759
+ }
760
+ const suffix = key.startsWith(prefix) ? key.slice(prefix.length) : "";
761
+ return {
762
+ key,
763
+ byteSize,
764
+ ...HASH_KEY_SUFFIX.test(suffix) ? { hash: contentHash(`sha256:${suffix}`) } : {}
765
+ };
766
+ });
767
+ if (isTruncated === "false") return { objects };
768
+ const nextContinuationToken = requiredText(root, "NextContinuationToken", attempts);
769
+ if (nextContinuationToken.length === 0) {
770
+ throw new ObjectStoreRequestError(
771
+ "ListObjectsV2 XML was truncated but its continuation token was empty.",
772
+ attempts
773
+ );
774
+ }
775
+ return { objects, nextContinuationToken };
776
+ }
777
+ var HASH_KEY_SUFFIX = /^[0-9a-f]{64}$/;
778
+ function asRecord(value) {
779
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
780
+ }
781
+ function requiredText(record, field, attempts) {
782
+ const value = record[field];
783
+ if (typeof value === "string") return value;
784
+ throw new ObjectStoreRequestError(
785
+ `ListObjectsV2 XML omitted text field ${field}.`,
786
+ attempts
787
+ );
788
+ }
789
+
790
+ // src/stores/devices.ts
791
+ function toRecord(row) {
792
+ return {
793
+ tenantId: row.tenant_id,
794
+ productId: row.product_id,
795
+ deviceId: row.device_id,
796
+ deviceName: row.device_name,
797
+ devicePublicKey: row.device_public_key,
798
+ proofKeyId: row.proof_key_id,
799
+ proofKeyEpoch: row.proof_key_epoch,
800
+ revoked: row.revoked
801
+ };
802
+ }
803
+ var SELECT_COLUMNS = "tenant_id, device_id, product_id, device_name, device_public_key, proof_key_id, proof_key_epoch, revoked";
804
+ var PostgresDeviceDirectory = class {
805
+ #pool;
806
+ constructor(pool) {
807
+ this.#pool = pool;
808
+ }
809
+ async register(tenant, input) {
810
+ const result = await this.#pool.query(
811
+ `INSERT INTO device (
812
+ tenant_id, device_id, product_id, device_name, device_public_key,
813
+ proof_key_id, proof_key_epoch, revoked
814
+ )
815
+ VALUES ($1, $2, $3, $4, $5, $6, $7, false)
816
+ ON CONFLICT (tenant_id, device_id) DO UPDATE
817
+ SET product_id = EXCLUDED.product_id,
818
+ device_name = EXCLUDED.device_name,
819
+ device_public_key = EXCLUDED.device_public_key,
820
+ proof_key_id = EXCLUDED.proof_key_id,
821
+ proof_key_epoch = EXCLUDED.proof_key_epoch,
822
+ revoked = false
823
+ RETURNING ${SELECT_COLUMNS}`,
824
+ [
825
+ tenant,
826
+ input.deviceId,
827
+ input.productId,
828
+ input.deviceName,
829
+ input.devicePublicKey,
830
+ input.proofKeyId,
831
+ input.proofKeyEpoch
832
+ ]
833
+ );
834
+ return toRecord(result.rows[0]);
835
+ }
836
+ async get(tenant, deviceId) {
837
+ const result = await this.#pool.query(
838
+ `SELECT ${SELECT_COLUMNS} FROM device WHERE tenant_id = $1 AND device_id = $2`,
839
+ [tenant, deviceId]
840
+ );
841
+ const row = result.rows[0];
842
+ return row === void 0 ? void 0 : toRecord(row);
843
+ }
844
+ async revoke(tenant, deviceId) {
845
+ await this.#pool.query("UPDATE device SET revoked = true WHERE tenant_id = $1 AND device_id = $2", [
846
+ tenant,
847
+ deviceId
848
+ ]);
849
+ }
850
+ async list(tenant) {
851
+ const result = await this.#pool.query(
852
+ `SELECT ${SELECT_COLUMNS} FROM device WHERE tenant_id = $1 ORDER BY device_id`,
853
+ [tenant]
854
+ );
855
+ return result.rows.map(toRecord);
856
+ }
857
+ async resolveByDeviceId(deviceId) {
858
+ const result = await this.#pool.query(
859
+ `SELECT ${SELECT_COLUMNS} FROM device WHERE device_id = $1`,
860
+ [deviceId]
861
+ );
862
+ const row = result.rows[0];
863
+ return row === void 0 ? void 0 : toRecord(row);
864
+ }
865
+ };
866
+ var PostgresInboundDedupStore = class {
867
+ #pool;
868
+ #capacity;
869
+ constructor(pool, capacity = DEDUP_RING_CAPACITY) {
870
+ this.#pool = pool;
871
+ this.#capacity = capacity;
872
+ }
873
+ async checkAndRecord(tenant, deviceId, envelopeId) {
874
+ const inserted = await this.#pool.query(
875
+ `INSERT INTO inbound_dedup (tenant_id, device_id, envelope_id)
876
+ VALUES ($1, $2, $3)
877
+ ON CONFLICT (tenant_id, device_id, envelope_id) DO NOTHING
878
+ RETURNING 1`,
879
+ [tenant, deviceId, envelopeId]
880
+ );
881
+ if (inserted.rowCount === 0) return true;
882
+ await this.#pool.query(
883
+ `DELETE FROM inbound_dedup
884
+ WHERE tenant_id = $1 AND device_id = $2
885
+ AND recorded_seq <= (
886
+ SELECT recorded_seq FROM inbound_dedup
887
+ WHERE tenant_id = $1 AND device_id = $2
888
+ ORDER BY recorded_seq DESC
889
+ OFFSET $3 LIMIT 1)`,
890
+ [tenant, deviceId, this.#capacity]
891
+ );
892
+ return false;
893
+ }
894
+ };
895
+ var NONCE_BYTES = 24;
896
+ var PostgresNonceStore = class {
897
+ #pool;
898
+ #clock;
899
+ #crypto;
900
+ #ttlMs;
901
+ constructor(pool, clock, crypto, ttlMs = NONCE_TTL_MS) {
902
+ this.#pool = pool;
903
+ this.#clock = clock;
904
+ this.#crypto = crypto;
905
+ this.#ttlMs = ttlMs;
906
+ }
907
+ async issue(tenant, deviceId) {
908
+ const nowMs = this.#clock.now().getTime();
909
+ const now = new Date(nowMs).toISOString();
910
+ await this.#pool.query(
911
+ "DELETE FROM auth_nonce WHERE tenant_id = $1 AND device_id = $2 AND (used OR expires_at < $3)",
912
+ [tenant, deviceId, now]
913
+ );
914
+ const nonce = this.#crypto.randomToken(NONCE_BYTES);
915
+ await this.#pool.query(
916
+ `INSERT INTO auth_nonce (tenant_id, device_id, nonce, expires_at, used)
917
+ VALUES ($1, $2, $3, $4, false)`,
918
+ [tenant, deviceId, nonce, new Date(nowMs + this.#ttlMs).toISOString()]
919
+ );
920
+ return nonce;
921
+ }
922
+ async validate(tenant, deviceId, nonce) {
923
+ const result = await this.#pool.query(
924
+ `SELECT 1 FROM auth_nonce
925
+ WHERE tenant_id = $1 AND device_id = $2 AND nonce = $3
926
+ AND used = false AND expires_at >= $4`,
927
+ [tenant, deviceId, nonce, this.#clock.now().toISOString()]
928
+ );
929
+ return result.rowCount === 1;
930
+ }
931
+ async markUsed(tenant, nonce) {
932
+ await this.#pool.query("UPDATE auth_nonce SET used = true WHERE tenant_id = $1 AND nonce = $2", [
933
+ tenant,
934
+ nonce
935
+ ]);
936
+ }
937
+ };
938
+
939
+ // src/stores/pairing-codes.ts
940
+ var PostgresPairingCodeStore = class {
941
+ #pool;
942
+ #clock;
943
+ constructor(pool, clock) {
944
+ this.#pool = pool;
945
+ this.#clock = clock;
946
+ }
947
+ async issue(tenant, input) {
948
+ await this.#pool.query(
949
+ `INSERT INTO pairing_code (code, tenant_id, product_id, expires_at, redeemed_at)
950
+ VALUES ($1, $2, $3, $4, NULL)
951
+ ON CONFLICT (code) DO UPDATE
952
+ SET tenant_id = EXCLUDED.tenant_id,
953
+ product_id = EXCLUDED.product_id,
954
+ expires_at = EXCLUDED.expires_at,
955
+ redeemed_at = NULL`,
956
+ [input.code, tenant, input.productId, input.expiresAt]
957
+ );
958
+ return { code: input.code, expiresAt: input.expiresAt };
959
+ }
960
+ async redeem(code) {
961
+ const now = this.#clock.now().toISOString();
962
+ const result = await this.#pool.query(
963
+ `UPDATE pairing_code
964
+ SET redeemed_at = $2
965
+ WHERE code = $1 AND redeemed_at IS NULL AND expires_at >= $2
966
+ RETURNING tenant_id, product_id`,
967
+ [code, now]
968
+ );
969
+ const row = result.rows[0];
970
+ if (row === void 0) return void 0;
971
+ return { tenantId: row.tenant_id, productId: row.product_id };
972
+ }
973
+ };
974
+
975
+ // src/stores/receipts.ts
976
+ var SELECT_COLUMNS2 = "tenant_id, key, body, recorded_at";
977
+ function toReceipt(row) {
978
+ return {
979
+ tenantId: row.tenant_id,
980
+ key: row.key,
981
+ body: row.body,
982
+ recordedAt: row.recorded_at.toISOString()
983
+ };
984
+ }
985
+ var PostgresRequestReceiptStore = class {
986
+ #pool;
987
+ #clock;
988
+ constructor(pool, clock) {
989
+ this.#pool = pool;
990
+ this.#clock = clock;
991
+ }
992
+ async record(tenant, input) {
993
+ const inserted = await this.#pool.query(
994
+ `INSERT INTO device_request_receipts (tenant_id, key, body, recorded_at)
995
+ VALUES ($1, $2, $3, $4)
996
+ ON CONFLICT (tenant_id, key) DO NOTHING
997
+ RETURNING ${SELECT_COLUMNS2}`,
998
+ [tenant, input.key, input.body, this.#clock.now().toISOString()]
999
+ );
1000
+ const created = inserted.rows[0];
1001
+ if (created !== void 0) return { receipt: toReceipt(created), created: true };
1002
+ const existing = await this.get(tenant, input.key);
1003
+ if (existing === void 0) throw new Error(`receipt ${input.key} vanished during record`);
1004
+ return { receipt: existing, created: false };
1005
+ }
1006
+ async get(tenant, key) {
1007
+ const result = await this.#pool.query(
1008
+ `SELECT ${SELECT_COLUMNS2} FROM device_request_receipts WHERE tenant_id = $1 AND key = $2`,
1009
+ [tenant, key]
1010
+ );
1011
+ const row = result.rows[0];
1012
+ return row === void 0 ? void 0 : toReceipt(row);
1013
+ }
1014
+ };
1015
+
1016
+ // src/stores/proof-receipts.ts
1017
+ var SELECT_COLUMNS3 = `tenant_id, device_id, request_id, operation, resource,
1018
+ body_sha256, body_size, response_status, response_body, recorded_at`;
1019
+ function toReceipt2(row) {
1020
+ return {
1021
+ tenantId: row.tenant_id,
1022
+ deviceId: row.device_id,
1023
+ requestId: row.request_id,
1024
+ operation: row.operation,
1025
+ resource: row.resource,
1026
+ bodySha256: row.body_sha256,
1027
+ bodySize: BigInt(row.body_size),
1028
+ responseStatus: row.response_status,
1029
+ responseBody: row.response_body,
1030
+ recordedAt: row.recorded_at.toISOString()
1031
+ };
1032
+ }
1033
+ var PostgresProofRequestReceiptStore = class {
1034
+ #pool;
1035
+ #clock;
1036
+ constructor(pool, clock) {
1037
+ this.#pool = pool;
1038
+ this.#clock = clock;
1039
+ }
1040
+ async record(tenant, input) {
1041
+ const result = await this.#pool.query(
1042
+ `INSERT INTO proof_request_receipt (
1043
+ tenant_id, device_id, request_id, operation, resource, body_sha256,
1044
+ body_size, response_status, response_body, recorded_at
1045
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
1046
+ ON CONFLICT (tenant_id, device_id, request_id) DO NOTHING
1047
+ RETURNING ${SELECT_COLUMNS3}`,
1048
+ [
1049
+ tenant,
1050
+ input.deviceId,
1051
+ input.requestId,
1052
+ input.operation,
1053
+ input.resource,
1054
+ input.bodySha256,
1055
+ input.bodySize.toString(),
1056
+ input.responseStatus,
1057
+ input.responseBody,
1058
+ this.#clock.now().toISOString()
1059
+ ]
1060
+ );
1061
+ const inserted = result.rows[0];
1062
+ if (inserted !== void 0) return { receipt: toReceipt2(inserted), created: true };
1063
+ const existing = await this.get(tenant, input.deviceId, input.requestId);
1064
+ if (existing === void 0) throw new Error(`proof receipt ${input.requestId} vanished during record`);
1065
+ return { receipt: existing, created: false };
1066
+ }
1067
+ async get(tenant, deviceId, requestId) {
1068
+ const result = await this.#pool.query(
1069
+ `SELECT ${SELECT_COLUMNS3}
1070
+ FROM proof_request_receipt
1071
+ WHERE tenant_id = $1 AND device_id = $2 AND request_id = $3`,
1072
+ [tenant, deviceId, requestId]
1073
+ );
1074
+ const row = result.rows[0];
1075
+ return row === void 0 ? void 0 : toReceipt2(row);
1076
+ }
1077
+ };
1078
+
1079
+ // src/stores/task-attempts.ts
1080
+ var SELECT_COLUMNS4 = "tenant_id, task_id, device_id, owner_device_id, status, updated_at";
1081
+ function toAttempt(row) {
1082
+ return {
1083
+ tenantId: row.tenant_id,
1084
+ taskId: row.task_id,
1085
+ deviceId: row.device_id,
1086
+ // `exactOptionalPropertyTypes` is off here, but an explicit absent key is
1087
+ // still what the in-memory reference produces for an unclaimed attempt, and
1088
+ // `toEqual` in the suite treats `undefined` and absent alike only for the
1089
+ // former.
1090
+ ...row.owner_device_id === null ? {} : { ownerDeviceId: row.owner_device_id },
1091
+ status: row.status,
1092
+ updatedAt: row.updated_at.toISOString()
1093
+ };
1094
+ }
1095
+ var PostgresTaskAttemptStore = class {
1096
+ #pool;
1097
+ #clock;
1098
+ constructor(pool, clock) {
1099
+ this.#pool = pool;
1100
+ this.#clock = clock;
1101
+ }
1102
+ async open(tenant, input) {
1103
+ const inserted = await this.#pool.query(
1104
+ `INSERT INTO task (tenant_id, task_id, device_id, owner_device_id, status, updated_at)
1105
+ VALUES ($1, $2, $3, NULL, 'offered', $4)
1106
+ ON CONFLICT (tenant_id, task_id) DO NOTHING
1107
+ RETURNING ${SELECT_COLUMNS4}`,
1108
+ [tenant, input.taskId, input.deviceId, this.#now()]
1109
+ );
1110
+ const created = inserted.rows[0];
1111
+ if (created !== void 0) return toAttempt(created);
1112
+ const existing = await this.get(tenant, input.taskId);
1113
+ if (existing === void 0) throw new Error(`task ${input.taskId} vanished during open`);
1114
+ return existing;
1115
+ }
1116
+ async get(tenant, taskId) {
1117
+ const result = await this.#pool.query(
1118
+ `SELECT ${SELECT_COLUMNS4} FROM task WHERE tenant_id = $1 AND task_id = $2`,
1119
+ [tenant, taskId]
1120
+ );
1121
+ const row = result.rows[0];
1122
+ return row === void 0 ? void 0 : toAttempt(row);
1123
+ }
1124
+ async claim(tenant, input) {
1125
+ const claimed = await this.#pool.query(
1126
+ `UPDATE task
1127
+ SET owner_device_id = $3, status = 'claimed', updated_at = $4
1128
+ WHERE tenant_id = $1 AND task_id = $2 AND owner_device_id IS NULL
1129
+ RETURNING ${SELECT_COLUMNS4}`,
1130
+ [tenant, input.taskId, input.deviceId, this.#now()]
1131
+ );
1132
+ const won = claimed.rows[0];
1133
+ if (won !== void 0) return toAttempt(won);
1134
+ return this.get(tenant, input.taskId);
1135
+ }
1136
+ async recordStatus(tenant, input) {
1137
+ const result = await this.#pool.query(
1138
+ `UPDATE task
1139
+ SET status = $3, updated_at = $4
1140
+ WHERE tenant_id = $1 AND task_id = $2
1141
+ RETURNING ${SELECT_COLUMNS4}`,
1142
+ [tenant, input.taskId, input.status, this.#now()]
1143
+ );
1144
+ const row = result.rows[0];
1145
+ return row === void 0 ? void 0 : toAttempt(row);
1146
+ }
1147
+ #now() {
1148
+ return this.#clock.now().toISOString();
1149
+ }
1150
+ };
1151
+ function toTail(row) {
1152
+ const entries = parseTimelineEvents(row.entries);
1153
+ const cursor = activityCursor(entries);
1154
+ return {
1155
+ tenantId: row.tenant_id,
1156
+ taskId: row.task_id,
1157
+ entries,
1158
+ ...cursor === void 0 ? {} : { cursor },
1159
+ dropped: row.dropped,
1160
+ capacity: row.capacity,
1161
+ expiresAt: row.expires_at
1162
+ };
1163
+ }
1164
+ var PostgresActivityStore = class {
1165
+ constructor(pool, clock) {
1166
+ this.pool = pool;
1167
+ this.clock = clock;
1168
+ }
1169
+ pool;
1170
+ clock;
1171
+ async append(tenant, input) {
1172
+ const capacity = validateActivityAppend(input);
1173
+ const now = this.clock.now();
1174
+ const receivedAt = now.toISOString();
1175
+ const incoming = projectTimelineEvents(input, receivedAt);
1176
+ const expiresAt = new Date(now.getTime() + input.ttlMs).toISOString();
1177
+ const result = await this.pool.query(
1178
+ `WITH incoming AS (
1179
+ SELECT entry
1180
+ FROM jsonb_array_elements($4::jsonb) AS element(entry)
1181
+ ), trimmed AS (
1182
+ SELECT COALESCE(jsonb_agg(entry ORDER BY
1183
+ (entry->>'batchSeq')::bigint,
1184
+ (entry->>'eventIndex')::bigint), '[]'::jsonb) AS entries,
1185
+ $5::integer + GREATEST(jsonb_array_length($4::jsonb) - $6, 0) AS dropped
1186
+ FROM (
1187
+ SELECT entry
1188
+ FROM incoming
1189
+ ORDER BY (entry->>'batchSeq')::bigint DESC,
1190
+ (entry->>'eventIndex')::bigint DESC
1191
+ LIMIT $6
1192
+ ) retained
1193
+ )
1194
+ INSERT INTO activity_tail (tenant_id, task_id, entries, dropped, capacity, expires_at)
1195
+ SELECT $1, $2, trimmed.entries, trimmed.dropped, $6, $7 FROM trimmed
1196
+ ON CONFLICT (tenant_id, task_id) DO UPDATE
1197
+ SET entries = (
1198
+ SELECT COALESCE(jsonb_agg(entry ORDER BY
1199
+ (entry->>'batchSeq')::bigint,
1200
+ (entry->>'eventIndex')::bigint), '[]'::jsonb)
1201
+ FROM (
1202
+ SELECT entry
1203
+ FROM jsonb_array_elements(
1204
+ (CASE WHEN activity_tail.expires_at > $3
1205
+ THEN activity_tail.entries ELSE '[]'::jsonb END) || $4::jsonb
1206
+ ) AS element(entry)
1207
+ ORDER BY (entry->>'batchSeq')::bigint DESC,
1208
+ (entry->>'eventIndex')::bigint DESC
1209
+ LIMIT $6
1210
+ ) retained
1211
+ ),
1212
+ dropped = (CASE WHEN activity_tail.expires_at > $3
1213
+ THEN activity_tail.dropped ELSE 0 END)
1214
+ + $5::integer
1215
+ + GREATEST(
1216
+ jsonb_array_length(
1217
+ (CASE WHEN activity_tail.expires_at > $3
1218
+ THEN activity_tail.entries ELSE '[]'::jsonb END) || $4::jsonb
1219
+ ) - $6,
1220
+ 0
1221
+ ),
1222
+ capacity = EXCLUDED.capacity,
1223
+ expires_at = EXCLUDED.expires_at
1224
+ WHERE activity_tail.expires_at <= $3
1225
+ OR (
1226
+ NOT EXISTS (
1227
+ SELECT 1
1228
+ FROM jsonb_array_elements(activity_tail.entries) AS stored(entry)
1229
+ WHERE jsonb_typeof(entry) <> 'object'
1230
+ OR NOT (entry ?& ARRAY[
1231
+ 'taskId', 'sourceEnvelopeId', 'batchSeq',
1232
+ 'eventIndex', 'receivedAt', 'event'
1233
+ ])
1234
+ )
1235
+ AND NOT EXISTS (
1236
+ SELECT 1
1237
+ FROM jsonb_array_elements(activity_tail.entries) AS old_element(entry)
1238
+ CROSS JOIN jsonb_array_elements($4::jsonb) AS new_element(candidate)
1239
+ WHERE (entry->>'batchSeq')::bigint = (candidate->>'batchSeq')::bigint
1240
+ AND (entry->>'eventIndex')::bigint = (candidate->>'eventIndex')::bigint
1241
+ AND entry->>'sourceEnvelopeId' <> candidate->>'sourceEnvelopeId'
1242
+ )
1243
+ )
1244
+ RETURNING tenant_id, task_id, entries, dropped, capacity, expires_at`,
1245
+ [tenant, input.taskId, receivedAt, JSON.stringify(incoming), input.dropped, capacity, expiresAt]
1246
+ );
1247
+ const row = result.rows[0];
1248
+ if (row === void 0) {
1249
+ throw new ByokCloudError(
1250
+ "coordination_input_invalid",
1251
+ `Activity batch ${input.batchSeq} conflicts with the existing typed tail authority.`
1252
+ );
1253
+ }
1254
+ return toTail(row);
1255
+ }
1256
+ async read(tenant, taskId) {
1257
+ const result = await this.pool.query(
1258
+ `SELECT tenant_id, task_id, entries, dropped, capacity, expires_at
1259
+ FROM activity_tail
1260
+ WHERE tenant_id = $1 AND task_id = $2 AND expires_at > $3`,
1261
+ [tenant, taskId, this.clock.now().toISOString()]
1262
+ );
1263
+ const row = result.rows[0];
1264
+ return row === void 0 ? void 0 : toTail(row);
1265
+ }
1266
+ };
1267
+ function toTail2(row) {
1268
+ const entries = parseApprovalObservations(row.entries);
1269
+ const cursor = approvalTimelineCursor(entries);
1270
+ return {
1271
+ tenantId: row.tenant_id,
1272
+ taskId: row.task_id,
1273
+ entries,
1274
+ ...cursor === void 0 ? {} : { cursor },
1275
+ dropped: row.dropped,
1276
+ capacity: row.capacity,
1277
+ expiresAt: row.expires_at
1278
+ };
1279
+ }
1280
+ async function readLocked(client, tenant, taskId) {
1281
+ const result = await client.query(
1282
+ `SELECT tenant_id, task_id, entries, next_revision, dropped, capacity, expires_at
1283
+ FROM approval_timeline_tail
1284
+ WHERE tenant_id = $1 AND task_id = $2
1285
+ FOR UPDATE`,
1286
+ [tenant, taskId]
1287
+ );
1288
+ return result.rows[0];
1289
+ }
1290
+ var PostgresApprovalTimelineStore = class {
1291
+ constructor(pool, clock) {
1292
+ this.pool = pool;
1293
+ this.clock = clock;
1294
+ }
1295
+ pool;
1296
+ clock;
1297
+ async append(tenant, input) {
1298
+ const { capacity, ttlMs, event } = validateApprovalTimelineAppend(input);
1299
+ const now = this.clock.now();
1300
+ const receivedAt = now.toISOString();
1301
+ const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
1302
+ const client = await this.pool.connect();
1303
+ try {
1304
+ await client.query("BEGIN");
1305
+ await client.query(
1306
+ `SELECT pg_advisory_xact_lock(hashtextextended($1 || E'\\x1f' || $2, 0))`,
1307
+ [tenant, input.taskId]
1308
+ );
1309
+ const stored = await readLocked(client, tenant, input.taskId);
1310
+ const live = stored !== void 0 && receivedAt < stored.expires_at ? toTail2(stored) : void 0;
1311
+ const duplicate = live?.entries.find(
1312
+ (entry) => entry.sourceEnvelopeId === input.sourceEnvelopeId
1313
+ );
1314
+ if (duplicate !== void 0) {
1315
+ if (JSON.stringify(duplicate.event) !== JSON.stringify(event)) {
1316
+ throw new ByokCloudError(
1317
+ "coordination_input_invalid",
1318
+ "Approval source envelope identity already belongs to another lifecycle event."
1319
+ );
1320
+ }
1321
+ await client.query("COMMIT");
1322
+ return live;
1323
+ }
1324
+ const revision = live === void 0 ? 1 : Number(stored.next_revision);
1325
+ const expectedRevision = (live?.cursor ?? 0) + 1;
1326
+ if (!Number.isSafeInteger(revision) || revision <= 0 || revision !== expectedRevision) {
1327
+ throw new ByokCloudError(
1328
+ "coordination_input_invalid",
1329
+ "Approval timeline revision authority is malformed."
1330
+ );
1331
+ }
1332
+ const observation = ApprovalObservationSchema.parse({
1333
+ taskId: input.taskId,
1334
+ sourceEnvelopeId: input.sourceEnvelopeId,
1335
+ revision,
1336
+ receivedAt,
1337
+ event
1338
+ });
1339
+ const allEntries = [...live?.entries ?? [], observation];
1340
+ const evicted = Math.max(allEntries.length - capacity, 0);
1341
+ const entries = allEntries.slice(evicted);
1342
+ const dropped = (live?.dropped ?? 0) + evicted;
1343
+ const result = await client.query(
1344
+ `INSERT INTO approval_timeline_tail
1345
+ (tenant_id, task_id, entries, next_revision, dropped, capacity, expires_at)
1346
+ VALUES ($1, $2, $3::jsonb, $4, $5, $6, $7)
1347
+ ON CONFLICT (tenant_id, task_id) DO UPDATE
1348
+ SET entries = EXCLUDED.entries,
1349
+ next_revision = EXCLUDED.next_revision,
1350
+ dropped = EXCLUDED.dropped,
1351
+ capacity = EXCLUDED.capacity,
1352
+ expires_at = EXCLUDED.expires_at
1353
+ RETURNING tenant_id, task_id, entries, next_revision, dropped, capacity, expires_at`,
1354
+ [
1355
+ tenant,
1356
+ input.taskId,
1357
+ JSON.stringify(entries),
1358
+ revision + 1,
1359
+ dropped,
1360
+ capacity,
1361
+ expiresAt
1362
+ ]
1363
+ );
1364
+ await client.query("COMMIT");
1365
+ return toTail2(result.rows[0]);
1366
+ } catch (caught) {
1367
+ await client.query("ROLLBACK");
1368
+ throw caught;
1369
+ } finally {
1370
+ client.release();
1371
+ }
1372
+ }
1373
+ async read(tenant, taskId) {
1374
+ const result = await this.pool.query(
1375
+ `SELECT tenant_id, task_id, entries, next_revision, dropped, capacity, expires_at
1376
+ FROM approval_timeline_tail
1377
+ WHERE tenant_id = $1 AND task_id = $2 AND expires_at > $3`,
1378
+ [tenant, taskId, this.clock.now().toISOString()]
1379
+ );
1380
+ const row = result.rows[0];
1381
+ return row === void 0 ? void 0 : toTail2(row);
1382
+ }
1383
+ };
1384
+
1385
+ // src/stores/device-assertion-replay.ts
1386
+ var PostgresDeviceAssertionReplayAuthority = class {
1387
+ #pool;
1388
+ constructor(pool) {
1389
+ this.#pool = pool;
1390
+ }
1391
+ async consume(input) {
1392
+ if (!Number.isFinite(Date.parse(input.expiresAt))) {
1393
+ throw new Error("device assertion replay expiry is invalid");
1394
+ }
1395
+ const result = await this.#pool.query(
1396
+ `INSERT INTO device_assertion_replay (
1397
+ tenant_id, issuer, product_id, device_id, audience, jti, expires_at
1398
+ )
1399
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
1400
+ ON CONFLICT (tenant_id, issuer, product_id, device_id, audience, jti) DO NOTHING
1401
+ RETURNING jti`,
1402
+ [
1403
+ input.tenantId,
1404
+ input.issuer,
1405
+ input.productId,
1406
+ input.deviceId,
1407
+ input.audience,
1408
+ input.jti,
1409
+ input.expiresAt
1410
+ ]
1411
+ );
1412
+ return result.rowCount === 1;
1413
+ }
1414
+ /** Bounded retention cleanup; callers choose cadence and batch size. */
1415
+ async deleteExpired(before, limit) {
1416
+ if (!Number.isFinite(before.getTime()) || !Number.isSafeInteger(limit) || limit <= 0) {
1417
+ throw new Error("device assertion replay cleanup bounds are invalid");
1418
+ }
1419
+ const result = await this.#pool.query(
1420
+ `DELETE FROM device_assertion_replay
1421
+ WHERE ctid IN (
1422
+ SELECT ctid
1423
+ FROM device_assertion_replay
1424
+ WHERE expires_at <= $1
1425
+ ORDER BY expires_at
1426
+ LIMIT $2
1427
+ )`,
1428
+ [before.toISOString(), limit]
1429
+ );
1430
+ return result.rowCount ?? 0;
1431
+ }
1432
+ };
1433
+
1434
+ // src/stores/index.ts
1435
+ function createPostgresCloudStores(options) {
1436
+ const { pool, clock, crypto } = options;
1437
+ return {
1438
+ activity: new PostgresActivityStore(pool, clock),
1439
+ approvals: new PostgresApprovalTimelineStore(pool, clock),
1440
+ devices: new PostgresDeviceDirectory(pool),
1441
+ pairingCodes: new PostgresPairingCodeStore(pool, clock),
1442
+ nonces: new PostgresNonceStore(pool, clock, crypto),
1443
+ dedup: new PostgresInboundDedupStore(pool),
1444
+ tasks: new PostgresTaskAttemptStore(pool, clock),
1445
+ receipts: new PostgresRequestReceiptStore(pool, clock),
1446
+ proofReceipts: new PostgresProofRequestReceiptStore(pool, clock),
1447
+ // A second `PostgresObjectStore` instance, not a shared one: it is a
1448
+ // stateless wrapper over the pool, so the two read and write the same rows
1449
+ // under the same locks, and requiring the caller to build the core
1450
+ // composition first would be an ordering dependency bought for nothing.
1451
+ blobs: new R2CloudBlobStore({
1452
+ ...options.objectStorage,
1453
+ objects: new PostgresObjectStore(pool, clock)
1454
+ }),
1455
+ rateLimiter: new AllowAllRateLimiter()
1456
+ };
1457
+ }
1458
+ var PRESENCE_COLUMNS = "tenant_id, device_id, level, detail, configured_toolsets, observed_at, expires_at";
1459
+ function toHint(row) {
1460
+ return {
1461
+ tenantId: row.tenant_id,
1462
+ deviceId: row.device_id,
1463
+ level: row.level,
1464
+ ...row.detail === null ? {} : { detail: row.detail },
1465
+ ...row.configured_toolsets === null ? {} : { configuredToolsets: Object.freeze([...row.configured_toolsets]) },
1466
+ observedAt: row.observed_at,
1467
+ expiresAt: row.expires_at
1468
+ };
1469
+ }
1470
+ function assertTtl(ttlMs) {
1471
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
1472
+ throw new ByokCoreError(
1473
+ "hint_ttl_invalid",
1474
+ `Hint ttl must be a positive number of milliseconds, received ${String(ttlMs)}.`
1475
+ );
1476
+ }
1477
+ }
1478
+ function assertMinimumInterval(minimumIntervalMs) {
1479
+ if (!Number.isFinite(minimumIntervalMs) || minimumIntervalMs < 0) {
1480
+ throw new ByokCoreError(
1481
+ "hint_ttl_invalid",
1482
+ `Hint minimum interval must be a non-negative number of milliseconds, received ${String(minimumIntervalMs)}.`
1483
+ );
1484
+ }
1485
+ }
1486
+ var PostgresPresenceStore = class {
1487
+ #pool;
1488
+ #clock;
1489
+ constructor(pool, clock) {
1490
+ this.#pool = pool;
1491
+ this.#clock = clock;
1492
+ }
1493
+ async publish(tenant, input) {
1494
+ assertTtl(input.ttlMs);
1495
+ assertMinimumInterval(input.minimumIntervalMs);
1496
+ const now = this.#clock.now();
1497
+ const observedAt = now.toISOString();
1498
+ const allowedBefore = new Date(now.getTime() - input.minimumIntervalMs).toISOString();
1499
+ const result = await this.#pool.query(
1500
+ `INSERT INTO device_presence (${PRESENCE_COLUMNS})
1501
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)
1502
+ ON CONFLICT (tenant_id, device_id) DO UPDATE
1503
+ SET level = EXCLUDED.level,
1504
+ detail = EXCLUDED.detail,
1505
+ configured_toolsets = EXCLUDED.configured_toolsets,
1506
+ observed_at = EXCLUDED.observed_at,
1507
+ expires_at = EXCLUDED.expires_at
1508
+ WHERE device_presence.expires_at <= EXCLUDED.observed_at
1509
+ OR device_presence.observed_at <= $8
1510
+ RETURNING ${PRESENCE_COLUMNS}`,
1511
+ [
1512
+ tenant,
1513
+ input.deviceId,
1514
+ input.level,
1515
+ input.detail ?? null,
1516
+ input.configuredToolsets === void 0 ? null : JSON.stringify(input.configuredToolsets),
1517
+ observedAt,
1518
+ new Date(now.getTime() + input.ttlMs).toISOString(),
1519
+ allowedBefore
1520
+ ]
1521
+ );
1522
+ const row = result.rows[0];
1523
+ if (row === void 0) {
1524
+ throw new ByokCoreError(
1525
+ "hint_rate_limited",
1526
+ `Presence for ${input.deviceId} was published more recently than the configured minimum interval.`
1527
+ );
1528
+ }
1529
+ return toHint(row);
1530
+ }
1531
+ async read(tenant, deviceId) {
1532
+ const result = await this.#pool.query(
1533
+ `SELECT ${PRESENCE_COLUMNS} FROM device_presence
1534
+ WHERE tenant_id = $1 AND device_id = $2 AND expires_at > $3`,
1535
+ [tenant, deviceId, this.#now()]
1536
+ );
1537
+ const row = result.rows[0];
1538
+ return row === void 0 ? void 0 : toHint(row);
1539
+ }
1540
+ async list(tenant) {
1541
+ const result = await this.#pool.query(
1542
+ `SELECT ${PRESENCE_COLUMNS} FROM device_presence
1543
+ WHERE tenant_id = $1 AND expires_at > $2
1544
+ ORDER BY device_id COLLATE "C"`,
1545
+ [tenant, this.#now()]
1546
+ );
1547
+ return result.rows.map(toHint);
1548
+ }
1549
+ #expiry(ttlMs) {
1550
+ return new Date(this.#clock.now().getTime() + ttlMs).toISOString();
1551
+ }
1552
+ #now() {
1553
+ return this.#clock.now().toISOString();
1554
+ }
1555
+ };
1556
+ var DEFAULT_LIST_LIMIT2 = 50;
1557
+ var BOARD_COLUMNS = "tenant_id, item_id, channel, title, status, holder_id, held_since, board_seq, created_at, updated_at";
1558
+ function toItem(row) {
1559
+ return {
1560
+ tenantId: row.tenant_id,
1561
+ itemId: row.item_id,
1562
+ channel: row.channel,
1563
+ title: row.title,
1564
+ status: row.status,
1565
+ // An unheld item has NO assignee rather than an assignee with an empty
1566
+ // holder, which is what lets `expect(item.assignee).toBeUndefined()` mean
1567
+ // "nobody holds this" in both compositions.
1568
+ ...row.holder_id === null || row.held_since === null ? {} : { assignee: { holderId: row.holder_id, heldSince: row.held_since } },
1569
+ boardSeq: Number(row.board_seq),
1570
+ createdAt: row.created_at,
1571
+ updatedAt: row.updated_at
1572
+ };
1573
+ }
1574
+ var PostgresBoardStore = class {
1575
+ #pool;
1576
+ #clock;
1577
+ constructor(pool, clock) {
1578
+ this.#pool = pool;
1579
+ this.#clock = clock;
1580
+ }
1581
+ async create(tenant, input) {
1582
+ const now = this.#now();
1583
+ const boardSeq = await this.#allocateSeq(tenant);
1584
+ const inserted = await this.#pool.query(
1585
+ `INSERT INTO board_item (${BOARD_COLUMNS})
1586
+ VALUES ($1, $2, $3, $4, $5, NULL, NULL, $6::bigint, $7, $7)
1587
+ ON CONFLICT (tenant_id, item_id) DO NOTHING
1588
+ RETURNING ${BOARD_COLUMNS}`,
1589
+ [tenant, input.itemId, input.channel, input.title, input.status ?? "todo", boardSeq, now]
1590
+ );
1591
+ const row = inserted.rows[0];
1592
+ if (row === void 0) {
1593
+ throw new ByokCoreError(
1594
+ "board_item_exists",
1595
+ `Board item ${input.itemId} already exists in this tenant.`
1596
+ );
1597
+ }
1598
+ return toItem(row);
1599
+ }
1600
+ async get(tenant, itemId) {
1601
+ const result = await this.#pool.query(
1602
+ `SELECT ${BOARD_COLUMNS} FROM board_item WHERE tenant_id = $1 AND item_id = $2`,
1603
+ [tenant, itemId]
1604
+ );
1605
+ const row = result.rows[0];
1606
+ return row === void 0 ? void 0 : toItem(row);
1607
+ }
1608
+ async list(tenant, query) {
1609
+ const afterSeq = query.afterSeq ?? 0;
1610
+ const limit = query.limit ?? DEFAULT_LIST_LIMIT2;
1611
+ const result = await this.#pool.query(
1612
+ `SELECT ${BOARD_COLUMNS} FROM board_item
1613
+ WHERE tenant_id = $1
1614
+ AND board_seq > $2::bigint
1615
+ AND ($3::text IS NULL OR channel = $3::text)
1616
+ AND ($4::text IS NULL OR status = $4::text)
1617
+ ORDER BY board_seq
1618
+ LIMIT $5`,
1619
+ [tenant, afterSeq, query.channel ?? null, query.status ?? null, limit + 1]
1620
+ );
1621
+ const page = result.rows.slice(0, limit).map(toItem);
1622
+ return {
1623
+ items: page,
1624
+ nextSeq: page.at(-1)?.boardSeq ?? afterSeq,
1625
+ hasMore: result.rows.length > page.length
1626
+ };
1627
+ }
1628
+ async claim(tenant, input) {
1629
+ const expectedStatus = input.expectedStatus ?? "todo";
1630
+ const now = this.#now();
1631
+ const boardSeq = await this.#allocateSeq(tenant);
1632
+ const claimed = await this.#pool.query(
1633
+ `UPDATE board_item
1634
+ SET status = CASE WHEN status = 'todo' THEN 'in_progress' ELSE status END,
1635
+ holder_id = $3,
1636
+ held_since = $5,
1637
+ board_seq = $6::bigint,
1638
+ updated_at = $5
1639
+ WHERE tenant_id = $1 AND item_id = $2
1640
+ AND holder_id IS NULL
1641
+ AND status = $4::text
1642
+ AND status IN ('todo', 'in_progress')
1643
+ RETURNING ${BOARD_COLUMNS}`,
1644
+ [tenant, input.itemId, input.holderId, expectedStatus, now, boardSeq]
1645
+ );
1646
+ const won = claimed.rows[0];
1647
+ if (won !== void 0) return toItem(won);
1648
+ const current = await this.get(tenant, input.itemId);
1649
+ if (current === void 0) throw this.#itemNotFound(input.itemId);
1650
+ if (current.assignee !== void 0) {
1651
+ if (current.assignee.holderId === input.holderId) {
1652
+ if (input.expectedStatus !== void 0 && current.status !== input.expectedStatus) {
1653
+ throw this.#statusConflict(input.itemId, current, input.expectedStatus);
1654
+ }
1655
+ return current;
1656
+ }
1657
+ throw new CoreConflictError(
1658
+ "board_claim_conflict",
1659
+ `Board item ${input.itemId} is held by ${current.assignee.holderId}.`,
1660
+ current,
1661
+ this.#now()
1662
+ );
1663
+ }
1664
+ if (current.status !== expectedStatus) {
1665
+ throw this.#statusConflict(input.itemId, current, expectedStatus);
1666
+ }
1667
+ throw new CoreConflictError(
1668
+ "board_transition_invalid",
1669
+ `Board item ${input.itemId} cannot be claimed from ${current.status}.`,
1670
+ current,
1671
+ this.#now()
1672
+ );
1673
+ }
1674
+ async unclaim(tenant, input) {
1675
+ const now = this.#now();
1676
+ const boardSeq = await this.#allocateSeq(tenant);
1677
+ const released = await this.#pool.query(
1678
+ `UPDATE board_item
1679
+ SET status = CASE WHEN status = 'in_progress' THEN 'todo' ELSE status END,
1680
+ holder_id = NULL,
1681
+ held_since = NULL,
1682
+ board_seq = $4::bigint,
1683
+ updated_at = $5
1684
+ WHERE tenant_id = $1 AND item_id = $2 AND holder_id = $3
1685
+ RETURNING ${BOARD_COLUMNS}`,
1686
+ [tenant, input.itemId, input.holderId, boardSeq, now]
1687
+ );
1688
+ const row = released.rows[0];
1689
+ if (row !== void 0) return toItem(row);
1690
+ const current = await this.get(tenant, input.itemId);
1691
+ if (current === void 0) throw this.#itemNotFound(input.itemId);
1692
+ if (current.assignee === void 0) {
1693
+ throw new ByokCoreError(
1694
+ "board_not_held",
1695
+ `Board item ${input.itemId} is not held by anyone.`
1696
+ );
1697
+ }
1698
+ throw new CoreConflictError(
1699
+ "board_claim_conflict",
1700
+ `Board item ${input.itemId} is held by ${current.assignee.holderId}, not ${input.holderId}.`,
1701
+ current,
1702
+ this.#now()
1703
+ );
1704
+ }
1705
+ async updateStatus(tenant, input) {
1706
+ if (isLegalBoardTransition(input.expectedStatus, input.status)) {
1707
+ const now = this.#now();
1708
+ const boardSeq = await this.#allocateSeq(tenant);
1709
+ const updated = await this.#pool.query(
1710
+ `UPDATE board_item
1711
+ SET status = $4::text, board_seq = $6::bigint, updated_at = $7
1712
+ WHERE tenant_id = $1 AND item_id = $2
1713
+ AND status = $3::text
1714
+ AND ($5::text IS NULL OR holder_id = $5::text)
1715
+ RETURNING ${BOARD_COLUMNS}`,
1716
+ [
1717
+ tenant,
1718
+ input.itemId,
1719
+ input.expectedStatus,
1720
+ input.status,
1721
+ input.holderId ?? null,
1722
+ boardSeq,
1723
+ now
1724
+ ]
1725
+ );
1726
+ const row = updated.rows[0];
1727
+ if (row !== void 0) return toItem(row);
1728
+ }
1729
+ const current = await this.get(tenant, input.itemId);
1730
+ if (current === void 0) throw this.#itemNotFound(input.itemId);
1731
+ if (current.status !== input.expectedStatus) {
1732
+ throw this.#statusConflict(input.itemId, current, input.expectedStatus);
1733
+ }
1734
+ if (input.holderId !== void 0 && current.assignee?.holderId !== input.holderId) {
1735
+ throw new CoreConflictError(
1736
+ "board_claim_conflict",
1737
+ `Board item ${input.itemId} is not held by ${input.holderId}.`,
1738
+ current,
1739
+ this.#now()
1740
+ );
1741
+ }
1742
+ if (!isLegalBoardTransition(input.expectedStatus, input.status)) {
1743
+ throw new CoreConflictError(
1744
+ "board_transition_invalid",
1745
+ `${input.expectedStatus} to ${input.status} is not a legal board transition.`,
1746
+ current,
1747
+ this.#now()
1748
+ );
1749
+ }
1750
+ throw this.#statusConflict(input.itemId, current, input.expectedStatus);
1751
+ }
1752
+ /**
1753
+ * One statement, its own transaction, lock released immediately. See the file
1754
+ * header for why this is not a CTE inside the write it feeds.
1755
+ */
1756
+ async #allocateSeq(tenant) {
1757
+ const result = await this.#pool.query(
1758
+ `INSERT INTO tenant_stream (tenant_id, board_seq)
1759
+ VALUES ($1, 1)
1760
+ ON CONFLICT (tenant_id) DO UPDATE SET board_seq = tenant_stream.board_seq + 1
1761
+ RETURNING board_seq`,
1762
+ [tenant]
1763
+ );
1764
+ return result.rows[0].board_seq;
1765
+ }
1766
+ #itemNotFound(itemId) {
1767
+ return new ByokCoreError(
1768
+ "board_item_not_found",
1769
+ `Board item ${itemId} does not exist in this tenant.`
1770
+ );
1771
+ }
1772
+ #statusConflict(itemId, current, expected) {
1773
+ return new CoreConflictError(
1774
+ "board_status_conflict",
1775
+ `Board item ${itemId} is ${current.status}, not ${expected}.`,
1776
+ current,
1777
+ this.#now()
1778
+ );
1779
+ }
1780
+ #now() {
1781
+ return this.#clock.now().toISOString();
1782
+ }
1783
+ };
1784
+
1785
+ // src/stores/core/mailbox-sequence.ts
1786
+ async function allocateMailboxSequence(client, tenant, deviceId, now) {
1787
+ const allocation = await client.query(
1788
+ `INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
1789
+ VALUES ($1, $2, 2, 0, $3)
1790
+ ON CONFLICT (tenant_id, device_id) DO UPDATE
1791
+ SET next_seq = device_stream.next_seq + 1
1792
+ RETURNING next_seq - 1 AS seq`,
1793
+ [tenant, deviceId, now]
1794
+ );
1795
+ return Number(allocation.rows[0].seq);
1796
+ }
1797
+
1798
+ // src/stores/core/mailbox.ts
1799
+ var DEFAULT_READ_LIMIT = 50;
1800
+ var OUTBOX_COLUMNS = "tenant_id, device_id, seq, message_id, body, body_hash, byte_size, state, appended_at";
1801
+ function toMessage(row) {
1802
+ return {
1803
+ tenantId: row.tenant_id,
1804
+ deviceId: row.device_id,
1805
+ // `seq` is bigint in the column and `number` on the port, because it is the
1806
+ // envelope `seq` on the wire. The column is wide so the counter cannot wrap
1807
+ // into a redelivery bug; the narrowing happens once, here.
1808
+ seq: Number(row.seq),
1809
+ messageId: row.message_id,
1810
+ body: row.body,
1811
+ bodyHash: row.body_hash,
1812
+ byteSize: row.byte_size,
1813
+ state: row.state,
1814
+ appendedAt: row.appended_at
1815
+ };
1816
+ }
1817
+ var PostgresMailboxStore = class {
1818
+ #pool;
1819
+ #clock;
1820
+ constructor(pool, clock) {
1821
+ this.#pool = pool;
1822
+ this.#clock = clock;
1823
+ }
1824
+ async append(tenant, input) {
1825
+ this.#requireDeviceId(input.deviceId);
1826
+ const client = await this.#pool.connect();
1827
+ try {
1828
+ await client.query("BEGIN");
1829
+ const existing = await client.query(
1830
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1831
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1832
+ [tenant, input.deviceId, input.messageId]
1833
+ );
1834
+ const replayed = existing.rows[0];
1835
+ if (replayed !== void 0) {
1836
+ await client.query("COMMIT");
1837
+ return toMessage(replayed);
1838
+ }
1839
+ const seq = await allocateMailboxSequence(client, tenant, input.deviceId, this.#now());
1840
+ const serializedExisting = await client.query(
1841
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1842
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1843
+ [tenant, input.deviceId, input.messageId]
1844
+ );
1845
+ const winnerAfterLock = serializedExisting.rows[0];
1846
+ if (winnerAfterLock !== void 0) {
1847
+ await client.query("ROLLBACK");
1848
+ return toMessage(winnerAfterLock);
1849
+ }
1850
+ const materialized = await input.materialize(seq);
1851
+ const now = this.#now();
1852
+ const inserted = await client.query(
1853
+ `INSERT INTO outbox (${OUTBOX_COLUMNS})
1854
+ VALUES ($1, $2, $3::bigint, $4, $5, $6, $7::bigint, 'pending', $8)
1855
+ ON CONFLICT (tenant_id, device_id, message_id) DO NOTHING
1856
+ RETURNING ${OUTBOX_COLUMNS}`,
1857
+ [
1858
+ tenant,
1859
+ input.deviceId,
1860
+ seq,
1861
+ input.messageId,
1862
+ materialized.body,
1863
+ materialized.bodyHash,
1864
+ materialized.byteSize,
1865
+ now
1866
+ ]
1867
+ );
1868
+ const row = inserted.rows[0];
1869
+ if (row !== void 0) {
1870
+ await client.query("COMMIT");
1871
+ return toMessage(row);
1872
+ }
1873
+ const winner = await client.query(
1874
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1875
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1876
+ [tenant, input.deviceId, input.messageId]
1877
+ );
1878
+ await client.query("ROLLBACK");
1879
+ const won = winner.rows[0];
1880
+ if (won === void 0) {
1881
+ throw new ByokCoreError(
1882
+ "mailbox_message_not_found",
1883
+ `Message ${input.messageId} vanished during an idempotent append.`
1884
+ );
1885
+ }
1886
+ return toMessage(won);
1887
+ } catch (cause) {
1888
+ await client.query("ROLLBACK").catch(() => {
1889
+ });
1890
+ throw cause;
1891
+ } finally {
1892
+ client.release();
1893
+ }
1894
+ }
1895
+ async readAfter(tenant, query) {
1896
+ const limit = query.limit ?? DEFAULT_READ_LIMIT;
1897
+ const result = await this.#pool.query(
1898
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1899
+ WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending' AND seq > $3::bigint
1900
+ ORDER BY seq
1901
+ LIMIT $4`,
1902
+ [tenant, query.deviceId, query.afterSeq, limit + 1]
1903
+ );
1904
+ const page = result.rows.slice(0, limit).map(toMessage);
1905
+ return {
1906
+ messages: page,
1907
+ // Nothing above was mutated, so an identical call replays the same page.
1908
+ // The returned position is a READ cursor and moves no ack.
1909
+ nextSeq: page.at(-1)?.seq ?? query.afterSeq,
1910
+ hasMore: result.rows.length > page.length
1911
+ };
1912
+ }
1913
+ async advanceCursor(tenant, input) {
1914
+ this.#requireDeviceId(input.deviceId);
1915
+ const now = this.#now();
1916
+ const moved = await this.#pool.query(
1917
+ `WITH moved AS (
1918
+ INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
1919
+ VALUES ($1, $2, 1, $3::bigint, $4)
1920
+ ON CONFLICT (tenant_id, device_id) DO UPDATE
1921
+ SET acked_seq = EXCLUDED.acked_seq, acked_at = EXCLUDED.acked_at
1922
+ WHERE device_stream.acked_seq <= EXCLUDED.acked_seq
1923
+ RETURNING acked_seq, acked_at
1924
+ ), marked AS (
1925
+ UPDATE outbox
1926
+ SET state = 'acked'
1927
+ WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending'
1928
+ AND seq <= (SELECT acked_seq FROM moved)
1929
+ RETURNING 1
1930
+ )
1931
+ SELECT acked_seq, acked_at FROM moved`,
1932
+ [tenant, input.deviceId, input.ackedSeq, now]
1933
+ );
1934
+ const row = moved.rows[0];
1935
+ if (row !== void 0) {
1936
+ return {
1937
+ tenantId: tenant,
1938
+ deviceId: input.deviceId,
1939
+ ackedSeq: Number(row.acked_seq),
1940
+ updatedAt: row.acked_at ?? now
1941
+ };
1942
+ }
1943
+ const current = await this.readCursor(tenant, input.deviceId);
1944
+ throw new CoreConflictError(
1945
+ "mailbox_cursor_regression",
1946
+ `Cursor for device ${input.deviceId} is at ${current.ackedSeq}; refusing to move it back to ${input.ackedSeq}.`,
1947
+ current,
1948
+ this.#now()
1949
+ );
1950
+ }
1951
+ async readCursor(tenant, deviceId) {
1952
+ const result = await this.#pool.query(
1953
+ `SELECT acked_seq, acked_at FROM device_stream
1954
+ WHERE tenant_id = $1 AND device_id = $2`,
1955
+ [tenant, deviceId]
1956
+ );
1957
+ const row = result.rows[0];
1958
+ return {
1959
+ tenantId: tenant,
1960
+ deviceId,
1961
+ ackedSeq: row === void 0 ? 0 : Number(row.acked_seq),
1962
+ updatedAt: row?.acked_at ?? this.#now()
1963
+ };
1964
+ }
1965
+ async collectRetired(tenant, input) {
1966
+ assertCanonicalTimestamp(input.ackedBefore, "ackedBefore");
1967
+ assertCanonicalTimestamp(input.expireUnackedBefore, "expireUnackedBefore");
1968
+ const swept = await this.#pool.query(
1969
+ `WITH deleted AS (
1970
+ DELETE FROM outbox
1971
+ WHERE tenant_id = $1
1972
+ AND ($2::text IS NULL OR device_id = $2::text)
1973
+ AND state = 'acked'
1974
+ AND appended_at < $3
1975
+ RETURNING byte_size
1976
+ ), expired AS (
1977
+ UPDATE outbox
1978
+ SET state = 'expired'
1979
+ WHERE tenant_id = $1
1980
+ AND ($2::text IS NULL OR device_id = $2::text)
1981
+ AND state = 'pending'
1982
+ AND appended_at < $4
1983
+ RETURNING 1
1984
+ )
1985
+ SELECT (SELECT count(*) FROM deleted) AS deleted_count,
1986
+ (SELECT count(*) FROM expired) AS expired_count,
1987
+ (SELECT COALESCE(SUM(byte_size), 0) FROM deleted)::bigint AS released_bytes`,
1988
+ [tenant, input.deviceId ?? null, input.ackedBefore, input.expireUnackedBefore]
1989
+ );
1990
+ const row = swept.rows[0];
1991
+ return {
1992
+ deletedCount: Number(row.deleted_count),
1993
+ expiredCount: Number(row.expired_count),
1994
+ releasedBytes: row.released_bytes
1995
+ };
1996
+ }
1997
+ /**
1998
+ * The in-memory reference refuses an empty device id rather than opening a
1999
+ * mailbox nothing can address. Kept here so the two compositions answer the
2000
+ * same way; the table itself would happily store the row.
2001
+ */
2002
+ #requireDeviceId(deviceId) {
2003
+ if (deviceId.length === 0) {
2004
+ throw new ByokCoreError("mailbox_message_not_found", "Device id must not be empty.");
2005
+ }
2006
+ }
2007
+ #now() {
2008
+ return this.#clock.now().toISOString();
2009
+ }
2010
+ };
2011
+ var WARNING_NUMERATOR = 80n;
2012
+ var WARNING_DENOMINATOR = 100n;
2013
+ var ENTITLEMENT_COLUMNS = "tenant_id, version, hard_limit_bytes, max_object_bytes, max_inline_bytes, mailbox_limit_bytes, retention_policy_id, downgrade_grace_until";
2014
+ var RESERVATION_COLUMNS = "tenant_id, reservation_id, state, kind, expected_bytes, content_hash, content_type, created_at, expires_at, settled_at, deduplicated";
2015
+ var QUALIFIED_RESERVATION_COLUMNS = RESERVATION_COLUMNS.split(", ").map((column) => `r.${column}`).join(", ");
2016
+ function toEntitlement(row) {
2017
+ return {
2018
+ tenantId: row.tenant_id,
2019
+ version: row.version,
2020
+ hardLimitBytes: row.hard_limit_bytes,
2021
+ maxObjectBytes: row.max_object_bytes,
2022
+ maxInlineBytes: row.max_inline_bytes,
2023
+ mailboxLimitBytes: row.mailbox_limit_bytes,
2024
+ retentionPolicyId: row.retention_policy_id,
2025
+ ...row.downgrade_grace_until === null ? {} : { downgradeGraceUntil: row.downgrade_grace_until }
2026
+ };
2027
+ }
2028
+ function toReservation(row) {
2029
+ return {
2030
+ tenantId: row.tenant_id,
2031
+ reservationId: row.reservation_id,
2032
+ state: row.state,
2033
+ kind: row.kind,
2034
+ expectedBytes: row.expected_bytes,
2035
+ contentHash: row.content_hash,
2036
+ contentType: row.content_type,
2037
+ createdAt: row.created_at,
2038
+ expiresAt: row.expires_at,
2039
+ ...row.settled_at === null ? {} : { settledAt: row.settled_at }
2040
+ };
2041
+ }
2042
+ function toUsage(row) {
2043
+ return {
2044
+ committedObjectBytes: row.committed_object_bytes,
2045
+ committedInlineBytes: row.committed_inline_bytes,
2046
+ reservedBytes: row.reserved_bytes,
2047
+ mailboxBytes: row.mailbox_bytes,
2048
+ objectCount: row.object_count,
2049
+ updatedAt: row.updated_at
2050
+ };
2051
+ }
2052
+ var USAGE_SQL = `
2053
+ SELECT
2054
+ COALESCE(u.committed_object_bytes, 0)::bigint AS committed_object_bytes,
2055
+ COALESCE(u.committed_inline_bytes, 0)::bigint AS committed_inline_bytes,
2056
+ COALESCE(u.mailbox_bytes, 0)::bigint AS mailbox_bytes,
2057
+ COALESCE(u.object_count, 0)::bigint AS object_count,
2058
+ COALESCE(u.updated_at, $2) AS updated_at,
2059
+ COALESCE((SELECT SUM(r.expected_bytes) FROM storage_reservation r
2060
+ WHERE r.tenant_id = $1 AND r.state = 'reserved'), 0)::bigint AS reserved_bytes
2061
+ FROM (SELECT $1::text AS tenant_id) AS scope
2062
+ LEFT JOIN storage_usage u ON u.tenant_id = scope.tenant_id`;
2063
+ var PostgresQuotaStore = class {
2064
+ #pool;
2065
+ #clock;
2066
+ constructor(pool, clock) {
2067
+ this.#pool = pool;
2068
+ this.#clock = clock;
2069
+ }
2070
+ async readEntitlement(tenant) {
2071
+ const result = await this.#pool.query(
2072
+ `SELECT ${ENTITLEMENT_COLUMNS} FROM storage_entitlement WHERE tenant_id = $1`,
2073
+ [tenant]
2074
+ );
2075
+ const row = result.rows[0];
2076
+ return row === void 0 ? void 0 : toEntitlement(row);
2077
+ }
2078
+ async writeEntitlement(tenant, input) {
2079
+ if (input.downgradeGraceUntil !== void 0) {
2080
+ assertCanonicalTimestamp(input.downgradeGraceUntil, "downgradeGraceUntil");
2081
+ }
2082
+ const applied = await this.#pool.query(
2083
+ `WITH applied AS (
2084
+ INSERT INTO storage_entitlement (${ENTITLEMENT_COLUMNS})
2085
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
2086
+ ON CONFLICT (tenant_id) DO UPDATE
2087
+ SET version = EXCLUDED.version,
2088
+ hard_limit_bytes = EXCLUDED.hard_limit_bytes,
2089
+ max_object_bytes = EXCLUDED.max_object_bytes,
2090
+ max_inline_bytes = EXCLUDED.max_inline_bytes,
2091
+ mailbox_limit_bytes = EXCLUDED.mailbox_limit_bytes,
2092
+ retention_policy_id = EXCLUDED.retention_policy_id,
2093
+ downgrade_grace_until = EXCLUDED.downgrade_grace_until
2094
+ WHERE storage_entitlement.version < EXCLUDED.version
2095
+ RETURNING ${ENTITLEMENT_COLUMNS}
2096
+ ), seeded AS (
2097
+ INSERT INTO storage_usage (tenant_id, updated_at)
2098
+ SELECT $1, $9 FROM applied
2099
+ ON CONFLICT (tenant_id) DO NOTHING
2100
+ RETURNING 1
2101
+ )
2102
+ SELECT ${ENTITLEMENT_COLUMNS} FROM applied`,
2103
+ [
2104
+ tenant,
2105
+ input.version,
2106
+ input.hardLimitBytes,
2107
+ input.maxObjectBytes,
2108
+ input.maxInlineBytes,
2109
+ input.mailboxLimitBytes,
2110
+ input.retentionPolicyId,
2111
+ input.downgradeGraceUntil ?? null,
2112
+ this.#now()
2113
+ ]
2114
+ );
2115
+ const row = applied.rows[0];
2116
+ if (row !== void 0) return toEntitlement(row);
2117
+ const current = await this.readEntitlement(tenant);
2118
+ if (current === void 0) {
2119
+ throw new Error(`entitlement for ${tenant} vanished during a version CAS`);
2120
+ }
2121
+ throw new CoreConflictError(
2122
+ "storage_entitlement_version_conflict",
2123
+ `Entitlement is at version ${String(current.version)}; refusing to apply version ${String(input.version)}.`,
2124
+ current,
2125
+ this.#now()
2126
+ );
2127
+ }
2128
+ async readUsage(tenant) {
2129
+ const result = await this.#pool.query(USAGE_SQL, [tenant, this.#now()]);
2130
+ return toUsage(result.rows[0]);
2131
+ }
2132
+ async readStatus(tenant) {
2133
+ const entitlement = await this.readEntitlement(tenant);
2134
+ if (entitlement === void 0) throw this.#entitlementMissing();
2135
+ const usage = await this.readUsage(tenant);
2136
+ const used = usedBytes(usage);
2137
+ const graceActive = this.#graceActive(entitlement);
2138
+ return {
2139
+ entitlement,
2140
+ usage,
2141
+ posture: posture(entitlement, usage, graceActive),
2142
+ availableBytes: used >= entitlement.hardLimitBytes ? 0n : entitlement.hardLimitBytes - used,
2143
+ graceActive
2144
+ };
2145
+ }
2146
+ async readReservation(tenant, reservationId) {
2147
+ return (await this.#readReservation(tenant, reservationId))?.reservation;
2148
+ }
2149
+ async reserve(tenant, input) {
2150
+ const client = await this.#pool.connect();
2151
+ let admitted;
2152
+ let rejection;
2153
+ try {
2154
+ await client.query("BEGIN");
2155
+ const entitlement = await this.#lockEntitlement(client, tenant);
2156
+ if (entitlement === void 0) {
2157
+ rejection = this.#entitlementMissing();
2158
+ } else {
2159
+ const now = this.#now();
2160
+ await client.query(
2161
+ `UPDATE storage_reservation
2162
+ SET state = 'expired', settled_at = $2
2163
+ WHERE tenant_id = $1 AND state = 'reserved' AND expires_at <= $2`,
2164
+ [tenant, now]
2165
+ );
2166
+ const inserted = await client.query(
2167
+ `WITH ent AS (
2168
+ SELECT hard_limit_bytes, max_object_bytes, max_inline_bytes, downgrade_grace_until
2169
+ FROM storage_entitlement WHERE tenant_id = $1
2170
+ ), used AS (
2171
+ SELECT COALESCE((SELECT committed_object_bytes + committed_inline_bytes
2172
+ FROM storage_usage WHERE tenant_id = $1), 0)::bigint
2173
+ + COALESCE((SELECT SUM(expected_bytes) FROM storage_reservation
2174
+ WHERE tenant_id = $1 AND state = 'reserved'), 0)::bigint
2175
+ AS used_bytes
2176
+ )
2177
+ INSERT INTO storage_reservation
2178
+ (tenant_id, reservation_id, state, kind, expected_bytes,
2179
+ content_hash, content_type, created_at, expires_at)
2180
+ SELECT $1, $2, 'reserved', $3::text, $4::bigint, $5, $6, $7, $8
2181
+ FROM ent, used
2182
+ WHERE $4::bigint <= (CASE WHEN $3::text = 'object'
2183
+ THEN ent.max_object_bytes
2184
+ ELSE ent.max_inline_bytes END)
2185
+ AND used.used_bytes + $4::bigint <= ent.hard_limit_bytes
2186
+ AND ($3::text <> 'object' OR NOT EXISTS (
2187
+ SELECT 1 FROM object_manifest m
2188
+ WHERE m.tenant_id = $1 AND m.hash = $5
2189
+ AND m.state = 'delete_pending'
2190
+ ))
2191
+ AND NOT (used.used_bytes >= ent.hard_limit_bytes
2192
+ AND ent.downgrade_grace_until IS NOT NULL
2193
+ AND ent.downgrade_grace_until <= $9)
2194
+ ON CONFLICT (tenant_id, reservation_id) DO NOTHING
2195
+ RETURNING ${RESERVATION_COLUMNS}`,
2196
+ [
2197
+ tenant,
2198
+ input.reservationId,
2199
+ input.kind,
2200
+ input.expectedBytes,
2201
+ input.contentHash,
2202
+ input.contentType,
2203
+ now,
2204
+ this.#expiry(input.ttlMs),
2205
+ now
2206
+ ]
2207
+ );
2208
+ const row = inserted.rows[0];
2209
+ if (row !== void 0) {
2210
+ admitted = toReservation(row);
2211
+ } else {
2212
+ const outcome = await this.#explainRefusedReservation(
2213
+ client,
2214
+ tenant,
2215
+ input,
2216
+ entitlement
2217
+ );
2218
+ if (outcome instanceof ByokCoreError) rejection = outcome;
2219
+ else admitted = outcome;
2220
+ }
2221
+ }
2222
+ await client.query("COMMIT");
2223
+ } catch (error) {
2224
+ await client.query("ROLLBACK").catch(() => {
2225
+ });
2226
+ throw error;
2227
+ } finally {
2228
+ client.release();
2229
+ }
2230
+ if (rejection !== void 0) throw rejection;
2231
+ return admitted;
2232
+ }
2233
+ async finalizeReservation(tenant, input) {
2234
+ const existing = await this.#readReservation(tenant, input.reservationId);
2235
+ if (existing === void 0) throw this.#reservationMissing(input.reservationId);
2236
+ if (existing.reservation.state === "committed") {
2237
+ return {
2238
+ reservation: existing.reservation,
2239
+ usage: await this.readUsage(tenant),
2240
+ deduplicated: existing.deduplicated
2241
+ };
2242
+ }
2243
+ if (existing.reservation.state !== "reserved") {
2244
+ throw new ByokCoreError(
2245
+ "storage_reservation_expired",
2246
+ `Reservation ${input.reservationId} is ${existing.reservation.state}.`
2247
+ );
2248
+ }
2249
+ if (this.#now() >= existing.reservation.expiresAt) {
2250
+ await this.#settle(tenant, input.reservationId, "expired");
2251
+ throw new ByokCoreError(
2252
+ "storage_reservation_expired",
2253
+ `Reservation ${input.reservationId} expired at ${existing.reservation.expiresAt}.`
2254
+ );
2255
+ }
2256
+ if (input.observedByteSize !== existing.reservation.expectedBytes || input.observedContentType !== existing.reservation.contentType) {
2257
+ await this.#settle(tenant, input.reservationId, "aborted");
2258
+ throw new ByokCoreError(
2259
+ "storage_integrity_mismatch",
2260
+ `Observed object does not match reservation ${input.reservationId}.`
2261
+ );
2262
+ }
2263
+ const settled = await this.#pool.query(
2264
+ `WITH candidate AS MATERIALIZED (
2265
+ SELECT r.tenant_id,
2266
+ r.reservation_id,
2267
+ r.kind,
2268
+ r.content_hash,
2269
+ r.expected_bytes,
2270
+ r.content_type,
2271
+ m.state AS manifest_state,
2272
+ (m.tenant_id IS NOT NULL
2273
+ AND m.state IN ('pending', 'committed')
2274
+ AND m.byte_size = $4::bigint
2275
+ AND m.content_type = $5) AS manifest_valid,
2276
+ EXISTS (
2277
+ SELECT 1 FROM storage_reservation p
2278
+ WHERE p.tenant_id = r.tenant_id
2279
+ AND p.content_hash = r.content_hash
2280
+ AND p.state = 'committed'
2281
+ ) AS inline_deduplicated
2282
+ FROM storage_reservation r
2283
+ LEFT JOIN object_manifest m
2284
+ ON m.tenant_id = r.tenant_id AND m.hash = r.content_hash
2285
+ WHERE r.tenant_id = $1
2286
+ AND r.reservation_id = $2
2287
+ AND r.state = 'reserved'
2288
+ ), committed_manifest AS (
2289
+ UPDATE object_manifest m
2290
+ SET state = 'committed', updated_at = $3
2291
+ FROM candidate c
2292
+ WHERE c.kind = 'object'
2293
+ AND c.manifest_valid
2294
+ AND m.state = 'pending'
2295
+ AND m.tenant_id = c.tenant_id
2296
+ AND m.hash = c.content_hash
2297
+ RETURNING 1
2298
+ ), settled AS (
2299
+ UPDATE storage_reservation r
2300
+ SET state = CASE
2301
+ WHEN c.kind = 'object' AND NOT c.manifest_valid
2302
+ THEN 'aborted'
2303
+ ELSE 'committed'
2304
+ END,
2305
+ settled_at = $3,
2306
+ deduplicated = CASE
2307
+ WHEN c.kind = 'object'
2308
+ THEN NOT EXISTS (SELECT 1 FROM committed_manifest)
2309
+ ELSE c.inline_deduplicated
2310
+ END
2311
+ FROM candidate c,
2312
+ (SELECT COUNT(*) FROM committed_manifest) AS manifest_barrier
2313
+ WHERE r.tenant_id = c.tenant_id
2314
+ AND r.reservation_id = c.reservation_id
2315
+ AND r.state = 'reserved'
2316
+ RETURNING ${QUALIFIED_RESERVATION_COLUMNS}
2317
+ ), accounted AS (
2318
+ UPDATE storage_usage u
2319
+ SET committed_object_bytes = u.committed_object_bytes
2320
+ + CASE WHEN s.kind = 'object' AND NOT s.deduplicated
2321
+ THEN s.expected_bytes ELSE 0 END,
2322
+ committed_inline_bytes = u.committed_inline_bytes
2323
+ + CASE WHEN s.kind = 'inline' AND NOT s.deduplicated
2324
+ THEN s.expected_bytes ELSE 0 END,
2325
+ object_count = u.object_count
2326
+ + CASE WHEN s.kind = 'object' AND NOT s.deduplicated THEN 1 ELSE 0 END,
2327
+ updated_at = $3
2328
+ FROM settled s
2329
+ WHERE u.tenant_id = $1 AND s.state = 'committed'
2330
+ RETURNING 1
2331
+ )
2332
+ SELECT ${RESERVATION_COLUMNS} FROM settled`,
2333
+ [
2334
+ tenant,
2335
+ input.reservationId,
2336
+ this.#now(),
2337
+ input.observedByteSize,
2338
+ input.observedContentType
2339
+ ]
2340
+ );
2341
+ const row = settled.rows[0];
2342
+ if (row === void 0) {
2343
+ const raced = await this.#readReservation(tenant, input.reservationId);
2344
+ if (raced !== void 0 && raced.reservation.state === "committed") {
2345
+ return {
2346
+ reservation: raced.reservation,
2347
+ usage: await this.readUsage(tenant),
2348
+ deduplicated: raced.deduplicated
2349
+ };
2350
+ }
2351
+ throw new ByokCoreError(
2352
+ "storage_reservation_expired",
2353
+ `Reservation ${input.reservationId} was settled concurrently.`
2354
+ );
2355
+ }
2356
+ const reservation = toReservation(row);
2357
+ if (reservation.state === "aborted") {
2358
+ throw new ByokCoreError(
2359
+ "storage_integrity_mismatch",
2360
+ `Reservation ${input.reservationId} has no matching committable object manifest.`
2361
+ );
2362
+ }
2363
+ return {
2364
+ reservation,
2365
+ usage: await this.readUsage(tenant),
2366
+ deduplicated: row.deduplicated
2367
+ };
2368
+ }
2369
+ async abortReservation(tenant, reservationId) {
2370
+ const existing = await this.#readReservation(tenant, reservationId);
2371
+ if (existing === void 0) throw this.#reservationMissing(reservationId);
2372
+ if (existing.reservation.state !== "reserved") return existing.reservation;
2373
+ const settled = await this.#settle(tenant, reservationId, "aborted");
2374
+ if (settled !== void 0) return settled;
2375
+ const raced = await this.#readReservation(tenant, reservationId);
2376
+ if (raced === void 0) throw this.#reservationMissing(reservationId);
2377
+ return raced.reservation;
2378
+ }
2379
+ async expireReservations(tenant) {
2380
+ const expired = await this.#pool.query(
2381
+ `UPDATE storage_reservation
2382
+ SET state = 'expired', settled_at = $2
2383
+ WHERE tenant_id = $1 AND state = 'reserved' AND expires_at <= $2
2384
+ RETURNING ${RESERVATION_COLUMNS}`,
2385
+ [tenant, this.#now()]
2386
+ );
2387
+ return expired.rows.map(toReservation).sort((left, right) => left.reservationId.localeCompare(right.reservationId));
2388
+ }
2389
+ async applyMailboxDelta(tenant, input) {
2390
+ const applied = await this.#pool.query(
2391
+ `WITH ent AS (
2392
+ SELECT mailbox_limit_bytes FROM storage_entitlement WHERE tenant_id = $1
2393
+ )
2394
+ UPDATE storage_usage u
2395
+ SET mailbox_bytes = GREATEST(u.mailbox_bytes + $2::bigint, 0::bigint),
2396
+ updated_at = $3
2397
+ FROM ent
2398
+ WHERE u.tenant_id = $1
2399
+ AND ($2::bigint <= 0 OR u.mailbox_bytes + $2::bigint <= ent.mailbox_limit_bytes)
2400
+ RETURNING 1`,
2401
+ [tenant, input.deltaBytes, this.#now()]
2402
+ );
2403
+ if (applied.rowCount === 0) {
2404
+ const entitlement = await this.readEntitlement(tenant);
2405
+ if (entitlement === void 0) throw this.#entitlementMissing();
2406
+ const usage = await this.readUsage(tenant);
2407
+ throw new ByokCoreError(
2408
+ "storage_quota_exceeded",
2409
+ `Mailbox would reach ${String(usage.mailboxBytes + input.deltaBytes)} bytes, over the limit of ${String(entitlement.mailboxLimitBytes)} bytes.`
2410
+ );
2411
+ }
2412
+ return this.readUsage(tenant);
2413
+ }
2414
+ async #lockEntitlement(client, tenant) {
2415
+ const result = await client.query(
2416
+ `SELECT ${ENTITLEMENT_COLUMNS} FROM storage_entitlement WHERE tenant_id = $1 FOR UPDATE`,
2417
+ [tenant]
2418
+ );
2419
+ const row = result.rows[0];
2420
+ return row === void 0 ? void 0 : toEntitlement(row);
2421
+ }
2422
+ /**
2423
+ * Names the refusal. Runs only after the guarded insert has already declined,
2424
+ * inside the same locked transaction, so the state it reads is the state that
2425
+ * refused. The order matches the contract's precedence: an existing
2426
+ * reservation is an idempotent answer, not a rejection; a suspended tenant is
2427
+ * read-only (423) before it is over-quota (507); a single oversized write is
2428
+ * 413 regardless of how much room is left.
2429
+ */
2430
+ async #explainRefusedReservation(client, tenant, input, entitlement) {
2431
+ const existing = await client.query(
2432
+ `SELECT ${RESERVATION_COLUMNS} FROM storage_reservation
2433
+ WHERE tenant_id = $1 AND reservation_id = $2`,
2434
+ [tenant, input.reservationId]
2435
+ );
2436
+ const held = existing.rows[0];
2437
+ if (held !== void 0) {
2438
+ if (held.state === "reserved") {
2439
+ if (held.kind !== input.kind || held.expected_bytes !== input.expectedBytes || held.content_hash !== input.contentHash || held.content_type !== input.contentType) {
2440
+ return new ByokCoreError(
2441
+ "storage_integrity_mismatch",
2442
+ `Reservation ${input.reservationId} already binds a different storage declaration.`
2443
+ );
2444
+ }
2445
+ return toReservation(held);
2446
+ }
2447
+ return new ByokCoreError(
2448
+ "storage_reservation_expired",
2449
+ `Reservation ${input.reservationId} is already ${held.state}.`
2450
+ );
2451
+ }
2452
+ if (input.kind === "object") {
2453
+ const tombstone = await client.query(
2454
+ `SELECT state FROM object_manifest
2455
+ WHERE tenant_id = $1 AND hash = $2 AND state = 'delete_pending'`,
2456
+ [tenant, input.contentHash]
2457
+ );
2458
+ if (tombstone.rows[0] !== void 0) {
2459
+ return new ByokCoreError(
2460
+ "object_state_invalid",
2461
+ `Object ${input.contentHash} is pending deletion and cannot accept a new reservation.`
2462
+ );
2463
+ }
2464
+ }
2465
+ const usageResult = await client.query(USAGE_SQL, [tenant, this.#now()]);
2466
+ const usage = toUsage(usageResult.rows[0]);
2467
+ if (posture(entitlement, usage, this.#graceActive(entitlement)) === "suspended") {
2468
+ return new ByokCoreError(
2469
+ "storage_write_suspended",
2470
+ "Tenant is over its hard limit and its downgrade grace has ended; durable writes are suspended."
2471
+ );
2472
+ }
2473
+ const perWriteLimit = input.kind === "object" ? entitlement.maxObjectBytes : entitlement.maxInlineBytes;
2474
+ if (input.expectedBytes > perWriteLimit) {
2475
+ return new ByokCoreError(
2476
+ "storage_object_too_large",
2477
+ `${String(input.expectedBytes)} bytes exceeds the ${input.kind} limit of ${String(perWriteLimit)} bytes.`
2478
+ );
2479
+ }
2480
+ return new ByokCoreError(
2481
+ "storage_quota_exceeded",
2482
+ `Reserving ${String(input.expectedBytes)} bytes would exceed the hard limit of ${String(entitlement.hardLimitBytes)} bytes.`
2483
+ );
2484
+ }
2485
+ async #readReservation(tenant, reservationId) {
2486
+ const result = await this.#pool.query(
2487
+ `SELECT ${RESERVATION_COLUMNS} FROM storage_reservation
2488
+ WHERE tenant_id = $1 AND reservation_id = $2`,
2489
+ [tenant, reservationId]
2490
+ );
2491
+ const row = result.rows[0];
2492
+ if (row === void 0) return void 0;
2493
+ return { reservation: toReservation(row), deduplicated: row.deduplicated };
2494
+ }
2495
+ /**
2496
+ * The settle guard. `WHERE state = 'reserved'` is what makes two settlements
2497
+ * of the same reservation produce one winner; the loser gets zero rows and
2498
+ * re-reads rather than stamping a second `settled_at` over the first.
2499
+ * Releasing the reserved bytes needs no second write: they were never a
2500
+ * counter, only the sum of rows in this state.
2501
+ */
2502
+ async #settle(tenant, reservationId, state) {
2503
+ const result = await this.#pool.query(
2504
+ `UPDATE storage_reservation
2505
+ SET state = $3, settled_at = $4
2506
+ WHERE tenant_id = $1 AND reservation_id = $2 AND state = 'reserved'
2507
+ RETURNING ${RESERVATION_COLUMNS}`,
2508
+ [tenant, reservationId, state, this.#now()]
2509
+ );
2510
+ const row = result.rows[0];
2511
+ return row === void 0 ? void 0 : toReservation(row);
2512
+ }
2513
+ #graceActive(entitlement) {
2514
+ return entitlement.downgradeGraceUntil !== void 0 && this.#now() < entitlement.downgradeGraceUntil;
2515
+ }
2516
+ #entitlementMissing() {
2517
+ return new ByokCoreError(
2518
+ "storage_entitlement_missing",
2519
+ "No storage entitlement has been issued for this tenant."
2520
+ );
2521
+ }
2522
+ #reservationMissing(reservationId) {
2523
+ return new ByokCoreError(
2524
+ "storage_reservation_not_found",
2525
+ `Reservation ${reservationId} does not exist in this tenant.`
2526
+ );
2527
+ }
2528
+ #expiry(ttlMs) {
2529
+ return new Date(this.#clock.now().getTime() + ttlMs).toISOString();
2530
+ }
2531
+ #now() {
2532
+ return this.#clock.now().toISOString();
2533
+ }
2534
+ };
2535
+ function usedBytes(usage) {
2536
+ return usage.committedObjectBytes + usage.committedInlineBytes + usage.reservedBytes;
2537
+ }
2538
+ function posture(entitlement, usage, graceActive) {
2539
+ const used = usedBytes(usage);
2540
+ if (used >= entitlement.hardLimitBytes) {
2541
+ const graceConfigured = entitlement.downgradeGraceUntil !== void 0;
2542
+ return graceConfigured && !graceActive ? "suspended" : "blocked";
2543
+ }
2544
+ if (entitlement.hardLimitBytes > 0n && used * WARNING_DENOMINATOR >= entitlement.hardLimitBytes * WARNING_NUMERATOR) {
2545
+ return "warning";
2546
+ }
2547
+ return "normal";
2548
+ }
2549
+ var DEFAULT_LIST_LIMIT3 = 50;
2550
+ function toManifest(pack, files) {
2551
+ return {
2552
+ schema: SKILL_PACK_MANIFEST_SCHEMA_ID,
2553
+ name: pack.name,
2554
+ version: pack.version,
2555
+ description: pack.description,
2556
+ files: files.map(toFile),
2557
+ contentHash: pack.content_hash
2558
+ };
2559
+ }
2560
+ function toFile(row) {
2561
+ return {
2562
+ path: row.path,
2563
+ contentHash: row.content_hash,
2564
+ byteSize: row.byte_size
2565
+ };
2566
+ }
2567
+ var PostgresSkillPackStore = class {
2568
+ #pool;
2569
+ constructor(pool) {
2570
+ this.#pool = pool;
2571
+ }
2572
+ async publish(tenant, input) {
2573
+ const { manifest } = input;
2574
+ const structural = checkSkillPackManifest(manifest);
2575
+ if (!structural.ok) {
2576
+ throw new ByokCoreError(
2577
+ "skill_pack_manifest_invalid",
2578
+ `Refusing to publish ${JSON.stringify(manifest.name)}: ${structural.reason} \u2014 ${structural.detail}`
2579
+ );
2580
+ }
2581
+ const contents = /* @__PURE__ */ new Map();
2582
+ for (const file of input.files) {
2583
+ if (contents.has(file.path)) {
2584
+ throw new ByokCoreError(
2585
+ "skill_pack_manifest_invalid",
2586
+ `Refusing to publish ${JSON.stringify(manifest.name)}: ${JSON.stringify(file.path)} was supplied twice.`
2587
+ );
2588
+ }
2589
+ contents.set(file.path, file.content);
2590
+ }
2591
+ for (const declared of manifest.files) {
2592
+ const content = contents.get(declared.path);
2593
+ if (content === void 0) {
2594
+ throw new ByokCoreError(
2595
+ "skill_pack_manifest_invalid",
2596
+ `Refusing to publish ${JSON.stringify(manifest.name)}: ${JSON.stringify(declared.path)} is declared but was not supplied.`
2597
+ );
2598
+ }
2599
+ const bytes = new TextEncoder().encode(content).length;
2600
+ if (bytes !== declared.byteSize) {
2601
+ throw new ByokCoreError(
2602
+ "skill_pack_manifest_invalid",
2603
+ `Refusing to publish ${JSON.stringify(manifest.name)}: ${JSON.stringify(declared.path)} declares ${declared.byteSize} bytes and supplies ${bytes}.`
2604
+ );
2605
+ }
2606
+ }
2607
+ if (contents.size !== manifest.files.length) {
2608
+ throw new ByokCoreError(
2609
+ "skill_pack_manifest_invalid",
2610
+ `Refusing to publish ${JSON.stringify(manifest.name)}: ${contents.size} files were supplied for ${manifest.files.length} declared rows.`
2611
+ );
2612
+ }
2613
+ const entry = checkSkillPackEntry(manifest, contents.get(SKILL_PACK_ENTRY_PATH));
2614
+ if (!entry.ok) {
2615
+ throw new ByokCoreError(
2616
+ "skill_pack_manifest_invalid",
2617
+ `Refusing to publish ${JSON.stringify(manifest.name)}: ${entry.reason} \u2014 ${entry.detail}`
2618
+ );
2619
+ }
2620
+ await this.#inTransaction(async (client) => {
2621
+ await client.query(
2622
+ `INSERT INTO skill_pack (tenant_id, name, version, description, content_hash)
2623
+ VALUES ($1, $2, $3, $4, $5)
2624
+ ON CONFLICT (tenant_id, name) DO UPDATE
2625
+ SET version = EXCLUDED.version,
2626
+ description = EXCLUDED.description,
2627
+ content_hash = EXCLUDED.content_hash`,
2628
+ [tenant, manifest.name, manifest.version, manifest.description, manifest.contentHash]
2629
+ );
2630
+ await client.query(`DELETE FROM skill_pack_file WHERE tenant_id = $1 AND pack_name = $2`, [
2631
+ tenant,
2632
+ manifest.name
2633
+ ]);
2634
+ for (const declared of manifest.files) {
2635
+ await client.query(
2636
+ `INSERT INTO skill_pack_file (tenant_id, pack_name, path, content_hash, byte_size, content)
2637
+ VALUES ($1, $2, $3, $4, $5, $6)`,
2638
+ [
2639
+ tenant,
2640
+ manifest.name,
2641
+ declared.path,
2642
+ declared.contentHash,
2643
+ declared.byteSize,
2644
+ contents.get(declared.path)
2645
+ ]
2646
+ );
2647
+ }
2648
+ });
2649
+ return manifest;
2650
+ }
2651
+ async get(tenant, name) {
2652
+ const packResult = await this.#pool.query(
2653
+ `SELECT name, version, description, content_hash
2654
+ FROM skill_pack WHERE tenant_id = $1 AND name = $2`,
2655
+ [tenant, name]
2656
+ );
2657
+ const pack = packResult.rows[0];
2658
+ if (pack === void 0) return void 0;
2659
+ const files = await this.#filesFor(tenant, [name]);
2660
+ return toManifest(pack, files);
2661
+ }
2662
+ async list(tenant, query) {
2663
+ const packs = await this.#pool.query(
2664
+ `SELECT name, version, description, content_hash
2665
+ FROM skill_pack WHERE tenant_id = $1
2666
+ ORDER BY name COLLATE "C"
2667
+ LIMIT $2`,
2668
+ [tenant, query.limit ?? DEFAULT_LIST_LIMIT3]
2669
+ );
2670
+ if (packs.rows.length === 0) return [];
2671
+ const files = await this.#filesFor(
2672
+ tenant,
2673
+ packs.rows.map((row) => row.name)
2674
+ );
2675
+ const byPack = /* @__PURE__ */ new Map();
2676
+ for (const file of files) {
2677
+ const bucket = byPack.get(file.pack_name);
2678
+ if (bucket === void 0) byPack.set(file.pack_name, [file]);
2679
+ else bucket.push(file);
2680
+ }
2681
+ return packs.rows.map((pack) => toManifest(pack, byPack.get(pack.name) ?? []));
2682
+ }
2683
+ async readFile(tenant, name, path) {
2684
+ const result = await this.#pool.query(
2685
+ `SELECT pack_name, path, content_hash, byte_size, content
2686
+ FROM skill_pack_file
2687
+ WHERE tenant_id = $1 AND pack_name = $2 AND path = $3`,
2688
+ [tenant, name, path]
2689
+ );
2690
+ const row = result.rows[0];
2691
+ if (row === void 0) return void 0;
2692
+ return {
2693
+ path: row.path,
2694
+ contentHash: row.content_hash,
2695
+ byteSize: row.byte_size,
2696
+ content: row.content
2697
+ };
2698
+ }
2699
+ /** Every file row for the named packs, in byte order of (pack_name, path). */
2700
+ async #filesFor(tenant, names) {
2701
+ const result = await this.#pool.query(
2702
+ `SELECT pack_name, path, content_hash, byte_size, content
2703
+ FROM skill_pack_file
2704
+ WHERE tenant_id = $1 AND pack_name = ANY($2::text[])
2705
+ ORDER BY pack_name COLLATE "C", path COLLATE "C"`,
2706
+ [tenant, names]
2707
+ );
2708
+ return result.rows;
2709
+ }
2710
+ async #inTransaction(run) {
2711
+ const client = await this.#pool.connect();
2712
+ try {
2713
+ await client.query("BEGIN");
2714
+ await run(client);
2715
+ await client.query("COMMIT");
2716
+ } catch (error) {
2717
+ await client.query("ROLLBACK").catch(() => {
2718
+ });
2719
+ throw error;
2720
+ } finally {
2721
+ client.release();
2722
+ }
2723
+ }
2724
+ };
2725
+ var DEFAULT_MANIFEST_LIMIT = 100;
2726
+ var RECORD_COLUMNS = "tenant_id, kind, subject_id, rev, content_hash, byte_size, body_kind, body_inline, body_object_hash, label, request_id, written_at";
2727
+ function toBody(row) {
2728
+ return row.body_kind === "inline" ? { kind: "inline", body: row.body_inline ?? "" } : { kind: "object", hash: row.body_object_hash ?? "" };
2729
+ }
2730
+ function toRecord2(row) {
2731
+ return {
2732
+ tenantId: row.tenant_id,
2733
+ kind: row.kind,
2734
+ recordKey: row.subject_id,
2735
+ rev: row.rev,
2736
+ contentHash: row.content_hash,
2737
+ byteSize: row.byte_size,
2738
+ body: toBody(row),
2739
+ ...row.label === null ? {} : { label: row.label },
2740
+ ...row.request_id === null ? {} : { requestId: row.request_id },
2741
+ writtenAt: row.written_at
2742
+ };
2743
+ }
2744
+ function bodyColumns(body) {
2745
+ return body.kind === "inline" ? ["inline", body.body, null] : ["object", null, body.hash];
2746
+ }
2747
+ var PostgresTruthStore = class {
2748
+ #pool;
2749
+ #clock;
2750
+ constructor(pool, clock) {
2751
+ this.#pool = pool;
2752
+ this.#clock = clock;
2753
+ }
2754
+ async writeTerminal(tenant, input) {
2755
+ const [bodyKind, bodyInline, bodyObjectHash] = bodyColumns(input.body);
2756
+ const inserted = await this.#pool.query(
2757
+ `INSERT INTO attested_record (${RECORD_COLUMNS})
2758
+ VALUES ($1, 'task.terminal', $2, 1, $3, $4::bigint, $5, $6, $7, $8, $9, $10)
2759
+ ON CONFLICT (tenant_id, kind, subject_id) DO NOTHING
2760
+ RETURNING ${RECORD_COLUMNS}`,
2761
+ [
2762
+ tenant,
2763
+ input.taskId,
2764
+ input.contentHash,
2765
+ input.byteSize,
2766
+ bodyKind,
2767
+ bodyInline,
2768
+ bodyObjectHash,
2769
+ input.label ?? null,
2770
+ input.requestId ?? null,
2771
+ this.#now()
2772
+ ]
2773
+ );
2774
+ const row = inserted.rows[0];
2775
+ if (row !== void 0) return toRecord2(row);
2776
+ const existing = await this.getRecord(tenant, {
2777
+ kind: "task.terminal",
2778
+ recordKey: input.taskId
2779
+ });
2780
+ if (existing === void 0) {
2781
+ throw new Error(`terminal record for ${input.taskId} vanished during a first write`);
2782
+ }
2783
+ if (existing.contentHash === input.contentHash) return existing;
2784
+ throw new CoreConflictError(
2785
+ "terminal_conflict",
2786
+ `Task ${input.taskId} already has an immutable terminal record with a different hash.`,
2787
+ existing,
2788
+ this.#now()
2789
+ );
2790
+ }
2791
+ async writeSnapshot(tenant, input) {
2792
+ const [bodyKind, bodyInline, bodyObjectHash] = bodyColumns(input.body);
2793
+ const now = this.#now();
2794
+ const written = input.expectedRev === 0 ? await this.#pool.query(
2795
+ `INSERT INTO attested_record (${RECORD_COLUMNS})
2796
+ VALUES ($1, $2, $3, 1, $4, $5::bigint, $6, $7, $8, $9, $10, $11)
2797
+ ON CONFLICT (tenant_id, kind, subject_id) DO NOTHING
2798
+ RETURNING ${RECORD_COLUMNS}`,
2799
+ [
2800
+ tenant,
2801
+ input.kind,
2802
+ input.recordKey,
2803
+ input.contentHash,
2804
+ input.byteSize,
2805
+ bodyKind,
2806
+ bodyInline,
2807
+ bodyObjectHash,
2808
+ input.label ?? null,
2809
+ input.requestId ?? null,
2810
+ now
2811
+ ]
2812
+ ) : await this.#pool.query(
2813
+ `UPDATE attested_record
2814
+ SET rev = rev + 1,
2815
+ content_hash = $4,
2816
+ byte_size = $5::bigint,
2817
+ body_kind = $6,
2818
+ body_inline = $7,
2819
+ body_object_hash = $8,
2820
+ label = $9,
2821
+ request_id = $10,
2822
+ written_at = $11
2823
+ WHERE tenant_id = $1 AND kind = $2 AND subject_id = $3 AND rev = $12
2824
+ RETURNING ${RECORD_COLUMNS}`,
2825
+ [
2826
+ tenant,
2827
+ input.kind,
2828
+ input.recordKey,
2829
+ input.contentHash,
2830
+ input.byteSize,
2831
+ bodyKind,
2832
+ bodyInline,
2833
+ bodyObjectHash,
2834
+ input.label ?? null,
2835
+ input.requestId ?? null,
2836
+ now,
2837
+ input.expectedRev
2838
+ ]
2839
+ );
2840
+ const row = written.rows[0];
2841
+ if (row !== void 0) return toRecord2(row);
2842
+ const current = await this.getRecord(tenant, {
2843
+ kind: input.kind,
2844
+ recordKey: input.recordKey
2845
+ });
2846
+ throw new CoreConflictError(
2847
+ "truth_revision_conflict",
2848
+ `Record ${input.kind}/${input.recordKey} is at rev ${current?.rev ?? 0}, not ${input.expectedRev}.`,
2849
+ current,
2850
+ this.#now()
2851
+ );
2852
+ }
2853
+ async getRecord(tenant, selector) {
2854
+ const result = await this.#pool.query(
2855
+ `SELECT ${RECORD_COLUMNS} FROM attested_record
2856
+ WHERE tenant_id = $1 AND kind = $2 AND subject_id = $3`,
2857
+ [tenant, selector.kind, selector.recordKey]
2858
+ );
2859
+ const row = result.rows[0];
2860
+ return row === void 0 ? void 0 : toRecord2(row);
2861
+ }
2862
+ async listManifest(tenant, query) {
2863
+ const result = await this.#pool.query(
2864
+ `SELECT kind, subject_id, rev, content_hash, byte_size, label, written_at
2865
+ FROM attested_record
2866
+ WHERE tenant_id = $1
2867
+ AND ($2::text IS NULL OR kind = $2::text)
2868
+ AND ($3::text IS NULL OR starts_with(subject_id, $3::text))
2869
+ ORDER BY kind COLLATE "C", subject_id COLLATE "C"
2870
+ LIMIT $4`,
2871
+ [tenant, query.kind ?? null, query.keyPrefix ?? null, query.limit ?? DEFAULT_MANIFEST_LIMIT]
2872
+ );
2873
+ return result.rows.map((row) => ({
2874
+ kind: row.kind,
2875
+ recordKey: row.subject_id,
2876
+ rev: row.rev,
2877
+ contentHash: row.content_hash,
2878
+ byteSize: row.byte_size,
2879
+ ...row.label === null ? {} : { label: row.label },
2880
+ updatedAt: row.written_at
2881
+ }));
2882
+ }
2883
+ #now() {
2884
+ return this.#clock.now().toISOString();
2885
+ }
2886
+ };
2887
+
2888
+ // src/stores/core/index.ts
2889
+ function createPostgresCoreStores(options) {
2890
+ const { pool, clock } = options;
2891
+ return {
2892
+ mailbox: new PostgresMailboxStore(pool, clock),
2893
+ board: new PostgresBoardStore(pool, clock),
2894
+ truth: new PostgresTruthStore(pool, clock),
2895
+ presence: new PostgresPresenceStore(pool, clock),
2896
+ objects: new PostgresObjectStore(pool, clock),
2897
+ quota: new PostgresQuotaStore(pool, clock),
2898
+ // No clock: a skill-pack manifest carries no timestamp, so this store reads
2899
+ // none — the same shape as the cloud-local `devices` directory.
2900
+ skillPacks: new PostgresSkillPackStore(pool)
2901
+ };
2902
+ }
2903
+ var RECORD_COLUMNS2 = "tenant_id, kind, subject_id, rev, content_hash, byte_size, body_kind, body_inline, body_object_hash, label, request_id, written_at";
2904
+ var RECEIPT_COLUMNS = "tenant_id, device_id, request_id, operation, resource, body_sha256, body_size, response_status, response_body, recorded_at";
2905
+ function toBody2(row) {
2906
+ return row.body_kind === "inline" ? { kind: "inline", body: row.body_inline ?? "" } : { kind: "object", hash: row.body_object_hash ?? "" };
2907
+ }
2908
+ function toRecord3(tenant, row) {
2909
+ return {
2910
+ tenantId: tenant,
2911
+ kind: row.kind,
2912
+ recordKey: row.subject_id,
2913
+ rev: row.rev,
2914
+ contentHash: row.content_hash,
2915
+ byteSize: row.byte_size,
2916
+ body: toBody2(row),
2917
+ ...row.label === null ? {} : { label: row.label },
2918
+ ...row.request_id === null ? {} : { requestId: row.request_id },
2919
+ writtenAt: row.written_at
2920
+ };
2921
+ }
2922
+ function toReceipt3(tenant, row) {
2923
+ return {
2924
+ tenantId: tenant,
2925
+ deviceId: row.device_id,
2926
+ requestId: row.request_id,
2927
+ operation: row.operation,
2928
+ resource: row.resource,
2929
+ bodySha256: row.body_sha256,
2930
+ bodySize: row.body_size,
2931
+ responseStatus: row.response_status,
2932
+ responseBody: row.response_body,
2933
+ recordedAt: row.recorded_at.toISOString()
2934
+ };
2935
+ }
2936
+ function sameBinding(receipt, input) {
2937
+ return receipt.operation === input.operation && receipt.resource === input.resource && receipt.bodySha256 === input.proofBodySha256 && receipt.bodySize === input.proofBodySize;
2938
+ }
2939
+ function bodyColumns2(body) {
2940
+ return body.kind === "inline" ? ["inline", body.body, null] : ["object", null, body.hash];
2941
+ }
2942
+ function writeKey(write) {
2943
+ return `${write.kind}\0${write.recordKey}`;
2944
+ }
2945
+ function referenceId(write) {
2946
+ return `${write.kind}:${write.recordKey}`;
2947
+ }
2948
+ var PostgresTruthCommitter = class {
2949
+ #pool;
2950
+ #clock;
2951
+ #crypto;
2952
+ #truth;
2953
+ constructor(options) {
2954
+ this.#pool = options.pool;
2955
+ this.#clock = options.clock;
2956
+ this.#crypto = options.crypto;
2957
+ this.#truth = new PostgresTruthStore(options.pool, options.clock);
2958
+ }
2959
+ getRecord(tenant, selector) {
2960
+ return this.#truth.getRecord(tenant, selector);
2961
+ }
2962
+ listManifest(tenant, query) {
2963
+ return this.#truth.listManifest(tenant, query);
2964
+ }
2965
+ async commit(tenant, input) {
2966
+ await this.#validateInput(input);
2967
+ const client = await this.#pool.connect();
2968
+ try {
2969
+ await client.query("BEGIN");
2970
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
2971
+ JSON.stringify(["truth-receipt", tenant, input.deviceId, input.requestId])
2972
+ ]);
2973
+ const replay = await this.#readReceipt(client, tenant, input.deviceId, input.requestId);
2974
+ if (replay !== void 0) {
2975
+ if (!sameBinding(replay, input)) {
2976
+ throw new TruthCommitError(
2977
+ "proof_request_conflict",
2978
+ `Request ${input.requestId} was already used with a different binding.`
2979
+ );
2980
+ }
2981
+ const response2 = TruthCommitResponseSchema.parse(JSON.parse(replay.responseBody));
2982
+ await client.query("COMMIT");
2983
+ return { response: response2, replayed: true };
2984
+ }
2985
+ const before = await this.#lockCurrentRecords(client, tenant, input.writes);
2986
+ this.#assertWritePreconditions(input.writes, before);
2987
+ await this.#lockAndVerifyObjects(client, tenant, input.writes, before);
2988
+ const inlineAffected = input.writes.some((write) => {
2989
+ const current = before.get(writeKey(write));
2990
+ if (write.kind === "task.terminal" && current !== void 0) return false;
2991
+ return current?.body.kind === "inline" || write.body.kind === "inline";
2992
+ });
2993
+ const inlineDelta = inlineAffected ? await this.#prepareInlineAccounting(client, tenant, input.writes, before) : 0n;
2994
+ const applied = await this.#applyWrites(client, tenant, input, before);
2995
+ await this.#replaceObjectReferences(client, tenant, applied);
2996
+ await this.#settleInlineAccounting(client, tenant, inlineDelta);
2997
+ const response = {
2998
+ primary: truthRecordMetadata(applied[0].record),
2999
+ snapshots: applied.slice(1).map((entry) => truthRecordMetadata(entry.record))
3000
+ };
3001
+ await client.query(
3002
+ `INSERT INTO proof_request_receipt (${RECEIPT_COLUMNS})
3003
+ VALUES ($1, $2, $3, $4, $5, $6, $7::bigint, 200, $8, $9)`,
3004
+ [
3005
+ tenant,
3006
+ input.deviceId,
3007
+ input.requestId,
3008
+ input.operation,
3009
+ input.resource,
3010
+ input.proofBodySha256,
3011
+ input.proofBodySize,
3012
+ JSON.stringify(response),
3013
+ this.#now()
3014
+ ]
3015
+ );
3016
+ await client.query("COMMIT");
3017
+ return { response, replayed: false };
3018
+ } catch (error) {
3019
+ await client.query("ROLLBACK").catch(() => void 0);
3020
+ throw error;
3021
+ } finally {
3022
+ client.release();
3023
+ }
3024
+ }
3025
+ async #validateInput(input) {
3026
+ if (input.requestId.length === 0 || input.requestId.length > TRUTH_REQUEST_ID_MAX_LENGTH) {
3027
+ throw new TruthCommitError("proof_request_conflict", "Request id is outside the record contract.");
3028
+ }
3029
+ const seen = /* @__PURE__ */ new Set();
3030
+ const objectSizes = /* @__PURE__ */ new Map();
3031
+ for (const write of input.writes) {
3032
+ const key = writeKey(write);
3033
+ if (seen.has(key)) {
3034
+ throw new TruthCommitError("proof_request_conflict", `Duplicate truth write ${key}.`);
3035
+ }
3036
+ seen.add(key);
3037
+ if (write.body.kind === "inline") {
3038
+ const bytes = new TextEncoder().encode(write.body.body);
3039
+ if (BigInt(bytes.byteLength) !== write.byteSize) {
3040
+ throw new ByokCoreError("storage_integrity_mismatch", "Inline byte size disagrees with its content.");
3041
+ }
3042
+ if (await this.#crypto.sha256(bytes) !== write.contentHash) {
3043
+ throw new ByokCoreError("storage_integrity_mismatch", "Inline hash disagrees with its content.");
3044
+ }
3045
+ } else if (write.body.hash !== write.contentHash) {
3046
+ throw new ByokCoreError("storage_integrity_mismatch", "Object body hash disagrees with record hash.");
3047
+ } else {
3048
+ const priorSize = objectSizes.get(write.body.hash);
3049
+ if (priorSize !== void 0 && priorSize !== write.byteSize) {
3050
+ throw new ByokCoreError(
3051
+ "storage_integrity_mismatch",
3052
+ `Object ${write.body.hash} was declared with inconsistent byte sizes.`
3053
+ );
3054
+ }
3055
+ objectSizes.set(write.body.hash, write.byteSize);
3056
+ }
3057
+ }
3058
+ }
3059
+ async #readReceipt(client, tenant, deviceId, requestId) {
3060
+ const result = await client.query(
3061
+ `SELECT ${RECEIPT_COLUMNS} FROM proof_request_receipt
3062
+ WHERE tenant_id = $1 AND device_id = $2 AND request_id = $3`,
3063
+ [tenant, deviceId, requestId]
3064
+ );
3065
+ const row = result.rows[0];
3066
+ return row === void 0 ? void 0 : toReceipt3(tenant, row);
3067
+ }
3068
+ async #lockCurrentRecords(client, tenant, writes) {
3069
+ const current = /* @__PURE__ */ new Map();
3070
+ const ordered = [...writes].sort((a, b) => writeKey(a).localeCompare(writeKey(b)));
3071
+ for (const write of ordered) {
3072
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
3073
+ JSON.stringify(["truth-record", tenant, write.kind, write.recordKey])
3074
+ ]);
3075
+ }
3076
+ for (const write of ordered) {
3077
+ const result = await client.query(
3078
+ `SELECT ${RECORD_COLUMNS2} FROM attested_record
3079
+ WHERE tenant_id = $1 AND kind = $2 AND subject_id = $3
3080
+ FOR UPDATE`,
3081
+ [tenant, write.kind, write.recordKey]
3082
+ );
3083
+ current.set(
3084
+ writeKey(write),
3085
+ result.rows[0] === void 0 ? void 0 : toRecord3(tenant, result.rows[0])
3086
+ );
3087
+ }
3088
+ return current;
3089
+ }
3090
+ #assertWritePreconditions(writes, current) {
3091
+ for (const write of writes) {
3092
+ const before = current.get(writeKey(write));
3093
+ if (write.kind === "task.terminal") {
3094
+ if (before !== void 0 && before.contentHash !== write.contentHash) {
3095
+ throw new CoreConflictError(
3096
+ "terminal_conflict",
3097
+ `Task ${write.recordKey} already has a different immutable terminal.`,
3098
+ before,
3099
+ this.#now()
3100
+ );
3101
+ }
3102
+ } else if ((before?.rev ?? 0) !== write.expectedRev) {
3103
+ throw new CoreConflictError(
3104
+ "truth_revision_conflict",
3105
+ `${write.kind}/${write.recordKey} is at rev ${before?.rev ?? 0}, not ${write.expectedRev}.`,
3106
+ before,
3107
+ this.#now()
3108
+ );
3109
+ }
3110
+ }
3111
+ }
3112
+ async #lockAndVerifyObjects(client, tenant, writes, current) {
3113
+ const requested = /* @__PURE__ */ new Map();
3114
+ const affected = /* @__PURE__ */ new Set();
3115
+ for (const write of writes) {
3116
+ const before = current.get(writeKey(write));
3117
+ if (write.kind === "task.terminal" && before !== void 0) continue;
3118
+ if (before?.body.kind === "object") affected.add(before.body.hash);
3119
+ if (write.body.kind === "object") {
3120
+ const existing = requested.get(write.body.hash);
3121
+ if (existing !== void 0 && existing !== write.byteSize) {
3122
+ throw new ByokCoreError(
3123
+ "storage_integrity_mismatch",
3124
+ `Object ${write.body.hash} was declared with inconsistent byte sizes.`
3125
+ );
3126
+ }
3127
+ requested.set(write.body.hash, write.byteSize);
3128
+ affected.add(write.body.hash);
3129
+ }
3130
+ }
3131
+ for (const hash of [...affected].sort()) {
3132
+ const result = await client.query(
3133
+ `SELECT hash, byte_size, state FROM object_manifest
3134
+ WHERE tenant_id = $1 AND hash = $2 FOR UPDATE`,
3135
+ [tenant, hash]
3136
+ );
3137
+ const manifest = result.rows[0];
3138
+ const byteSize = requested.get(hash);
3139
+ if (manifest === void 0 || byteSize !== void 0 && (manifest.state !== "committed" || manifest.byte_size !== byteSize)) {
3140
+ throw new TruthCommitError(
3141
+ "truth_object_not_committed",
3142
+ `Object ${hash} is not a committed matching manifest.`
3143
+ );
3144
+ }
3145
+ }
3146
+ }
3147
+ async #prepareInlineAccounting(client, tenant, writes, current) {
3148
+ const entitlementResult = await client.query(
3149
+ `SELECT hard_limit_bytes, max_inline_bytes, downgrade_grace_until
3150
+ FROM storage_entitlement WHERE tenant_id = $1 FOR UPDATE`,
3151
+ [tenant]
3152
+ );
3153
+ const entitlement = entitlementResult.rows[0];
3154
+ if (entitlement === void 0) {
3155
+ throw new ByokCoreError("storage_entitlement_missing", "Tenant has no storage entitlement.");
3156
+ }
3157
+ const now = this.#now();
3158
+ await client.query(
3159
+ `UPDATE storage_reservation SET state = 'expired', settled_at = $2
3160
+ WHERE tenant_id = $1 AND state = 'reserved' AND expires_at <= $2`,
3161
+ [tenant, now]
3162
+ );
3163
+ const usageResult = await client.query(
3164
+ `SELECT u.committed_object_bytes, u.committed_inline_bytes,
3165
+ COALESCE((SELECT SUM(expected_bytes) FROM storage_reservation r
3166
+ WHERE r.tenant_id = $1 AND r.state = 'reserved'), 0)::bigint AS reserved_bytes
3167
+ FROM storage_usage u WHERE u.tenant_id = $1 FOR UPDATE`,
3168
+ [tenant]
3169
+ );
3170
+ const usage = usageResult.rows[0];
3171
+ if (usage === void 0) throw new Error(`storage usage for ${tenant} is missing`);
3172
+ const affectedHashes = /* @__PURE__ */ new Set();
3173
+ const sizes = /* @__PURE__ */ new Map();
3174
+ for (const write of writes) {
3175
+ const before = current.get(writeKey(write));
3176
+ if (write.kind === "task.terminal" && before !== void 0) continue;
3177
+ if (before?.body.kind === "inline") {
3178
+ affectedHashes.add(before.contentHash);
3179
+ sizes.set(before.contentHash, before.byteSize);
3180
+ }
3181
+ if (write.body.kind !== "inline") continue;
3182
+ if (write.byteSize > entitlement.max_inline_bytes) {
3183
+ throw new ByokCoreError(
3184
+ "storage_object_too_large",
3185
+ `Inline truth ${write.kind}/${write.recordKey} exceeds maxInlineBytes.`
3186
+ );
3187
+ }
3188
+ const knownSize = sizes.get(write.contentHash);
3189
+ if (knownSize !== void 0 && knownSize !== write.byteSize) {
3190
+ throw new ByokCoreError(
3191
+ "storage_integrity_mismatch",
3192
+ `Inline hash ${write.contentHash} was declared with inconsistent byte sizes.`
3193
+ );
3194
+ }
3195
+ affectedHashes.add(write.contentHash);
3196
+ sizes.set(write.contentHash, write.byteSize);
3197
+ }
3198
+ const hashes = [...affectedHashes].sort();
3199
+ const baseline = new Map(hashes.map((hash) => [hash, 0n]));
3200
+ const existing = await client.query(
3201
+ `SELECT content_hash, byte_size, count(*)::bigint AS ref_count
3202
+ FROM attested_record
3203
+ WHERE tenant_id = $1 AND body_kind = 'inline' AND content_hash = ANY($2::text[])
3204
+ GROUP BY content_hash, byte_size`,
3205
+ [tenant, hashes]
3206
+ );
3207
+ for (const row of existing.rows) {
3208
+ const knownSize = sizes.get(row.content_hash);
3209
+ if (knownSize !== void 0 && knownSize !== row.byte_size) {
3210
+ throw new ByokCoreError(
3211
+ "storage_integrity_mismatch",
3212
+ `Stored inline hash ${row.content_hash} disagrees on byte size.`
3213
+ );
3214
+ }
3215
+ if ((baseline.get(row.content_hash) ?? 0n) !== 0n) {
3216
+ throw new ByokCoreError(
3217
+ "storage_integrity_mismatch",
3218
+ `Stored inline hash ${row.content_hash} has multiple byte sizes.`
3219
+ );
3220
+ }
3221
+ baseline.set(row.content_hash, row.ref_count);
3222
+ sizes.set(row.content_hash, row.byte_size);
3223
+ }
3224
+ const projected = new Map(baseline);
3225
+ for (const write of writes) {
3226
+ const before = current.get(writeKey(write));
3227
+ if (write.kind === "task.terminal" && before !== void 0) continue;
3228
+ if (before?.body.kind === "inline") {
3229
+ projected.set(before.contentHash, (projected.get(before.contentHash) ?? 0n) - 1n);
3230
+ }
3231
+ if (write.body.kind === "inline") {
3232
+ projected.set(write.contentHash, (projected.get(write.contentHash) ?? 0n) + 1n);
3233
+ }
3234
+ }
3235
+ let delta = 0n;
3236
+ let newlyCommitted = 0n;
3237
+ for (const hash of hashes) {
3238
+ const before = baseline.get(hash) ?? 0n;
3239
+ const after = projected.get(hash) ?? 0n;
3240
+ if (after < 0n) throw new Error(`inline reference count for ${hash} would become negative`);
3241
+ const byteSize = sizes.get(hash);
3242
+ if (byteSize === void 0) throw new Error(`inline byte size for ${hash} is missing`);
3243
+ if (before === 0n && after > 0n) {
3244
+ delta += byteSize;
3245
+ newlyCommitted += byteSize;
3246
+ } else if (before > 0n && after === 0n) {
3247
+ delta -= byteSize;
3248
+ }
3249
+ }
3250
+ const used = usage.committed_object_bytes + usage.committed_inline_bytes + usage.reserved_bytes;
3251
+ if (newlyCommitted > 0n && used >= entitlement.hard_limit_bytes && entitlement.downgrade_grace_until !== null && entitlement.downgrade_grace_until <= now) {
3252
+ throw new ByokCoreError("storage_write_suspended", "Durable writes are suspended.");
3253
+ }
3254
+ if (used + delta > entitlement.hard_limit_bytes) {
3255
+ throw new ByokCoreError("storage_quota_exceeded", "Final inline truth usage exceeds quota.");
3256
+ }
3257
+ return delta;
3258
+ }
3259
+ async #applyWrites(client, tenant, input, current) {
3260
+ const applied = [];
3261
+ for (const write of input.writes) {
3262
+ const before = current.get(writeKey(write));
3263
+ if (write.kind === "task.terminal" && before !== void 0) {
3264
+ applied.push({ input: write, before, record: before, mutated: false });
3265
+ continue;
3266
+ }
3267
+ const [bodyKind, bodyInline, bodyObjectHash] = bodyColumns2(write.body);
3268
+ const values = [
3269
+ tenant,
3270
+ write.kind,
3271
+ write.recordKey,
3272
+ write.contentHash,
3273
+ write.byteSize,
3274
+ bodyKind,
3275
+ bodyInline,
3276
+ bodyObjectHash,
3277
+ write.label ?? null,
3278
+ input.requestId,
3279
+ this.#now()
3280
+ ];
3281
+ const result = before === void 0 ? await client.query(
3282
+ `INSERT INTO attested_record (${RECORD_COLUMNS2})
3283
+ VALUES ($1, $2, $3, 1, $4, $5, $6, $7, $8, $9, $10, $11)
3284
+ RETURNING ${RECORD_COLUMNS2}`,
3285
+ values
3286
+ ) : await client.query(
3287
+ `UPDATE attested_record
3288
+ SET rev = rev + 1, content_hash = $4, byte_size = $5,
3289
+ body_kind = $6, body_inline = $7, body_object_hash = $8,
3290
+ label = $9, request_id = $10, written_at = $11
3291
+ WHERE tenant_id = $1 AND kind = $2 AND subject_id = $3
3292
+ RETURNING ${RECORD_COLUMNS2}`,
3293
+ values
3294
+ );
3295
+ applied.push({
3296
+ input: write,
3297
+ before,
3298
+ record: toRecord3(tenant, result.rows[0]),
3299
+ mutated: true
3300
+ });
3301
+ }
3302
+ return applied;
3303
+ }
3304
+ async #replaceObjectReferences(client, tenant, applied) {
3305
+ const affected = /* @__PURE__ */ new Set();
3306
+ for (const entry of applied) {
3307
+ if (!entry.mutated) continue;
3308
+ const refId = referenceId(entry.input);
3309
+ if (entry.input.body.kind === "object") {
3310
+ affected.add(entry.input.body.hash);
3311
+ await client.query(
3312
+ `INSERT INTO object_reference (tenant_id, hash, ref_kind, ref_id, created_at)
3313
+ VALUES ($1, $2, 'truth', $3, $4)
3314
+ ON CONFLICT (tenant_id, hash, ref_kind, ref_id) DO NOTHING`,
3315
+ [tenant, entry.input.body.hash, refId, this.#now()]
3316
+ );
3317
+ }
3318
+ if (entry.before?.body.kind === "object" && (entry.input.body.kind !== "object" || entry.input.body.hash !== entry.before.body.hash)) {
3319
+ affected.add(entry.before.body.hash);
3320
+ await client.query(
3321
+ `DELETE FROM object_reference
3322
+ WHERE tenant_id = $1 AND hash = $2 AND ref_kind = 'truth' AND ref_id = $3`,
3323
+ [tenant, entry.before.body.hash, refId]
3324
+ );
3325
+ }
3326
+ }
3327
+ for (const hash of [...affected].sort()) {
3328
+ await client.query(
3329
+ `UPDATE object_manifest
3330
+ SET ref_count = (SELECT count(*) FROM object_reference r
3331
+ WHERE r.tenant_id = $1 AND r.hash = $2),
3332
+ updated_at = $3
3333
+ WHERE tenant_id = $1 AND hash = $2`,
3334
+ [tenant, hash, this.#now()]
3335
+ );
3336
+ }
3337
+ }
3338
+ async #settleInlineAccounting(client, tenant, delta) {
3339
+ if (delta !== 0n) {
3340
+ const updated = await client.query(
3341
+ `UPDATE storage_usage
3342
+ SET committed_inline_bytes = committed_inline_bytes + $2::bigint,
3343
+ updated_at = $3
3344
+ WHERE tenant_id = $1 AND committed_inline_bytes + $2::bigint >= 0
3345
+ RETURNING 1`,
3346
+ [tenant, delta, this.#now()]
3347
+ );
3348
+ if (updated.rowCount !== 1) throw new Error("inline accounting would become negative");
3349
+ }
3350
+ }
3351
+ #now() {
3352
+ return this.#clock.now().toISOString();
3353
+ }
3354
+ };
3355
+
3356
+ export { DEFAULT_MAX_ATTEMPTS, DEFAULT_PRESIGN_TTL_SECONDS, DEFAULT_RETRY_DELAY_MS, MAX_PRESIGN_TTL_SECONDS, MIN_PRESIGN_TTL_SECONDS, ObjectStoreRequestError, PostgresActivityStore, PostgresApprovalTimelineStore, PostgresBoardStore, PostgresDeviceAssertionReplayAuthority, PostgresDeviceDirectory, PostgresInboundDedupStore, PostgresMailboxStore, PostgresNonceStore, PostgresObjectStore, PostgresPairingCodeStore, PostgresPresenceStore, PostgresProofRequestReceiptStore, PostgresQuotaStore, PostgresRequestReceiptStore, PostgresSkillPackStore, PostgresTaskAttemptStore, PostgresTruthCommitter, PostgresTruthStore, R2BlobStoreError, R2CloudBlobStore, R2ObjectMaintenanceStore, R2_BLOB_ERROR_CODES, createByokPool, createPostgresCloudStores, createPostgresCoreStores };
3357
+ //# sourceMappingURL=runtime.js.map
3358
+ //# sourceMappingURL=runtime.js.map