@byok-sdk/cloud-dataplane 0.4.1 → 0.4.2

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,3309 @@
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/index.ts
1386
+ function createPostgresCloudStores(options) {
1387
+ const { pool, clock, crypto } = options;
1388
+ return {
1389
+ activity: new PostgresActivityStore(pool, clock),
1390
+ approvals: new PostgresApprovalTimelineStore(pool, clock),
1391
+ devices: new PostgresDeviceDirectory(pool),
1392
+ pairingCodes: new PostgresPairingCodeStore(pool, clock),
1393
+ nonces: new PostgresNonceStore(pool, clock, crypto),
1394
+ dedup: new PostgresInboundDedupStore(pool),
1395
+ tasks: new PostgresTaskAttemptStore(pool, clock),
1396
+ receipts: new PostgresRequestReceiptStore(pool, clock),
1397
+ proofReceipts: new PostgresProofRequestReceiptStore(pool, clock),
1398
+ // A second `PostgresObjectStore` instance, not a shared one: it is a
1399
+ // stateless wrapper over the pool, so the two read and write the same rows
1400
+ // under the same locks, and requiring the caller to build the core
1401
+ // composition first would be an ordering dependency bought for nothing.
1402
+ blobs: new R2CloudBlobStore({
1403
+ ...options.objectStorage,
1404
+ objects: new PostgresObjectStore(pool, clock)
1405
+ }),
1406
+ rateLimiter: new AllowAllRateLimiter()
1407
+ };
1408
+ }
1409
+ var PRESENCE_COLUMNS = "tenant_id, device_id, level, detail, configured_toolsets, observed_at, expires_at";
1410
+ function toHint(row) {
1411
+ return {
1412
+ tenantId: row.tenant_id,
1413
+ deviceId: row.device_id,
1414
+ level: row.level,
1415
+ ...row.detail === null ? {} : { detail: row.detail },
1416
+ ...row.configured_toolsets === null ? {} : { configuredToolsets: Object.freeze([...row.configured_toolsets]) },
1417
+ observedAt: row.observed_at,
1418
+ expiresAt: row.expires_at
1419
+ };
1420
+ }
1421
+ function assertTtl(ttlMs) {
1422
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
1423
+ throw new ByokCoreError(
1424
+ "hint_ttl_invalid",
1425
+ `Hint ttl must be a positive number of milliseconds, received ${String(ttlMs)}.`
1426
+ );
1427
+ }
1428
+ }
1429
+ function assertMinimumInterval(minimumIntervalMs) {
1430
+ if (!Number.isFinite(minimumIntervalMs) || minimumIntervalMs < 0) {
1431
+ throw new ByokCoreError(
1432
+ "hint_ttl_invalid",
1433
+ `Hint minimum interval must be a non-negative number of milliseconds, received ${String(minimumIntervalMs)}.`
1434
+ );
1435
+ }
1436
+ }
1437
+ var PostgresPresenceStore = class {
1438
+ #pool;
1439
+ #clock;
1440
+ constructor(pool, clock) {
1441
+ this.#pool = pool;
1442
+ this.#clock = clock;
1443
+ }
1444
+ async publish(tenant, input) {
1445
+ assertTtl(input.ttlMs);
1446
+ assertMinimumInterval(input.minimumIntervalMs);
1447
+ const now = this.#clock.now();
1448
+ const observedAt = now.toISOString();
1449
+ const allowedBefore = new Date(now.getTime() - input.minimumIntervalMs).toISOString();
1450
+ const result = await this.#pool.query(
1451
+ `INSERT INTO device_presence (${PRESENCE_COLUMNS})
1452
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)
1453
+ ON CONFLICT (tenant_id, device_id) DO UPDATE
1454
+ SET level = EXCLUDED.level,
1455
+ detail = EXCLUDED.detail,
1456
+ configured_toolsets = EXCLUDED.configured_toolsets,
1457
+ observed_at = EXCLUDED.observed_at,
1458
+ expires_at = EXCLUDED.expires_at
1459
+ WHERE device_presence.expires_at <= EXCLUDED.observed_at
1460
+ OR device_presence.observed_at <= $8
1461
+ RETURNING ${PRESENCE_COLUMNS}`,
1462
+ [
1463
+ tenant,
1464
+ input.deviceId,
1465
+ input.level,
1466
+ input.detail ?? null,
1467
+ input.configuredToolsets === void 0 ? null : JSON.stringify(input.configuredToolsets),
1468
+ observedAt,
1469
+ new Date(now.getTime() + input.ttlMs).toISOString(),
1470
+ allowedBefore
1471
+ ]
1472
+ );
1473
+ const row = result.rows[0];
1474
+ if (row === void 0) {
1475
+ throw new ByokCoreError(
1476
+ "hint_rate_limited",
1477
+ `Presence for ${input.deviceId} was published more recently than the configured minimum interval.`
1478
+ );
1479
+ }
1480
+ return toHint(row);
1481
+ }
1482
+ async read(tenant, deviceId) {
1483
+ const result = await this.#pool.query(
1484
+ `SELECT ${PRESENCE_COLUMNS} FROM device_presence
1485
+ WHERE tenant_id = $1 AND device_id = $2 AND expires_at > $3`,
1486
+ [tenant, deviceId, this.#now()]
1487
+ );
1488
+ const row = result.rows[0];
1489
+ return row === void 0 ? void 0 : toHint(row);
1490
+ }
1491
+ async list(tenant) {
1492
+ const result = await this.#pool.query(
1493
+ `SELECT ${PRESENCE_COLUMNS} FROM device_presence
1494
+ WHERE tenant_id = $1 AND expires_at > $2
1495
+ ORDER BY device_id COLLATE "C"`,
1496
+ [tenant, this.#now()]
1497
+ );
1498
+ return result.rows.map(toHint);
1499
+ }
1500
+ #expiry(ttlMs) {
1501
+ return new Date(this.#clock.now().getTime() + ttlMs).toISOString();
1502
+ }
1503
+ #now() {
1504
+ return this.#clock.now().toISOString();
1505
+ }
1506
+ };
1507
+ var DEFAULT_LIST_LIMIT2 = 50;
1508
+ var BOARD_COLUMNS = "tenant_id, item_id, channel, title, status, holder_id, held_since, board_seq, created_at, updated_at";
1509
+ function toItem(row) {
1510
+ return {
1511
+ tenantId: row.tenant_id,
1512
+ itemId: row.item_id,
1513
+ channel: row.channel,
1514
+ title: row.title,
1515
+ status: row.status,
1516
+ // An unheld item has NO assignee rather than an assignee with an empty
1517
+ // holder, which is what lets `expect(item.assignee).toBeUndefined()` mean
1518
+ // "nobody holds this" in both compositions.
1519
+ ...row.holder_id === null || row.held_since === null ? {} : { assignee: { holderId: row.holder_id, heldSince: row.held_since } },
1520
+ boardSeq: Number(row.board_seq),
1521
+ createdAt: row.created_at,
1522
+ updatedAt: row.updated_at
1523
+ };
1524
+ }
1525
+ var PostgresBoardStore = class {
1526
+ #pool;
1527
+ #clock;
1528
+ constructor(pool, clock) {
1529
+ this.#pool = pool;
1530
+ this.#clock = clock;
1531
+ }
1532
+ async create(tenant, input) {
1533
+ const now = this.#now();
1534
+ const boardSeq = await this.#allocateSeq(tenant);
1535
+ const inserted = await this.#pool.query(
1536
+ `INSERT INTO board_item (${BOARD_COLUMNS})
1537
+ VALUES ($1, $2, $3, $4, $5, NULL, NULL, $6::bigint, $7, $7)
1538
+ ON CONFLICT (tenant_id, item_id) DO NOTHING
1539
+ RETURNING ${BOARD_COLUMNS}`,
1540
+ [tenant, input.itemId, input.channel, input.title, input.status ?? "todo", boardSeq, now]
1541
+ );
1542
+ const row = inserted.rows[0];
1543
+ if (row === void 0) {
1544
+ throw new ByokCoreError(
1545
+ "board_item_exists",
1546
+ `Board item ${input.itemId} already exists in this tenant.`
1547
+ );
1548
+ }
1549
+ return toItem(row);
1550
+ }
1551
+ async get(tenant, itemId) {
1552
+ const result = await this.#pool.query(
1553
+ `SELECT ${BOARD_COLUMNS} FROM board_item WHERE tenant_id = $1 AND item_id = $2`,
1554
+ [tenant, itemId]
1555
+ );
1556
+ const row = result.rows[0];
1557
+ return row === void 0 ? void 0 : toItem(row);
1558
+ }
1559
+ async list(tenant, query) {
1560
+ const afterSeq = query.afterSeq ?? 0;
1561
+ const limit = query.limit ?? DEFAULT_LIST_LIMIT2;
1562
+ const result = await this.#pool.query(
1563
+ `SELECT ${BOARD_COLUMNS} FROM board_item
1564
+ WHERE tenant_id = $1
1565
+ AND board_seq > $2::bigint
1566
+ AND ($3::text IS NULL OR channel = $3::text)
1567
+ AND ($4::text IS NULL OR status = $4::text)
1568
+ ORDER BY board_seq
1569
+ LIMIT $5`,
1570
+ [tenant, afterSeq, query.channel ?? null, query.status ?? null, limit + 1]
1571
+ );
1572
+ const page = result.rows.slice(0, limit).map(toItem);
1573
+ return {
1574
+ items: page,
1575
+ nextSeq: page.at(-1)?.boardSeq ?? afterSeq,
1576
+ hasMore: result.rows.length > page.length
1577
+ };
1578
+ }
1579
+ async claim(tenant, input) {
1580
+ const expectedStatus = input.expectedStatus ?? "todo";
1581
+ const now = this.#now();
1582
+ const boardSeq = await this.#allocateSeq(tenant);
1583
+ const claimed = await this.#pool.query(
1584
+ `UPDATE board_item
1585
+ SET status = CASE WHEN status = 'todo' THEN 'in_progress' ELSE status END,
1586
+ holder_id = $3,
1587
+ held_since = $5,
1588
+ board_seq = $6::bigint,
1589
+ updated_at = $5
1590
+ WHERE tenant_id = $1 AND item_id = $2
1591
+ AND holder_id IS NULL
1592
+ AND status = $4::text
1593
+ AND status IN ('todo', 'in_progress')
1594
+ RETURNING ${BOARD_COLUMNS}`,
1595
+ [tenant, input.itemId, input.holderId, expectedStatus, now, boardSeq]
1596
+ );
1597
+ const won = claimed.rows[0];
1598
+ if (won !== void 0) return toItem(won);
1599
+ const current = await this.get(tenant, input.itemId);
1600
+ if (current === void 0) throw this.#itemNotFound(input.itemId);
1601
+ if (current.assignee !== void 0) {
1602
+ if (current.assignee.holderId === input.holderId) {
1603
+ if (input.expectedStatus !== void 0 && current.status !== input.expectedStatus) {
1604
+ throw this.#statusConflict(input.itemId, current, input.expectedStatus);
1605
+ }
1606
+ return current;
1607
+ }
1608
+ throw new CoreConflictError(
1609
+ "board_claim_conflict",
1610
+ `Board item ${input.itemId} is held by ${current.assignee.holderId}.`,
1611
+ current,
1612
+ this.#now()
1613
+ );
1614
+ }
1615
+ if (current.status !== expectedStatus) {
1616
+ throw this.#statusConflict(input.itemId, current, expectedStatus);
1617
+ }
1618
+ throw new CoreConflictError(
1619
+ "board_transition_invalid",
1620
+ `Board item ${input.itemId} cannot be claimed from ${current.status}.`,
1621
+ current,
1622
+ this.#now()
1623
+ );
1624
+ }
1625
+ async unclaim(tenant, input) {
1626
+ const now = this.#now();
1627
+ const boardSeq = await this.#allocateSeq(tenant);
1628
+ const released = await this.#pool.query(
1629
+ `UPDATE board_item
1630
+ SET status = CASE WHEN status = 'in_progress' THEN 'todo' ELSE status END,
1631
+ holder_id = NULL,
1632
+ held_since = NULL,
1633
+ board_seq = $4::bigint,
1634
+ updated_at = $5
1635
+ WHERE tenant_id = $1 AND item_id = $2 AND holder_id = $3
1636
+ RETURNING ${BOARD_COLUMNS}`,
1637
+ [tenant, input.itemId, input.holderId, boardSeq, now]
1638
+ );
1639
+ const row = released.rows[0];
1640
+ if (row !== void 0) return toItem(row);
1641
+ const current = await this.get(tenant, input.itemId);
1642
+ if (current === void 0) throw this.#itemNotFound(input.itemId);
1643
+ if (current.assignee === void 0) {
1644
+ throw new ByokCoreError(
1645
+ "board_not_held",
1646
+ `Board item ${input.itemId} is not held by anyone.`
1647
+ );
1648
+ }
1649
+ throw new CoreConflictError(
1650
+ "board_claim_conflict",
1651
+ `Board item ${input.itemId} is held by ${current.assignee.holderId}, not ${input.holderId}.`,
1652
+ current,
1653
+ this.#now()
1654
+ );
1655
+ }
1656
+ async updateStatus(tenant, input) {
1657
+ if (isLegalBoardTransition(input.expectedStatus, input.status)) {
1658
+ const now = this.#now();
1659
+ const boardSeq = await this.#allocateSeq(tenant);
1660
+ const updated = await this.#pool.query(
1661
+ `UPDATE board_item
1662
+ SET status = $4::text, board_seq = $6::bigint, updated_at = $7
1663
+ WHERE tenant_id = $1 AND item_id = $2
1664
+ AND status = $3::text
1665
+ AND ($5::text IS NULL OR holder_id = $5::text)
1666
+ RETURNING ${BOARD_COLUMNS}`,
1667
+ [
1668
+ tenant,
1669
+ input.itemId,
1670
+ input.expectedStatus,
1671
+ input.status,
1672
+ input.holderId ?? null,
1673
+ boardSeq,
1674
+ now
1675
+ ]
1676
+ );
1677
+ const row = updated.rows[0];
1678
+ if (row !== void 0) return toItem(row);
1679
+ }
1680
+ const current = await this.get(tenant, input.itemId);
1681
+ if (current === void 0) throw this.#itemNotFound(input.itemId);
1682
+ if (current.status !== input.expectedStatus) {
1683
+ throw this.#statusConflict(input.itemId, current, input.expectedStatus);
1684
+ }
1685
+ if (input.holderId !== void 0 && current.assignee?.holderId !== input.holderId) {
1686
+ throw new CoreConflictError(
1687
+ "board_claim_conflict",
1688
+ `Board item ${input.itemId} is not held by ${input.holderId}.`,
1689
+ current,
1690
+ this.#now()
1691
+ );
1692
+ }
1693
+ if (!isLegalBoardTransition(input.expectedStatus, input.status)) {
1694
+ throw new CoreConflictError(
1695
+ "board_transition_invalid",
1696
+ `${input.expectedStatus} to ${input.status} is not a legal board transition.`,
1697
+ current,
1698
+ this.#now()
1699
+ );
1700
+ }
1701
+ throw this.#statusConflict(input.itemId, current, input.expectedStatus);
1702
+ }
1703
+ /**
1704
+ * One statement, its own transaction, lock released immediately. See the file
1705
+ * header for why this is not a CTE inside the write it feeds.
1706
+ */
1707
+ async #allocateSeq(tenant) {
1708
+ const result = await this.#pool.query(
1709
+ `INSERT INTO tenant_stream (tenant_id, board_seq)
1710
+ VALUES ($1, 1)
1711
+ ON CONFLICT (tenant_id) DO UPDATE SET board_seq = tenant_stream.board_seq + 1
1712
+ RETURNING board_seq`,
1713
+ [tenant]
1714
+ );
1715
+ return result.rows[0].board_seq;
1716
+ }
1717
+ #itemNotFound(itemId) {
1718
+ return new ByokCoreError(
1719
+ "board_item_not_found",
1720
+ `Board item ${itemId} does not exist in this tenant.`
1721
+ );
1722
+ }
1723
+ #statusConflict(itemId, current, expected) {
1724
+ return new CoreConflictError(
1725
+ "board_status_conflict",
1726
+ `Board item ${itemId} is ${current.status}, not ${expected}.`,
1727
+ current,
1728
+ this.#now()
1729
+ );
1730
+ }
1731
+ #now() {
1732
+ return this.#clock.now().toISOString();
1733
+ }
1734
+ };
1735
+
1736
+ // src/stores/core/mailbox-sequence.ts
1737
+ async function allocateMailboxSequence(client, tenant, deviceId, now) {
1738
+ const allocation = await client.query(
1739
+ `INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
1740
+ VALUES ($1, $2, 2, 0, $3)
1741
+ ON CONFLICT (tenant_id, device_id) DO UPDATE
1742
+ SET next_seq = device_stream.next_seq + 1
1743
+ RETURNING next_seq - 1 AS seq`,
1744
+ [tenant, deviceId, now]
1745
+ );
1746
+ return Number(allocation.rows[0].seq);
1747
+ }
1748
+
1749
+ // src/stores/core/mailbox.ts
1750
+ var DEFAULT_READ_LIMIT = 50;
1751
+ var OUTBOX_COLUMNS = "tenant_id, device_id, seq, message_id, body, body_hash, byte_size, state, appended_at";
1752
+ function toMessage(row) {
1753
+ return {
1754
+ tenantId: row.tenant_id,
1755
+ deviceId: row.device_id,
1756
+ // `seq` is bigint in the column and `number` on the port, because it is the
1757
+ // envelope `seq` on the wire. The column is wide so the counter cannot wrap
1758
+ // into a redelivery bug; the narrowing happens once, here.
1759
+ seq: Number(row.seq),
1760
+ messageId: row.message_id,
1761
+ body: row.body,
1762
+ bodyHash: row.body_hash,
1763
+ byteSize: row.byte_size,
1764
+ state: row.state,
1765
+ appendedAt: row.appended_at
1766
+ };
1767
+ }
1768
+ var PostgresMailboxStore = class {
1769
+ #pool;
1770
+ #clock;
1771
+ constructor(pool, clock) {
1772
+ this.#pool = pool;
1773
+ this.#clock = clock;
1774
+ }
1775
+ async append(tenant, input) {
1776
+ this.#requireDeviceId(input.deviceId);
1777
+ const client = await this.#pool.connect();
1778
+ try {
1779
+ await client.query("BEGIN");
1780
+ const existing = await client.query(
1781
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1782
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1783
+ [tenant, input.deviceId, input.messageId]
1784
+ );
1785
+ const replayed = existing.rows[0];
1786
+ if (replayed !== void 0) {
1787
+ await client.query("COMMIT");
1788
+ return toMessage(replayed);
1789
+ }
1790
+ const seq = await allocateMailboxSequence(client, tenant, input.deviceId, this.#now());
1791
+ const serializedExisting = await client.query(
1792
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1793
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1794
+ [tenant, input.deviceId, input.messageId]
1795
+ );
1796
+ const winnerAfterLock = serializedExisting.rows[0];
1797
+ if (winnerAfterLock !== void 0) {
1798
+ await client.query("ROLLBACK");
1799
+ return toMessage(winnerAfterLock);
1800
+ }
1801
+ const materialized = await input.materialize(seq);
1802
+ const now = this.#now();
1803
+ const inserted = await client.query(
1804
+ `INSERT INTO outbox (${OUTBOX_COLUMNS})
1805
+ VALUES ($1, $2, $3::bigint, $4, $5, $6, $7::bigint, 'pending', $8)
1806
+ ON CONFLICT (tenant_id, device_id, message_id) DO NOTHING
1807
+ RETURNING ${OUTBOX_COLUMNS}`,
1808
+ [
1809
+ tenant,
1810
+ input.deviceId,
1811
+ seq,
1812
+ input.messageId,
1813
+ materialized.body,
1814
+ materialized.bodyHash,
1815
+ materialized.byteSize,
1816
+ now
1817
+ ]
1818
+ );
1819
+ const row = inserted.rows[0];
1820
+ if (row !== void 0) {
1821
+ await client.query("COMMIT");
1822
+ return toMessage(row);
1823
+ }
1824
+ const winner = await client.query(
1825
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1826
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1827
+ [tenant, input.deviceId, input.messageId]
1828
+ );
1829
+ await client.query("ROLLBACK");
1830
+ const won = winner.rows[0];
1831
+ if (won === void 0) {
1832
+ throw new ByokCoreError(
1833
+ "mailbox_message_not_found",
1834
+ `Message ${input.messageId} vanished during an idempotent append.`
1835
+ );
1836
+ }
1837
+ return toMessage(won);
1838
+ } catch (cause) {
1839
+ await client.query("ROLLBACK").catch(() => {
1840
+ });
1841
+ throw cause;
1842
+ } finally {
1843
+ client.release();
1844
+ }
1845
+ }
1846
+ async readAfter(tenant, query) {
1847
+ const limit = query.limit ?? DEFAULT_READ_LIMIT;
1848
+ const result = await this.#pool.query(
1849
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1850
+ WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending' AND seq > $3::bigint
1851
+ ORDER BY seq
1852
+ LIMIT $4`,
1853
+ [tenant, query.deviceId, query.afterSeq, limit + 1]
1854
+ );
1855
+ const page = result.rows.slice(0, limit).map(toMessage);
1856
+ return {
1857
+ messages: page,
1858
+ // Nothing above was mutated, so an identical call replays the same page.
1859
+ // The returned position is a READ cursor and moves no ack.
1860
+ nextSeq: page.at(-1)?.seq ?? query.afterSeq,
1861
+ hasMore: result.rows.length > page.length
1862
+ };
1863
+ }
1864
+ async advanceCursor(tenant, input) {
1865
+ this.#requireDeviceId(input.deviceId);
1866
+ const now = this.#now();
1867
+ const moved = await this.#pool.query(
1868
+ `WITH moved AS (
1869
+ INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
1870
+ VALUES ($1, $2, 1, $3::bigint, $4)
1871
+ ON CONFLICT (tenant_id, device_id) DO UPDATE
1872
+ SET acked_seq = EXCLUDED.acked_seq, acked_at = EXCLUDED.acked_at
1873
+ WHERE device_stream.acked_seq <= EXCLUDED.acked_seq
1874
+ RETURNING acked_seq, acked_at
1875
+ ), marked AS (
1876
+ UPDATE outbox
1877
+ SET state = 'acked'
1878
+ WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending'
1879
+ AND seq <= (SELECT acked_seq FROM moved)
1880
+ RETURNING 1
1881
+ )
1882
+ SELECT acked_seq, acked_at FROM moved`,
1883
+ [tenant, input.deviceId, input.ackedSeq, now]
1884
+ );
1885
+ const row = moved.rows[0];
1886
+ if (row !== void 0) {
1887
+ return {
1888
+ tenantId: tenant,
1889
+ deviceId: input.deviceId,
1890
+ ackedSeq: Number(row.acked_seq),
1891
+ updatedAt: row.acked_at ?? now
1892
+ };
1893
+ }
1894
+ const current = await this.readCursor(tenant, input.deviceId);
1895
+ throw new CoreConflictError(
1896
+ "mailbox_cursor_regression",
1897
+ `Cursor for device ${input.deviceId} is at ${current.ackedSeq}; refusing to move it back to ${input.ackedSeq}.`,
1898
+ current,
1899
+ this.#now()
1900
+ );
1901
+ }
1902
+ async readCursor(tenant, deviceId) {
1903
+ const result = await this.#pool.query(
1904
+ `SELECT acked_seq, acked_at FROM device_stream
1905
+ WHERE tenant_id = $1 AND device_id = $2`,
1906
+ [tenant, deviceId]
1907
+ );
1908
+ const row = result.rows[0];
1909
+ return {
1910
+ tenantId: tenant,
1911
+ deviceId,
1912
+ ackedSeq: row === void 0 ? 0 : Number(row.acked_seq),
1913
+ updatedAt: row?.acked_at ?? this.#now()
1914
+ };
1915
+ }
1916
+ async collectRetired(tenant, input) {
1917
+ assertCanonicalTimestamp(input.ackedBefore, "ackedBefore");
1918
+ assertCanonicalTimestamp(input.expireUnackedBefore, "expireUnackedBefore");
1919
+ const swept = await this.#pool.query(
1920
+ `WITH deleted AS (
1921
+ DELETE FROM outbox
1922
+ WHERE tenant_id = $1
1923
+ AND ($2::text IS NULL OR device_id = $2::text)
1924
+ AND state = 'acked'
1925
+ AND appended_at < $3
1926
+ RETURNING byte_size
1927
+ ), expired AS (
1928
+ UPDATE outbox
1929
+ SET state = 'expired'
1930
+ WHERE tenant_id = $1
1931
+ AND ($2::text IS NULL OR device_id = $2::text)
1932
+ AND state = 'pending'
1933
+ AND appended_at < $4
1934
+ RETURNING 1
1935
+ )
1936
+ SELECT (SELECT count(*) FROM deleted) AS deleted_count,
1937
+ (SELECT count(*) FROM expired) AS expired_count,
1938
+ (SELECT COALESCE(SUM(byte_size), 0) FROM deleted)::bigint AS released_bytes`,
1939
+ [tenant, input.deviceId ?? null, input.ackedBefore, input.expireUnackedBefore]
1940
+ );
1941
+ const row = swept.rows[0];
1942
+ return {
1943
+ deletedCount: Number(row.deleted_count),
1944
+ expiredCount: Number(row.expired_count),
1945
+ releasedBytes: row.released_bytes
1946
+ };
1947
+ }
1948
+ /**
1949
+ * The in-memory reference refuses an empty device id rather than opening a
1950
+ * mailbox nothing can address. Kept here so the two compositions answer the
1951
+ * same way; the table itself would happily store the row.
1952
+ */
1953
+ #requireDeviceId(deviceId) {
1954
+ if (deviceId.length === 0) {
1955
+ throw new ByokCoreError("mailbox_message_not_found", "Device id must not be empty.");
1956
+ }
1957
+ }
1958
+ #now() {
1959
+ return this.#clock.now().toISOString();
1960
+ }
1961
+ };
1962
+ var WARNING_NUMERATOR = 80n;
1963
+ var WARNING_DENOMINATOR = 100n;
1964
+ var ENTITLEMENT_COLUMNS = "tenant_id, version, hard_limit_bytes, max_object_bytes, max_inline_bytes, mailbox_limit_bytes, retention_policy_id, downgrade_grace_until";
1965
+ var RESERVATION_COLUMNS = "tenant_id, reservation_id, state, kind, expected_bytes, content_hash, content_type, created_at, expires_at, settled_at, deduplicated";
1966
+ var QUALIFIED_RESERVATION_COLUMNS = RESERVATION_COLUMNS.split(", ").map((column) => `r.${column}`).join(", ");
1967
+ function toEntitlement(row) {
1968
+ return {
1969
+ tenantId: row.tenant_id,
1970
+ version: row.version,
1971
+ hardLimitBytes: row.hard_limit_bytes,
1972
+ maxObjectBytes: row.max_object_bytes,
1973
+ maxInlineBytes: row.max_inline_bytes,
1974
+ mailboxLimitBytes: row.mailbox_limit_bytes,
1975
+ retentionPolicyId: row.retention_policy_id,
1976
+ ...row.downgrade_grace_until === null ? {} : { downgradeGraceUntil: row.downgrade_grace_until }
1977
+ };
1978
+ }
1979
+ function toReservation(row) {
1980
+ return {
1981
+ tenantId: row.tenant_id,
1982
+ reservationId: row.reservation_id,
1983
+ state: row.state,
1984
+ kind: row.kind,
1985
+ expectedBytes: row.expected_bytes,
1986
+ contentHash: row.content_hash,
1987
+ contentType: row.content_type,
1988
+ createdAt: row.created_at,
1989
+ expiresAt: row.expires_at,
1990
+ ...row.settled_at === null ? {} : { settledAt: row.settled_at }
1991
+ };
1992
+ }
1993
+ function toUsage(row) {
1994
+ return {
1995
+ committedObjectBytes: row.committed_object_bytes,
1996
+ committedInlineBytes: row.committed_inline_bytes,
1997
+ reservedBytes: row.reserved_bytes,
1998
+ mailboxBytes: row.mailbox_bytes,
1999
+ objectCount: row.object_count,
2000
+ updatedAt: row.updated_at
2001
+ };
2002
+ }
2003
+ var USAGE_SQL = `
2004
+ SELECT
2005
+ COALESCE(u.committed_object_bytes, 0)::bigint AS committed_object_bytes,
2006
+ COALESCE(u.committed_inline_bytes, 0)::bigint AS committed_inline_bytes,
2007
+ COALESCE(u.mailbox_bytes, 0)::bigint AS mailbox_bytes,
2008
+ COALESCE(u.object_count, 0)::bigint AS object_count,
2009
+ COALESCE(u.updated_at, $2) AS updated_at,
2010
+ COALESCE((SELECT SUM(r.expected_bytes) FROM storage_reservation r
2011
+ WHERE r.tenant_id = $1 AND r.state = 'reserved'), 0)::bigint AS reserved_bytes
2012
+ FROM (SELECT $1::text AS tenant_id) AS scope
2013
+ LEFT JOIN storage_usage u ON u.tenant_id = scope.tenant_id`;
2014
+ var PostgresQuotaStore = class {
2015
+ #pool;
2016
+ #clock;
2017
+ constructor(pool, clock) {
2018
+ this.#pool = pool;
2019
+ this.#clock = clock;
2020
+ }
2021
+ async readEntitlement(tenant) {
2022
+ const result = await this.#pool.query(
2023
+ `SELECT ${ENTITLEMENT_COLUMNS} FROM storage_entitlement WHERE tenant_id = $1`,
2024
+ [tenant]
2025
+ );
2026
+ const row = result.rows[0];
2027
+ return row === void 0 ? void 0 : toEntitlement(row);
2028
+ }
2029
+ async writeEntitlement(tenant, input) {
2030
+ if (input.downgradeGraceUntil !== void 0) {
2031
+ assertCanonicalTimestamp(input.downgradeGraceUntil, "downgradeGraceUntil");
2032
+ }
2033
+ const applied = await this.#pool.query(
2034
+ `WITH applied AS (
2035
+ INSERT INTO storage_entitlement (${ENTITLEMENT_COLUMNS})
2036
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
2037
+ ON CONFLICT (tenant_id) DO UPDATE
2038
+ SET version = EXCLUDED.version,
2039
+ hard_limit_bytes = EXCLUDED.hard_limit_bytes,
2040
+ max_object_bytes = EXCLUDED.max_object_bytes,
2041
+ max_inline_bytes = EXCLUDED.max_inline_bytes,
2042
+ mailbox_limit_bytes = EXCLUDED.mailbox_limit_bytes,
2043
+ retention_policy_id = EXCLUDED.retention_policy_id,
2044
+ downgrade_grace_until = EXCLUDED.downgrade_grace_until
2045
+ WHERE storage_entitlement.version < EXCLUDED.version
2046
+ RETURNING ${ENTITLEMENT_COLUMNS}
2047
+ ), seeded AS (
2048
+ INSERT INTO storage_usage (tenant_id, updated_at)
2049
+ SELECT $1, $9 FROM applied
2050
+ ON CONFLICT (tenant_id) DO NOTHING
2051
+ RETURNING 1
2052
+ )
2053
+ SELECT ${ENTITLEMENT_COLUMNS} FROM applied`,
2054
+ [
2055
+ tenant,
2056
+ input.version,
2057
+ input.hardLimitBytes,
2058
+ input.maxObjectBytes,
2059
+ input.maxInlineBytes,
2060
+ input.mailboxLimitBytes,
2061
+ input.retentionPolicyId,
2062
+ input.downgradeGraceUntil ?? null,
2063
+ this.#now()
2064
+ ]
2065
+ );
2066
+ const row = applied.rows[0];
2067
+ if (row !== void 0) return toEntitlement(row);
2068
+ const current = await this.readEntitlement(tenant);
2069
+ if (current === void 0) {
2070
+ throw new Error(`entitlement for ${tenant} vanished during a version CAS`);
2071
+ }
2072
+ throw new CoreConflictError(
2073
+ "storage_entitlement_version_conflict",
2074
+ `Entitlement is at version ${String(current.version)}; refusing to apply version ${String(input.version)}.`,
2075
+ current,
2076
+ this.#now()
2077
+ );
2078
+ }
2079
+ async readUsage(tenant) {
2080
+ const result = await this.#pool.query(USAGE_SQL, [tenant, this.#now()]);
2081
+ return toUsage(result.rows[0]);
2082
+ }
2083
+ async readStatus(tenant) {
2084
+ const entitlement = await this.readEntitlement(tenant);
2085
+ if (entitlement === void 0) throw this.#entitlementMissing();
2086
+ const usage = await this.readUsage(tenant);
2087
+ const used = usedBytes(usage);
2088
+ const graceActive = this.#graceActive(entitlement);
2089
+ return {
2090
+ entitlement,
2091
+ usage,
2092
+ posture: posture(entitlement, usage, graceActive),
2093
+ availableBytes: used >= entitlement.hardLimitBytes ? 0n : entitlement.hardLimitBytes - used,
2094
+ graceActive
2095
+ };
2096
+ }
2097
+ async readReservation(tenant, reservationId) {
2098
+ return (await this.#readReservation(tenant, reservationId))?.reservation;
2099
+ }
2100
+ async reserve(tenant, input) {
2101
+ const client = await this.#pool.connect();
2102
+ let admitted;
2103
+ let rejection;
2104
+ try {
2105
+ await client.query("BEGIN");
2106
+ const entitlement = await this.#lockEntitlement(client, tenant);
2107
+ if (entitlement === void 0) {
2108
+ rejection = this.#entitlementMissing();
2109
+ } else {
2110
+ const now = this.#now();
2111
+ await client.query(
2112
+ `UPDATE storage_reservation
2113
+ SET state = 'expired', settled_at = $2
2114
+ WHERE tenant_id = $1 AND state = 'reserved' AND expires_at <= $2`,
2115
+ [tenant, now]
2116
+ );
2117
+ const inserted = await client.query(
2118
+ `WITH ent AS (
2119
+ SELECT hard_limit_bytes, max_object_bytes, max_inline_bytes, downgrade_grace_until
2120
+ FROM storage_entitlement WHERE tenant_id = $1
2121
+ ), used AS (
2122
+ SELECT COALESCE((SELECT committed_object_bytes + committed_inline_bytes
2123
+ FROM storage_usage WHERE tenant_id = $1), 0)::bigint
2124
+ + COALESCE((SELECT SUM(expected_bytes) FROM storage_reservation
2125
+ WHERE tenant_id = $1 AND state = 'reserved'), 0)::bigint
2126
+ AS used_bytes
2127
+ )
2128
+ INSERT INTO storage_reservation
2129
+ (tenant_id, reservation_id, state, kind, expected_bytes,
2130
+ content_hash, content_type, created_at, expires_at)
2131
+ SELECT $1, $2, 'reserved', $3::text, $4::bigint, $5, $6, $7, $8
2132
+ FROM ent, used
2133
+ WHERE $4::bigint <= (CASE WHEN $3::text = 'object'
2134
+ THEN ent.max_object_bytes
2135
+ ELSE ent.max_inline_bytes END)
2136
+ AND used.used_bytes + $4::bigint <= ent.hard_limit_bytes
2137
+ AND ($3::text <> 'object' OR NOT EXISTS (
2138
+ SELECT 1 FROM object_manifest m
2139
+ WHERE m.tenant_id = $1 AND m.hash = $5
2140
+ AND m.state = 'delete_pending'
2141
+ ))
2142
+ AND NOT (used.used_bytes >= ent.hard_limit_bytes
2143
+ AND ent.downgrade_grace_until IS NOT NULL
2144
+ AND ent.downgrade_grace_until <= $9)
2145
+ ON CONFLICT (tenant_id, reservation_id) DO NOTHING
2146
+ RETURNING ${RESERVATION_COLUMNS}`,
2147
+ [
2148
+ tenant,
2149
+ input.reservationId,
2150
+ input.kind,
2151
+ input.expectedBytes,
2152
+ input.contentHash,
2153
+ input.contentType,
2154
+ now,
2155
+ this.#expiry(input.ttlMs),
2156
+ now
2157
+ ]
2158
+ );
2159
+ const row = inserted.rows[0];
2160
+ if (row !== void 0) {
2161
+ admitted = toReservation(row);
2162
+ } else {
2163
+ const outcome = await this.#explainRefusedReservation(
2164
+ client,
2165
+ tenant,
2166
+ input,
2167
+ entitlement
2168
+ );
2169
+ if (outcome instanceof ByokCoreError) rejection = outcome;
2170
+ else admitted = outcome;
2171
+ }
2172
+ }
2173
+ await client.query("COMMIT");
2174
+ } catch (error) {
2175
+ await client.query("ROLLBACK").catch(() => {
2176
+ });
2177
+ throw error;
2178
+ } finally {
2179
+ client.release();
2180
+ }
2181
+ if (rejection !== void 0) throw rejection;
2182
+ return admitted;
2183
+ }
2184
+ async finalizeReservation(tenant, input) {
2185
+ const existing = await this.#readReservation(tenant, input.reservationId);
2186
+ if (existing === void 0) throw this.#reservationMissing(input.reservationId);
2187
+ if (existing.reservation.state === "committed") {
2188
+ return {
2189
+ reservation: existing.reservation,
2190
+ usage: await this.readUsage(tenant),
2191
+ deduplicated: existing.deduplicated
2192
+ };
2193
+ }
2194
+ if (existing.reservation.state !== "reserved") {
2195
+ throw new ByokCoreError(
2196
+ "storage_reservation_expired",
2197
+ `Reservation ${input.reservationId} is ${existing.reservation.state}.`
2198
+ );
2199
+ }
2200
+ if (this.#now() >= existing.reservation.expiresAt) {
2201
+ await this.#settle(tenant, input.reservationId, "expired");
2202
+ throw new ByokCoreError(
2203
+ "storage_reservation_expired",
2204
+ `Reservation ${input.reservationId} expired at ${existing.reservation.expiresAt}.`
2205
+ );
2206
+ }
2207
+ if (input.observedByteSize !== existing.reservation.expectedBytes || input.observedContentType !== existing.reservation.contentType) {
2208
+ await this.#settle(tenant, input.reservationId, "aborted");
2209
+ throw new ByokCoreError(
2210
+ "storage_integrity_mismatch",
2211
+ `Observed object does not match reservation ${input.reservationId}.`
2212
+ );
2213
+ }
2214
+ const settled = await this.#pool.query(
2215
+ `WITH candidate AS MATERIALIZED (
2216
+ SELECT r.tenant_id,
2217
+ r.reservation_id,
2218
+ r.kind,
2219
+ r.content_hash,
2220
+ r.expected_bytes,
2221
+ r.content_type,
2222
+ m.state AS manifest_state,
2223
+ (m.tenant_id IS NOT NULL
2224
+ AND m.state IN ('pending', 'committed')
2225
+ AND m.byte_size = $4::bigint
2226
+ AND m.content_type = $5) AS manifest_valid,
2227
+ EXISTS (
2228
+ SELECT 1 FROM storage_reservation p
2229
+ WHERE p.tenant_id = r.tenant_id
2230
+ AND p.content_hash = r.content_hash
2231
+ AND p.state = 'committed'
2232
+ ) AS inline_deduplicated
2233
+ FROM storage_reservation r
2234
+ LEFT JOIN object_manifest m
2235
+ ON m.tenant_id = r.tenant_id AND m.hash = r.content_hash
2236
+ WHERE r.tenant_id = $1
2237
+ AND r.reservation_id = $2
2238
+ AND r.state = 'reserved'
2239
+ ), committed_manifest AS (
2240
+ UPDATE object_manifest m
2241
+ SET state = 'committed', updated_at = $3
2242
+ FROM candidate c
2243
+ WHERE c.kind = 'object'
2244
+ AND c.manifest_valid
2245
+ AND m.state = 'pending'
2246
+ AND m.tenant_id = c.tenant_id
2247
+ AND m.hash = c.content_hash
2248
+ RETURNING 1
2249
+ ), settled AS (
2250
+ UPDATE storage_reservation r
2251
+ SET state = CASE
2252
+ WHEN c.kind = 'object' AND NOT c.manifest_valid
2253
+ THEN 'aborted'
2254
+ ELSE 'committed'
2255
+ END,
2256
+ settled_at = $3,
2257
+ deduplicated = CASE
2258
+ WHEN c.kind = 'object'
2259
+ THEN NOT EXISTS (SELECT 1 FROM committed_manifest)
2260
+ ELSE c.inline_deduplicated
2261
+ END
2262
+ FROM candidate c,
2263
+ (SELECT COUNT(*) FROM committed_manifest) AS manifest_barrier
2264
+ WHERE r.tenant_id = c.tenant_id
2265
+ AND r.reservation_id = c.reservation_id
2266
+ AND r.state = 'reserved'
2267
+ RETURNING ${QUALIFIED_RESERVATION_COLUMNS}
2268
+ ), accounted AS (
2269
+ UPDATE storage_usage u
2270
+ SET committed_object_bytes = u.committed_object_bytes
2271
+ + CASE WHEN s.kind = 'object' AND NOT s.deduplicated
2272
+ THEN s.expected_bytes ELSE 0 END,
2273
+ committed_inline_bytes = u.committed_inline_bytes
2274
+ + CASE WHEN s.kind = 'inline' AND NOT s.deduplicated
2275
+ THEN s.expected_bytes ELSE 0 END,
2276
+ object_count = u.object_count
2277
+ + CASE WHEN s.kind = 'object' AND NOT s.deduplicated THEN 1 ELSE 0 END,
2278
+ updated_at = $3
2279
+ FROM settled s
2280
+ WHERE u.tenant_id = $1 AND s.state = 'committed'
2281
+ RETURNING 1
2282
+ )
2283
+ SELECT ${RESERVATION_COLUMNS} FROM settled`,
2284
+ [
2285
+ tenant,
2286
+ input.reservationId,
2287
+ this.#now(),
2288
+ input.observedByteSize,
2289
+ input.observedContentType
2290
+ ]
2291
+ );
2292
+ const row = settled.rows[0];
2293
+ if (row === void 0) {
2294
+ const raced = await this.#readReservation(tenant, input.reservationId);
2295
+ if (raced !== void 0 && raced.reservation.state === "committed") {
2296
+ return {
2297
+ reservation: raced.reservation,
2298
+ usage: await this.readUsage(tenant),
2299
+ deduplicated: raced.deduplicated
2300
+ };
2301
+ }
2302
+ throw new ByokCoreError(
2303
+ "storage_reservation_expired",
2304
+ `Reservation ${input.reservationId} was settled concurrently.`
2305
+ );
2306
+ }
2307
+ const reservation = toReservation(row);
2308
+ if (reservation.state === "aborted") {
2309
+ throw new ByokCoreError(
2310
+ "storage_integrity_mismatch",
2311
+ `Reservation ${input.reservationId} has no matching committable object manifest.`
2312
+ );
2313
+ }
2314
+ return {
2315
+ reservation,
2316
+ usage: await this.readUsage(tenant),
2317
+ deduplicated: row.deduplicated
2318
+ };
2319
+ }
2320
+ async abortReservation(tenant, reservationId) {
2321
+ const existing = await this.#readReservation(tenant, reservationId);
2322
+ if (existing === void 0) throw this.#reservationMissing(reservationId);
2323
+ if (existing.reservation.state !== "reserved") return existing.reservation;
2324
+ const settled = await this.#settle(tenant, reservationId, "aborted");
2325
+ if (settled !== void 0) return settled;
2326
+ const raced = await this.#readReservation(tenant, reservationId);
2327
+ if (raced === void 0) throw this.#reservationMissing(reservationId);
2328
+ return raced.reservation;
2329
+ }
2330
+ async expireReservations(tenant) {
2331
+ const expired = await this.#pool.query(
2332
+ `UPDATE storage_reservation
2333
+ SET state = 'expired', settled_at = $2
2334
+ WHERE tenant_id = $1 AND state = 'reserved' AND expires_at <= $2
2335
+ RETURNING ${RESERVATION_COLUMNS}`,
2336
+ [tenant, this.#now()]
2337
+ );
2338
+ return expired.rows.map(toReservation).sort((left, right) => left.reservationId.localeCompare(right.reservationId));
2339
+ }
2340
+ async applyMailboxDelta(tenant, input) {
2341
+ const applied = await this.#pool.query(
2342
+ `WITH ent AS (
2343
+ SELECT mailbox_limit_bytes FROM storage_entitlement WHERE tenant_id = $1
2344
+ )
2345
+ UPDATE storage_usage u
2346
+ SET mailbox_bytes = GREATEST(u.mailbox_bytes + $2::bigint, 0::bigint),
2347
+ updated_at = $3
2348
+ FROM ent
2349
+ WHERE u.tenant_id = $1
2350
+ AND ($2::bigint <= 0 OR u.mailbox_bytes + $2::bigint <= ent.mailbox_limit_bytes)
2351
+ RETURNING 1`,
2352
+ [tenant, input.deltaBytes, this.#now()]
2353
+ );
2354
+ if (applied.rowCount === 0) {
2355
+ const entitlement = await this.readEntitlement(tenant);
2356
+ if (entitlement === void 0) throw this.#entitlementMissing();
2357
+ const usage = await this.readUsage(tenant);
2358
+ throw new ByokCoreError(
2359
+ "storage_quota_exceeded",
2360
+ `Mailbox would reach ${String(usage.mailboxBytes + input.deltaBytes)} bytes, over the limit of ${String(entitlement.mailboxLimitBytes)} bytes.`
2361
+ );
2362
+ }
2363
+ return this.readUsage(tenant);
2364
+ }
2365
+ async #lockEntitlement(client, tenant) {
2366
+ const result = await client.query(
2367
+ `SELECT ${ENTITLEMENT_COLUMNS} FROM storage_entitlement WHERE tenant_id = $1 FOR UPDATE`,
2368
+ [tenant]
2369
+ );
2370
+ const row = result.rows[0];
2371
+ return row === void 0 ? void 0 : toEntitlement(row);
2372
+ }
2373
+ /**
2374
+ * Names the refusal. Runs only after the guarded insert has already declined,
2375
+ * inside the same locked transaction, so the state it reads is the state that
2376
+ * refused. The order matches the contract's precedence: an existing
2377
+ * reservation is an idempotent answer, not a rejection; a suspended tenant is
2378
+ * read-only (423) before it is over-quota (507); a single oversized write is
2379
+ * 413 regardless of how much room is left.
2380
+ */
2381
+ async #explainRefusedReservation(client, tenant, input, entitlement) {
2382
+ const existing = await client.query(
2383
+ `SELECT ${RESERVATION_COLUMNS} FROM storage_reservation
2384
+ WHERE tenant_id = $1 AND reservation_id = $2`,
2385
+ [tenant, input.reservationId]
2386
+ );
2387
+ const held = existing.rows[0];
2388
+ if (held !== void 0) {
2389
+ if (held.state === "reserved") {
2390
+ if (held.kind !== input.kind || held.expected_bytes !== input.expectedBytes || held.content_hash !== input.contentHash || held.content_type !== input.contentType) {
2391
+ return new ByokCoreError(
2392
+ "storage_integrity_mismatch",
2393
+ `Reservation ${input.reservationId} already binds a different storage declaration.`
2394
+ );
2395
+ }
2396
+ return toReservation(held);
2397
+ }
2398
+ return new ByokCoreError(
2399
+ "storage_reservation_expired",
2400
+ `Reservation ${input.reservationId} is already ${held.state}.`
2401
+ );
2402
+ }
2403
+ if (input.kind === "object") {
2404
+ const tombstone = await client.query(
2405
+ `SELECT state FROM object_manifest
2406
+ WHERE tenant_id = $1 AND hash = $2 AND state = 'delete_pending'`,
2407
+ [tenant, input.contentHash]
2408
+ );
2409
+ if (tombstone.rows[0] !== void 0) {
2410
+ return new ByokCoreError(
2411
+ "object_state_invalid",
2412
+ `Object ${input.contentHash} is pending deletion and cannot accept a new reservation.`
2413
+ );
2414
+ }
2415
+ }
2416
+ const usageResult = await client.query(USAGE_SQL, [tenant, this.#now()]);
2417
+ const usage = toUsage(usageResult.rows[0]);
2418
+ if (posture(entitlement, usage, this.#graceActive(entitlement)) === "suspended") {
2419
+ return new ByokCoreError(
2420
+ "storage_write_suspended",
2421
+ "Tenant is over its hard limit and its downgrade grace has ended; durable writes are suspended."
2422
+ );
2423
+ }
2424
+ const perWriteLimit = input.kind === "object" ? entitlement.maxObjectBytes : entitlement.maxInlineBytes;
2425
+ if (input.expectedBytes > perWriteLimit) {
2426
+ return new ByokCoreError(
2427
+ "storage_object_too_large",
2428
+ `${String(input.expectedBytes)} bytes exceeds the ${input.kind} limit of ${String(perWriteLimit)} bytes.`
2429
+ );
2430
+ }
2431
+ return new ByokCoreError(
2432
+ "storage_quota_exceeded",
2433
+ `Reserving ${String(input.expectedBytes)} bytes would exceed the hard limit of ${String(entitlement.hardLimitBytes)} bytes.`
2434
+ );
2435
+ }
2436
+ async #readReservation(tenant, reservationId) {
2437
+ const result = await this.#pool.query(
2438
+ `SELECT ${RESERVATION_COLUMNS} FROM storage_reservation
2439
+ WHERE tenant_id = $1 AND reservation_id = $2`,
2440
+ [tenant, reservationId]
2441
+ );
2442
+ const row = result.rows[0];
2443
+ if (row === void 0) return void 0;
2444
+ return { reservation: toReservation(row), deduplicated: row.deduplicated };
2445
+ }
2446
+ /**
2447
+ * The settle guard. `WHERE state = 'reserved'` is what makes two settlements
2448
+ * of the same reservation produce one winner; the loser gets zero rows and
2449
+ * re-reads rather than stamping a second `settled_at` over the first.
2450
+ * Releasing the reserved bytes needs no second write: they were never a
2451
+ * counter, only the sum of rows in this state.
2452
+ */
2453
+ async #settle(tenant, reservationId, state) {
2454
+ const result = await this.#pool.query(
2455
+ `UPDATE storage_reservation
2456
+ SET state = $3, settled_at = $4
2457
+ WHERE tenant_id = $1 AND reservation_id = $2 AND state = 'reserved'
2458
+ RETURNING ${RESERVATION_COLUMNS}`,
2459
+ [tenant, reservationId, state, this.#now()]
2460
+ );
2461
+ const row = result.rows[0];
2462
+ return row === void 0 ? void 0 : toReservation(row);
2463
+ }
2464
+ #graceActive(entitlement) {
2465
+ return entitlement.downgradeGraceUntil !== void 0 && this.#now() < entitlement.downgradeGraceUntil;
2466
+ }
2467
+ #entitlementMissing() {
2468
+ return new ByokCoreError(
2469
+ "storage_entitlement_missing",
2470
+ "No storage entitlement has been issued for this tenant."
2471
+ );
2472
+ }
2473
+ #reservationMissing(reservationId) {
2474
+ return new ByokCoreError(
2475
+ "storage_reservation_not_found",
2476
+ `Reservation ${reservationId} does not exist in this tenant.`
2477
+ );
2478
+ }
2479
+ #expiry(ttlMs) {
2480
+ return new Date(this.#clock.now().getTime() + ttlMs).toISOString();
2481
+ }
2482
+ #now() {
2483
+ return this.#clock.now().toISOString();
2484
+ }
2485
+ };
2486
+ function usedBytes(usage) {
2487
+ return usage.committedObjectBytes + usage.committedInlineBytes + usage.reservedBytes;
2488
+ }
2489
+ function posture(entitlement, usage, graceActive) {
2490
+ const used = usedBytes(usage);
2491
+ if (used >= entitlement.hardLimitBytes) {
2492
+ const graceConfigured = entitlement.downgradeGraceUntil !== void 0;
2493
+ return graceConfigured && !graceActive ? "suspended" : "blocked";
2494
+ }
2495
+ if (entitlement.hardLimitBytes > 0n && used * WARNING_DENOMINATOR >= entitlement.hardLimitBytes * WARNING_NUMERATOR) {
2496
+ return "warning";
2497
+ }
2498
+ return "normal";
2499
+ }
2500
+ var DEFAULT_LIST_LIMIT3 = 50;
2501
+ function toManifest(pack, files) {
2502
+ return {
2503
+ schema: SKILL_PACK_MANIFEST_SCHEMA_ID,
2504
+ name: pack.name,
2505
+ version: pack.version,
2506
+ description: pack.description,
2507
+ files: files.map(toFile),
2508
+ contentHash: pack.content_hash
2509
+ };
2510
+ }
2511
+ function toFile(row) {
2512
+ return {
2513
+ path: row.path,
2514
+ contentHash: row.content_hash,
2515
+ byteSize: row.byte_size
2516
+ };
2517
+ }
2518
+ var PostgresSkillPackStore = class {
2519
+ #pool;
2520
+ constructor(pool) {
2521
+ this.#pool = pool;
2522
+ }
2523
+ async publish(tenant, input) {
2524
+ const { manifest } = input;
2525
+ const structural = checkSkillPackManifest(manifest);
2526
+ if (!structural.ok) {
2527
+ throw new ByokCoreError(
2528
+ "skill_pack_manifest_invalid",
2529
+ `Refusing to publish ${JSON.stringify(manifest.name)}: ${structural.reason} \u2014 ${structural.detail}`
2530
+ );
2531
+ }
2532
+ const contents = /* @__PURE__ */ new Map();
2533
+ for (const file of input.files) {
2534
+ if (contents.has(file.path)) {
2535
+ throw new ByokCoreError(
2536
+ "skill_pack_manifest_invalid",
2537
+ `Refusing to publish ${JSON.stringify(manifest.name)}: ${JSON.stringify(file.path)} was supplied twice.`
2538
+ );
2539
+ }
2540
+ contents.set(file.path, file.content);
2541
+ }
2542
+ for (const declared of manifest.files) {
2543
+ const content = contents.get(declared.path);
2544
+ if (content === void 0) {
2545
+ throw new ByokCoreError(
2546
+ "skill_pack_manifest_invalid",
2547
+ `Refusing to publish ${JSON.stringify(manifest.name)}: ${JSON.stringify(declared.path)} is declared but was not supplied.`
2548
+ );
2549
+ }
2550
+ const bytes = new TextEncoder().encode(content).length;
2551
+ if (bytes !== declared.byteSize) {
2552
+ throw new ByokCoreError(
2553
+ "skill_pack_manifest_invalid",
2554
+ `Refusing to publish ${JSON.stringify(manifest.name)}: ${JSON.stringify(declared.path)} declares ${declared.byteSize} bytes and supplies ${bytes}.`
2555
+ );
2556
+ }
2557
+ }
2558
+ if (contents.size !== manifest.files.length) {
2559
+ throw new ByokCoreError(
2560
+ "skill_pack_manifest_invalid",
2561
+ `Refusing to publish ${JSON.stringify(manifest.name)}: ${contents.size} files were supplied for ${manifest.files.length} declared rows.`
2562
+ );
2563
+ }
2564
+ const entry = checkSkillPackEntry(manifest, contents.get(SKILL_PACK_ENTRY_PATH));
2565
+ if (!entry.ok) {
2566
+ throw new ByokCoreError(
2567
+ "skill_pack_manifest_invalid",
2568
+ `Refusing to publish ${JSON.stringify(manifest.name)}: ${entry.reason} \u2014 ${entry.detail}`
2569
+ );
2570
+ }
2571
+ await this.#inTransaction(async (client) => {
2572
+ await client.query(
2573
+ `INSERT INTO skill_pack (tenant_id, name, version, description, content_hash)
2574
+ VALUES ($1, $2, $3, $4, $5)
2575
+ ON CONFLICT (tenant_id, name) DO UPDATE
2576
+ SET version = EXCLUDED.version,
2577
+ description = EXCLUDED.description,
2578
+ content_hash = EXCLUDED.content_hash`,
2579
+ [tenant, manifest.name, manifest.version, manifest.description, manifest.contentHash]
2580
+ );
2581
+ await client.query(`DELETE FROM skill_pack_file WHERE tenant_id = $1 AND pack_name = $2`, [
2582
+ tenant,
2583
+ manifest.name
2584
+ ]);
2585
+ for (const declared of manifest.files) {
2586
+ await client.query(
2587
+ `INSERT INTO skill_pack_file (tenant_id, pack_name, path, content_hash, byte_size, content)
2588
+ VALUES ($1, $2, $3, $4, $5, $6)`,
2589
+ [
2590
+ tenant,
2591
+ manifest.name,
2592
+ declared.path,
2593
+ declared.contentHash,
2594
+ declared.byteSize,
2595
+ contents.get(declared.path)
2596
+ ]
2597
+ );
2598
+ }
2599
+ });
2600
+ return manifest;
2601
+ }
2602
+ async get(tenant, name) {
2603
+ const packResult = await this.#pool.query(
2604
+ `SELECT name, version, description, content_hash
2605
+ FROM skill_pack WHERE tenant_id = $1 AND name = $2`,
2606
+ [tenant, name]
2607
+ );
2608
+ const pack = packResult.rows[0];
2609
+ if (pack === void 0) return void 0;
2610
+ const files = await this.#filesFor(tenant, [name]);
2611
+ return toManifest(pack, files);
2612
+ }
2613
+ async list(tenant, query) {
2614
+ const packs = await this.#pool.query(
2615
+ `SELECT name, version, description, content_hash
2616
+ FROM skill_pack WHERE tenant_id = $1
2617
+ ORDER BY name COLLATE "C"
2618
+ LIMIT $2`,
2619
+ [tenant, query.limit ?? DEFAULT_LIST_LIMIT3]
2620
+ );
2621
+ if (packs.rows.length === 0) return [];
2622
+ const files = await this.#filesFor(
2623
+ tenant,
2624
+ packs.rows.map((row) => row.name)
2625
+ );
2626
+ const byPack = /* @__PURE__ */ new Map();
2627
+ for (const file of files) {
2628
+ const bucket = byPack.get(file.pack_name);
2629
+ if (bucket === void 0) byPack.set(file.pack_name, [file]);
2630
+ else bucket.push(file);
2631
+ }
2632
+ return packs.rows.map((pack) => toManifest(pack, byPack.get(pack.name) ?? []));
2633
+ }
2634
+ async readFile(tenant, name, path) {
2635
+ const result = await this.#pool.query(
2636
+ `SELECT pack_name, path, content_hash, byte_size, content
2637
+ FROM skill_pack_file
2638
+ WHERE tenant_id = $1 AND pack_name = $2 AND path = $3`,
2639
+ [tenant, name, path]
2640
+ );
2641
+ const row = result.rows[0];
2642
+ if (row === void 0) return void 0;
2643
+ return {
2644
+ path: row.path,
2645
+ contentHash: row.content_hash,
2646
+ byteSize: row.byte_size,
2647
+ content: row.content
2648
+ };
2649
+ }
2650
+ /** Every file row for the named packs, in byte order of (pack_name, path). */
2651
+ async #filesFor(tenant, names) {
2652
+ const result = await this.#pool.query(
2653
+ `SELECT pack_name, path, content_hash, byte_size, content
2654
+ FROM skill_pack_file
2655
+ WHERE tenant_id = $1 AND pack_name = ANY($2::text[])
2656
+ ORDER BY pack_name COLLATE "C", path COLLATE "C"`,
2657
+ [tenant, names]
2658
+ );
2659
+ return result.rows;
2660
+ }
2661
+ async #inTransaction(run) {
2662
+ const client = await this.#pool.connect();
2663
+ try {
2664
+ await client.query("BEGIN");
2665
+ await run(client);
2666
+ await client.query("COMMIT");
2667
+ } catch (error) {
2668
+ await client.query("ROLLBACK").catch(() => {
2669
+ });
2670
+ throw error;
2671
+ } finally {
2672
+ client.release();
2673
+ }
2674
+ }
2675
+ };
2676
+ var DEFAULT_MANIFEST_LIMIT = 100;
2677
+ 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";
2678
+ function toBody(row) {
2679
+ return row.body_kind === "inline" ? { kind: "inline", body: row.body_inline ?? "" } : { kind: "object", hash: row.body_object_hash ?? "" };
2680
+ }
2681
+ function toRecord2(row) {
2682
+ return {
2683
+ tenantId: row.tenant_id,
2684
+ kind: row.kind,
2685
+ recordKey: row.subject_id,
2686
+ rev: row.rev,
2687
+ contentHash: row.content_hash,
2688
+ byteSize: row.byte_size,
2689
+ body: toBody(row),
2690
+ ...row.label === null ? {} : { label: row.label },
2691
+ ...row.request_id === null ? {} : { requestId: row.request_id },
2692
+ writtenAt: row.written_at
2693
+ };
2694
+ }
2695
+ function bodyColumns(body) {
2696
+ return body.kind === "inline" ? ["inline", body.body, null] : ["object", null, body.hash];
2697
+ }
2698
+ var PostgresTruthStore = class {
2699
+ #pool;
2700
+ #clock;
2701
+ constructor(pool, clock) {
2702
+ this.#pool = pool;
2703
+ this.#clock = clock;
2704
+ }
2705
+ async writeTerminal(tenant, input) {
2706
+ const [bodyKind, bodyInline, bodyObjectHash] = bodyColumns(input.body);
2707
+ const inserted = await this.#pool.query(
2708
+ `INSERT INTO attested_record (${RECORD_COLUMNS})
2709
+ VALUES ($1, 'task.terminal', $2, 1, $3, $4::bigint, $5, $6, $7, $8, $9, $10)
2710
+ ON CONFLICT (tenant_id, kind, subject_id) DO NOTHING
2711
+ RETURNING ${RECORD_COLUMNS}`,
2712
+ [
2713
+ tenant,
2714
+ input.taskId,
2715
+ input.contentHash,
2716
+ input.byteSize,
2717
+ bodyKind,
2718
+ bodyInline,
2719
+ bodyObjectHash,
2720
+ input.label ?? null,
2721
+ input.requestId ?? null,
2722
+ this.#now()
2723
+ ]
2724
+ );
2725
+ const row = inserted.rows[0];
2726
+ if (row !== void 0) return toRecord2(row);
2727
+ const existing = await this.getRecord(tenant, {
2728
+ kind: "task.terminal",
2729
+ recordKey: input.taskId
2730
+ });
2731
+ if (existing === void 0) {
2732
+ throw new Error(`terminal record for ${input.taskId} vanished during a first write`);
2733
+ }
2734
+ if (existing.contentHash === input.contentHash) return existing;
2735
+ throw new CoreConflictError(
2736
+ "terminal_conflict",
2737
+ `Task ${input.taskId} already has an immutable terminal record with a different hash.`,
2738
+ existing,
2739
+ this.#now()
2740
+ );
2741
+ }
2742
+ async writeSnapshot(tenant, input) {
2743
+ const [bodyKind, bodyInline, bodyObjectHash] = bodyColumns(input.body);
2744
+ const now = this.#now();
2745
+ const written = input.expectedRev === 0 ? await this.#pool.query(
2746
+ `INSERT INTO attested_record (${RECORD_COLUMNS})
2747
+ VALUES ($1, $2, $3, 1, $4, $5::bigint, $6, $7, $8, $9, $10, $11)
2748
+ ON CONFLICT (tenant_id, kind, subject_id) DO NOTHING
2749
+ RETURNING ${RECORD_COLUMNS}`,
2750
+ [
2751
+ tenant,
2752
+ input.kind,
2753
+ input.recordKey,
2754
+ input.contentHash,
2755
+ input.byteSize,
2756
+ bodyKind,
2757
+ bodyInline,
2758
+ bodyObjectHash,
2759
+ input.label ?? null,
2760
+ input.requestId ?? null,
2761
+ now
2762
+ ]
2763
+ ) : await this.#pool.query(
2764
+ `UPDATE attested_record
2765
+ SET rev = rev + 1,
2766
+ content_hash = $4,
2767
+ byte_size = $5::bigint,
2768
+ body_kind = $6,
2769
+ body_inline = $7,
2770
+ body_object_hash = $8,
2771
+ label = $9,
2772
+ request_id = $10,
2773
+ written_at = $11
2774
+ WHERE tenant_id = $1 AND kind = $2 AND subject_id = $3 AND rev = $12
2775
+ RETURNING ${RECORD_COLUMNS}`,
2776
+ [
2777
+ tenant,
2778
+ input.kind,
2779
+ input.recordKey,
2780
+ input.contentHash,
2781
+ input.byteSize,
2782
+ bodyKind,
2783
+ bodyInline,
2784
+ bodyObjectHash,
2785
+ input.label ?? null,
2786
+ input.requestId ?? null,
2787
+ now,
2788
+ input.expectedRev
2789
+ ]
2790
+ );
2791
+ const row = written.rows[0];
2792
+ if (row !== void 0) return toRecord2(row);
2793
+ const current = await this.getRecord(tenant, {
2794
+ kind: input.kind,
2795
+ recordKey: input.recordKey
2796
+ });
2797
+ throw new CoreConflictError(
2798
+ "truth_revision_conflict",
2799
+ `Record ${input.kind}/${input.recordKey} is at rev ${current?.rev ?? 0}, not ${input.expectedRev}.`,
2800
+ current,
2801
+ this.#now()
2802
+ );
2803
+ }
2804
+ async getRecord(tenant, selector) {
2805
+ const result = await this.#pool.query(
2806
+ `SELECT ${RECORD_COLUMNS} FROM attested_record
2807
+ WHERE tenant_id = $1 AND kind = $2 AND subject_id = $3`,
2808
+ [tenant, selector.kind, selector.recordKey]
2809
+ );
2810
+ const row = result.rows[0];
2811
+ return row === void 0 ? void 0 : toRecord2(row);
2812
+ }
2813
+ async listManifest(tenant, query) {
2814
+ const result = await this.#pool.query(
2815
+ `SELECT kind, subject_id, rev, content_hash, byte_size, label, written_at
2816
+ FROM attested_record
2817
+ WHERE tenant_id = $1
2818
+ AND ($2::text IS NULL OR kind = $2::text)
2819
+ AND ($3::text IS NULL OR starts_with(subject_id, $3::text))
2820
+ ORDER BY kind COLLATE "C", subject_id COLLATE "C"
2821
+ LIMIT $4`,
2822
+ [tenant, query.kind ?? null, query.keyPrefix ?? null, query.limit ?? DEFAULT_MANIFEST_LIMIT]
2823
+ );
2824
+ return result.rows.map((row) => ({
2825
+ kind: row.kind,
2826
+ recordKey: row.subject_id,
2827
+ rev: row.rev,
2828
+ contentHash: row.content_hash,
2829
+ byteSize: row.byte_size,
2830
+ ...row.label === null ? {} : { label: row.label },
2831
+ updatedAt: row.written_at
2832
+ }));
2833
+ }
2834
+ #now() {
2835
+ return this.#clock.now().toISOString();
2836
+ }
2837
+ };
2838
+
2839
+ // src/stores/core/index.ts
2840
+ function createPostgresCoreStores(options) {
2841
+ const { pool, clock } = options;
2842
+ return {
2843
+ mailbox: new PostgresMailboxStore(pool, clock),
2844
+ board: new PostgresBoardStore(pool, clock),
2845
+ truth: new PostgresTruthStore(pool, clock),
2846
+ presence: new PostgresPresenceStore(pool, clock),
2847
+ objects: new PostgresObjectStore(pool, clock),
2848
+ quota: new PostgresQuotaStore(pool, clock),
2849
+ // No clock: a skill-pack manifest carries no timestamp, so this store reads
2850
+ // none — the same shape as the cloud-local `devices` directory.
2851
+ skillPacks: new PostgresSkillPackStore(pool)
2852
+ };
2853
+ }
2854
+ 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";
2855
+ var RECEIPT_COLUMNS = "tenant_id, device_id, request_id, operation, resource, body_sha256, body_size, response_status, response_body, recorded_at";
2856
+ function toBody2(row) {
2857
+ return row.body_kind === "inline" ? { kind: "inline", body: row.body_inline ?? "" } : { kind: "object", hash: row.body_object_hash ?? "" };
2858
+ }
2859
+ function toRecord3(tenant, row) {
2860
+ return {
2861
+ tenantId: tenant,
2862
+ kind: row.kind,
2863
+ recordKey: row.subject_id,
2864
+ rev: row.rev,
2865
+ contentHash: row.content_hash,
2866
+ byteSize: row.byte_size,
2867
+ body: toBody2(row),
2868
+ ...row.label === null ? {} : { label: row.label },
2869
+ ...row.request_id === null ? {} : { requestId: row.request_id },
2870
+ writtenAt: row.written_at
2871
+ };
2872
+ }
2873
+ function toReceipt3(tenant, row) {
2874
+ return {
2875
+ tenantId: tenant,
2876
+ deviceId: row.device_id,
2877
+ requestId: row.request_id,
2878
+ operation: row.operation,
2879
+ resource: row.resource,
2880
+ bodySha256: row.body_sha256,
2881
+ bodySize: row.body_size,
2882
+ responseStatus: row.response_status,
2883
+ responseBody: row.response_body,
2884
+ recordedAt: row.recorded_at.toISOString()
2885
+ };
2886
+ }
2887
+ function sameBinding(receipt, input) {
2888
+ return receipt.operation === input.operation && receipt.resource === input.resource && receipt.bodySha256 === input.proofBodySha256 && receipt.bodySize === input.proofBodySize;
2889
+ }
2890
+ function bodyColumns2(body) {
2891
+ return body.kind === "inline" ? ["inline", body.body, null] : ["object", null, body.hash];
2892
+ }
2893
+ function writeKey(write) {
2894
+ return `${write.kind}\0${write.recordKey}`;
2895
+ }
2896
+ function referenceId(write) {
2897
+ return `${write.kind}:${write.recordKey}`;
2898
+ }
2899
+ var PostgresTruthCommitter = class {
2900
+ #pool;
2901
+ #clock;
2902
+ #crypto;
2903
+ #truth;
2904
+ constructor(options) {
2905
+ this.#pool = options.pool;
2906
+ this.#clock = options.clock;
2907
+ this.#crypto = options.crypto;
2908
+ this.#truth = new PostgresTruthStore(options.pool, options.clock);
2909
+ }
2910
+ getRecord(tenant, selector) {
2911
+ return this.#truth.getRecord(tenant, selector);
2912
+ }
2913
+ listManifest(tenant, query) {
2914
+ return this.#truth.listManifest(tenant, query);
2915
+ }
2916
+ async commit(tenant, input) {
2917
+ await this.#validateInput(input);
2918
+ const client = await this.#pool.connect();
2919
+ try {
2920
+ await client.query("BEGIN");
2921
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
2922
+ JSON.stringify(["truth-receipt", tenant, input.deviceId, input.requestId])
2923
+ ]);
2924
+ const replay = await this.#readReceipt(client, tenant, input.deviceId, input.requestId);
2925
+ if (replay !== void 0) {
2926
+ if (!sameBinding(replay, input)) {
2927
+ throw new TruthCommitError(
2928
+ "proof_request_conflict",
2929
+ `Request ${input.requestId} was already used with a different binding.`
2930
+ );
2931
+ }
2932
+ const response2 = TruthCommitResponseSchema.parse(JSON.parse(replay.responseBody));
2933
+ await client.query("COMMIT");
2934
+ return { response: response2, replayed: true };
2935
+ }
2936
+ const before = await this.#lockCurrentRecords(client, tenant, input.writes);
2937
+ this.#assertWritePreconditions(input.writes, before);
2938
+ await this.#lockAndVerifyObjects(client, tenant, input.writes, before);
2939
+ const inlineAffected = input.writes.some((write) => {
2940
+ const current = before.get(writeKey(write));
2941
+ if (write.kind === "task.terminal" && current !== void 0) return false;
2942
+ return current?.body.kind === "inline" || write.body.kind === "inline";
2943
+ });
2944
+ const inlineDelta = inlineAffected ? await this.#prepareInlineAccounting(client, tenant, input.writes, before) : 0n;
2945
+ const applied = await this.#applyWrites(client, tenant, input, before);
2946
+ await this.#replaceObjectReferences(client, tenant, applied);
2947
+ await this.#settleInlineAccounting(client, tenant, inlineDelta);
2948
+ const response = {
2949
+ primary: truthRecordMetadata(applied[0].record),
2950
+ snapshots: applied.slice(1).map((entry) => truthRecordMetadata(entry.record))
2951
+ };
2952
+ await client.query(
2953
+ `INSERT INTO proof_request_receipt (${RECEIPT_COLUMNS})
2954
+ VALUES ($1, $2, $3, $4, $5, $6, $7::bigint, 200, $8, $9)`,
2955
+ [
2956
+ tenant,
2957
+ input.deviceId,
2958
+ input.requestId,
2959
+ input.operation,
2960
+ input.resource,
2961
+ input.proofBodySha256,
2962
+ input.proofBodySize,
2963
+ JSON.stringify(response),
2964
+ this.#now()
2965
+ ]
2966
+ );
2967
+ await client.query("COMMIT");
2968
+ return { response, replayed: false };
2969
+ } catch (error) {
2970
+ await client.query("ROLLBACK").catch(() => void 0);
2971
+ throw error;
2972
+ } finally {
2973
+ client.release();
2974
+ }
2975
+ }
2976
+ async #validateInput(input) {
2977
+ if (input.requestId.length === 0 || input.requestId.length > TRUTH_REQUEST_ID_MAX_LENGTH) {
2978
+ throw new TruthCommitError("proof_request_conflict", "Request id is outside the record contract.");
2979
+ }
2980
+ const seen = /* @__PURE__ */ new Set();
2981
+ const objectSizes = /* @__PURE__ */ new Map();
2982
+ for (const write of input.writes) {
2983
+ const key = writeKey(write);
2984
+ if (seen.has(key)) {
2985
+ throw new TruthCommitError("proof_request_conflict", `Duplicate truth write ${key}.`);
2986
+ }
2987
+ seen.add(key);
2988
+ if (write.body.kind === "inline") {
2989
+ const bytes = new TextEncoder().encode(write.body.body);
2990
+ if (BigInt(bytes.byteLength) !== write.byteSize) {
2991
+ throw new ByokCoreError("storage_integrity_mismatch", "Inline byte size disagrees with its content.");
2992
+ }
2993
+ if (await this.#crypto.sha256(bytes) !== write.contentHash) {
2994
+ throw new ByokCoreError("storage_integrity_mismatch", "Inline hash disagrees with its content.");
2995
+ }
2996
+ } else if (write.body.hash !== write.contentHash) {
2997
+ throw new ByokCoreError("storage_integrity_mismatch", "Object body hash disagrees with record hash.");
2998
+ } else {
2999
+ const priorSize = objectSizes.get(write.body.hash);
3000
+ if (priorSize !== void 0 && priorSize !== write.byteSize) {
3001
+ throw new ByokCoreError(
3002
+ "storage_integrity_mismatch",
3003
+ `Object ${write.body.hash} was declared with inconsistent byte sizes.`
3004
+ );
3005
+ }
3006
+ objectSizes.set(write.body.hash, write.byteSize);
3007
+ }
3008
+ }
3009
+ }
3010
+ async #readReceipt(client, tenant, deviceId, requestId) {
3011
+ const result = await client.query(
3012
+ `SELECT ${RECEIPT_COLUMNS} FROM proof_request_receipt
3013
+ WHERE tenant_id = $1 AND device_id = $2 AND request_id = $3`,
3014
+ [tenant, deviceId, requestId]
3015
+ );
3016
+ const row = result.rows[0];
3017
+ return row === void 0 ? void 0 : toReceipt3(tenant, row);
3018
+ }
3019
+ async #lockCurrentRecords(client, tenant, writes) {
3020
+ const current = /* @__PURE__ */ new Map();
3021
+ const ordered = [...writes].sort((a, b) => writeKey(a).localeCompare(writeKey(b)));
3022
+ for (const write of ordered) {
3023
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
3024
+ JSON.stringify(["truth-record", tenant, write.kind, write.recordKey])
3025
+ ]);
3026
+ }
3027
+ for (const write of ordered) {
3028
+ const result = await client.query(
3029
+ `SELECT ${RECORD_COLUMNS2} FROM attested_record
3030
+ WHERE tenant_id = $1 AND kind = $2 AND subject_id = $3
3031
+ FOR UPDATE`,
3032
+ [tenant, write.kind, write.recordKey]
3033
+ );
3034
+ current.set(
3035
+ writeKey(write),
3036
+ result.rows[0] === void 0 ? void 0 : toRecord3(tenant, result.rows[0])
3037
+ );
3038
+ }
3039
+ return current;
3040
+ }
3041
+ #assertWritePreconditions(writes, current) {
3042
+ for (const write of writes) {
3043
+ const before = current.get(writeKey(write));
3044
+ if (write.kind === "task.terminal") {
3045
+ if (before !== void 0 && before.contentHash !== write.contentHash) {
3046
+ throw new CoreConflictError(
3047
+ "terminal_conflict",
3048
+ `Task ${write.recordKey} already has a different immutable terminal.`,
3049
+ before,
3050
+ this.#now()
3051
+ );
3052
+ }
3053
+ } else if ((before?.rev ?? 0) !== write.expectedRev) {
3054
+ throw new CoreConflictError(
3055
+ "truth_revision_conflict",
3056
+ `${write.kind}/${write.recordKey} is at rev ${before?.rev ?? 0}, not ${write.expectedRev}.`,
3057
+ before,
3058
+ this.#now()
3059
+ );
3060
+ }
3061
+ }
3062
+ }
3063
+ async #lockAndVerifyObjects(client, tenant, writes, current) {
3064
+ const requested = /* @__PURE__ */ new Map();
3065
+ const affected = /* @__PURE__ */ new Set();
3066
+ for (const write of writes) {
3067
+ const before = current.get(writeKey(write));
3068
+ if (write.kind === "task.terminal" && before !== void 0) continue;
3069
+ if (before?.body.kind === "object") affected.add(before.body.hash);
3070
+ if (write.body.kind === "object") {
3071
+ const existing = requested.get(write.body.hash);
3072
+ if (existing !== void 0 && existing !== write.byteSize) {
3073
+ throw new ByokCoreError(
3074
+ "storage_integrity_mismatch",
3075
+ `Object ${write.body.hash} was declared with inconsistent byte sizes.`
3076
+ );
3077
+ }
3078
+ requested.set(write.body.hash, write.byteSize);
3079
+ affected.add(write.body.hash);
3080
+ }
3081
+ }
3082
+ for (const hash of [...affected].sort()) {
3083
+ const result = await client.query(
3084
+ `SELECT hash, byte_size, state FROM object_manifest
3085
+ WHERE tenant_id = $1 AND hash = $2 FOR UPDATE`,
3086
+ [tenant, hash]
3087
+ );
3088
+ const manifest = result.rows[0];
3089
+ const byteSize = requested.get(hash);
3090
+ if (manifest === void 0 || byteSize !== void 0 && (manifest.state !== "committed" || manifest.byte_size !== byteSize)) {
3091
+ throw new TruthCommitError(
3092
+ "truth_object_not_committed",
3093
+ `Object ${hash} is not a committed matching manifest.`
3094
+ );
3095
+ }
3096
+ }
3097
+ }
3098
+ async #prepareInlineAccounting(client, tenant, writes, current) {
3099
+ const entitlementResult = await client.query(
3100
+ `SELECT hard_limit_bytes, max_inline_bytes, downgrade_grace_until
3101
+ FROM storage_entitlement WHERE tenant_id = $1 FOR UPDATE`,
3102
+ [tenant]
3103
+ );
3104
+ const entitlement = entitlementResult.rows[0];
3105
+ if (entitlement === void 0) {
3106
+ throw new ByokCoreError("storage_entitlement_missing", "Tenant has no storage entitlement.");
3107
+ }
3108
+ const now = this.#now();
3109
+ await client.query(
3110
+ `UPDATE storage_reservation SET state = 'expired', settled_at = $2
3111
+ WHERE tenant_id = $1 AND state = 'reserved' AND expires_at <= $2`,
3112
+ [tenant, now]
3113
+ );
3114
+ const usageResult = await client.query(
3115
+ `SELECT u.committed_object_bytes, u.committed_inline_bytes,
3116
+ COALESCE((SELECT SUM(expected_bytes) FROM storage_reservation r
3117
+ WHERE r.tenant_id = $1 AND r.state = 'reserved'), 0)::bigint AS reserved_bytes
3118
+ FROM storage_usage u WHERE u.tenant_id = $1 FOR UPDATE`,
3119
+ [tenant]
3120
+ );
3121
+ const usage = usageResult.rows[0];
3122
+ if (usage === void 0) throw new Error(`storage usage for ${tenant} is missing`);
3123
+ const affectedHashes = /* @__PURE__ */ new Set();
3124
+ const sizes = /* @__PURE__ */ new Map();
3125
+ for (const write of writes) {
3126
+ const before = current.get(writeKey(write));
3127
+ if (write.kind === "task.terminal" && before !== void 0) continue;
3128
+ if (before?.body.kind === "inline") {
3129
+ affectedHashes.add(before.contentHash);
3130
+ sizes.set(before.contentHash, before.byteSize);
3131
+ }
3132
+ if (write.body.kind !== "inline") continue;
3133
+ if (write.byteSize > entitlement.max_inline_bytes) {
3134
+ throw new ByokCoreError(
3135
+ "storage_object_too_large",
3136
+ `Inline truth ${write.kind}/${write.recordKey} exceeds maxInlineBytes.`
3137
+ );
3138
+ }
3139
+ const knownSize = sizes.get(write.contentHash);
3140
+ if (knownSize !== void 0 && knownSize !== write.byteSize) {
3141
+ throw new ByokCoreError(
3142
+ "storage_integrity_mismatch",
3143
+ `Inline hash ${write.contentHash} was declared with inconsistent byte sizes.`
3144
+ );
3145
+ }
3146
+ affectedHashes.add(write.contentHash);
3147
+ sizes.set(write.contentHash, write.byteSize);
3148
+ }
3149
+ const hashes = [...affectedHashes].sort();
3150
+ const baseline = new Map(hashes.map((hash) => [hash, 0n]));
3151
+ const existing = await client.query(
3152
+ `SELECT content_hash, byte_size, count(*)::bigint AS ref_count
3153
+ FROM attested_record
3154
+ WHERE tenant_id = $1 AND body_kind = 'inline' AND content_hash = ANY($2::text[])
3155
+ GROUP BY content_hash, byte_size`,
3156
+ [tenant, hashes]
3157
+ );
3158
+ for (const row of existing.rows) {
3159
+ const knownSize = sizes.get(row.content_hash);
3160
+ if (knownSize !== void 0 && knownSize !== row.byte_size) {
3161
+ throw new ByokCoreError(
3162
+ "storage_integrity_mismatch",
3163
+ `Stored inline hash ${row.content_hash} disagrees on byte size.`
3164
+ );
3165
+ }
3166
+ if ((baseline.get(row.content_hash) ?? 0n) !== 0n) {
3167
+ throw new ByokCoreError(
3168
+ "storage_integrity_mismatch",
3169
+ `Stored inline hash ${row.content_hash} has multiple byte sizes.`
3170
+ );
3171
+ }
3172
+ baseline.set(row.content_hash, row.ref_count);
3173
+ sizes.set(row.content_hash, row.byte_size);
3174
+ }
3175
+ const projected = new Map(baseline);
3176
+ for (const write of writes) {
3177
+ const before = current.get(writeKey(write));
3178
+ if (write.kind === "task.terminal" && before !== void 0) continue;
3179
+ if (before?.body.kind === "inline") {
3180
+ projected.set(before.contentHash, (projected.get(before.contentHash) ?? 0n) - 1n);
3181
+ }
3182
+ if (write.body.kind === "inline") {
3183
+ projected.set(write.contentHash, (projected.get(write.contentHash) ?? 0n) + 1n);
3184
+ }
3185
+ }
3186
+ let delta = 0n;
3187
+ let newlyCommitted = 0n;
3188
+ for (const hash of hashes) {
3189
+ const before = baseline.get(hash) ?? 0n;
3190
+ const after = projected.get(hash) ?? 0n;
3191
+ if (after < 0n) throw new Error(`inline reference count for ${hash} would become negative`);
3192
+ const byteSize = sizes.get(hash);
3193
+ if (byteSize === void 0) throw new Error(`inline byte size for ${hash} is missing`);
3194
+ if (before === 0n && after > 0n) {
3195
+ delta += byteSize;
3196
+ newlyCommitted += byteSize;
3197
+ } else if (before > 0n && after === 0n) {
3198
+ delta -= byteSize;
3199
+ }
3200
+ }
3201
+ const used = usage.committed_object_bytes + usage.committed_inline_bytes + usage.reserved_bytes;
3202
+ if (newlyCommitted > 0n && used >= entitlement.hard_limit_bytes && entitlement.downgrade_grace_until !== null && entitlement.downgrade_grace_until <= now) {
3203
+ throw new ByokCoreError("storage_write_suspended", "Durable writes are suspended.");
3204
+ }
3205
+ if (used + delta > entitlement.hard_limit_bytes) {
3206
+ throw new ByokCoreError("storage_quota_exceeded", "Final inline truth usage exceeds quota.");
3207
+ }
3208
+ return delta;
3209
+ }
3210
+ async #applyWrites(client, tenant, input, current) {
3211
+ const applied = [];
3212
+ for (const write of input.writes) {
3213
+ const before = current.get(writeKey(write));
3214
+ if (write.kind === "task.terminal" && before !== void 0) {
3215
+ applied.push({ input: write, before, record: before, mutated: false });
3216
+ continue;
3217
+ }
3218
+ const [bodyKind, bodyInline, bodyObjectHash] = bodyColumns2(write.body);
3219
+ const values = [
3220
+ tenant,
3221
+ write.kind,
3222
+ write.recordKey,
3223
+ write.contentHash,
3224
+ write.byteSize,
3225
+ bodyKind,
3226
+ bodyInline,
3227
+ bodyObjectHash,
3228
+ write.label ?? null,
3229
+ input.requestId,
3230
+ this.#now()
3231
+ ];
3232
+ const result = before === void 0 ? await client.query(
3233
+ `INSERT INTO attested_record (${RECORD_COLUMNS2})
3234
+ VALUES ($1, $2, $3, 1, $4, $5, $6, $7, $8, $9, $10, $11)
3235
+ RETURNING ${RECORD_COLUMNS2}`,
3236
+ values
3237
+ ) : await client.query(
3238
+ `UPDATE attested_record
3239
+ SET rev = rev + 1, content_hash = $4, byte_size = $5,
3240
+ body_kind = $6, body_inline = $7, body_object_hash = $8,
3241
+ label = $9, request_id = $10, written_at = $11
3242
+ WHERE tenant_id = $1 AND kind = $2 AND subject_id = $3
3243
+ RETURNING ${RECORD_COLUMNS2}`,
3244
+ values
3245
+ );
3246
+ applied.push({
3247
+ input: write,
3248
+ before,
3249
+ record: toRecord3(tenant, result.rows[0]),
3250
+ mutated: true
3251
+ });
3252
+ }
3253
+ return applied;
3254
+ }
3255
+ async #replaceObjectReferences(client, tenant, applied) {
3256
+ const affected = /* @__PURE__ */ new Set();
3257
+ for (const entry of applied) {
3258
+ if (!entry.mutated) continue;
3259
+ const refId = referenceId(entry.input);
3260
+ if (entry.input.body.kind === "object") {
3261
+ affected.add(entry.input.body.hash);
3262
+ await client.query(
3263
+ `INSERT INTO object_reference (tenant_id, hash, ref_kind, ref_id, created_at)
3264
+ VALUES ($1, $2, 'truth', $3, $4)
3265
+ ON CONFLICT (tenant_id, hash, ref_kind, ref_id) DO NOTHING`,
3266
+ [tenant, entry.input.body.hash, refId, this.#now()]
3267
+ );
3268
+ }
3269
+ if (entry.before?.body.kind === "object" && (entry.input.body.kind !== "object" || entry.input.body.hash !== entry.before.body.hash)) {
3270
+ affected.add(entry.before.body.hash);
3271
+ await client.query(
3272
+ `DELETE FROM object_reference
3273
+ WHERE tenant_id = $1 AND hash = $2 AND ref_kind = 'truth' AND ref_id = $3`,
3274
+ [tenant, entry.before.body.hash, refId]
3275
+ );
3276
+ }
3277
+ }
3278
+ for (const hash of [...affected].sort()) {
3279
+ await client.query(
3280
+ `UPDATE object_manifest
3281
+ SET ref_count = (SELECT count(*) FROM object_reference r
3282
+ WHERE r.tenant_id = $1 AND r.hash = $2),
3283
+ updated_at = $3
3284
+ WHERE tenant_id = $1 AND hash = $2`,
3285
+ [tenant, hash, this.#now()]
3286
+ );
3287
+ }
3288
+ }
3289
+ async #settleInlineAccounting(client, tenant, delta) {
3290
+ if (delta !== 0n) {
3291
+ const updated = await client.query(
3292
+ `UPDATE storage_usage
3293
+ SET committed_inline_bytes = committed_inline_bytes + $2::bigint,
3294
+ updated_at = $3
3295
+ WHERE tenant_id = $1 AND committed_inline_bytes + $2::bigint >= 0
3296
+ RETURNING 1`,
3297
+ [tenant, delta, this.#now()]
3298
+ );
3299
+ if (updated.rowCount !== 1) throw new Error("inline accounting would become negative");
3300
+ }
3301
+ }
3302
+ #now() {
3303
+ return this.#clock.now().toISOString();
3304
+ }
3305
+ };
3306
+
3307
+ export { DEFAULT_MAX_ATTEMPTS, DEFAULT_PRESIGN_TTL_SECONDS, DEFAULT_RETRY_DELAY_MS, MAX_PRESIGN_TTL_SECONDS, MIN_PRESIGN_TTL_SECONDS, ObjectStoreRequestError, PostgresActivityStore, PostgresApprovalTimelineStore, PostgresBoardStore, PostgresDeviceDirectory, PostgresInboundDedupStore, PostgresMailboxStore, PostgresNonceStore, PostgresObjectStore, PostgresPairingCodeStore, PostgresPresenceStore, PostgresProofRequestReceiptStore, PostgresQuotaStore, PostgresRequestReceiptStore, PostgresSkillPackStore, PostgresTaskAttemptStore, PostgresTruthCommitter, PostgresTruthStore, R2BlobStoreError, R2CloudBlobStore, R2ObjectMaintenanceStore, R2_BLOB_ERROR_CODES, createByokPool, createPostgresCloudStores, createPostgresCoreStores };
3308
+ //# sourceMappingURL=runtime.js.map
3309
+ //# sourceMappingURL=runtime.js.map