@byok-sdk/cloud-dataplane 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,4284 @@
1
+ import pg from 'pg';
2
+ import { createHash } from 'crypto';
3
+ import { readdir, readFile } from 'fs/promises';
4
+ import { join } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import { DEDUP_RING_CAPACITY, NONCE_TTL_MS, AllowAllRateLimiter, TruthCommitError, TruthCommitResponseSchema, truthRecordMetadata, TRUTH_REQUEST_ID_MAX_LENGTH } from '@byok-sdk/cloud';
7
+ import { ByokCoreError, assertCanonicalTimestamp, contentHash, isContentHash, tenantObjectKey, objectKeyPrefix, DEFAULT_ACTIVITY_CAPACITY, CoreConflictError, isLegalBoardTransition, tenantId, checkSkillPackManifest, checkSkillPackEntry, SKILL_PACK_ENTRY_PATH, SKILL_PACK_MANIFEST_SCHEMA_ID } from '@byok-sdk/core';
8
+ import { AwsClient } from 'aws4fetch';
9
+ import { XMLParser } from 'fast-xml-parser';
10
+ import { decodeEnvelope, isServerToDaemonType, EnvelopeSchema, encodeEnvelope } from '@byok-sdk/protocol';
11
+
12
+ // src/pool.ts
13
+ var defaultTypeParser = pg.types.getTypeParser;
14
+ var int8Parsers = {
15
+ getTypeParser(id, format) {
16
+ if (id === pg.types.builtins.INT8) return (value) => BigInt(value);
17
+ return defaultTypeParser(id, format);
18
+ }
19
+ };
20
+ function createByokPool(options) {
21
+ const { onPoolError, ...poolConfig } = options;
22
+ const pool = new pg.Pool({ ...poolConfig, types: int8Parsers });
23
+ pool.on("error", (err, client) => {
24
+ if (onPoolError) {
25
+ onPoolError(err, client);
26
+ return;
27
+ }
28
+ console.error("[byok-sdk] idle pg pool client error (handled, not fatal)", err);
29
+ });
30
+ return pool;
31
+ }
32
+ var MIGRATION_ADVISORY_LOCK_KEY = "4021960801";
33
+ var MIGRATION_FILENAME_PATTERN = /^(\d{4})[_-].+\.sql$/;
34
+ var LEDGER_DDL = `
35
+ CREATE TABLE IF NOT EXISTS byok_schema_migration (
36
+ version text PRIMARY KEY,
37
+ checksum text NOT NULL,
38
+ applied_at timestamptz NOT NULL
39
+ )`;
40
+ var MigrationChecksumMismatchError = class extends Error {
41
+ version;
42
+ expectedChecksum;
43
+ actualChecksum;
44
+ constructor(version, expectedChecksum, actualChecksum) {
45
+ super(
46
+ `Migration ${version} was already applied with checksum ${expectedChecksum}, but the file on disk hashes to ${actualChecksum}. Published migrations are immutable: add a new file instead of editing this one.`
47
+ );
48
+ this.name = "MigrationChecksumMismatchError";
49
+ this.version = version;
50
+ this.expectedChecksum = expectedChecksum;
51
+ this.actualChecksum = actualChecksum;
52
+ }
53
+ };
54
+ var MigrationFilenameError = class extends Error {
55
+ filename;
56
+ constructor(filename, reason) {
57
+ super(`Migration file ${filename} is not usable: ${reason}`);
58
+ this.name = "MigrationFilenameError";
59
+ this.filename = filename;
60
+ }
61
+ };
62
+ function sha256(text) {
63
+ return createHash("sha256").update(text, "utf8").digest("hex");
64
+ }
65
+ async function readMigrationFiles(directory) {
66
+ const entries = await readdir(directory, { withFileTypes: true });
67
+ const files = [];
68
+ for (const entry of entries) {
69
+ if (!entry.isFile() || !entry.name.endsWith(".sql")) continue;
70
+ const match = MIGRATION_FILENAME_PATTERN.exec(entry.name);
71
+ if (match === null) {
72
+ throw new MigrationFilenameError(
73
+ entry.name,
74
+ "expected a four-digit prefix, e.g. 0001_cloud_local.sql"
75
+ );
76
+ }
77
+ const sql = await readFile(join(directory, entry.name), "utf8");
78
+ files.push({
79
+ version: entry.name,
80
+ ordinal: Number.parseInt(match[1], 10),
81
+ checksum: sha256(sql),
82
+ sql
83
+ });
84
+ }
85
+ files.sort((left, right) => left.ordinal - right.ordinal);
86
+ for (let index = 1; index < files.length; index += 1) {
87
+ const previous = files[index - 1];
88
+ const current = files[index];
89
+ if (previous.ordinal === current.ordinal) {
90
+ throw new MigrationFilenameError(
91
+ current.version,
92
+ `duplicate prefix ${String(current.ordinal).padStart(4, "0")}, already used by ${previous.version}`
93
+ );
94
+ }
95
+ }
96
+ return files;
97
+ }
98
+ async function readLedger(client) {
99
+ const result = await client.query(
100
+ "SELECT version, checksum FROM byok_schema_migration"
101
+ );
102
+ return new Map(result.rows.map((row) => [row.version, row.checksum]));
103
+ }
104
+ async function migrate(pool, directory) {
105
+ const files = await readMigrationFiles(directory);
106
+ const client = await pool.connect();
107
+ try {
108
+ await client.query("SELECT pg_advisory_lock($1)", [MIGRATION_ADVISORY_LOCK_KEY]);
109
+ try {
110
+ await client.query(LEDGER_DDL);
111
+ const ledger = await readLedger(client);
112
+ const applied = [];
113
+ const alreadyApplied = [];
114
+ for (const file of files) {
115
+ const recordedChecksum = ledger.get(file.version);
116
+ if (recordedChecksum !== void 0) {
117
+ if (recordedChecksum !== file.checksum) {
118
+ throw new MigrationChecksumMismatchError(file.version, recordedChecksum, file.checksum);
119
+ }
120
+ alreadyApplied.push(file.version);
121
+ continue;
122
+ }
123
+ await client.query("BEGIN");
124
+ try {
125
+ await client.query(file.sql);
126
+ await client.query(
127
+ "INSERT INTO byok_schema_migration (version, checksum, applied_at) VALUES ($1, $2, now())",
128
+ [file.version, file.checksum]
129
+ );
130
+ await client.query("COMMIT");
131
+ } catch (error) {
132
+ await client.query("ROLLBACK").catch(() => {
133
+ });
134
+ throw error;
135
+ }
136
+ applied.push(file.version);
137
+ }
138
+ return { applied, alreadyApplied };
139
+ } finally {
140
+ await client.query("SELECT pg_advisory_unlock($1)", [MIGRATION_ADVISORY_LOCK_KEY]);
141
+ }
142
+ } finally {
143
+ client.release();
144
+ }
145
+ }
146
+ function migrationsDir() {
147
+ return fileURLToPath(new URL("./sql", import.meta.url));
148
+ }
149
+ var DEFAULT_LIST_LIMIT = 100;
150
+ var MANIFEST_COLUMNS = "tenant_id, hash, byte_size, content_type, state, ref_count, created_at, updated_at, delete_pending_at";
151
+ function toEntry(row) {
152
+ return {
153
+ tenantId: row.tenant_id,
154
+ hash: row.hash,
155
+ byteSize: row.byte_size,
156
+ contentType: row.content_type,
157
+ state: row.state,
158
+ refCount: row.ref_count,
159
+ createdAt: row.created_at,
160
+ updatedAt: row.updated_at,
161
+ ...row.delete_pending_at === null ? {} : { deletePendingAt: row.delete_pending_at }
162
+ };
163
+ }
164
+ var PostgresObjectStore = class {
165
+ #pool;
166
+ #clock;
167
+ constructor(pool, clock) {
168
+ this.#pool = pool;
169
+ this.#clock = clock;
170
+ }
171
+ async putManifest(tenant, input) {
172
+ const now = this.#now();
173
+ const upserted = await this.#pool.query(
174
+ `INSERT INTO object_manifest (${MANIFEST_COLUMNS})
175
+ VALUES ($1, $2, $3::bigint, $4, 'pending', 0, $5, $5, NULL)
176
+ ON CONFLICT (tenant_id, hash) DO UPDATE
177
+ SET byte_size = EXCLUDED.byte_size,
178
+ content_type = EXCLUDED.content_type,
179
+ state = 'pending',
180
+ ref_count = 0,
181
+ created_at = EXCLUDED.created_at,
182
+ updated_at = EXCLUDED.updated_at,
183
+ delete_pending_at = NULL
184
+ WHERE object_manifest.state = 'deleted'
185
+ RETURNING ${MANIFEST_COLUMNS}`,
186
+ [tenant, input.hash, input.byteSize, input.contentType, now]
187
+ );
188
+ const row = upserted.rows[0];
189
+ if (row !== void 0) return toEntry(row);
190
+ return this.#require(tenant, input.hash);
191
+ }
192
+ async commit(tenant, input) {
193
+ const committed = await this.#pool.query(
194
+ `UPDATE object_manifest
195
+ SET state = 'committed', updated_at = $3
196
+ WHERE tenant_id = $1 AND hash = $2
197
+ AND state = 'pending'
198
+ AND byte_size = $4::bigint
199
+ AND content_type = $5
200
+ RETURNING ${MANIFEST_COLUMNS}`,
201
+ [tenant, input.hash, this.#now(), input.observedByteSize, input.observedContentType]
202
+ );
203
+ const row = committed.rows[0];
204
+ if (row !== void 0) return toEntry(row);
205
+ const current = await this.#require(tenant, input.hash);
206
+ if (current.byteSize !== input.observedByteSize || current.contentType !== input.observedContentType) {
207
+ throw new ByokCoreError(
208
+ "storage_integrity_mismatch",
209
+ `Observed object ${input.hash} (${String(input.observedByteSize)} bytes, ${input.observedContentType}) does not match the declared manifest.`
210
+ );
211
+ }
212
+ if (current.state === "committed") return current;
213
+ throw this.#stateInvalid(current.state, "committed");
214
+ }
215
+ async get(tenant, hash) {
216
+ const result = await this.#pool.query(
217
+ `SELECT ${MANIFEST_COLUMNS} FROM object_manifest WHERE tenant_id = $1 AND hash = $2`,
218
+ [tenant, hash]
219
+ );
220
+ const row = result.rows[0];
221
+ return row === void 0 ? void 0 : toEntry(row);
222
+ }
223
+ async list(tenant, query) {
224
+ if (query.deletePendingBefore !== void 0) {
225
+ assertCanonicalTimestamp(query.deletePendingBefore, "deletePendingBefore");
226
+ }
227
+ const result = await this.#pool.query(
228
+ `SELECT ${MANIFEST_COLUMNS} FROM object_manifest
229
+ WHERE tenant_id = $1
230
+ AND ($2::text IS NULL OR state = $2::text)
231
+ AND ($3::text IS NULL
232
+ OR (delete_pending_at IS NOT NULL AND delete_pending_at < $3::text))
233
+ ORDER BY hash COLLATE "C"
234
+ LIMIT $4`,
235
+ [
236
+ tenant,
237
+ query.state ?? null,
238
+ query.deletePendingBefore ?? null,
239
+ query.limit ?? DEFAULT_LIST_LIMIT
240
+ ]
241
+ );
242
+ return result.rows.map(toEntry);
243
+ }
244
+ async addReference(tenant, input) {
245
+ return this.#withManifestLocked(tenant, input.hash, async (client, current) => {
246
+ if (current.state !== "committed") {
247
+ return new ByokCoreError(
248
+ "object_state_invalid",
249
+ `Only committed objects can be referenced; ${input.hash} is ${current.state}.`
250
+ );
251
+ }
252
+ await client.query(
253
+ `INSERT INTO object_reference (tenant_id, hash, ref_kind, ref_id, created_at)
254
+ VALUES ($1, $2, $3, $4, $5)
255
+ ON CONFLICT (tenant_id, hash, ref_kind, ref_id) DO NOTHING`,
256
+ [tenant, input.hash, input.refKind, input.refId, this.#now()]
257
+ );
258
+ return this.#recount(client, tenant, input.hash);
259
+ });
260
+ }
261
+ async removeReference(tenant, input) {
262
+ return this.#withManifestLocked(tenant, input.hash, async (client) => {
263
+ await client.query(
264
+ `DELETE FROM object_reference
265
+ WHERE tenant_id = $1 AND hash = $2 AND ref_kind = $3 AND ref_id = $4`,
266
+ [tenant, input.hash, input.refKind, input.refId]
267
+ );
268
+ return this.#recount(client, tenant, input.hash);
269
+ });
270
+ }
271
+ async markDeletePending(tenant, hash) {
272
+ const marked = await this.#pool.query(
273
+ `UPDATE object_manifest
274
+ SET gc_accounted_bytes = CASE WHEN state = 'committed' THEN byte_size ELSE 0 END,
275
+ gc_accounted_object = (state = 'committed'),
276
+ state = 'delete_pending', delete_pending_at = $3, updated_at = $3
277
+ WHERE tenant_id = $1 AND hash = $2
278
+ AND ref_count = 0
279
+ AND state IN ('pending', 'committed')
280
+ RETURNING ${MANIFEST_COLUMNS}`,
281
+ [tenant, hash, this.#now()]
282
+ );
283
+ const row = marked.rows[0];
284
+ if (row !== void 0) return toEntry(row);
285
+ const current = await this.#require(tenant, hash);
286
+ if (current.refCount !== 0) {
287
+ throw new ByokCoreError(
288
+ "object_state_invalid",
289
+ `Object ${hash} still has ${current.refCount} reference(s).`
290
+ );
291
+ }
292
+ throw this.#stateInvalid(current.state, "delete_pending");
293
+ }
294
+ async markDeleted(tenant, hash) {
295
+ const deleted = await this.#pool.query(
296
+ `UPDATE object_manifest
297
+ SET state = 'deleted', updated_at = $3
298
+ WHERE tenant_id = $1 AND hash = $2 AND state = 'delete_pending'
299
+ RETURNING ${MANIFEST_COLUMNS}`,
300
+ [tenant, hash, this.#now()]
301
+ );
302
+ const row = deleted.rows[0];
303
+ if (row !== void 0) return toEntry(row);
304
+ const current = await this.#require(tenant, hash);
305
+ throw this.#stateInvalid(current.state, "deleted");
306
+ }
307
+ /**
308
+ * Runs a reference mutation with the manifest row held under `FOR UPDATE`.
309
+ *
310
+ * The lock is taken before anything is read, so the state the callback judges
311
+ * is the state no concurrent `markDeletePending` can move underneath it. A
312
+ * typed refusal is RETURNED rather than thrown, the way
313
+ * `PostgresQuotaStore.reserve` defers its rejection past `COMMIT`: it keeps
314
+ * the `catch` reserved for genuine faults, so a rollback that itself fails
315
+ * cannot replace this store's own answer.
316
+ */
317
+ async #withManifestLocked(tenant, hash, mutate) {
318
+ const client = await this.#pool.connect();
319
+ let settled;
320
+ try {
321
+ await client.query("BEGIN");
322
+ const locked = await client.query(
323
+ `SELECT ${MANIFEST_COLUMNS} FROM object_manifest
324
+ WHERE tenant_id = $1 AND hash = $2
325
+ FOR UPDATE`,
326
+ [tenant, hash]
327
+ );
328
+ const row = locked.rows[0];
329
+ settled = row === void 0 ? this.#notFound(hash) : await mutate(client, toEntry(row));
330
+ await client.query("COMMIT");
331
+ } catch (error) {
332
+ await client.query("ROLLBACK").catch(() => {
333
+ });
334
+ throw error;
335
+ } finally {
336
+ client.release();
337
+ }
338
+ if (settled instanceof ByokCoreError) throw settled;
339
+ return settled;
340
+ }
341
+ /** Sets `ref_count` to what the reference rows actually say. */
342
+ async #recount(client, tenant, hash) {
343
+ const result = await client.query(
344
+ `UPDATE object_manifest
345
+ SET ref_count = (SELECT count(*) FROM object_reference r
346
+ WHERE r.tenant_id = $1 AND r.hash = $2),
347
+ updated_at = $3
348
+ WHERE tenant_id = $1 AND hash = $2
349
+ RETURNING ${MANIFEST_COLUMNS}`,
350
+ [tenant, hash, this.#now()]
351
+ );
352
+ const row = result.rows[0];
353
+ if (row === void 0) throw this.#notFound(hash);
354
+ return toEntry(row);
355
+ }
356
+ async #require(tenant, hash) {
357
+ const entry = await this.get(tenant, hash);
358
+ if (entry === void 0) throw this.#notFound(hash);
359
+ return entry;
360
+ }
361
+ #notFound(hash) {
362
+ return new ByokCoreError(
363
+ "object_not_found",
364
+ `Object ${hash} has no manifest row in this tenant.`
365
+ );
366
+ }
367
+ #stateInvalid(from, to) {
368
+ return new ByokCoreError(
369
+ "object_state_invalid",
370
+ `${from} to ${to} is not a legal object manifest transition.`
371
+ );
372
+ }
373
+ #now() {
374
+ return this.#clock.now().toISOString();
375
+ }
376
+ };
377
+ var DEFAULT_PRESIGN_TTL_SECONDS = 15 * 60;
378
+ var MIN_PRESIGN_TTL_SECONDS = 1;
379
+ var MAX_PRESIGN_TTL_SECONDS = 604800;
380
+ var DEFAULT_MAX_ATTEMPTS = 3;
381
+ var DEFAULT_RETRY_DELAY_MS = 100;
382
+ var ObjectStoreRequestError = class extends Error {
383
+ status;
384
+ attempts;
385
+ constructor(message, attempts, status, options) {
386
+ super(message, options);
387
+ this.name = "ObjectStoreRequestError";
388
+ this.attempts = attempts;
389
+ this.status = status;
390
+ }
391
+ };
392
+ var R2_BLOB_ERROR_CODES = {
393
+ /**
394
+ * A tenant id that cannot be one safe path segment. Wire-relevant: it is the
395
+ * only signal a control plane gets that the id it issued cannot address
396
+ * object storage, and it is raised BEFORE any key is built.
397
+ */
398
+ storage_tenant_key_unsafe: "storage_tenant_key_unsafe",
399
+ /** A presign lifetime outside `[MIN_PRESIGN_TTL_SECONDS, MAX_PRESIGN_TTL_SECONDS]`. Construction-time only. */
400
+ storage_presign_ttl_invalid: "storage_presign_ttl_invalid",
401
+ /** ListObjectsV2 accepts 1..1000 keys per page. Maintenance input only. */
402
+ storage_list_limit_invalid: "storage_list_limit_invalid",
403
+ /** Continuation tokens are opaque but non-empty. Maintenance input only. */
404
+ storage_list_cursor_invalid: "storage_list_cursor_invalid"
405
+ };
406
+ var R2BlobStoreError = class extends Error {
407
+ code;
408
+ constructor(code, message, options) {
409
+ super(message, options);
410
+ this.name = "R2BlobStoreError";
411
+ this.code = code;
412
+ }
413
+ };
414
+ var R2CloudBlobStore = class {
415
+ #objects;
416
+ #signingClock;
417
+ #client;
418
+ #origin;
419
+ #bucket;
420
+ #keyPrefix;
421
+ #presignTtlSeconds;
422
+ #fetch;
423
+ #maxAttempts;
424
+ #retryDelayMs;
425
+ constructor(options) {
426
+ this.#objects = options.objects;
427
+ this.#signingClock = options.signingClock;
428
+ this.#client = new AwsClient({
429
+ accessKeyId: options.accessKeyId,
430
+ secretAccessKey: options.secretAccessKey,
431
+ service: "s3",
432
+ region: options.region,
433
+ // The client's own retry loop is bypassed: this store only ever calls
434
+ // `sign`, and drives its own bounded, jitter-free retries below so the
435
+ // transient-error dimension can assert an exact attempt sequence.
436
+ retries: 0
437
+ });
438
+ this.#origin = options.endpoint.replace(/\/+$/, "");
439
+ this.#bucket = options.bucket;
440
+ this.#keyPrefix = resolveKeyPrefix(options.keyPrefix);
441
+ this.#presignTtlSeconds = assertPresignTtl(options.presignTtlSeconds ?? DEFAULT_PRESIGN_TTL_SECONDS);
442
+ this.#fetch = options.fetch ?? ((request) => globalThis.fetch(request));
443
+ this.#maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
444
+ this.#retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
445
+ }
446
+ /**
447
+ * Reserve the manifest row, then hand back a PUT bound to this tenant, this
448
+ * key, this length, this type, and this expiry.
449
+ *
450
+ * `putManifest` is idempotent per (tenant, hash), so a device that declares
451
+ * the same content twice while it is still `pending` gets the same row and
452
+ * the same key — an interrupted upload is retried, not duplicated. It is
453
+ * idempotent per TENANT, which is the same reason the key embeds the tenant:
454
+ * two tenants holding identical bytes hold two independent objects, and
455
+ * neither can learn of the other's.
456
+ *
457
+ * Idempotence stops at `committed`, and that boundary is the point: a
458
+ * committed object is what a truth record is allowed to reference, so it has
459
+ * to be immutable, and re-issuing a write grant for one is the only way this
460
+ * adapter could make it otherwise.
461
+ */
462
+ async createUpload(tenant, reservation) {
463
+ this.#assertReservedObject(tenant, reservation);
464
+ const hash = contentHash(reservation.contentHash);
465
+ const byteSize = reservation.expectedBytes;
466
+ assertKeySegmentTenant(tenant);
467
+ const entry = await this.#objects.putManifest(tenant, {
468
+ hash,
469
+ byteSize,
470
+ contentType: reservation.contentType
471
+ });
472
+ if (entry.byteSize !== byteSize || entry.contentType !== reservation.contentType) {
473
+ throw new ByokCoreError(
474
+ "storage_integrity_mismatch",
475
+ `Object ${hash} is already declared as ${String(entry.byteSize)} bytes of ${entry.contentType}; this upload declares ${String(byteSize)} bytes of ${reservation.contentType}.`
476
+ );
477
+ }
478
+ if (entry.state !== "pending") {
479
+ throw new ByokCoreError(
480
+ "object_state_invalid",
481
+ `Object ${hash} is ${entry.state}; only a pending object can receive an upload grant.`
482
+ );
483
+ }
484
+ const url = this.#objectUrl(tenant, hash);
485
+ url.searchParams.set("X-Amz-Expires", String(this.#presignTtlSeconds));
486
+ const signed = await this.#client.sign(
487
+ new Request(url, {
488
+ method: "PUT",
489
+ headers: {
490
+ "content-length": String(byteSize),
491
+ "content-type": reservation.contentType
492
+ }
493
+ }),
494
+ {
495
+ // `allHeaders` is load-bearing: aws4fetch treats `content-length` and
496
+ // `content-type` as unsignable by default (they are per-hop headers for
497
+ // most services), and without this the grant would bind the key and the
498
+ // expiry but not the SHAPE of what may be written to it.
499
+ aws: { signQuery: true, allHeaders: true, datetime: this.#datetime() }
500
+ }
501
+ );
502
+ return { blobId: hash, uploadUrl: signed.url };
503
+ }
504
+ async observeUpload(tenant, blobId, reservation) {
505
+ if (!isContentHash(blobId) || reservation.tenantId !== tenant || reservation.kind !== "object" || reservation.contentHash !== blobId) {
506
+ return void 0;
507
+ }
508
+ const entry = await this.#objects.get(tenant, reservation.contentHash);
509
+ if (entry === void 0 || entry.state !== "pending" && entry.state !== "committed") {
510
+ return void 0;
511
+ }
512
+ const observed = await this.#head(tenant, reservation.contentHash);
513
+ if (!observed.present) return void 0;
514
+ return {
515
+ observedByteSize: observed.byteSize,
516
+ observedContentType: observed.contentType
517
+ };
518
+ }
519
+ /**
520
+ * A GET for a committed object this tenant owns; `undefined` otherwise.
521
+ *
522
+ * Every miss answers identically — unknown hash, another tenant's object, a
523
+ * malformed id, bytes that never landed, a tombstoned row. A caller cannot
524
+ * tell them apart, which is what keeps `getDownloadUrl` from being an
525
+ * existence oracle across tenants.
526
+ *
527
+ * This is a pure committed-manifest gate. Observation and commit belong to
528
+ * the explicit finalize route; a download must never decide accounting.
529
+ */
530
+ async getDownloadUrl(tenant, blobId) {
531
+ if (!isContentHash(blobId)) return void 0;
532
+ const hash = blobId;
533
+ const entry = await this.#objects.get(tenant, hash);
534
+ if (entry === void 0) return void 0;
535
+ if (entry.state !== "committed") {
536
+ return void 0;
537
+ }
538
+ const url = this.#objectUrl(tenant, hash);
539
+ url.searchParams.set("X-Amz-Expires", String(this.#presignTtlSeconds));
540
+ const signed = await this.#client.sign(new Request(url, { method: "GET" }), {
541
+ aws: { signQuery: true, datetime: this.#datetime() }
542
+ });
543
+ return signed.url;
544
+ }
545
+ /**
546
+ * The ONLY place an object key is built, and therefore the only place the
547
+ * key's two segments have to be safe.
548
+ *
549
+ * The hash half is closed by construction: `tenantObjectKey` is core's and it
550
+ * takes a `ContentHash` — a branded type with exactly one mint point that
551
+ * rejects anything but 64 lowercase hex — so a traversal segment, an absolute
552
+ * path, or an uppercase digest cannot be smuggled through a parameter that
553
+ * will not accept them.
554
+ *
555
+ * The tenant half is NOT, and that asymmetry is why the guard below exists.
556
+ * `tenantId()` fails closed on empty, padded, over-long, and `NUL`-bearing
557
+ * values, and deliberately normalizes nothing else — normalizing would make
558
+ * the SDK disagree with the control plane that issued the id about its
559
+ * canonical form (`@byok-sdk/core`'s `tenant.ts`). So `a/../b` is a legitimate
560
+ * tenant id, and the line below would otherwise hand it to `new URL()`, which
561
+ * resolves the traversal and returns tenant `b`'s key. That is a cross-tenant
562
+ * alias: `a/../b` could probe, overwrite, and read what `b` owns.
563
+ *
564
+ * Refused rather than encoded. Percent-encoding the segment would keep the
565
+ * key distinct, and would also give every tenant id two spellings — the one
566
+ * the control plane issued and the one at rest in the object store — which is
567
+ * the second source of truth core refused to create in the first place.
568
+ */
569
+ #objectUrl(tenant, hash) {
570
+ assertKeySegmentTenant(tenant);
571
+ return new URL(
572
+ `${this.#origin}/${this.#bucket}/${tenantObjectKey(tenant, hash, this.#keyPrefix)}`
573
+ );
574
+ }
575
+ /**
576
+ * `HEAD` the key, with bounded retries for transient faults.
577
+ *
578
+ * `HEAD` is idempotent by construction, so a retry can never double an
579
+ * effect — which is the whole reason the retry loop is allowed to exist here
580
+ * and not around anything that writes.
581
+ */
582
+ async #head(tenant, hash) {
583
+ const url = this.#objectUrl(tenant, hash);
584
+ const { response, attempts } = await this.#send(new Request(url, { method: "HEAD" }));
585
+ if (response.status === 404) {
586
+ return { present: false, byteSize: 0n, contentType: "" };
587
+ }
588
+ if (!response.ok) {
589
+ throw new ObjectStoreRequestError(
590
+ `HEAD on the object store answered ${response.status}.`,
591
+ attempts,
592
+ response.status
593
+ );
594
+ }
595
+ const length = response.headers.get("content-length");
596
+ const contentType = response.headers.get("content-type");
597
+ if (length === null || contentType === null) {
598
+ throw new ObjectStoreRequestError(
599
+ `HEAD on the object store omitted content-length or content-type, so ${hash} cannot be verified.`,
600
+ attempts,
601
+ response.status
602
+ );
603
+ }
604
+ return { present: true, byteSize: BigInt(length), contentType };
605
+ }
606
+ /**
607
+ * Signs, sends, and retries 5xx/429/network faults with a doubling delay.
608
+ *
609
+ * Returns the attempt count alongside the response because the caller is what
610
+ * decides the answer was a failure. A 4xx costs however many attempts the
611
+ * transient faults before it did, and `attempts` is the only field
612
+ * {@link ObjectStoreRequestError} carries that an operator can use to tell a
613
+ * flapping remote from a hard refusal — so it has to be counted here, where
614
+ * the loop is, rather than assumed to be 1 at the throw site.
615
+ */
616
+ async #send(request) {
617
+ const failures = [];
618
+ for (let attempt = 1; attempt <= this.#maxAttempts; attempt += 1) {
619
+ const signed = await this.#client.sign(request.clone(), {
620
+ aws: { datetime: this.#datetime() }
621
+ });
622
+ const outcome = await this.#attempt(signed);
623
+ if (outcome.response !== void 0) return { response: outcome.response, attempts: attempt };
624
+ failures.push(outcome.failure);
625
+ if (attempt < this.#maxAttempts) {
626
+ await this.#sleep(this.#retryDelayMs * 2 ** (attempt - 1));
627
+ }
628
+ }
629
+ throw new ObjectStoreRequestError(
630
+ `The object store failed ${this.#maxAttempts} attempt(s): ${failures.join(", ")}.`,
631
+ this.#maxAttempts
632
+ );
633
+ }
634
+ async #attempt(signed) {
635
+ try {
636
+ const response = await this.#fetch(signed);
637
+ if (response.status !== 429 && response.status < 500) return { response, failure: "" };
638
+ return { failure: `HTTP ${response.status}` };
639
+ } catch (cause) {
640
+ return { failure: cause instanceof Error ? cause.message : String(cause) };
641
+ }
642
+ }
643
+ #sleep(ms) {
644
+ return new Promise((resolve) => {
645
+ setTimeout(resolve, ms);
646
+ });
647
+ }
648
+ /** SigV4's `YYYYMMDDTHHmmssZ`, off the signing clock — see its doc for why that is a separate one. */
649
+ #datetime() {
650
+ return this.#signingClock.now().toISOString().replaceAll(/[:-]|\.\d{3}/g, "");
651
+ }
652
+ #assertReservedObject(tenant, reservation) {
653
+ if (reservation.tenantId === tenant && reservation.kind === "object" && reservation.state === "reserved" && reservation.expectedBytes >= 0n) {
654
+ return;
655
+ }
656
+ throw new ByokCoreError(
657
+ "storage_integrity_mismatch",
658
+ "An upload grant requires a reserved object reservation owned by this tenant."
659
+ );
660
+ }
661
+ };
662
+ var R2ObjectMaintenanceStore = class {
663
+ #signingClock;
664
+ #client;
665
+ #origin;
666
+ #bucket;
667
+ #keyPrefix;
668
+ #fetch;
669
+ #maxAttempts;
670
+ #retryDelayMs;
671
+ constructor(options) {
672
+ this.#signingClock = options.signingClock;
673
+ this.#client = new AwsClient({
674
+ accessKeyId: options.accessKeyId,
675
+ secretAccessKey: options.secretAccessKey,
676
+ service: "s3",
677
+ region: options.region,
678
+ retries: 0
679
+ });
680
+ this.#origin = options.endpoint.replace(/\/+$/, "");
681
+ this.#bucket = options.bucket;
682
+ this.#keyPrefix = resolveKeyPrefix(options.keyPrefix);
683
+ this.#fetch = options.fetch ?? ((request) => globalThis.fetch(request));
684
+ this.#maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
685
+ this.#retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
686
+ }
687
+ async inspectObject(tenant, hash) {
688
+ const observed = await this.#head(tenant, hash);
689
+ if (!observed.present) return void 0;
690
+ return {
691
+ observedByteSize: observed.byteSize,
692
+ observedContentType: observed.contentType
693
+ };
694
+ }
695
+ async deleteObject(tenant, hash) {
696
+ const url = this.#objectUrl(tenant, hash);
697
+ const { response, attempts } = await this.#send(new Request(url, { method: "DELETE" }));
698
+ if (response.status === 404) return "absent";
699
+ if (!response.ok) {
700
+ throw new ObjectStoreRequestError(
701
+ `DELETE on the object store answered ${response.status}.`,
702
+ attempts,
703
+ response.status
704
+ );
705
+ }
706
+ return "deleted";
707
+ }
708
+ async listTenantObjects(tenant, continuationToken, limit = 100) {
709
+ assertKeySegmentTenant(tenant);
710
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1e3) {
711
+ throw new R2BlobStoreError(
712
+ "storage_list_limit_invalid",
713
+ `A ListObjectsV2 page limit of ${String(limit)} is not a whole number in [1, 1000].`
714
+ );
715
+ }
716
+ const prefix = `${tenant}/sha256/`;
717
+ const url = new URL(`${this.#origin}/${this.#bucket}`);
718
+ url.searchParams.set("list-type", "2");
719
+ url.searchParams.set("prefix", prefix);
720
+ url.searchParams.set("max-keys", String(limit));
721
+ if (continuationToken !== void 0) {
722
+ if (continuationToken.length === 0) {
723
+ throw new R2BlobStoreError(
724
+ "storage_list_cursor_invalid",
725
+ "A ListObjectsV2 continuation token must not be empty."
726
+ );
727
+ }
728
+ url.searchParams.set("continuation-token", continuationToken);
729
+ }
730
+ const { response, attempts } = await this.#send(new Request(url, { method: "GET" }));
731
+ if (!response.ok) {
732
+ throw new ObjectStoreRequestError(
733
+ `ListObjectsV2 on the object store answered ${response.status}.`,
734
+ attempts,
735
+ response.status
736
+ );
737
+ }
738
+ return parseListObjectsV2(await response.text(), prefix, attempts);
739
+ }
740
+ async #head(tenant, hash) {
741
+ const { response, attempts } = await this.#send(
742
+ new Request(this.#objectUrl(tenant, hash), { method: "HEAD" })
743
+ );
744
+ if (response.status === 404) {
745
+ return { present: false, byteSize: 0n, contentType: "" };
746
+ }
747
+ if (!response.ok) {
748
+ throw new ObjectStoreRequestError(
749
+ `HEAD on the object store answered ${response.status}.`,
750
+ attempts,
751
+ response.status
752
+ );
753
+ }
754
+ const length = response.headers.get("content-length");
755
+ const contentType = response.headers.get("content-type");
756
+ if (length === null || contentType === null) {
757
+ throw new ObjectStoreRequestError(
758
+ `HEAD on the object store omitted content-length or content-type, so ${hash} cannot be observed.`,
759
+ attempts,
760
+ response.status
761
+ );
762
+ }
763
+ return { present: true, byteSize: BigInt(length), contentType };
764
+ }
765
+ #objectUrl(tenant, hash) {
766
+ assertKeySegmentTenant(tenant);
767
+ return new URL(
768
+ `${this.#origin}/${this.#bucket}/${tenantObjectKey(tenant, hash, this.#keyPrefix)}`
769
+ );
770
+ }
771
+ async #send(request) {
772
+ const failures = [];
773
+ for (let attempt = 1; attempt <= this.#maxAttempts; attempt += 1) {
774
+ const signed = await this.#client.sign(request.clone(), {
775
+ aws: { datetime: this.#datetime() }
776
+ });
777
+ try {
778
+ const response = await this.#fetch(signed);
779
+ if (response.status !== 429 && response.status < 500) {
780
+ return { response, attempts: attempt };
781
+ }
782
+ failures.push(`HTTP ${response.status}`);
783
+ } catch (cause) {
784
+ failures.push(cause instanceof Error ? cause.message : String(cause));
785
+ }
786
+ if (attempt < this.#maxAttempts) {
787
+ await new Promise((resolve) => {
788
+ setTimeout(resolve, this.#retryDelayMs * 2 ** (attempt - 1));
789
+ });
790
+ }
791
+ }
792
+ throw new ObjectStoreRequestError(
793
+ `The object store failed ${this.#maxAttempts} attempt(s): ${failures.join(", ")}.`,
794
+ this.#maxAttempts
795
+ );
796
+ }
797
+ #datetime() {
798
+ return this.#signingClock.now().toISOString().replaceAll(/[:-]|\.\d{3}/g, "");
799
+ }
800
+ };
801
+ var SAFE_TENANT_SEGMENT = /^[A-Za-z0-9._~-]+$/;
802
+ function assertKeySegmentTenant(tenant) {
803
+ if (SAFE_TENANT_SEGMENT.test(tenant) && !tenant.startsWith(".")) return;
804
+ throw new R2BlobStoreError(
805
+ "storage_tenant_key_unsafe",
806
+ `Tenant id ${JSON.stringify(tenant)} is not a single safe object-key segment, so no key can be built for it.`
807
+ );
808
+ }
809
+ function resolveKeyPrefix(keyPrefix) {
810
+ return keyPrefix === void 0 ? void 0 : objectKeyPrefix(keyPrefix);
811
+ }
812
+ function assertPresignTtl(seconds) {
813
+ if (Number.isInteger(seconds) && seconds >= MIN_PRESIGN_TTL_SECONDS && seconds <= MAX_PRESIGN_TTL_SECONDS) {
814
+ return seconds;
815
+ }
816
+ throw new R2BlobStoreError(
817
+ "storage_presign_ttl_invalid",
818
+ `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.`
819
+ );
820
+ }
821
+ var LIST_OBJECTS_XML = new XMLParser({
822
+ ignoreAttributes: true,
823
+ parseTagValue: false,
824
+ trimValues: true,
825
+ processEntities: false
826
+ });
827
+ function parseListObjectsV2(xml, prefix, attempts) {
828
+ let parsed;
829
+ try {
830
+ parsed = LIST_OBJECTS_XML.parse(xml);
831
+ } catch (cause) {
832
+ throw new ObjectStoreRequestError(
833
+ "ListObjectsV2 returned malformed XML.",
834
+ attempts,
835
+ void 0,
836
+ { cause }
837
+ );
838
+ }
839
+ const document = asRecord(parsed);
840
+ const root = asRecord(document?.ListBucketResult);
841
+ if (root === void 0) {
842
+ throw new ObjectStoreRequestError(
843
+ "ListObjectsV2 XML omitted ListBucketResult.",
844
+ attempts
845
+ );
846
+ }
847
+ const isTruncated = requiredText(root, "IsTruncated", attempts);
848
+ if (isTruncated !== "true" && isTruncated !== "false") {
849
+ throw new ObjectStoreRequestError(
850
+ `ListObjectsV2 XML contained invalid IsTruncated=${JSON.stringify(isTruncated)}.`,
851
+ attempts
852
+ );
853
+ }
854
+ const rawContents = root.Contents === void 0 ? [] : Array.isArray(root.Contents) ? root.Contents : [root.Contents];
855
+ const objects = rawContents.map((raw, index) => {
856
+ const content = asRecord(raw);
857
+ if (content === void 0) {
858
+ throw new ObjectStoreRequestError(
859
+ `ListObjectsV2 XML contained a non-object Contents entry at index ${String(index)}.`,
860
+ attempts
861
+ );
862
+ }
863
+ const key = requiredText(content, "Key", attempts);
864
+ const sizeText = requiredText(content, "Size", attempts);
865
+ let byteSize;
866
+ try {
867
+ byteSize = BigInt(sizeText);
868
+ } catch (cause) {
869
+ throw new ObjectStoreRequestError(
870
+ `ListObjectsV2 XML contained invalid Size=${JSON.stringify(sizeText)}.`,
871
+ attempts,
872
+ void 0,
873
+ { cause }
874
+ );
875
+ }
876
+ if (byteSize < 0n) {
877
+ throw new ObjectStoreRequestError(
878
+ `ListObjectsV2 XML contained negative Size=${sizeText}.`,
879
+ attempts
880
+ );
881
+ }
882
+ const suffix = key.startsWith(prefix) ? key.slice(prefix.length) : "";
883
+ return {
884
+ key,
885
+ byteSize,
886
+ ...HASH_KEY_SUFFIX.test(suffix) ? { hash: contentHash(`sha256:${suffix}`) } : {}
887
+ };
888
+ });
889
+ if (isTruncated === "false") return { objects };
890
+ const nextContinuationToken = requiredText(root, "NextContinuationToken", attempts);
891
+ if (nextContinuationToken.length === 0) {
892
+ throw new ObjectStoreRequestError(
893
+ "ListObjectsV2 XML was truncated but its continuation token was empty.",
894
+ attempts
895
+ );
896
+ }
897
+ return { objects, nextContinuationToken };
898
+ }
899
+ var HASH_KEY_SUFFIX = /^[0-9a-f]{64}$/;
900
+ function asRecord(value) {
901
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
902
+ }
903
+ function requiredText(record, field, attempts) {
904
+ const value = record[field];
905
+ if (typeof value === "string") return value;
906
+ throw new ObjectStoreRequestError(
907
+ `ListObjectsV2 XML omitted text field ${field}.`,
908
+ attempts
909
+ );
910
+ }
911
+
912
+ // src/stores/devices.ts
913
+ function toRecord(row) {
914
+ return {
915
+ tenantId: row.tenant_id,
916
+ productId: row.product_id,
917
+ deviceId: row.device_id,
918
+ deviceName: row.device_name,
919
+ devicePublicKey: row.device_public_key,
920
+ proofKeyId: row.proof_key_id,
921
+ proofKeyEpoch: row.proof_key_epoch,
922
+ revoked: row.revoked
923
+ };
924
+ }
925
+ var SELECT_COLUMNS = "tenant_id, device_id, product_id, device_name, device_public_key, proof_key_id, proof_key_epoch, revoked";
926
+ var PostgresDeviceDirectory = class {
927
+ #pool;
928
+ constructor(pool) {
929
+ this.#pool = pool;
930
+ }
931
+ async register(tenant, input) {
932
+ const result = await this.#pool.query(
933
+ `INSERT INTO device (
934
+ tenant_id, device_id, product_id, device_name, device_public_key,
935
+ proof_key_id, proof_key_epoch, revoked
936
+ )
937
+ VALUES ($1, $2, $3, $4, $5, $6, $7, false)
938
+ ON CONFLICT (tenant_id, device_id) DO UPDATE
939
+ SET product_id = EXCLUDED.product_id,
940
+ device_name = EXCLUDED.device_name,
941
+ device_public_key = EXCLUDED.device_public_key,
942
+ proof_key_id = EXCLUDED.proof_key_id,
943
+ proof_key_epoch = EXCLUDED.proof_key_epoch,
944
+ revoked = false
945
+ RETURNING ${SELECT_COLUMNS}`,
946
+ [
947
+ tenant,
948
+ input.deviceId,
949
+ input.productId,
950
+ input.deviceName,
951
+ input.devicePublicKey,
952
+ input.proofKeyId,
953
+ input.proofKeyEpoch
954
+ ]
955
+ );
956
+ return toRecord(result.rows[0]);
957
+ }
958
+ async get(tenant, deviceId) {
959
+ const result = await this.#pool.query(
960
+ `SELECT ${SELECT_COLUMNS} FROM device WHERE tenant_id = $1 AND device_id = $2`,
961
+ [tenant, deviceId]
962
+ );
963
+ const row = result.rows[0];
964
+ return row === void 0 ? void 0 : toRecord(row);
965
+ }
966
+ async revoke(tenant, deviceId) {
967
+ await this.#pool.query("UPDATE device SET revoked = true WHERE tenant_id = $1 AND device_id = $2", [
968
+ tenant,
969
+ deviceId
970
+ ]);
971
+ }
972
+ async list(tenant) {
973
+ const result = await this.#pool.query(
974
+ `SELECT ${SELECT_COLUMNS} FROM device WHERE tenant_id = $1 ORDER BY device_id`,
975
+ [tenant]
976
+ );
977
+ return result.rows.map(toRecord);
978
+ }
979
+ async resolveByDeviceId(deviceId) {
980
+ const result = await this.#pool.query(
981
+ `SELECT ${SELECT_COLUMNS} FROM device WHERE device_id = $1`,
982
+ [deviceId]
983
+ );
984
+ const row = result.rows[0];
985
+ return row === void 0 ? void 0 : toRecord(row);
986
+ }
987
+ };
988
+ var PostgresInboundDedupStore = class {
989
+ #pool;
990
+ #capacity;
991
+ constructor(pool, capacity = DEDUP_RING_CAPACITY) {
992
+ this.#pool = pool;
993
+ this.#capacity = capacity;
994
+ }
995
+ async checkAndRecord(tenant, deviceId, envelopeId) {
996
+ const inserted = await this.#pool.query(
997
+ `INSERT INTO inbound_dedup (tenant_id, device_id, envelope_id)
998
+ VALUES ($1, $2, $3)
999
+ ON CONFLICT (tenant_id, device_id, envelope_id) DO NOTHING
1000
+ RETURNING 1`,
1001
+ [tenant, deviceId, envelopeId]
1002
+ );
1003
+ if (inserted.rowCount === 0) return true;
1004
+ await this.#pool.query(
1005
+ `DELETE FROM inbound_dedup
1006
+ WHERE tenant_id = $1 AND device_id = $2
1007
+ AND recorded_seq <= (
1008
+ SELECT recorded_seq FROM inbound_dedup
1009
+ WHERE tenant_id = $1 AND device_id = $2
1010
+ ORDER BY recorded_seq DESC
1011
+ OFFSET $3 LIMIT 1)`,
1012
+ [tenant, deviceId, this.#capacity]
1013
+ );
1014
+ return false;
1015
+ }
1016
+ };
1017
+ var NONCE_BYTES = 24;
1018
+ var PostgresNonceStore = class {
1019
+ #pool;
1020
+ #clock;
1021
+ #crypto;
1022
+ #ttlMs;
1023
+ constructor(pool, clock, crypto, ttlMs = NONCE_TTL_MS) {
1024
+ this.#pool = pool;
1025
+ this.#clock = clock;
1026
+ this.#crypto = crypto;
1027
+ this.#ttlMs = ttlMs;
1028
+ }
1029
+ async issue(tenant, deviceId) {
1030
+ const nowMs = this.#clock.now().getTime();
1031
+ const now = new Date(nowMs).toISOString();
1032
+ await this.#pool.query(
1033
+ "DELETE FROM auth_nonce WHERE tenant_id = $1 AND device_id = $2 AND (used OR expires_at < $3)",
1034
+ [tenant, deviceId, now]
1035
+ );
1036
+ const nonce = this.#crypto.randomToken(NONCE_BYTES);
1037
+ await this.#pool.query(
1038
+ `INSERT INTO auth_nonce (tenant_id, device_id, nonce, expires_at, used)
1039
+ VALUES ($1, $2, $3, $4, false)`,
1040
+ [tenant, deviceId, nonce, new Date(nowMs + this.#ttlMs).toISOString()]
1041
+ );
1042
+ return nonce;
1043
+ }
1044
+ async validate(tenant, deviceId, nonce) {
1045
+ const result = await this.#pool.query(
1046
+ `SELECT 1 FROM auth_nonce
1047
+ WHERE tenant_id = $1 AND device_id = $2 AND nonce = $3
1048
+ AND used = false AND expires_at >= $4`,
1049
+ [tenant, deviceId, nonce, this.#clock.now().toISOString()]
1050
+ );
1051
+ return result.rowCount === 1;
1052
+ }
1053
+ async markUsed(tenant, nonce) {
1054
+ await this.#pool.query("UPDATE auth_nonce SET used = true WHERE tenant_id = $1 AND nonce = $2", [
1055
+ tenant,
1056
+ nonce
1057
+ ]);
1058
+ }
1059
+ };
1060
+
1061
+ // src/stores/pairing-codes.ts
1062
+ var PostgresPairingCodeStore = class {
1063
+ #pool;
1064
+ #clock;
1065
+ constructor(pool, clock) {
1066
+ this.#pool = pool;
1067
+ this.#clock = clock;
1068
+ }
1069
+ async issue(tenant, input) {
1070
+ await this.#pool.query(
1071
+ `INSERT INTO pairing_code (code, tenant_id, product_id, expires_at, redeemed_at)
1072
+ VALUES ($1, $2, $3, $4, NULL)
1073
+ ON CONFLICT (code) DO UPDATE
1074
+ SET tenant_id = EXCLUDED.tenant_id,
1075
+ product_id = EXCLUDED.product_id,
1076
+ expires_at = EXCLUDED.expires_at,
1077
+ redeemed_at = NULL`,
1078
+ [input.code, tenant, input.productId, input.expiresAt]
1079
+ );
1080
+ return { code: input.code, expiresAt: input.expiresAt };
1081
+ }
1082
+ async redeem(code) {
1083
+ const now = this.#clock.now().toISOString();
1084
+ const result = await this.#pool.query(
1085
+ `UPDATE pairing_code
1086
+ SET redeemed_at = $2
1087
+ WHERE code = $1 AND redeemed_at IS NULL AND expires_at >= $2
1088
+ RETURNING tenant_id, product_id`,
1089
+ [code, now]
1090
+ );
1091
+ const row = result.rows[0];
1092
+ if (row === void 0) return void 0;
1093
+ return { tenantId: row.tenant_id, productId: row.product_id };
1094
+ }
1095
+ };
1096
+
1097
+ // src/stores/receipts.ts
1098
+ var SELECT_COLUMNS2 = "tenant_id, key, body, recorded_at";
1099
+ function toReceipt(row) {
1100
+ return {
1101
+ tenantId: row.tenant_id,
1102
+ key: row.key,
1103
+ body: row.body,
1104
+ recordedAt: row.recorded_at.toISOString()
1105
+ };
1106
+ }
1107
+ var PostgresRequestReceiptStore = class {
1108
+ #pool;
1109
+ #clock;
1110
+ constructor(pool, clock) {
1111
+ this.#pool = pool;
1112
+ this.#clock = clock;
1113
+ }
1114
+ async record(tenant, input) {
1115
+ const inserted = await this.#pool.query(
1116
+ `INSERT INTO device_request_receipts (tenant_id, key, body, recorded_at)
1117
+ VALUES ($1, $2, $3, $4)
1118
+ ON CONFLICT (tenant_id, key) DO NOTHING
1119
+ RETURNING ${SELECT_COLUMNS2}`,
1120
+ [tenant, input.key, input.body, this.#clock.now().toISOString()]
1121
+ );
1122
+ const created = inserted.rows[0];
1123
+ if (created !== void 0) return { receipt: toReceipt(created), created: true };
1124
+ const existing = await this.get(tenant, input.key);
1125
+ if (existing === void 0) throw new Error(`receipt ${input.key} vanished during record`);
1126
+ return { receipt: existing, created: false };
1127
+ }
1128
+ async get(tenant, key) {
1129
+ const result = await this.#pool.query(
1130
+ `SELECT ${SELECT_COLUMNS2} FROM device_request_receipts WHERE tenant_id = $1 AND key = $2`,
1131
+ [tenant, key]
1132
+ );
1133
+ const row = result.rows[0];
1134
+ return row === void 0 ? void 0 : toReceipt(row);
1135
+ }
1136
+ };
1137
+
1138
+ // src/stores/proof-receipts.ts
1139
+ var SELECT_COLUMNS3 = `tenant_id, device_id, request_id, operation, resource,
1140
+ body_sha256, body_size, response_status, response_body, recorded_at`;
1141
+ function toReceipt2(row) {
1142
+ return {
1143
+ tenantId: row.tenant_id,
1144
+ deviceId: row.device_id,
1145
+ requestId: row.request_id,
1146
+ operation: row.operation,
1147
+ resource: row.resource,
1148
+ bodySha256: row.body_sha256,
1149
+ bodySize: BigInt(row.body_size),
1150
+ responseStatus: row.response_status,
1151
+ responseBody: row.response_body,
1152
+ recordedAt: row.recorded_at.toISOString()
1153
+ };
1154
+ }
1155
+ var PostgresProofRequestReceiptStore = class {
1156
+ #pool;
1157
+ #clock;
1158
+ constructor(pool, clock) {
1159
+ this.#pool = pool;
1160
+ this.#clock = clock;
1161
+ }
1162
+ async record(tenant, input) {
1163
+ const result = await this.#pool.query(
1164
+ `INSERT INTO proof_request_receipt (
1165
+ tenant_id, device_id, request_id, operation, resource, body_sha256,
1166
+ body_size, response_status, response_body, recorded_at
1167
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
1168
+ ON CONFLICT (tenant_id, device_id, request_id) DO NOTHING
1169
+ RETURNING ${SELECT_COLUMNS3}`,
1170
+ [
1171
+ tenant,
1172
+ input.deviceId,
1173
+ input.requestId,
1174
+ input.operation,
1175
+ input.resource,
1176
+ input.bodySha256,
1177
+ input.bodySize.toString(),
1178
+ input.responseStatus,
1179
+ input.responseBody,
1180
+ this.#clock.now().toISOString()
1181
+ ]
1182
+ );
1183
+ const inserted = result.rows[0];
1184
+ if (inserted !== void 0) return { receipt: toReceipt2(inserted), created: true };
1185
+ const existing = await this.get(tenant, input.deviceId, input.requestId);
1186
+ if (existing === void 0) throw new Error(`proof receipt ${input.requestId} vanished during record`);
1187
+ return { receipt: existing, created: false };
1188
+ }
1189
+ async get(tenant, deviceId, requestId) {
1190
+ const result = await this.#pool.query(
1191
+ `SELECT ${SELECT_COLUMNS3}
1192
+ FROM proof_request_receipt
1193
+ WHERE tenant_id = $1 AND device_id = $2 AND request_id = $3`,
1194
+ [tenant, deviceId, requestId]
1195
+ );
1196
+ const row = result.rows[0];
1197
+ return row === void 0 ? void 0 : toReceipt2(row);
1198
+ }
1199
+ };
1200
+
1201
+ // src/stores/task-attempts.ts
1202
+ var SELECT_COLUMNS4 = "tenant_id, task_id, device_id, owner_device_id, status, updated_at";
1203
+ function toAttempt(row) {
1204
+ return {
1205
+ tenantId: row.tenant_id,
1206
+ taskId: row.task_id,
1207
+ deviceId: row.device_id,
1208
+ // `exactOptionalPropertyTypes` is off here, but an explicit absent key is
1209
+ // still what the in-memory reference produces for an unclaimed attempt, and
1210
+ // `toEqual` in the suite treats `undefined` and absent alike only for the
1211
+ // former.
1212
+ ...row.owner_device_id === null ? {} : { ownerDeviceId: row.owner_device_id },
1213
+ status: row.status,
1214
+ updatedAt: row.updated_at.toISOString()
1215
+ };
1216
+ }
1217
+ var PostgresTaskAttemptStore = class {
1218
+ #pool;
1219
+ #clock;
1220
+ constructor(pool, clock) {
1221
+ this.#pool = pool;
1222
+ this.#clock = clock;
1223
+ }
1224
+ async open(tenant, input) {
1225
+ const inserted = await this.#pool.query(
1226
+ `INSERT INTO task (tenant_id, task_id, device_id, owner_device_id, status, updated_at)
1227
+ VALUES ($1, $2, $3, NULL, 'offered', $4)
1228
+ ON CONFLICT (tenant_id, task_id) DO NOTHING
1229
+ RETURNING ${SELECT_COLUMNS4}`,
1230
+ [tenant, input.taskId, input.deviceId, this.#now()]
1231
+ );
1232
+ const created = inserted.rows[0];
1233
+ if (created !== void 0) return toAttempt(created);
1234
+ const existing = await this.get(tenant, input.taskId);
1235
+ if (existing === void 0) throw new Error(`task ${input.taskId} vanished during open`);
1236
+ return existing;
1237
+ }
1238
+ async get(tenant, taskId) {
1239
+ const result = await this.#pool.query(
1240
+ `SELECT ${SELECT_COLUMNS4} FROM task WHERE tenant_id = $1 AND task_id = $2`,
1241
+ [tenant, taskId]
1242
+ );
1243
+ const row = result.rows[0];
1244
+ return row === void 0 ? void 0 : toAttempt(row);
1245
+ }
1246
+ async claim(tenant, input) {
1247
+ const claimed = await this.#pool.query(
1248
+ `UPDATE task
1249
+ SET owner_device_id = $3, status = 'claimed', updated_at = $4
1250
+ WHERE tenant_id = $1 AND task_id = $2 AND owner_device_id IS NULL
1251
+ RETURNING ${SELECT_COLUMNS4}`,
1252
+ [tenant, input.taskId, input.deviceId, this.#now()]
1253
+ );
1254
+ const won = claimed.rows[0];
1255
+ if (won !== void 0) return toAttempt(won);
1256
+ return this.get(tenant, input.taskId);
1257
+ }
1258
+ async recordStatus(tenant, input) {
1259
+ const result = await this.#pool.query(
1260
+ `UPDATE task
1261
+ SET status = $3, updated_at = $4
1262
+ WHERE tenant_id = $1 AND task_id = $2
1263
+ RETURNING ${SELECT_COLUMNS4}`,
1264
+ [tenant, input.taskId, input.status, this.#now()]
1265
+ );
1266
+ const row = result.rows[0];
1267
+ return row === void 0 ? void 0 : toAttempt(row);
1268
+ }
1269
+ #now() {
1270
+ return this.#clock.now().toISOString();
1271
+ }
1272
+ };
1273
+
1274
+ // src/stores/index.ts
1275
+ function createPostgresCloudStores(options) {
1276
+ const { pool, clock, crypto } = options;
1277
+ return {
1278
+ devices: new PostgresDeviceDirectory(pool),
1279
+ pairingCodes: new PostgresPairingCodeStore(pool, clock),
1280
+ nonces: new PostgresNonceStore(pool, clock, crypto),
1281
+ dedup: new PostgresInboundDedupStore(pool),
1282
+ tasks: new PostgresTaskAttemptStore(pool, clock),
1283
+ receipts: new PostgresRequestReceiptStore(pool, clock),
1284
+ proofReceipts: new PostgresProofRequestReceiptStore(pool, clock),
1285
+ // A second `PostgresObjectStore` instance, not a shared one: it is a
1286
+ // stateless wrapper over the pool, so the two read and write the same rows
1287
+ // under the same locks, and requiring the caller to build the core
1288
+ // composition first would be an ordering dependency bought for nothing.
1289
+ blobs: new R2CloudBlobStore({
1290
+ ...options.objectStorage,
1291
+ objects: new PostgresObjectStore(pool, clock)
1292
+ }),
1293
+ rateLimiter: new AllowAllRateLimiter()
1294
+ };
1295
+ }
1296
+ var PRESENCE_COLUMNS = "tenant_id, device_id, level, detail, configured_toolsets, observed_at, expires_at";
1297
+ function toHint(row) {
1298
+ return {
1299
+ tenantId: row.tenant_id,
1300
+ deviceId: row.device_id,
1301
+ level: row.level,
1302
+ ...row.detail === null ? {} : { detail: row.detail },
1303
+ ...row.configured_toolsets === null ? {} : { configuredToolsets: Object.freeze([...row.configured_toolsets]) },
1304
+ observedAt: row.observed_at,
1305
+ expiresAt: row.expires_at
1306
+ };
1307
+ }
1308
+ function toTail(row) {
1309
+ return {
1310
+ tenantId: row.tenant_id,
1311
+ taskId: row.task_id,
1312
+ entries: row.entries,
1313
+ dropped: row.dropped,
1314
+ capacity: row.capacity,
1315
+ expiresAt: row.expires_at
1316
+ };
1317
+ }
1318
+ function assertTtl(ttlMs) {
1319
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
1320
+ throw new ByokCoreError(
1321
+ "hint_ttl_invalid",
1322
+ `Hint ttl must be a positive number of milliseconds, received ${String(ttlMs)}.`
1323
+ );
1324
+ }
1325
+ }
1326
+ function assertMinimumInterval(minimumIntervalMs) {
1327
+ if (!Number.isFinite(minimumIntervalMs) || minimumIntervalMs < 0) {
1328
+ throw new ByokCoreError(
1329
+ "hint_ttl_invalid",
1330
+ `Hint minimum interval must be a non-negative number of milliseconds, received ${String(minimumIntervalMs)}.`
1331
+ );
1332
+ }
1333
+ }
1334
+ var PostgresPresenceStore = class {
1335
+ #pool;
1336
+ #clock;
1337
+ constructor(pool, clock) {
1338
+ this.#pool = pool;
1339
+ this.#clock = clock;
1340
+ }
1341
+ async publish(tenant, input) {
1342
+ assertTtl(input.ttlMs);
1343
+ assertMinimumInterval(input.minimumIntervalMs);
1344
+ const now = this.#clock.now();
1345
+ const observedAt = now.toISOString();
1346
+ const allowedBefore = new Date(now.getTime() - input.minimumIntervalMs).toISOString();
1347
+ const result = await this.#pool.query(
1348
+ `INSERT INTO device_presence (${PRESENCE_COLUMNS})
1349
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)
1350
+ ON CONFLICT (tenant_id, device_id) DO UPDATE
1351
+ SET level = EXCLUDED.level,
1352
+ detail = EXCLUDED.detail,
1353
+ configured_toolsets = EXCLUDED.configured_toolsets,
1354
+ observed_at = EXCLUDED.observed_at,
1355
+ expires_at = EXCLUDED.expires_at
1356
+ WHERE device_presence.expires_at <= EXCLUDED.observed_at
1357
+ OR device_presence.observed_at <= $8
1358
+ RETURNING ${PRESENCE_COLUMNS}`,
1359
+ [
1360
+ tenant,
1361
+ input.deviceId,
1362
+ input.level,
1363
+ input.detail ?? null,
1364
+ input.configuredToolsets === void 0 ? null : JSON.stringify(input.configuredToolsets),
1365
+ observedAt,
1366
+ new Date(now.getTime() + input.ttlMs).toISOString(),
1367
+ allowedBefore
1368
+ ]
1369
+ );
1370
+ const row = result.rows[0];
1371
+ if (row === void 0) {
1372
+ throw new ByokCoreError(
1373
+ "hint_rate_limited",
1374
+ `Presence for ${input.deviceId} was published more recently than the configured minimum interval.`
1375
+ );
1376
+ }
1377
+ return toHint(row);
1378
+ }
1379
+ async read(tenant, deviceId) {
1380
+ const result = await this.#pool.query(
1381
+ `SELECT ${PRESENCE_COLUMNS} FROM device_presence
1382
+ WHERE tenant_id = $1 AND device_id = $2 AND expires_at > $3`,
1383
+ [tenant, deviceId, this.#now()]
1384
+ );
1385
+ const row = result.rows[0];
1386
+ return row === void 0 ? void 0 : toHint(row);
1387
+ }
1388
+ async list(tenant) {
1389
+ const result = await this.#pool.query(
1390
+ `SELECT ${PRESENCE_COLUMNS} FROM device_presence
1391
+ WHERE tenant_id = $1 AND expires_at > $2
1392
+ ORDER BY device_id COLLATE "C"`,
1393
+ [tenant, this.#now()]
1394
+ );
1395
+ return result.rows.map(toHint);
1396
+ }
1397
+ #expiry(ttlMs) {
1398
+ return new Date(this.#clock.now().getTime() + ttlMs).toISOString();
1399
+ }
1400
+ #now() {
1401
+ return this.#clock.now().toISOString();
1402
+ }
1403
+ };
1404
+ var PostgresActivityStore = class {
1405
+ #pool;
1406
+ #clock;
1407
+ constructor(pool, clock) {
1408
+ this.#pool = pool;
1409
+ this.#clock = clock;
1410
+ }
1411
+ async append(tenant, input) {
1412
+ assertTtl(input.ttlMs);
1413
+ const capacity = input.capacity ?? DEFAULT_ACTIVITY_CAPACITY;
1414
+ if (!Number.isSafeInteger(capacity) || capacity <= 0) {
1415
+ throw new ByokCoreError(
1416
+ "activity_capacity_invalid",
1417
+ `Activity capacity must be a positive integer, received ${String(capacity)}.`
1418
+ );
1419
+ }
1420
+ if (input.details.length === 0 || !Number.isSafeInteger(input.dropped) || input.dropped < 0) {
1421
+ throw new ByokCoreError(
1422
+ "activity_batch_invalid",
1423
+ "Activity batches require at least one detail and a non-negative integer dropped count."
1424
+ );
1425
+ }
1426
+ const now = this.#now();
1427
+ const incoming = input.details.map((detail) => ({ at: now, detail }));
1428
+ const result = await this.#pool.query(
1429
+ `WITH trimmed AS (
1430
+ SELECT COALESCE(
1431
+ (SELECT jsonb_agg(entry ORDER BY ordinality)
1432
+ FROM jsonb_array_elements($4::jsonb)
1433
+ WITH ORDINALITY AS element(entry, ordinality)
1434
+ WHERE ordinality > GREATEST(jsonb_array_length($4::jsonb) - $6, 0)),
1435
+ '[]'::jsonb) AS entries,
1436
+ $5::integer + GREATEST(jsonb_array_length($4::jsonb) - $6, 0) AS dropped
1437
+ )
1438
+ INSERT INTO activity_tail (tenant_id, task_id, entries, dropped, capacity, expires_at)
1439
+ SELECT $1, $2, trimmed.entries, trimmed.dropped, $6, $7 FROM trimmed
1440
+ ON CONFLICT (tenant_id, task_id) DO UPDATE
1441
+ SET entries = (
1442
+ SELECT COALESCE(jsonb_agg(entry ORDER BY ordinality), '[]'::jsonb)
1443
+ FROM jsonb_array_elements(
1444
+ (CASE WHEN activity_tail.expires_at > $3
1445
+ THEN activity_tail.entries ELSE '[]'::jsonb END) || $4::jsonb
1446
+ ) WITH ORDINALITY AS element(entry, ordinality)
1447
+ WHERE ordinality > GREATEST(
1448
+ jsonb_array_length(
1449
+ (CASE WHEN activity_tail.expires_at > $3
1450
+ THEN activity_tail.entries ELSE '[]'::jsonb END) || $4::jsonb
1451
+ ) - $6,
1452
+ 0
1453
+ )
1454
+ ),
1455
+ dropped = (CASE WHEN activity_tail.expires_at > $3
1456
+ THEN activity_tail.dropped ELSE 0 END)
1457
+ + $5::integer
1458
+ + GREATEST(
1459
+ jsonb_array_length(
1460
+ (CASE WHEN activity_tail.expires_at > $3
1461
+ THEN activity_tail.entries ELSE '[]'::jsonb END) || $4::jsonb
1462
+ ) - $6,
1463
+ 0
1464
+ ),
1465
+ capacity = EXCLUDED.capacity,
1466
+ expires_at = EXCLUDED.expires_at
1467
+ RETURNING tenant_id, task_id, entries, dropped, capacity, expires_at`,
1468
+ [
1469
+ tenant,
1470
+ input.taskId,
1471
+ now,
1472
+ JSON.stringify(incoming),
1473
+ input.dropped,
1474
+ capacity,
1475
+ this.#expiry(input.ttlMs)
1476
+ ]
1477
+ );
1478
+ return toTail(result.rows[0]);
1479
+ }
1480
+ async read(tenant, taskId) {
1481
+ const result = await this.#pool.query(
1482
+ `SELECT tenant_id, task_id, entries, dropped, capacity, expires_at
1483
+ FROM activity_tail
1484
+ WHERE tenant_id = $1 AND task_id = $2 AND expires_at > $3`,
1485
+ [tenant, taskId, this.#now()]
1486
+ );
1487
+ const row = result.rows[0];
1488
+ return row === void 0 ? void 0 : toTail(row);
1489
+ }
1490
+ #expiry(ttlMs) {
1491
+ return new Date(this.#clock.now().getTime() + ttlMs).toISOString();
1492
+ }
1493
+ #now() {
1494
+ return this.#clock.now().toISOString();
1495
+ }
1496
+ };
1497
+ var DEFAULT_LIST_LIMIT2 = 50;
1498
+ var BOARD_COLUMNS = "tenant_id, item_id, channel, title, status, holder_id, held_since, board_seq, created_at, updated_at";
1499
+ function toItem(row) {
1500
+ return {
1501
+ tenantId: row.tenant_id,
1502
+ itemId: row.item_id,
1503
+ channel: row.channel,
1504
+ title: row.title,
1505
+ status: row.status,
1506
+ // An unheld item has NO assignee rather than an assignee with an empty
1507
+ // holder, which is what lets `expect(item.assignee).toBeUndefined()` mean
1508
+ // "nobody holds this" in both compositions.
1509
+ ...row.holder_id === null || row.held_since === null ? {} : { assignee: { holderId: row.holder_id, heldSince: row.held_since } },
1510
+ boardSeq: Number(row.board_seq),
1511
+ createdAt: row.created_at,
1512
+ updatedAt: row.updated_at
1513
+ };
1514
+ }
1515
+ var PostgresBoardStore = class {
1516
+ #pool;
1517
+ #clock;
1518
+ constructor(pool, clock) {
1519
+ this.#pool = pool;
1520
+ this.#clock = clock;
1521
+ }
1522
+ async create(tenant, input) {
1523
+ const now = this.#now();
1524
+ const boardSeq = await this.#allocateSeq(tenant);
1525
+ const inserted = await this.#pool.query(
1526
+ `INSERT INTO board_item (${BOARD_COLUMNS})
1527
+ VALUES ($1, $2, $3, $4, $5, NULL, NULL, $6::bigint, $7, $7)
1528
+ ON CONFLICT (tenant_id, item_id) DO NOTHING
1529
+ RETURNING ${BOARD_COLUMNS}`,
1530
+ [tenant, input.itemId, input.channel, input.title, input.status ?? "todo", boardSeq, now]
1531
+ );
1532
+ const row = inserted.rows[0];
1533
+ if (row === void 0) {
1534
+ throw new ByokCoreError(
1535
+ "board_item_exists",
1536
+ `Board item ${input.itemId} already exists in this tenant.`
1537
+ );
1538
+ }
1539
+ return toItem(row);
1540
+ }
1541
+ async get(tenant, itemId) {
1542
+ const result = await this.#pool.query(
1543
+ `SELECT ${BOARD_COLUMNS} FROM board_item WHERE tenant_id = $1 AND item_id = $2`,
1544
+ [tenant, itemId]
1545
+ );
1546
+ const row = result.rows[0];
1547
+ return row === void 0 ? void 0 : toItem(row);
1548
+ }
1549
+ async list(tenant, query) {
1550
+ const afterSeq = query.afterSeq ?? 0;
1551
+ const limit = query.limit ?? DEFAULT_LIST_LIMIT2;
1552
+ const result = await this.#pool.query(
1553
+ `SELECT ${BOARD_COLUMNS} FROM board_item
1554
+ WHERE tenant_id = $1
1555
+ AND board_seq > $2::bigint
1556
+ AND ($3::text IS NULL OR channel = $3::text)
1557
+ AND ($4::text IS NULL OR status = $4::text)
1558
+ ORDER BY board_seq
1559
+ LIMIT $5`,
1560
+ [tenant, afterSeq, query.channel ?? null, query.status ?? null, limit + 1]
1561
+ );
1562
+ const page = result.rows.slice(0, limit).map(toItem);
1563
+ return {
1564
+ items: page,
1565
+ nextSeq: page.at(-1)?.boardSeq ?? afterSeq,
1566
+ hasMore: result.rows.length > page.length
1567
+ };
1568
+ }
1569
+ async claim(tenant, input) {
1570
+ const expectedStatus = input.expectedStatus ?? "todo";
1571
+ const now = this.#now();
1572
+ const boardSeq = await this.#allocateSeq(tenant);
1573
+ const claimed = await this.#pool.query(
1574
+ `UPDATE board_item
1575
+ SET status = CASE WHEN status = 'todo' THEN 'in_progress' ELSE status END,
1576
+ holder_id = $3,
1577
+ held_since = $5,
1578
+ board_seq = $6::bigint,
1579
+ updated_at = $5
1580
+ WHERE tenant_id = $1 AND item_id = $2
1581
+ AND holder_id IS NULL
1582
+ AND status = $4::text
1583
+ AND status IN ('todo', 'in_progress')
1584
+ RETURNING ${BOARD_COLUMNS}`,
1585
+ [tenant, input.itemId, input.holderId, expectedStatus, now, boardSeq]
1586
+ );
1587
+ const won = claimed.rows[0];
1588
+ if (won !== void 0) return toItem(won);
1589
+ const current = await this.get(tenant, input.itemId);
1590
+ if (current === void 0) throw this.#itemNotFound(input.itemId);
1591
+ if (current.assignee !== void 0) {
1592
+ if (current.assignee.holderId === input.holderId) {
1593
+ if (input.expectedStatus !== void 0 && current.status !== input.expectedStatus) {
1594
+ throw this.#statusConflict(input.itemId, current, input.expectedStatus);
1595
+ }
1596
+ return current;
1597
+ }
1598
+ throw new CoreConflictError(
1599
+ "board_claim_conflict",
1600
+ `Board item ${input.itemId} is held by ${current.assignee.holderId}.`,
1601
+ current,
1602
+ this.#now()
1603
+ );
1604
+ }
1605
+ if (current.status !== expectedStatus) {
1606
+ throw this.#statusConflict(input.itemId, current, expectedStatus);
1607
+ }
1608
+ throw new CoreConflictError(
1609
+ "board_transition_invalid",
1610
+ `Board item ${input.itemId} cannot be claimed from ${current.status}.`,
1611
+ current,
1612
+ this.#now()
1613
+ );
1614
+ }
1615
+ async unclaim(tenant, input) {
1616
+ const now = this.#now();
1617
+ const boardSeq = await this.#allocateSeq(tenant);
1618
+ const released = await this.#pool.query(
1619
+ `UPDATE board_item
1620
+ SET status = CASE WHEN status = 'in_progress' THEN 'todo' ELSE status END,
1621
+ holder_id = NULL,
1622
+ held_since = NULL,
1623
+ board_seq = $4::bigint,
1624
+ updated_at = $5
1625
+ WHERE tenant_id = $1 AND item_id = $2 AND holder_id = $3
1626
+ RETURNING ${BOARD_COLUMNS}`,
1627
+ [tenant, input.itemId, input.holderId, boardSeq, now]
1628
+ );
1629
+ const row = released.rows[0];
1630
+ if (row !== void 0) return toItem(row);
1631
+ const current = await this.get(tenant, input.itemId);
1632
+ if (current === void 0) throw this.#itemNotFound(input.itemId);
1633
+ if (current.assignee === void 0) {
1634
+ throw new ByokCoreError(
1635
+ "board_not_held",
1636
+ `Board item ${input.itemId} is not held by anyone.`
1637
+ );
1638
+ }
1639
+ throw new CoreConflictError(
1640
+ "board_claim_conflict",
1641
+ `Board item ${input.itemId} is held by ${current.assignee.holderId}, not ${input.holderId}.`,
1642
+ current,
1643
+ this.#now()
1644
+ );
1645
+ }
1646
+ async updateStatus(tenant, input) {
1647
+ if (isLegalBoardTransition(input.expectedStatus, input.status)) {
1648
+ const now = this.#now();
1649
+ const boardSeq = await this.#allocateSeq(tenant);
1650
+ const updated = await this.#pool.query(
1651
+ `UPDATE board_item
1652
+ SET status = $4::text, board_seq = $6::bigint, updated_at = $7
1653
+ WHERE tenant_id = $1 AND item_id = $2
1654
+ AND status = $3::text
1655
+ AND ($5::text IS NULL OR holder_id = $5::text)
1656
+ RETURNING ${BOARD_COLUMNS}`,
1657
+ [
1658
+ tenant,
1659
+ input.itemId,
1660
+ input.expectedStatus,
1661
+ input.status,
1662
+ input.holderId ?? null,
1663
+ boardSeq,
1664
+ now
1665
+ ]
1666
+ );
1667
+ const row = updated.rows[0];
1668
+ if (row !== void 0) return toItem(row);
1669
+ }
1670
+ const current = await this.get(tenant, input.itemId);
1671
+ if (current === void 0) throw this.#itemNotFound(input.itemId);
1672
+ if (current.status !== input.expectedStatus) {
1673
+ throw this.#statusConflict(input.itemId, current, input.expectedStatus);
1674
+ }
1675
+ if (input.holderId !== void 0 && current.assignee?.holderId !== input.holderId) {
1676
+ throw new CoreConflictError(
1677
+ "board_claim_conflict",
1678
+ `Board item ${input.itemId} is not held by ${input.holderId}.`,
1679
+ current,
1680
+ this.#now()
1681
+ );
1682
+ }
1683
+ if (!isLegalBoardTransition(input.expectedStatus, input.status)) {
1684
+ throw new CoreConflictError(
1685
+ "board_transition_invalid",
1686
+ `${input.expectedStatus} to ${input.status} is not a legal board transition.`,
1687
+ current,
1688
+ this.#now()
1689
+ );
1690
+ }
1691
+ throw this.#statusConflict(input.itemId, current, input.expectedStatus);
1692
+ }
1693
+ /**
1694
+ * One statement, its own transaction, lock released immediately. See the file
1695
+ * header for why this is not a CTE inside the write it feeds.
1696
+ */
1697
+ async #allocateSeq(tenant) {
1698
+ const result = await this.#pool.query(
1699
+ `INSERT INTO tenant_stream (tenant_id, board_seq)
1700
+ VALUES ($1, 1)
1701
+ ON CONFLICT (tenant_id) DO UPDATE SET board_seq = tenant_stream.board_seq + 1
1702
+ RETURNING board_seq`,
1703
+ [tenant]
1704
+ );
1705
+ return result.rows[0].board_seq;
1706
+ }
1707
+ #itemNotFound(itemId) {
1708
+ return new ByokCoreError(
1709
+ "board_item_not_found",
1710
+ `Board item ${itemId} does not exist in this tenant.`
1711
+ );
1712
+ }
1713
+ #statusConflict(itemId, current, expected) {
1714
+ return new CoreConflictError(
1715
+ "board_status_conflict",
1716
+ `Board item ${itemId} is ${current.status}, not ${expected}.`,
1717
+ current,
1718
+ this.#now()
1719
+ );
1720
+ }
1721
+ #now() {
1722
+ return this.#clock.now().toISOString();
1723
+ }
1724
+ };
1725
+
1726
+ // src/stores/core/mailbox-sequence.ts
1727
+ async function allocateMailboxSequence(client, tenant, deviceId, now) {
1728
+ const allocation = await client.query(
1729
+ `INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
1730
+ VALUES ($1, $2, 2, 0, $3)
1731
+ ON CONFLICT (tenant_id, device_id) DO UPDATE
1732
+ SET next_seq = device_stream.next_seq + 1
1733
+ RETURNING next_seq - 1 AS seq`,
1734
+ [tenant, deviceId, now]
1735
+ );
1736
+ return Number(allocation.rows[0].seq);
1737
+ }
1738
+
1739
+ // src/stores/core/mailbox.ts
1740
+ var DEFAULT_READ_LIMIT = 50;
1741
+ var OUTBOX_COLUMNS = "tenant_id, device_id, seq, message_id, body, body_hash, byte_size, state, appended_at";
1742
+ function toMessage(row) {
1743
+ return {
1744
+ tenantId: row.tenant_id,
1745
+ deviceId: row.device_id,
1746
+ // `seq` is bigint in the column and `number` on the port, because it is the
1747
+ // envelope `seq` on the wire. The column is wide so the counter cannot wrap
1748
+ // into a redelivery bug; the narrowing happens once, here.
1749
+ seq: Number(row.seq),
1750
+ messageId: row.message_id,
1751
+ body: row.body,
1752
+ bodyHash: row.body_hash,
1753
+ byteSize: row.byte_size,
1754
+ state: row.state,
1755
+ appendedAt: row.appended_at
1756
+ };
1757
+ }
1758
+ var PostgresMailboxStore = class {
1759
+ #pool;
1760
+ #clock;
1761
+ constructor(pool, clock) {
1762
+ this.#pool = pool;
1763
+ this.#clock = clock;
1764
+ }
1765
+ async append(tenant, input) {
1766
+ this.#requireDeviceId(input.deviceId);
1767
+ const client = await this.#pool.connect();
1768
+ try {
1769
+ await client.query("BEGIN");
1770
+ const existing = await client.query(
1771
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1772
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1773
+ [tenant, input.deviceId, input.messageId]
1774
+ );
1775
+ const replayed = existing.rows[0];
1776
+ if (replayed !== void 0) {
1777
+ await client.query("COMMIT");
1778
+ return toMessage(replayed);
1779
+ }
1780
+ const seq = await allocateMailboxSequence(client, tenant, input.deviceId, this.#now());
1781
+ const serializedExisting = await client.query(
1782
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1783
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1784
+ [tenant, input.deviceId, input.messageId]
1785
+ );
1786
+ const winnerAfterLock = serializedExisting.rows[0];
1787
+ if (winnerAfterLock !== void 0) {
1788
+ await client.query("ROLLBACK");
1789
+ return toMessage(winnerAfterLock);
1790
+ }
1791
+ const materialized = await input.materialize(seq);
1792
+ const now = this.#now();
1793
+ const inserted = await client.query(
1794
+ `INSERT INTO outbox (${OUTBOX_COLUMNS})
1795
+ VALUES ($1, $2, $3::bigint, $4, $5, $6, $7::bigint, 'pending', $8)
1796
+ ON CONFLICT (tenant_id, device_id, message_id) DO NOTHING
1797
+ RETURNING ${OUTBOX_COLUMNS}`,
1798
+ [
1799
+ tenant,
1800
+ input.deviceId,
1801
+ seq,
1802
+ input.messageId,
1803
+ materialized.body,
1804
+ materialized.bodyHash,
1805
+ materialized.byteSize,
1806
+ now
1807
+ ]
1808
+ );
1809
+ const row = inserted.rows[0];
1810
+ if (row !== void 0) {
1811
+ await client.query("COMMIT");
1812
+ return toMessage(row);
1813
+ }
1814
+ const winner = await client.query(
1815
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1816
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1817
+ [tenant, input.deviceId, input.messageId]
1818
+ );
1819
+ await client.query("ROLLBACK");
1820
+ const won = winner.rows[0];
1821
+ if (won === void 0) {
1822
+ throw new ByokCoreError(
1823
+ "mailbox_message_not_found",
1824
+ `Message ${input.messageId} vanished during an idempotent append.`
1825
+ );
1826
+ }
1827
+ return toMessage(won);
1828
+ } catch (cause) {
1829
+ await client.query("ROLLBACK").catch(() => {
1830
+ });
1831
+ throw cause;
1832
+ } finally {
1833
+ client.release();
1834
+ }
1835
+ }
1836
+ async readAfter(tenant, query) {
1837
+ const limit = query.limit ?? DEFAULT_READ_LIMIT;
1838
+ const result = await this.#pool.query(
1839
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1840
+ WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending' AND seq > $3::bigint
1841
+ ORDER BY seq
1842
+ LIMIT $4`,
1843
+ [tenant, query.deviceId, query.afterSeq, limit + 1]
1844
+ );
1845
+ const page = result.rows.slice(0, limit).map(toMessage);
1846
+ return {
1847
+ messages: page,
1848
+ // Nothing above was mutated, so an identical call replays the same page.
1849
+ // The returned position is a READ cursor and moves no ack.
1850
+ nextSeq: page.at(-1)?.seq ?? query.afterSeq,
1851
+ hasMore: result.rows.length > page.length
1852
+ };
1853
+ }
1854
+ async advanceCursor(tenant, input) {
1855
+ this.#requireDeviceId(input.deviceId);
1856
+ const now = this.#now();
1857
+ const moved = await this.#pool.query(
1858
+ `WITH moved AS (
1859
+ INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
1860
+ VALUES ($1, $2, 1, $3::bigint, $4)
1861
+ ON CONFLICT (tenant_id, device_id) DO UPDATE
1862
+ SET acked_seq = EXCLUDED.acked_seq, acked_at = EXCLUDED.acked_at
1863
+ WHERE device_stream.acked_seq <= EXCLUDED.acked_seq
1864
+ RETURNING acked_seq, acked_at
1865
+ ), marked AS (
1866
+ UPDATE outbox
1867
+ SET state = 'acked'
1868
+ WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending'
1869
+ AND seq <= (SELECT acked_seq FROM moved)
1870
+ RETURNING 1
1871
+ )
1872
+ SELECT acked_seq, acked_at FROM moved`,
1873
+ [tenant, input.deviceId, input.ackedSeq, now]
1874
+ );
1875
+ const row = moved.rows[0];
1876
+ if (row !== void 0) {
1877
+ return {
1878
+ tenantId: tenant,
1879
+ deviceId: input.deviceId,
1880
+ ackedSeq: Number(row.acked_seq),
1881
+ updatedAt: row.acked_at ?? now
1882
+ };
1883
+ }
1884
+ const current = await this.readCursor(tenant, input.deviceId);
1885
+ throw new CoreConflictError(
1886
+ "mailbox_cursor_regression",
1887
+ `Cursor for device ${input.deviceId} is at ${current.ackedSeq}; refusing to move it back to ${input.ackedSeq}.`,
1888
+ current,
1889
+ this.#now()
1890
+ );
1891
+ }
1892
+ async readCursor(tenant, deviceId) {
1893
+ const result = await this.#pool.query(
1894
+ `SELECT acked_seq, acked_at FROM device_stream
1895
+ WHERE tenant_id = $1 AND device_id = $2`,
1896
+ [tenant, deviceId]
1897
+ );
1898
+ const row = result.rows[0];
1899
+ return {
1900
+ tenantId: tenant,
1901
+ deviceId,
1902
+ ackedSeq: row === void 0 ? 0 : Number(row.acked_seq),
1903
+ updatedAt: row?.acked_at ?? this.#now()
1904
+ };
1905
+ }
1906
+ async collectRetired(tenant, input) {
1907
+ assertCanonicalTimestamp(input.ackedBefore, "ackedBefore");
1908
+ assertCanonicalTimestamp(input.expireUnackedBefore, "expireUnackedBefore");
1909
+ const swept = await this.#pool.query(
1910
+ `WITH deleted AS (
1911
+ DELETE FROM outbox
1912
+ WHERE tenant_id = $1
1913
+ AND ($2::text IS NULL OR device_id = $2::text)
1914
+ AND state = 'acked'
1915
+ AND appended_at < $3
1916
+ RETURNING byte_size
1917
+ ), expired AS (
1918
+ UPDATE outbox
1919
+ SET state = 'expired'
1920
+ WHERE tenant_id = $1
1921
+ AND ($2::text IS NULL OR device_id = $2::text)
1922
+ AND state = 'pending'
1923
+ AND appended_at < $4
1924
+ RETURNING 1
1925
+ )
1926
+ SELECT (SELECT count(*) FROM deleted) AS deleted_count,
1927
+ (SELECT count(*) FROM expired) AS expired_count,
1928
+ (SELECT COALESCE(SUM(byte_size), 0) FROM deleted)::bigint AS released_bytes`,
1929
+ [tenant, input.deviceId ?? null, input.ackedBefore, input.expireUnackedBefore]
1930
+ );
1931
+ const row = swept.rows[0];
1932
+ return {
1933
+ deletedCount: Number(row.deleted_count),
1934
+ expiredCount: Number(row.expired_count),
1935
+ releasedBytes: row.released_bytes
1936
+ };
1937
+ }
1938
+ /**
1939
+ * The in-memory reference refuses an empty device id rather than opening a
1940
+ * mailbox nothing can address. Kept here so the two compositions answer the
1941
+ * same way; the table itself would happily store the row.
1942
+ */
1943
+ #requireDeviceId(deviceId) {
1944
+ if (deviceId.length === 0) {
1945
+ throw new ByokCoreError("mailbox_message_not_found", "Device id must not be empty.");
1946
+ }
1947
+ }
1948
+ #now() {
1949
+ return this.#clock.now().toISOString();
1950
+ }
1951
+ };
1952
+ var WARNING_NUMERATOR = 80n;
1953
+ var WARNING_DENOMINATOR = 100n;
1954
+ var ENTITLEMENT_COLUMNS = "tenant_id, version, hard_limit_bytes, max_object_bytes, max_inline_bytes, mailbox_limit_bytes, retention_policy_id, downgrade_grace_until";
1955
+ var RESERVATION_COLUMNS = "tenant_id, reservation_id, state, kind, expected_bytes, content_hash, content_type, created_at, expires_at, settled_at, deduplicated";
1956
+ var QUALIFIED_RESERVATION_COLUMNS = RESERVATION_COLUMNS.split(", ").map((column) => `r.${column}`).join(", ");
1957
+ function toEntitlement(row) {
1958
+ return {
1959
+ tenantId: row.tenant_id,
1960
+ version: row.version,
1961
+ hardLimitBytes: row.hard_limit_bytes,
1962
+ maxObjectBytes: row.max_object_bytes,
1963
+ maxInlineBytes: row.max_inline_bytes,
1964
+ mailboxLimitBytes: row.mailbox_limit_bytes,
1965
+ retentionPolicyId: row.retention_policy_id,
1966
+ ...row.downgrade_grace_until === null ? {} : { downgradeGraceUntil: row.downgrade_grace_until }
1967
+ };
1968
+ }
1969
+ function toReservation(row) {
1970
+ return {
1971
+ tenantId: row.tenant_id,
1972
+ reservationId: row.reservation_id,
1973
+ state: row.state,
1974
+ kind: row.kind,
1975
+ expectedBytes: row.expected_bytes,
1976
+ contentHash: row.content_hash,
1977
+ contentType: row.content_type,
1978
+ createdAt: row.created_at,
1979
+ expiresAt: row.expires_at,
1980
+ ...row.settled_at === null ? {} : { settledAt: row.settled_at }
1981
+ };
1982
+ }
1983
+ function toUsage(row) {
1984
+ return {
1985
+ committedObjectBytes: row.committed_object_bytes,
1986
+ committedInlineBytes: row.committed_inline_bytes,
1987
+ reservedBytes: row.reserved_bytes,
1988
+ mailboxBytes: row.mailbox_bytes,
1989
+ objectCount: row.object_count,
1990
+ updatedAt: row.updated_at
1991
+ };
1992
+ }
1993
+ var USAGE_SQL = `
1994
+ SELECT
1995
+ COALESCE(u.committed_object_bytes, 0)::bigint AS committed_object_bytes,
1996
+ COALESCE(u.committed_inline_bytes, 0)::bigint AS committed_inline_bytes,
1997
+ COALESCE(u.mailbox_bytes, 0)::bigint AS mailbox_bytes,
1998
+ COALESCE(u.object_count, 0)::bigint AS object_count,
1999
+ COALESCE(u.updated_at, $2) AS updated_at,
2000
+ COALESCE((SELECT SUM(r.expected_bytes) FROM storage_reservation r
2001
+ WHERE r.tenant_id = $1 AND r.state = 'reserved'), 0)::bigint AS reserved_bytes
2002
+ FROM (SELECT $1::text AS tenant_id) AS scope
2003
+ LEFT JOIN storage_usage u ON u.tenant_id = scope.tenant_id`;
2004
+ var PostgresQuotaStore = class {
2005
+ #pool;
2006
+ #clock;
2007
+ constructor(pool, clock) {
2008
+ this.#pool = pool;
2009
+ this.#clock = clock;
2010
+ }
2011
+ async readEntitlement(tenant) {
2012
+ const result = await this.#pool.query(
2013
+ `SELECT ${ENTITLEMENT_COLUMNS} FROM storage_entitlement WHERE tenant_id = $1`,
2014
+ [tenant]
2015
+ );
2016
+ const row = result.rows[0];
2017
+ return row === void 0 ? void 0 : toEntitlement(row);
2018
+ }
2019
+ async writeEntitlement(tenant, input) {
2020
+ if (input.downgradeGraceUntil !== void 0) {
2021
+ assertCanonicalTimestamp(input.downgradeGraceUntil, "downgradeGraceUntil");
2022
+ }
2023
+ const applied = await this.#pool.query(
2024
+ `WITH applied AS (
2025
+ INSERT INTO storage_entitlement (${ENTITLEMENT_COLUMNS})
2026
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
2027
+ ON CONFLICT (tenant_id) DO UPDATE
2028
+ SET version = EXCLUDED.version,
2029
+ hard_limit_bytes = EXCLUDED.hard_limit_bytes,
2030
+ max_object_bytes = EXCLUDED.max_object_bytes,
2031
+ max_inline_bytes = EXCLUDED.max_inline_bytes,
2032
+ mailbox_limit_bytes = EXCLUDED.mailbox_limit_bytes,
2033
+ retention_policy_id = EXCLUDED.retention_policy_id,
2034
+ downgrade_grace_until = EXCLUDED.downgrade_grace_until
2035
+ WHERE storage_entitlement.version < EXCLUDED.version
2036
+ RETURNING ${ENTITLEMENT_COLUMNS}
2037
+ ), seeded AS (
2038
+ INSERT INTO storage_usage (tenant_id, updated_at)
2039
+ SELECT $1, $9 FROM applied
2040
+ ON CONFLICT (tenant_id) DO NOTHING
2041
+ RETURNING 1
2042
+ )
2043
+ SELECT ${ENTITLEMENT_COLUMNS} FROM applied`,
2044
+ [
2045
+ tenant,
2046
+ input.version,
2047
+ input.hardLimitBytes,
2048
+ input.maxObjectBytes,
2049
+ input.maxInlineBytes,
2050
+ input.mailboxLimitBytes,
2051
+ input.retentionPolicyId,
2052
+ input.downgradeGraceUntil ?? null,
2053
+ this.#now()
2054
+ ]
2055
+ );
2056
+ const row = applied.rows[0];
2057
+ if (row !== void 0) return toEntitlement(row);
2058
+ const current = await this.readEntitlement(tenant);
2059
+ if (current === void 0) {
2060
+ throw new Error(`entitlement for ${tenant} vanished during a version CAS`);
2061
+ }
2062
+ throw new CoreConflictError(
2063
+ "storage_entitlement_version_conflict",
2064
+ `Entitlement is at version ${String(current.version)}; refusing to apply version ${String(input.version)}.`,
2065
+ current,
2066
+ this.#now()
2067
+ );
2068
+ }
2069
+ async readUsage(tenant) {
2070
+ const result = await this.#pool.query(USAGE_SQL, [tenant, this.#now()]);
2071
+ return toUsage(result.rows[0]);
2072
+ }
2073
+ async readStatus(tenant) {
2074
+ const entitlement = await this.readEntitlement(tenant);
2075
+ if (entitlement === void 0) throw this.#entitlementMissing();
2076
+ const usage = await this.readUsage(tenant);
2077
+ const used = usedBytes(usage);
2078
+ const graceActive = this.#graceActive(entitlement);
2079
+ return {
2080
+ entitlement,
2081
+ usage,
2082
+ posture: posture(entitlement, usage, graceActive),
2083
+ availableBytes: used >= entitlement.hardLimitBytes ? 0n : entitlement.hardLimitBytes - used,
2084
+ graceActive
2085
+ };
2086
+ }
2087
+ async readReservation(tenant, reservationId) {
2088
+ return (await this.#readReservation(tenant, reservationId))?.reservation;
2089
+ }
2090
+ async reserve(tenant, input) {
2091
+ const client = await this.#pool.connect();
2092
+ let admitted;
2093
+ let rejection;
2094
+ try {
2095
+ await client.query("BEGIN");
2096
+ const entitlement = await this.#lockEntitlement(client, tenant);
2097
+ if (entitlement === void 0) {
2098
+ rejection = this.#entitlementMissing();
2099
+ } else {
2100
+ const now = this.#now();
2101
+ await client.query(
2102
+ `UPDATE storage_reservation
2103
+ SET state = 'expired', settled_at = $2
2104
+ WHERE tenant_id = $1 AND state = 'reserved' AND expires_at <= $2`,
2105
+ [tenant, now]
2106
+ );
2107
+ const inserted = await client.query(
2108
+ `WITH ent AS (
2109
+ SELECT hard_limit_bytes, max_object_bytes, max_inline_bytes, downgrade_grace_until
2110
+ FROM storage_entitlement WHERE tenant_id = $1
2111
+ ), used AS (
2112
+ SELECT COALESCE((SELECT committed_object_bytes + committed_inline_bytes
2113
+ FROM storage_usage WHERE tenant_id = $1), 0)::bigint
2114
+ + COALESCE((SELECT SUM(expected_bytes) FROM storage_reservation
2115
+ WHERE tenant_id = $1 AND state = 'reserved'), 0)::bigint
2116
+ AS used_bytes
2117
+ )
2118
+ INSERT INTO storage_reservation
2119
+ (tenant_id, reservation_id, state, kind, expected_bytes,
2120
+ content_hash, content_type, created_at, expires_at)
2121
+ SELECT $1, $2, 'reserved', $3::text, $4::bigint, $5, $6, $7, $8
2122
+ FROM ent, used
2123
+ WHERE $4::bigint <= (CASE WHEN $3::text = 'object'
2124
+ THEN ent.max_object_bytes
2125
+ ELSE ent.max_inline_bytes END)
2126
+ AND used.used_bytes + $4::bigint <= ent.hard_limit_bytes
2127
+ AND ($3::text <> 'object' OR NOT EXISTS (
2128
+ SELECT 1 FROM object_manifest m
2129
+ WHERE m.tenant_id = $1 AND m.hash = $5
2130
+ AND m.state = 'delete_pending'
2131
+ ))
2132
+ AND NOT (used.used_bytes >= ent.hard_limit_bytes
2133
+ AND ent.downgrade_grace_until IS NOT NULL
2134
+ AND ent.downgrade_grace_until <= $9)
2135
+ ON CONFLICT (tenant_id, reservation_id) DO NOTHING
2136
+ RETURNING ${RESERVATION_COLUMNS}`,
2137
+ [
2138
+ tenant,
2139
+ input.reservationId,
2140
+ input.kind,
2141
+ input.expectedBytes,
2142
+ input.contentHash,
2143
+ input.contentType,
2144
+ now,
2145
+ this.#expiry(input.ttlMs),
2146
+ now
2147
+ ]
2148
+ );
2149
+ const row = inserted.rows[0];
2150
+ if (row !== void 0) {
2151
+ admitted = toReservation(row);
2152
+ } else {
2153
+ const outcome = await this.#explainRefusedReservation(
2154
+ client,
2155
+ tenant,
2156
+ input,
2157
+ entitlement
2158
+ );
2159
+ if (outcome instanceof ByokCoreError) rejection = outcome;
2160
+ else admitted = outcome;
2161
+ }
2162
+ }
2163
+ await client.query("COMMIT");
2164
+ } catch (error) {
2165
+ await client.query("ROLLBACK").catch(() => {
2166
+ });
2167
+ throw error;
2168
+ } finally {
2169
+ client.release();
2170
+ }
2171
+ if (rejection !== void 0) throw rejection;
2172
+ return admitted;
2173
+ }
2174
+ async finalizeReservation(tenant, input) {
2175
+ const existing = await this.#readReservation(tenant, input.reservationId);
2176
+ if (existing === void 0) throw this.#reservationMissing(input.reservationId);
2177
+ if (existing.reservation.state === "committed") {
2178
+ return {
2179
+ reservation: existing.reservation,
2180
+ usage: await this.readUsage(tenant),
2181
+ deduplicated: existing.deduplicated
2182
+ };
2183
+ }
2184
+ if (existing.reservation.state !== "reserved") {
2185
+ throw new ByokCoreError(
2186
+ "storage_reservation_expired",
2187
+ `Reservation ${input.reservationId} is ${existing.reservation.state}.`
2188
+ );
2189
+ }
2190
+ if (this.#now() >= existing.reservation.expiresAt) {
2191
+ await this.#settle(tenant, input.reservationId, "expired");
2192
+ throw new ByokCoreError(
2193
+ "storage_reservation_expired",
2194
+ `Reservation ${input.reservationId} expired at ${existing.reservation.expiresAt}.`
2195
+ );
2196
+ }
2197
+ if (input.observedByteSize !== existing.reservation.expectedBytes || input.observedContentType !== existing.reservation.contentType) {
2198
+ await this.#settle(tenant, input.reservationId, "aborted");
2199
+ throw new ByokCoreError(
2200
+ "storage_integrity_mismatch",
2201
+ `Observed object does not match reservation ${input.reservationId}.`
2202
+ );
2203
+ }
2204
+ const settled = await this.#pool.query(
2205
+ `WITH candidate AS MATERIALIZED (
2206
+ SELECT r.tenant_id,
2207
+ r.reservation_id,
2208
+ r.kind,
2209
+ r.content_hash,
2210
+ r.expected_bytes,
2211
+ r.content_type,
2212
+ m.state AS manifest_state,
2213
+ (m.tenant_id IS NOT NULL
2214
+ AND m.state IN ('pending', 'committed')
2215
+ AND m.byte_size = $4::bigint
2216
+ AND m.content_type = $5) AS manifest_valid,
2217
+ EXISTS (
2218
+ SELECT 1 FROM storage_reservation p
2219
+ WHERE p.tenant_id = r.tenant_id
2220
+ AND p.content_hash = r.content_hash
2221
+ AND p.state = 'committed'
2222
+ ) AS inline_deduplicated
2223
+ FROM storage_reservation r
2224
+ LEFT JOIN object_manifest m
2225
+ ON m.tenant_id = r.tenant_id AND m.hash = r.content_hash
2226
+ WHERE r.tenant_id = $1
2227
+ AND r.reservation_id = $2
2228
+ AND r.state = 'reserved'
2229
+ ), committed_manifest AS (
2230
+ UPDATE object_manifest m
2231
+ SET state = 'committed', updated_at = $3
2232
+ FROM candidate c
2233
+ WHERE c.kind = 'object'
2234
+ AND c.manifest_valid
2235
+ AND m.state = 'pending'
2236
+ AND m.tenant_id = c.tenant_id
2237
+ AND m.hash = c.content_hash
2238
+ RETURNING 1
2239
+ ), settled AS (
2240
+ UPDATE storage_reservation r
2241
+ SET state = CASE
2242
+ WHEN c.kind = 'object' AND NOT c.manifest_valid
2243
+ THEN 'aborted'
2244
+ ELSE 'committed'
2245
+ END,
2246
+ settled_at = $3,
2247
+ deduplicated = CASE
2248
+ WHEN c.kind = 'object'
2249
+ THEN NOT EXISTS (SELECT 1 FROM committed_manifest)
2250
+ ELSE c.inline_deduplicated
2251
+ END
2252
+ FROM candidate c,
2253
+ (SELECT COUNT(*) FROM committed_manifest) AS manifest_barrier
2254
+ WHERE r.tenant_id = c.tenant_id
2255
+ AND r.reservation_id = c.reservation_id
2256
+ AND r.state = 'reserved'
2257
+ RETURNING ${QUALIFIED_RESERVATION_COLUMNS}
2258
+ ), accounted AS (
2259
+ UPDATE storage_usage u
2260
+ SET committed_object_bytes = u.committed_object_bytes
2261
+ + CASE WHEN s.kind = 'object' AND NOT s.deduplicated
2262
+ THEN s.expected_bytes ELSE 0 END,
2263
+ committed_inline_bytes = u.committed_inline_bytes
2264
+ + CASE WHEN s.kind = 'inline' AND NOT s.deduplicated
2265
+ THEN s.expected_bytes ELSE 0 END,
2266
+ object_count = u.object_count
2267
+ + CASE WHEN s.kind = 'object' AND NOT s.deduplicated THEN 1 ELSE 0 END,
2268
+ updated_at = $3
2269
+ FROM settled s
2270
+ WHERE u.tenant_id = $1 AND s.state = 'committed'
2271
+ RETURNING 1
2272
+ )
2273
+ SELECT ${RESERVATION_COLUMNS} FROM settled`,
2274
+ [
2275
+ tenant,
2276
+ input.reservationId,
2277
+ this.#now(),
2278
+ input.observedByteSize,
2279
+ input.observedContentType
2280
+ ]
2281
+ );
2282
+ const row = settled.rows[0];
2283
+ if (row === void 0) {
2284
+ const raced = await this.#readReservation(tenant, input.reservationId);
2285
+ if (raced !== void 0 && raced.reservation.state === "committed") {
2286
+ return {
2287
+ reservation: raced.reservation,
2288
+ usage: await this.readUsage(tenant),
2289
+ deduplicated: raced.deduplicated
2290
+ };
2291
+ }
2292
+ throw new ByokCoreError(
2293
+ "storage_reservation_expired",
2294
+ `Reservation ${input.reservationId} was settled concurrently.`
2295
+ );
2296
+ }
2297
+ const reservation = toReservation(row);
2298
+ if (reservation.state === "aborted") {
2299
+ throw new ByokCoreError(
2300
+ "storage_integrity_mismatch",
2301
+ `Reservation ${input.reservationId} has no matching committable object manifest.`
2302
+ );
2303
+ }
2304
+ return {
2305
+ reservation,
2306
+ usage: await this.readUsage(tenant),
2307
+ deduplicated: row.deduplicated
2308
+ };
2309
+ }
2310
+ async abortReservation(tenant, reservationId) {
2311
+ const existing = await this.#readReservation(tenant, reservationId);
2312
+ if (existing === void 0) throw this.#reservationMissing(reservationId);
2313
+ if (existing.reservation.state !== "reserved") return existing.reservation;
2314
+ const settled = await this.#settle(tenant, reservationId, "aborted");
2315
+ if (settled !== void 0) return settled;
2316
+ const raced = await this.#readReservation(tenant, reservationId);
2317
+ if (raced === void 0) throw this.#reservationMissing(reservationId);
2318
+ return raced.reservation;
2319
+ }
2320
+ async expireReservations(tenant) {
2321
+ const expired = await this.#pool.query(
2322
+ `UPDATE storage_reservation
2323
+ SET state = 'expired', settled_at = $2
2324
+ WHERE tenant_id = $1 AND state = 'reserved' AND expires_at <= $2
2325
+ RETURNING ${RESERVATION_COLUMNS}`,
2326
+ [tenant, this.#now()]
2327
+ );
2328
+ return expired.rows.map(toReservation).sort((left, right) => left.reservationId.localeCompare(right.reservationId));
2329
+ }
2330
+ async applyMailboxDelta(tenant, input) {
2331
+ const applied = await this.#pool.query(
2332
+ `WITH ent AS (
2333
+ SELECT mailbox_limit_bytes FROM storage_entitlement WHERE tenant_id = $1
2334
+ )
2335
+ UPDATE storage_usage u
2336
+ SET mailbox_bytes = GREATEST(u.mailbox_bytes + $2::bigint, 0::bigint),
2337
+ updated_at = $3
2338
+ FROM ent
2339
+ WHERE u.tenant_id = $1
2340
+ AND ($2::bigint <= 0 OR u.mailbox_bytes + $2::bigint <= ent.mailbox_limit_bytes)
2341
+ RETURNING 1`,
2342
+ [tenant, input.deltaBytes, this.#now()]
2343
+ );
2344
+ if (applied.rowCount === 0) {
2345
+ const entitlement = await this.readEntitlement(tenant);
2346
+ if (entitlement === void 0) throw this.#entitlementMissing();
2347
+ const usage = await this.readUsage(tenant);
2348
+ throw new ByokCoreError(
2349
+ "storage_quota_exceeded",
2350
+ `Mailbox would reach ${String(usage.mailboxBytes + input.deltaBytes)} bytes, over the limit of ${String(entitlement.mailboxLimitBytes)} bytes.`
2351
+ );
2352
+ }
2353
+ return this.readUsage(tenant);
2354
+ }
2355
+ async #lockEntitlement(client, tenant) {
2356
+ const result = await client.query(
2357
+ `SELECT ${ENTITLEMENT_COLUMNS} FROM storage_entitlement WHERE tenant_id = $1 FOR UPDATE`,
2358
+ [tenant]
2359
+ );
2360
+ const row = result.rows[0];
2361
+ return row === void 0 ? void 0 : toEntitlement(row);
2362
+ }
2363
+ /**
2364
+ * Names the refusal. Runs only after the guarded insert has already declined,
2365
+ * inside the same locked transaction, so the state it reads is the state that
2366
+ * refused. The order matches the contract's precedence: an existing
2367
+ * reservation is an idempotent answer, not a rejection; a suspended tenant is
2368
+ * read-only (423) before it is over-quota (507); a single oversized write is
2369
+ * 413 regardless of how much room is left.
2370
+ */
2371
+ async #explainRefusedReservation(client, tenant, input, entitlement) {
2372
+ const existing = await client.query(
2373
+ `SELECT ${RESERVATION_COLUMNS} FROM storage_reservation
2374
+ WHERE tenant_id = $1 AND reservation_id = $2`,
2375
+ [tenant, input.reservationId]
2376
+ );
2377
+ const held = existing.rows[0];
2378
+ if (held !== void 0) {
2379
+ if (held.state === "reserved") {
2380
+ if (held.kind !== input.kind || held.expected_bytes !== input.expectedBytes || held.content_hash !== input.contentHash || held.content_type !== input.contentType) {
2381
+ return new ByokCoreError(
2382
+ "storage_integrity_mismatch",
2383
+ `Reservation ${input.reservationId} already binds a different storage declaration.`
2384
+ );
2385
+ }
2386
+ return toReservation(held);
2387
+ }
2388
+ return new ByokCoreError(
2389
+ "storage_reservation_expired",
2390
+ `Reservation ${input.reservationId} is already ${held.state}.`
2391
+ );
2392
+ }
2393
+ if (input.kind === "object") {
2394
+ const tombstone = await client.query(
2395
+ `SELECT state FROM object_manifest
2396
+ WHERE tenant_id = $1 AND hash = $2 AND state = 'delete_pending'`,
2397
+ [tenant, input.contentHash]
2398
+ );
2399
+ if (tombstone.rows[0] !== void 0) {
2400
+ return new ByokCoreError(
2401
+ "object_state_invalid",
2402
+ `Object ${input.contentHash} is pending deletion and cannot accept a new reservation.`
2403
+ );
2404
+ }
2405
+ }
2406
+ const usageResult = await client.query(USAGE_SQL, [tenant, this.#now()]);
2407
+ const usage = toUsage(usageResult.rows[0]);
2408
+ if (posture(entitlement, usage, this.#graceActive(entitlement)) === "suspended") {
2409
+ return new ByokCoreError(
2410
+ "storage_write_suspended",
2411
+ "Tenant is over its hard limit and its downgrade grace has ended; durable writes are suspended."
2412
+ );
2413
+ }
2414
+ const perWriteLimit = input.kind === "object" ? entitlement.maxObjectBytes : entitlement.maxInlineBytes;
2415
+ if (input.expectedBytes > perWriteLimit) {
2416
+ return new ByokCoreError(
2417
+ "storage_object_too_large",
2418
+ `${String(input.expectedBytes)} bytes exceeds the ${input.kind} limit of ${String(perWriteLimit)} bytes.`
2419
+ );
2420
+ }
2421
+ return new ByokCoreError(
2422
+ "storage_quota_exceeded",
2423
+ `Reserving ${String(input.expectedBytes)} bytes would exceed the hard limit of ${String(entitlement.hardLimitBytes)} bytes.`
2424
+ );
2425
+ }
2426
+ async #readReservation(tenant, reservationId) {
2427
+ const result = await this.#pool.query(
2428
+ `SELECT ${RESERVATION_COLUMNS} FROM storage_reservation
2429
+ WHERE tenant_id = $1 AND reservation_id = $2`,
2430
+ [tenant, reservationId]
2431
+ );
2432
+ const row = result.rows[0];
2433
+ if (row === void 0) return void 0;
2434
+ return { reservation: toReservation(row), deduplicated: row.deduplicated };
2435
+ }
2436
+ /**
2437
+ * The settle guard. `WHERE state = 'reserved'` is what makes two settlements
2438
+ * of the same reservation produce one winner; the loser gets zero rows and
2439
+ * re-reads rather than stamping a second `settled_at` over the first.
2440
+ * Releasing the reserved bytes needs no second write: they were never a
2441
+ * counter, only the sum of rows in this state.
2442
+ */
2443
+ async #settle(tenant, reservationId, state) {
2444
+ const result = await this.#pool.query(
2445
+ `UPDATE storage_reservation
2446
+ SET state = $3, settled_at = $4
2447
+ WHERE tenant_id = $1 AND reservation_id = $2 AND state = 'reserved'
2448
+ RETURNING ${RESERVATION_COLUMNS}`,
2449
+ [tenant, reservationId, state, this.#now()]
2450
+ );
2451
+ const row = result.rows[0];
2452
+ return row === void 0 ? void 0 : toReservation(row);
2453
+ }
2454
+ #graceActive(entitlement) {
2455
+ return entitlement.downgradeGraceUntil !== void 0 && this.#now() < entitlement.downgradeGraceUntil;
2456
+ }
2457
+ #entitlementMissing() {
2458
+ return new ByokCoreError(
2459
+ "storage_entitlement_missing",
2460
+ "No storage entitlement has been issued for this tenant."
2461
+ );
2462
+ }
2463
+ #reservationMissing(reservationId) {
2464
+ return new ByokCoreError(
2465
+ "storage_reservation_not_found",
2466
+ `Reservation ${reservationId} does not exist in this tenant.`
2467
+ );
2468
+ }
2469
+ #expiry(ttlMs) {
2470
+ return new Date(this.#clock.now().getTime() + ttlMs).toISOString();
2471
+ }
2472
+ #now() {
2473
+ return this.#clock.now().toISOString();
2474
+ }
2475
+ };
2476
+ function usedBytes(usage) {
2477
+ return usage.committedObjectBytes + usage.committedInlineBytes + usage.reservedBytes;
2478
+ }
2479
+ function posture(entitlement, usage, graceActive) {
2480
+ const used = usedBytes(usage);
2481
+ if (used >= entitlement.hardLimitBytes) {
2482
+ const graceConfigured = entitlement.downgradeGraceUntil !== void 0;
2483
+ return graceConfigured && !graceActive ? "suspended" : "blocked";
2484
+ }
2485
+ if (entitlement.hardLimitBytes > 0n && used * WARNING_DENOMINATOR >= entitlement.hardLimitBytes * WARNING_NUMERATOR) {
2486
+ return "warning";
2487
+ }
2488
+ return "normal";
2489
+ }
2490
+ var DEFAULT_LIST_LIMIT3 = 50;
2491
+ function toManifest(pack, files) {
2492
+ return {
2493
+ schema: SKILL_PACK_MANIFEST_SCHEMA_ID,
2494
+ name: pack.name,
2495
+ version: pack.version,
2496
+ description: pack.description,
2497
+ files: files.map(toFile),
2498
+ contentHash: pack.content_hash
2499
+ };
2500
+ }
2501
+ function toFile(row) {
2502
+ return {
2503
+ path: row.path,
2504
+ contentHash: row.content_hash,
2505
+ byteSize: row.byte_size
2506
+ };
2507
+ }
2508
+ var PostgresSkillPackStore = class {
2509
+ #pool;
2510
+ constructor(pool) {
2511
+ this.#pool = pool;
2512
+ }
2513
+ async publish(tenant, input) {
2514
+ const { manifest } = input;
2515
+ const structural = checkSkillPackManifest(manifest);
2516
+ if (!structural.ok) {
2517
+ throw new ByokCoreError(
2518
+ "skill_pack_manifest_invalid",
2519
+ `Refusing to publish ${JSON.stringify(manifest.name)}: ${structural.reason} \u2014 ${structural.detail}`
2520
+ );
2521
+ }
2522
+ const contents = /* @__PURE__ */ new Map();
2523
+ for (const file of input.files) {
2524
+ if (contents.has(file.path)) {
2525
+ throw new ByokCoreError(
2526
+ "skill_pack_manifest_invalid",
2527
+ `Refusing to publish ${JSON.stringify(manifest.name)}: ${JSON.stringify(file.path)} was supplied twice.`
2528
+ );
2529
+ }
2530
+ contents.set(file.path, file.content);
2531
+ }
2532
+ for (const declared of manifest.files) {
2533
+ const content = contents.get(declared.path);
2534
+ if (content === void 0) {
2535
+ throw new ByokCoreError(
2536
+ "skill_pack_manifest_invalid",
2537
+ `Refusing to publish ${JSON.stringify(manifest.name)}: ${JSON.stringify(declared.path)} is declared but was not supplied.`
2538
+ );
2539
+ }
2540
+ const bytes = new TextEncoder().encode(content).length;
2541
+ if (bytes !== declared.byteSize) {
2542
+ throw new ByokCoreError(
2543
+ "skill_pack_manifest_invalid",
2544
+ `Refusing to publish ${JSON.stringify(manifest.name)}: ${JSON.stringify(declared.path)} declares ${declared.byteSize} bytes and supplies ${bytes}.`
2545
+ );
2546
+ }
2547
+ }
2548
+ if (contents.size !== manifest.files.length) {
2549
+ throw new ByokCoreError(
2550
+ "skill_pack_manifest_invalid",
2551
+ `Refusing to publish ${JSON.stringify(manifest.name)}: ${contents.size} files were supplied for ${manifest.files.length} declared rows.`
2552
+ );
2553
+ }
2554
+ const entry = checkSkillPackEntry(manifest, contents.get(SKILL_PACK_ENTRY_PATH));
2555
+ if (!entry.ok) {
2556
+ throw new ByokCoreError(
2557
+ "skill_pack_manifest_invalid",
2558
+ `Refusing to publish ${JSON.stringify(manifest.name)}: ${entry.reason} \u2014 ${entry.detail}`
2559
+ );
2560
+ }
2561
+ await this.#inTransaction(async (client) => {
2562
+ await client.query(
2563
+ `INSERT INTO skill_pack (tenant_id, name, version, description, content_hash)
2564
+ VALUES ($1, $2, $3, $4, $5)
2565
+ ON CONFLICT (tenant_id, name) DO UPDATE
2566
+ SET version = EXCLUDED.version,
2567
+ description = EXCLUDED.description,
2568
+ content_hash = EXCLUDED.content_hash`,
2569
+ [tenant, manifest.name, manifest.version, manifest.description, manifest.contentHash]
2570
+ );
2571
+ await client.query(`DELETE FROM skill_pack_file WHERE tenant_id = $1 AND pack_name = $2`, [
2572
+ tenant,
2573
+ manifest.name
2574
+ ]);
2575
+ for (const declared of manifest.files) {
2576
+ await client.query(
2577
+ `INSERT INTO skill_pack_file (tenant_id, pack_name, path, content_hash, byte_size, content)
2578
+ VALUES ($1, $2, $3, $4, $5, $6)`,
2579
+ [
2580
+ tenant,
2581
+ manifest.name,
2582
+ declared.path,
2583
+ declared.contentHash,
2584
+ declared.byteSize,
2585
+ contents.get(declared.path)
2586
+ ]
2587
+ );
2588
+ }
2589
+ });
2590
+ return manifest;
2591
+ }
2592
+ async get(tenant, name) {
2593
+ const packResult = await this.#pool.query(
2594
+ `SELECT name, version, description, content_hash
2595
+ FROM skill_pack WHERE tenant_id = $1 AND name = $2`,
2596
+ [tenant, name]
2597
+ );
2598
+ const pack = packResult.rows[0];
2599
+ if (pack === void 0) return void 0;
2600
+ const files = await this.#filesFor(tenant, [name]);
2601
+ return toManifest(pack, files);
2602
+ }
2603
+ async list(tenant, query) {
2604
+ const packs = await this.#pool.query(
2605
+ `SELECT name, version, description, content_hash
2606
+ FROM skill_pack WHERE tenant_id = $1
2607
+ ORDER BY name COLLATE "C"
2608
+ LIMIT $2`,
2609
+ [tenant, query.limit ?? DEFAULT_LIST_LIMIT3]
2610
+ );
2611
+ if (packs.rows.length === 0) return [];
2612
+ const files = await this.#filesFor(
2613
+ tenant,
2614
+ packs.rows.map((row) => row.name)
2615
+ );
2616
+ const byPack = /* @__PURE__ */ new Map();
2617
+ for (const file of files) {
2618
+ const bucket = byPack.get(file.pack_name);
2619
+ if (bucket === void 0) byPack.set(file.pack_name, [file]);
2620
+ else bucket.push(file);
2621
+ }
2622
+ return packs.rows.map((pack) => toManifest(pack, byPack.get(pack.name) ?? []));
2623
+ }
2624
+ async readFile(tenant, name, path) {
2625
+ const result = await this.#pool.query(
2626
+ `SELECT pack_name, path, content_hash, byte_size, content
2627
+ FROM skill_pack_file
2628
+ WHERE tenant_id = $1 AND pack_name = $2 AND path = $3`,
2629
+ [tenant, name, path]
2630
+ );
2631
+ const row = result.rows[0];
2632
+ if (row === void 0) return void 0;
2633
+ return {
2634
+ path: row.path,
2635
+ contentHash: row.content_hash,
2636
+ byteSize: row.byte_size,
2637
+ content: row.content
2638
+ };
2639
+ }
2640
+ /** Every file row for the named packs, in byte order of (pack_name, path). */
2641
+ async #filesFor(tenant, names) {
2642
+ const result = await this.#pool.query(
2643
+ `SELECT pack_name, path, content_hash, byte_size, content
2644
+ FROM skill_pack_file
2645
+ WHERE tenant_id = $1 AND pack_name = ANY($2::text[])
2646
+ ORDER BY pack_name COLLATE "C", path COLLATE "C"`,
2647
+ [tenant, names]
2648
+ );
2649
+ return result.rows;
2650
+ }
2651
+ async #inTransaction(run) {
2652
+ const client = await this.#pool.connect();
2653
+ try {
2654
+ await client.query("BEGIN");
2655
+ await run(client);
2656
+ await client.query("COMMIT");
2657
+ } catch (error) {
2658
+ await client.query("ROLLBACK").catch(() => {
2659
+ });
2660
+ throw error;
2661
+ } finally {
2662
+ client.release();
2663
+ }
2664
+ }
2665
+ };
2666
+ var DEFAULT_MANIFEST_LIMIT = 100;
2667
+ 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";
2668
+ function toBody(row) {
2669
+ return row.body_kind === "inline" ? { kind: "inline", body: row.body_inline ?? "" } : { kind: "object", hash: row.body_object_hash ?? "" };
2670
+ }
2671
+ function toRecord2(row) {
2672
+ return {
2673
+ tenantId: row.tenant_id,
2674
+ kind: row.kind,
2675
+ recordKey: row.subject_id,
2676
+ rev: row.rev,
2677
+ contentHash: row.content_hash,
2678
+ byteSize: row.byte_size,
2679
+ body: toBody(row),
2680
+ ...row.label === null ? {} : { label: row.label },
2681
+ ...row.request_id === null ? {} : { requestId: row.request_id },
2682
+ writtenAt: row.written_at
2683
+ };
2684
+ }
2685
+ function bodyColumns(body) {
2686
+ return body.kind === "inline" ? ["inline", body.body, null] : ["object", null, body.hash];
2687
+ }
2688
+ var PostgresTruthStore = class {
2689
+ #pool;
2690
+ #clock;
2691
+ constructor(pool, clock) {
2692
+ this.#pool = pool;
2693
+ this.#clock = clock;
2694
+ }
2695
+ async writeTerminal(tenant, input) {
2696
+ const [bodyKind, bodyInline, bodyObjectHash] = bodyColumns(input.body);
2697
+ const inserted = await this.#pool.query(
2698
+ `INSERT INTO attested_record (${RECORD_COLUMNS})
2699
+ VALUES ($1, 'task.terminal', $2, 1, $3, $4::bigint, $5, $6, $7, $8, $9, $10)
2700
+ ON CONFLICT (tenant_id, kind, subject_id) DO NOTHING
2701
+ RETURNING ${RECORD_COLUMNS}`,
2702
+ [
2703
+ tenant,
2704
+ input.taskId,
2705
+ input.contentHash,
2706
+ input.byteSize,
2707
+ bodyKind,
2708
+ bodyInline,
2709
+ bodyObjectHash,
2710
+ input.label ?? null,
2711
+ input.requestId ?? null,
2712
+ this.#now()
2713
+ ]
2714
+ );
2715
+ const row = inserted.rows[0];
2716
+ if (row !== void 0) return toRecord2(row);
2717
+ const existing = await this.getRecord(tenant, {
2718
+ kind: "task.terminal",
2719
+ recordKey: input.taskId
2720
+ });
2721
+ if (existing === void 0) {
2722
+ throw new Error(`terminal record for ${input.taskId} vanished during a first write`);
2723
+ }
2724
+ if (existing.contentHash === input.contentHash) return existing;
2725
+ throw new CoreConflictError(
2726
+ "terminal_conflict",
2727
+ `Task ${input.taskId} already has an immutable terminal record with a different hash.`,
2728
+ existing,
2729
+ this.#now()
2730
+ );
2731
+ }
2732
+ async writeSnapshot(tenant, input) {
2733
+ const [bodyKind, bodyInline, bodyObjectHash] = bodyColumns(input.body);
2734
+ const now = this.#now();
2735
+ const written = input.expectedRev === 0 ? await this.#pool.query(
2736
+ `INSERT INTO attested_record (${RECORD_COLUMNS})
2737
+ VALUES ($1, $2, $3, 1, $4, $5::bigint, $6, $7, $8, $9, $10, $11)
2738
+ ON CONFLICT (tenant_id, kind, subject_id) DO NOTHING
2739
+ RETURNING ${RECORD_COLUMNS}`,
2740
+ [
2741
+ tenant,
2742
+ input.kind,
2743
+ input.recordKey,
2744
+ input.contentHash,
2745
+ input.byteSize,
2746
+ bodyKind,
2747
+ bodyInline,
2748
+ bodyObjectHash,
2749
+ input.label ?? null,
2750
+ input.requestId ?? null,
2751
+ now
2752
+ ]
2753
+ ) : await this.#pool.query(
2754
+ `UPDATE attested_record
2755
+ SET rev = rev + 1,
2756
+ content_hash = $4,
2757
+ byte_size = $5::bigint,
2758
+ body_kind = $6,
2759
+ body_inline = $7,
2760
+ body_object_hash = $8,
2761
+ label = $9,
2762
+ request_id = $10,
2763
+ written_at = $11
2764
+ WHERE tenant_id = $1 AND kind = $2 AND subject_id = $3 AND rev = $12
2765
+ RETURNING ${RECORD_COLUMNS}`,
2766
+ [
2767
+ tenant,
2768
+ input.kind,
2769
+ input.recordKey,
2770
+ input.contentHash,
2771
+ input.byteSize,
2772
+ bodyKind,
2773
+ bodyInline,
2774
+ bodyObjectHash,
2775
+ input.label ?? null,
2776
+ input.requestId ?? null,
2777
+ now,
2778
+ input.expectedRev
2779
+ ]
2780
+ );
2781
+ const row = written.rows[0];
2782
+ if (row !== void 0) return toRecord2(row);
2783
+ const current = await this.getRecord(tenant, {
2784
+ kind: input.kind,
2785
+ recordKey: input.recordKey
2786
+ });
2787
+ throw new CoreConflictError(
2788
+ "truth_revision_conflict",
2789
+ `Record ${input.kind}/${input.recordKey} is at rev ${current?.rev ?? 0}, not ${input.expectedRev}.`,
2790
+ current,
2791
+ this.#now()
2792
+ );
2793
+ }
2794
+ async getRecord(tenant, selector) {
2795
+ const result = await this.#pool.query(
2796
+ `SELECT ${RECORD_COLUMNS} FROM attested_record
2797
+ WHERE tenant_id = $1 AND kind = $2 AND subject_id = $3`,
2798
+ [tenant, selector.kind, selector.recordKey]
2799
+ );
2800
+ const row = result.rows[0];
2801
+ return row === void 0 ? void 0 : toRecord2(row);
2802
+ }
2803
+ async listManifest(tenant, query) {
2804
+ const result = await this.#pool.query(
2805
+ `SELECT kind, subject_id, rev, content_hash, byte_size, label, written_at
2806
+ FROM attested_record
2807
+ WHERE tenant_id = $1
2808
+ AND ($2::text IS NULL OR kind = $2::text)
2809
+ AND ($3::text IS NULL OR starts_with(subject_id, $3::text))
2810
+ ORDER BY kind COLLATE "C", subject_id COLLATE "C"
2811
+ LIMIT $4`,
2812
+ [tenant, query.kind ?? null, query.keyPrefix ?? null, query.limit ?? DEFAULT_MANIFEST_LIMIT]
2813
+ );
2814
+ return result.rows.map((row) => ({
2815
+ kind: row.kind,
2816
+ recordKey: row.subject_id,
2817
+ rev: row.rev,
2818
+ contentHash: row.content_hash,
2819
+ byteSize: row.byte_size,
2820
+ ...row.label === null ? {} : { label: row.label },
2821
+ updatedAt: row.written_at
2822
+ }));
2823
+ }
2824
+ #now() {
2825
+ return this.#clock.now().toISOString();
2826
+ }
2827
+ };
2828
+
2829
+ // src/stores/core/index.ts
2830
+ function createPostgresCoreStores(options) {
2831
+ const { pool, clock } = options;
2832
+ return {
2833
+ mailbox: new PostgresMailboxStore(pool, clock),
2834
+ board: new PostgresBoardStore(pool, clock),
2835
+ truth: new PostgresTruthStore(pool, clock),
2836
+ presence: new PostgresPresenceStore(pool, clock),
2837
+ activity: new PostgresActivityStore(pool, clock),
2838
+ objects: new PostgresObjectStore(pool, clock),
2839
+ quota: new PostgresQuotaStore(pool, clock),
2840
+ // No clock: a skill-pack manifest carries no timestamp, so this store reads
2841
+ // none — the same shape as the cloud-local `devices` directory.
2842
+ skillPacks: new PostgresSkillPackStore(pool)
2843
+ };
2844
+ }
2845
+ var DEFAULT_BATCH_SIZE = 100;
2846
+ var MAX_BATCH_SIZE = 1e3;
2847
+ var ADVISORY_LOCK_NAMESPACE = 1106736963;
2848
+ var OUTBOX_COLUMNS2 = "tenant_id, device_id, seq, message_id, body, body_hash, byte_size, state, appended_at, replay_source_seq";
2849
+ var CLOUD_CLEANUP_ERROR_CODES = {
2850
+ cleanup_invalid_input: "cleanup_invalid_input",
2851
+ cleanup_policy_missing: "cleanup_policy_missing",
2852
+ cleanup_job_running: "cleanup_job_running",
2853
+ cleanup_dead_letter_not_found: "cleanup_dead_letter_not_found",
2854
+ cleanup_accounting_drift: "cleanup_accounting_drift"
2855
+ };
2856
+ var CloudCleanupError = class extends Error {
2857
+ code;
2858
+ constructor(code, message, options) {
2859
+ super(message, options);
2860
+ this.name = "CloudCleanupError";
2861
+ this.code = code;
2862
+ }
2863
+ };
2864
+ var PostgresCloudCleanup = class {
2865
+ #pool;
2866
+ #clock;
2867
+ #objectStorage;
2868
+ #batchSize;
2869
+ constructor(options) {
2870
+ this.#pool = options.pool;
2871
+ this.#clock = options.clock;
2872
+ this.#objectStorage = options.objectStorage;
2873
+ this.#batchSize = assertBatchSize(options.batchSize ?? DEFAULT_BATCH_SIZE);
2874
+ }
2875
+ async writeRetentionPolicy(tenant, input) {
2876
+ assertPolicy(input);
2877
+ const written = await this.#pool.query(
2878
+ `INSERT INTO tenant_retention_policy (
2879
+ tenant_id, policy_id, mailbox_acked_retention_ms,
2880
+ mailbox_unacked_retention_ms, request_receipt_retention_ms,
2881
+ object_orphan_grace_ms, updated_at
2882
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7)
2883
+ ON CONFLICT (tenant_id, policy_id) DO UPDATE
2884
+ SET mailbox_acked_retention_ms = EXCLUDED.mailbox_acked_retention_ms,
2885
+ mailbox_unacked_retention_ms = EXCLUDED.mailbox_unacked_retention_ms,
2886
+ request_receipt_retention_ms = EXCLUDED.request_receipt_retention_ms,
2887
+ object_orphan_grace_ms = EXCLUDED.object_orphan_grace_ms,
2888
+ updated_at = EXCLUDED.updated_at
2889
+ RETURNING tenant_id, policy_id, mailbox_acked_retention_ms,
2890
+ mailbox_unacked_retention_ms, request_receipt_retention_ms,
2891
+ object_orphan_grace_ms, updated_at`,
2892
+ [
2893
+ tenant,
2894
+ input.policyId,
2895
+ input.mailboxAckedRetentionMs,
2896
+ input.mailboxUnackedRetentionMs,
2897
+ input.requestReceiptRetentionMs,
2898
+ input.objectOrphanGraceMs,
2899
+ this.#now()
2900
+ ]
2901
+ );
2902
+ return toPolicy(written.rows[0]);
2903
+ }
2904
+ async readRetentionPolicy(tenant) {
2905
+ return this.#readRetentionPolicy(this.#pool, tenant);
2906
+ }
2907
+ /** Run one bounded tenant maintenance cycle. Completed job ids are replay-safe. */
2908
+ async runTenant(tenant, jobId) {
2909
+ assertIdentifier(jobId, "jobId");
2910
+ const client = await this.#pool.connect();
2911
+ let jobStarted = false;
2912
+ try {
2913
+ const lock = await client.query(
2914
+ "SELECT pg_try_advisory_lock(hashtextextended($1, $2)) AS locked",
2915
+ [tenant, ADVISORY_LOCK_NAMESPACE]
2916
+ );
2917
+ if (lock.rows[0]?.locked !== true) {
2918
+ throw new CloudCleanupError(
2919
+ "cleanup_job_running",
2920
+ `A cleanup job is already running for tenant ${tenant}.`
2921
+ );
2922
+ }
2923
+ const replay = await this.#startJob(client, tenant, jobId);
2924
+ if (replay !== void 0) return replay;
2925
+ jobStarted = true;
2926
+ const policy = await this.#readRetentionPolicy(client, tenant);
2927
+ const counts = emptyCounts();
2928
+ const retention = await this.#runRetention(client, tenant, policy);
2929
+ counts.mailboxDeletedCount = retention.mailbox_deleted_count;
2930
+ counts.mailboxExpiredCount = retention.mailbox_expired_count;
2931
+ counts.mailboxReleasedBytes = retention.mailbox_released_bytes;
2932
+ counts.reservationsExpired = retention.reservations_expired;
2933
+ counts.ttlRowsDeleted = retention.ttl_rows_deleted;
2934
+ const orphanCutoff = cutoff(this.#clock.now(), policy.objectOrphanGraceMs);
2935
+ counts.objectsTombstoned = await this.#markTombstones(client, tenant, orphanCutoff);
2936
+ const deleteCursor = await this.#readCursor(client, tenant, "delete");
2937
+ const pending = await client.query(
2938
+ `SELECT hash, byte_size, content_type, state,
2939
+ gc_accounted_bytes, gc_accounted_object
2940
+ FROM object_manifest
2941
+ WHERE tenant_id = $1
2942
+ AND state = 'delete_pending'
2943
+ AND gc_accounted_bytes IS NOT NULL
2944
+ AND gc_accounted_object IS NOT NULL
2945
+ AND hash > $2
2946
+ ORDER BY hash
2947
+ LIMIT $3`,
2948
+ [tenant, deleteCursor ?? "", this.#batchSize]
2949
+ );
2950
+ for (const manifest of pending.rows) {
2951
+ try {
2952
+ await this.#objectStorage.deleteObject(tenant, manifest.hash);
2953
+ const released = await this.#settleDeleted(client, tenant, manifest.hash);
2954
+ if (released !== void 0) {
2955
+ counts.objectsDeleted += 1n;
2956
+ counts.objectReleasedBytes += released;
2957
+ }
2958
+ } catch {
2959
+ counts.operationErrors += 1n;
2960
+ }
2961
+ }
2962
+ await this.#advanceLexicalCursor(
2963
+ client,
2964
+ tenant,
2965
+ "delete",
2966
+ pending.rows.at(-1)?.hash,
2967
+ pending.rows.length
2968
+ );
2969
+ await this.#reconcileManifests(client, tenant, counts);
2970
+ await this.#reconcileR2(client, tenant, counts);
2971
+ const state = counts.operationErrors === 0n ? "completed" : "completed_with_errors";
2972
+ return this.#finishJob(client, tenant, jobId, state, counts);
2973
+ } catch (cause) {
2974
+ if (jobStarted) {
2975
+ await this.#failJob(client, tenant, jobId, cause).catch(() => {
2976
+ });
2977
+ }
2978
+ throw cause;
2979
+ } finally {
2980
+ await client.query("SELECT pg_advisory_unlock(hashtextextended($1, $2))", [
2981
+ tenant,
2982
+ ADVISORY_LOCK_NAMESPACE
2983
+ ]).catch(() => {
2984
+ });
2985
+ client.release();
2986
+ }
2987
+ }
2988
+ async listDeadLetters(tenant, query = {}) {
2989
+ const limit = assertBatchSize(query.limit ?? DEFAULT_BATCH_SIZE);
2990
+ if (query.deviceId !== void 0) assertIdentifier(query.deviceId, "deviceId");
2991
+ if (query.after !== void 0) assertDeadLetterRef(query.after);
2992
+ if (query.deviceId !== void 0 && query.after !== void 0 && query.deviceId !== query.after.deviceId) {
2993
+ throw new CloudCleanupError(
2994
+ "cleanup_invalid_input",
2995
+ "A device-scoped dead-letter cursor must belong to the same device."
2996
+ );
2997
+ }
2998
+ const listed = await this.#pool.query(
2999
+ `SELECT ${OUTBOX_COLUMNS2} FROM outbox
3000
+ WHERE tenant_id = $1
3001
+ AND state = 'expired'
3002
+ AND ($2::text IS NULL OR device_id = $2::text)
3003
+ AND (device_id > $3 OR (device_id = $3 AND seq > $4::bigint))
3004
+ ORDER BY device_id, seq
3005
+ LIMIT $5`,
3006
+ [
3007
+ tenant,
3008
+ query.deviceId ?? null,
3009
+ query.after?.deviceId ?? "",
3010
+ query.after?.seq ?? 0,
3011
+ limit + 1
3012
+ ]
3013
+ );
3014
+ return {
3015
+ messages: listed.rows.slice(0, limit).map(toMailboxMessage),
3016
+ hasMore: listed.rows.length > limit
3017
+ };
3018
+ }
3019
+ /** Clone an expired row to a new monotonic seq. The original remains evidence. */
3020
+ async replayDeadLetter(tenant, input) {
3021
+ assertDeadLetterRef(input);
3022
+ assertIdentifier(input.replayMessageId, "replayMessageId");
3023
+ const client = await this.#pool.connect();
3024
+ let result;
3025
+ let rejection;
3026
+ let rollbackAllocation = false;
3027
+ try {
3028
+ await client.query("BEGIN");
3029
+ const originalResult = await client.query(
3030
+ `SELECT ${OUTBOX_COLUMNS2} FROM outbox
3031
+ WHERE tenant_id = $1 AND device_id = $2 AND seq = $3::bigint
3032
+ AND state = 'expired'
3033
+ FOR UPDATE`,
3034
+ [tenant, input.deviceId, input.seq]
3035
+ );
3036
+ const original = originalResult.rows[0];
3037
+ if (original === void 0) {
3038
+ rejection = deadLetterMissing(input);
3039
+ } else {
3040
+ const existingResult = await client.query(
3041
+ `SELECT ${OUTBOX_COLUMNS2} FROM outbox
3042
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
3043
+ [tenant, input.deviceId, input.replayMessageId]
3044
+ );
3045
+ const existing = existingResult.rows[0];
3046
+ if (existing !== void 0) {
3047
+ if (!replayMatches(existing, original)) {
3048
+ rejection = new CloudCleanupError(
3049
+ "cleanup_invalid_input",
3050
+ `Replay id ${input.replayMessageId} already binds a different replay delivery.`
3051
+ );
3052
+ } else {
3053
+ result = toMailboxMessage(existing);
3054
+ }
3055
+ } else {
3056
+ const entitlement = await client.query(
3057
+ `SELECT e.mailbox_limit_bytes, u.mailbox_bytes
3058
+ FROM storage_entitlement e
3059
+ JOIN storage_usage u ON u.tenant_id = e.tenant_id
3060
+ WHERE e.tenant_id = $1
3061
+ FOR UPDATE OF e, u`,
3062
+ [tenant]
3063
+ );
3064
+ const capacity = entitlement.rows[0];
3065
+ const serializedExisting = await client.query(
3066
+ `SELECT ${OUTBOX_COLUMNS2} FROM outbox
3067
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
3068
+ [tenant, input.deviceId, input.replayMessageId]
3069
+ );
3070
+ const winner = serializedExisting.rows[0];
3071
+ if (winner !== void 0) {
3072
+ if (!replayMatches(winner, original)) {
3073
+ rejection = new CloudCleanupError(
3074
+ "cleanup_invalid_input",
3075
+ `Replay id ${input.replayMessageId} already binds a different replay delivery.`
3076
+ );
3077
+ } else {
3078
+ result = toMailboxMessage(winner);
3079
+ }
3080
+ } else if (capacity === void 0) {
3081
+ rejection = new CloudCleanupError(
3082
+ "cleanup_policy_missing",
3083
+ `Tenant ${tenant} has no storage entitlement/usage row.`
3084
+ );
3085
+ } else {
3086
+ const seq = await allocateMailboxSequence(
3087
+ client,
3088
+ tenant,
3089
+ input.deviceId,
3090
+ this.#now()
3091
+ );
3092
+ const afterAllocation = await client.query(
3093
+ `SELECT ${OUTBOX_COLUMNS2} FROM outbox
3094
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
3095
+ [tenant, input.deviceId, input.replayMessageId]
3096
+ );
3097
+ const appendWinner = afterAllocation.rows[0];
3098
+ if (appendWinner !== void 0) {
3099
+ rollbackAllocation = true;
3100
+ if (!replayMatches(appendWinner, original)) {
3101
+ rejection = new CloudCleanupError(
3102
+ "cleanup_invalid_input",
3103
+ `Replay id ${input.replayMessageId} already binds a different replay delivery.`
3104
+ );
3105
+ } else {
3106
+ result = toMailboxMessage(appendWinner);
3107
+ }
3108
+ } else {
3109
+ const rebound = materializeReplayBody(original, seq);
3110
+ if (capacity.mailbox_bytes + rebound.byteSize > capacity.mailbox_limit_bytes) {
3111
+ rejection = new ByokCoreError(
3112
+ "storage_quota_exceeded",
3113
+ `Replaying the dead letter would exceed tenant ${tenant}'s mailbox limit.`
3114
+ );
3115
+ } else {
3116
+ const inserted = await client.query(
3117
+ `INSERT INTO outbox (${OUTBOX_COLUMNS2})
3118
+ VALUES ($1, $2, $3::bigint, $4, $5, $6, $7::bigint, 'pending', $8, $9)
3119
+ RETURNING ${OUTBOX_COLUMNS2}`,
3120
+ [
3121
+ tenant,
3122
+ input.deviceId,
3123
+ seq,
3124
+ input.replayMessageId,
3125
+ rebound.body,
3126
+ rebound.bodyHash,
3127
+ rebound.byteSize,
3128
+ this.#now(),
3129
+ original.seq
3130
+ ]
3131
+ );
3132
+ await client.query(
3133
+ `UPDATE storage_usage
3134
+ SET mailbox_bytes = mailbox_bytes + $2::bigint, updated_at = $3
3135
+ WHERE tenant_id = $1`,
3136
+ [tenant, rebound.byteSize, this.#now()]
3137
+ );
3138
+ result = toMailboxMessage(inserted.rows[0]);
3139
+ }
3140
+ }
3141
+ }
3142
+ }
3143
+ }
3144
+ if (rejection === void 0 && !rollbackAllocation) await client.query("COMMIT");
3145
+ else await client.query("ROLLBACK");
3146
+ } catch (cause) {
3147
+ await client.query("ROLLBACK").catch(() => {
3148
+ });
3149
+ throw cause;
3150
+ } finally {
3151
+ client.release();
3152
+ }
3153
+ if (rejection !== void 0) throw rejection;
3154
+ return result;
3155
+ }
3156
+ /** Explicit operator discard. Automatic retention never deletes dead letters. */
3157
+ async discardDeadLetter(tenant, ref) {
3158
+ assertDeadLetterRef(ref);
3159
+ const client = await this.#pool.connect();
3160
+ let row;
3161
+ let rejection;
3162
+ try {
3163
+ await client.query("BEGIN");
3164
+ const existing = await client.query(
3165
+ `SELECT ${OUTBOX_COLUMNS2} FROM outbox
3166
+ WHERE tenant_id = $1 AND device_id = $2 AND seq = $3::bigint
3167
+ AND state = 'expired'
3168
+ FOR UPDATE`,
3169
+ [tenant, ref.deviceId, ref.seq]
3170
+ );
3171
+ const deadLetter = existing.rows[0];
3172
+ const usage = await client.query(
3173
+ "SELECT mailbox_bytes FROM storage_usage WHERE tenant_id = $1 FOR UPDATE",
3174
+ [tenant]
3175
+ );
3176
+ if (deadLetter === void 0) {
3177
+ rejection = deadLetterMissing(ref);
3178
+ } else if (usage.rows[0] === void 0 || usage.rows[0].mailbox_bytes < deadLetter.byte_size) {
3179
+ rejection = new CloudCleanupError(
3180
+ "cleanup_accounting_drift",
3181
+ `Mailbox accounting cannot release dead letter ${ref.deviceId}/${String(ref.seq)}.`
3182
+ );
3183
+ } else {
3184
+ const removed = await client.query(
3185
+ `DELETE FROM outbox
3186
+ WHERE tenant_id = $1 AND device_id = $2 AND seq = $3::bigint
3187
+ AND state = 'expired'
3188
+ RETURNING ${OUTBOX_COLUMNS2}`,
3189
+ [tenant, ref.deviceId, ref.seq]
3190
+ );
3191
+ await client.query(
3192
+ `UPDATE storage_usage
3193
+ SET mailbox_bytes = mailbox_bytes - $2::bigint, updated_at = $3
3194
+ WHERE tenant_id = $1`,
3195
+ [tenant, deadLetter.byte_size, this.#now()]
3196
+ );
3197
+ row = removed.rows[0];
3198
+ }
3199
+ if (rejection === void 0) await client.query("COMMIT");
3200
+ else await client.query("ROLLBACK");
3201
+ } catch (cause) {
3202
+ await client.query("ROLLBACK").catch(() => {
3203
+ });
3204
+ throw cause;
3205
+ } finally {
3206
+ client.release();
3207
+ }
3208
+ if (rejection !== void 0) throw rejection;
3209
+ return toMailboxMessage(row);
3210
+ }
3211
+ /**
3212
+ * Explicit recovery operation: rebuild object accounting from committed
3213
+ * Postgres manifests. Reconciliation must run first; R2 LIST is never used as
3214
+ * billing authority and inline/mailbox usage is left untouched.
3215
+ */
3216
+ async rebuildObjectUsage(tenant) {
3217
+ const client = await this.#pool.connect();
3218
+ try {
3219
+ await client.query("BEGIN");
3220
+ const locked = await client.query(
3221
+ "SELECT 1 FROM storage_usage WHERE tenant_id = $1 FOR UPDATE",
3222
+ [tenant]
3223
+ );
3224
+ if (locked.rowCount === 0) {
3225
+ throw new CloudCleanupError(
3226
+ "cleanup_policy_missing",
3227
+ `Tenant ${tenant} has no storage usage row to rebuild.`
3228
+ );
3229
+ }
3230
+ const rebuilt = await client.query(
3231
+ `WITH authority AS MATERIALIZED (
3232
+ SELECT COALESCE(SUM(byte_size), 0)::bigint AS committed_object_bytes,
3233
+ count(*)::bigint AS object_count
3234
+ FROM object_manifest
3235
+ WHERE tenant_id = $1 AND state = 'committed'
3236
+ )
3237
+ UPDATE storage_usage u
3238
+ SET committed_object_bytes = authority.committed_object_bytes,
3239
+ object_count = authority.object_count,
3240
+ updated_at = $2
3241
+ FROM authority
3242
+ WHERE u.tenant_id = $1
3243
+ RETURNING u.committed_object_bytes, u.object_count, u.updated_at`,
3244
+ [tenant, this.#now()]
3245
+ );
3246
+ await client.query("COMMIT");
3247
+ const row = rebuilt.rows[0];
3248
+ return {
3249
+ committedObjectBytes: row.committed_object_bytes,
3250
+ objectCount: row.object_count,
3251
+ updatedAt: row.updated_at
3252
+ };
3253
+ } catch (cause) {
3254
+ await client.query("ROLLBACK").catch(() => {
3255
+ });
3256
+ throw cause;
3257
+ } finally {
3258
+ client.release();
3259
+ }
3260
+ }
3261
+ async #readRetentionPolicy(queryable, tenant) {
3262
+ const result = await queryable.query(
3263
+ `SELECT p.tenant_id, p.policy_id, p.mailbox_acked_retention_ms,
3264
+ p.mailbox_unacked_retention_ms, p.request_receipt_retention_ms,
3265
+ p.object_orphan_grace_ms, p.updated_at
3266
+ FROM storage_entitlement e
3267
+ JOIN tenant_retention_policy p
3268
+ ON p.tenant_id = e.tenant_id AND p.policy_id = e.retention_policy_id
3269
+ WHERE e.tenant_id = $1`,
3270
+ [tenant]
3271
+ );
3272
+ const row = result.rows[0];
3273
+ if (row === void 0) {
3274
+ throw new CloudCleanupError(
3275
+ "cleanup_policy_missing",
3276
+ `Tenant ${tenant} has no retention policy matching its entitlement.`
3277
+ );
3278
+ }
3279
+ const policy = toPolicy(row);
3280
+ assertPolicy(policy);
3281
+ return policy;
3282
+ }
3283
+ async #startJob(client, tenant, jobId) {
3284
+ const now = this.#now();
3285
+ const started = await client.query(
3286
+ `INSERT INTO cleanup_job (tenant_id, job_id, kind, state, started_at)
3287
+ VALUES ($1, $2, 'tenant_cleanup', 'running', $3)
3288
+ ON CONFLICT (tenant_id, job_id) DO UPDATE
3289
+ SET state = 'running', started_at = EXCLUDED.started_at,
3290
+ finished_at = NULL, error_message = NULL
3291
+ WHERE cleanup_job.state IN ('running', 'failed')
3292
+ RETURNING ${JOB_COLUMNS}`,
3293
+ [tenant, jobId, now]
3294
+ );
3295
+ if (started.rows[0] !== void 0) return void 0;
3296
+ const existing = await this.#readJob(client, tenant, jobId);
3297
+ return toCleanupResult(existing);
3298
+ }
3299
+ async #runRetention(client, tenant, policy) {
3300
+ const ackedBefore = cutoff(this.#clock.now(), policy.mailboxAckedRetentionMs);
3301
+ const expireBefore = cutoff(this.#clock.now(), policy.mailboxUnackedRetentionMs);
3302
+ const receiptBefore = cutoff(this.#clock.now(), policy.requestReceiptRetentionMs);
3303
+ const now = this.#now();
3304
+ try {
3305
+ await client.query("BEGIN");
3306
+ const swept = await client.query(
3307
+ `WITH deleted AS (
3308
+ DELETE FROM outbox
3309
+ WHERE tenant_id = $1 AND state = 'acked' AND appended_at < $2
3310
+ RETURNING byte_size
3311
+ ), released AS MATERIALIZED (
3312
+ SELECT COALESCE(SUM(byte_size), 0)::bigint AS bytes FROM deleted
3313
+ ), expired AS (
3314
+ UPDATE outbox SET state = 'expired'
3315
+ WHERE tenant_id = $1 AND state = 'pending' AND appended_at < $3
3316
+ RETURNING 1
3317
+ ), reservations AS (
3318
+ UPDATE storage_reservation SET state = 'expired', settled_at = $4
3319
+ WHERE tenant_id = $1 AND state = 'reserved' AND expires_at <= $4
3320
+ RETURNING 1
3321
+ ), nonces AS (
3322
+ DELETE FROM auth_nonce
3323
+ WHERE tenant_id = $1 AND (used OR expires_at <= $4::timestamptz)
3324
+ RETURNING 1
3325
+ ), pairing_codes AS (
3326
+ DELETE FROM pairing_code
3327
+ WHERE tenant_id = $1
3328
+ AND (redeemed_at IS NOT NULL OR expires_at <= $4::timestamptz)
3329
+ RETURNING 1
3330
+ ), receipts AS (
3331
+ DELETE FROM device_request_receipts
3332
+ WHERE tenant_id = $1 AND recorded_at < $5::timestamptz
3333
+ RETURNING 1
3334
+ ), presence AS (
3335
+ DELETE FROM device_presence
3336
+ WHERE tenant_id = $1 AND expires_at <= $4
3337
+ RETURNING 1
3338
+ ), activity AS (
3339
+ DELETE FROM activity_tail
3340
+ WHERE tenant_id = $1 AND expires_at <= $4
3341
+ RETURNING 1
3342
+ ), accounted AS (
3343
+ UPDATE storage_usage u
3344
+ SET mailbox_bytes = u.mailbox_bytes - released.bytes, updated_at = $4
3345
+ FROM released
3346
+ WHERE u.tenant_id = $1 AND u.mailbox_bytes >= released.bytes
3347
+ RETURNING released.bytes
3348
+ )
3349
+ SELECT (SELECT count(*) FROM deleted)::bigint AS mailbox_deleted_count,
3350
+ (SELECT count(*) FROM expired)::bigint AS mailbox_expired_count,
3351
+ (SELECT bytes FROM released)::bigint AS mailbox_released_bytes,
3352
+ (SELECT count(*) FROM accounted)::bigint AS usage_accounted,
3353
+ (SELECT count(*) FROM reservations)::bigint AS reservations_expired,
3354
+ ((SELECT count(*) FROM nonces)
3355
+ + (SELECT count(*) FROM pairing_codes)
3356
+ + (SELECT count(*) FROM receipts)
3357
+ + (SELECT count(*) FROM presence)
3358
+ + (SELECT count(*) FROM activity))::bigint AS ttl_rows_deleted`,
3359
+ [tenant, ackedBefore, expireBefore, now, receiptBefore]
3360
+ );
3361
+ const result = swept.rows[0];
3362
+ if (result.usage_accounted !== 1n) {
3363
+ throw new CloudCleanupError(
3364
+ "cleanup_accounting_drift",
3365
+ `Mailbox accounting cannot release ${String(result.mailbox_released_bytes)} deleted bytes for tenant ${tenant}.`
3366
+ );
3367
+ }
3368
+ await client.query("COMMIT");
3369
+ return result;
3370
+ } catch (cause) {
3371
+ await client.query("ROLLBACK").catch(() => {
3372
+ });
3373
+ throw cause;
3374
+ }
3375
+ }
3376
+ async #markTombstones(client, tenant, orphanCutoff) {
3377
+ try {
3378
+ await client.query("BEGIN");
3379
+ await client.query(
3380
+ "SELECT 1 FROM storage_entitlement WHERE tenant_id = $1 FOR UPDATE",
3381
+ [tenant]
3382
+ );
3383
+ const marked = await client.query(
3384
+ `WITH candidates AS MATERIALIZED (
3385
+ SELECT m.tenant_id, m.hash
3386
+ FROM object_manifest m
3387
+ WHERE m.tenant_id = $1
3388
+ AND m.state IN ('pending', 'committed')
3389
+ AND m.ref_count = 0
3390
+ AND m.updated_at < $2
3391
+ AND NOT EXISTS (
3392
+ SELECT 1 FROM object_reference r
3393
+ WHERE r.tenant_id = m.tenant_id AND r.hash = m.hash
3394
+ )
3395
+ AND NOT EXISTS (
3396
+ SELECT 1 FROM storage_reservation s
3397
+ WHERE s.tenant_id = m.tenant_id AND s.content_hash = m.hash
3398
+ AND s.state = 'reserved'
3399
+ )
3400
+ ORDER BY m.updated_at, m.hash
3401
+ LIMIT $3
3402
+ FOR UPDATE OF m SKIP LOCKED
3403
+ )
3404
+ UPDATE object_manifest m
3405
+ SET gc_accounted_bytes = CASE WHEN m.state = 'committed' THEN m.byte_size ELSE 0 END,
3406
+ gc_accounted_object = (m.state = 'committed'),
3407
+ state = 'delete_pending', delete_pending_at = $4, updated_at = $4
3408
+ FROM candidates c
3409
+ WHERE m.tenant_id = c.tenant_id AND m.hash = c.hash
3410
+ RETURNING m.hash`,
3411
+ [tenant, orphanCutoff, this.#batchSize, this.#now()]
3412
+ );
3413
+ await client.query("COMMIT");
3414
+ return BigInt(marked.rowCount ?? 0);
3415
+ } catch (cause) {
3416
+ await client.query("ROLLBACK").catch(() => {
3417
+ });
3418
+ throw cause;
3419
+ }
3420
+ }
3421
+ async #settleDeleted(client, tenant, hash) {
3422
+ const settled = await client.query(
3423
+ `WITH candidate AS MATERIALIZED (
3424
+ SELECT m.gc_accounted_bytes, m.gc_accounted_object
3425
+ FROM object_manifest m
3426
+ JOIN storage_usage u ON u.tenant_id = m.tenant_id
3427
+ WHERE m.tenant_id = $1 AND m.hash = $2
3428
+ AND m.state = 'delete_pending'
3429
+ AND m.ref_count = 0
3430
+ AND m.gc_accounted_bytes IS NOT NULL
3431
+ AND m.gc_accounted_object IS NOT NULL
3432
+ AND NOT EXISTS (
3433
+ SELECT 1 FROM object_reference r
3434
+ WHERE r.tenant_id = m.tenant_id AND r.hash = m.hash
3435
+ )
3436
+ AND u.committed_object_bytes >= m.gc_accounted_bytes
3437
+ AND u.object_count >= CASE WHEN m.gc_accounted_object THEN 1 ELSE 0 END
3438
+ FOR UPDATE OF m, u
3439
+ ), moved AS (
3440
+ UPDATE object_manifest m
3441
+ SET state = 'deleted', updated_at = $3
3442
+ FROM candidate c
3443
+ WHERE m.tenant_id = $1 AND m.hash = $2 AND m.state = 'delete_pending'
3444
+ RETURNING c.gc_accounted_bytes, c.gc_accounted_object
3445
+ ), accounted AS (
3446
+ UPDATE storage_usage u
3447
+ SET committed_object_bytes = u.committed_object_bytes - moved.gc_accounted_bytes,
3448
+ object_count = u.object_count - CASE WHEN moved.gc_accounted_object THEN 1 ELSE 0 END,
3449
+ updated_at = $3
3450
+ FROM moved
3451
+ WHERE u.tenant_id = $1
3452
+ RETURNING moved.gc_accounted_bytes
3453
+ )
3454
+ SELECT gc_accounted_bytes FROM accounted`,
3455
+ [tenant, hash, this.#now()]
3456
+ );
3457
+ const row = settled.rows[0];
3458
+ if (row !== void 0) return row.gc_accounted_bytes;
3459
+ const current = await client.query(
3460
+ "SELECT state FROM object_manifest WHERE tenant_id = $1 AND hash = $2",
3461
+ [tenant, hash]
3462
+ );
3463
+ if (current.rows[0]?.state === "deleted") return void 0;
3464
+ throw new CloudCleanupError(
3465
+ "cleanup_accounting_drift",
3466
+ `Object ${hash} could not settle its delete tombstone against storage usage.`
3467
+ );
3468
+ }
3469
+ async #reconcileManifests(client, tenant, counts) {
3470
+ const cursor = await this.#readCursor(client, tenant, "manifest");
3471
+ const page = await client.query(
3472
+ `SELECT hash, byte_size, content_type, state,
3473
+ gc_accounted_bytes, gc_accounted_object
3474
+ FROM object_manifest
3475
+ WHERE tenant_id = $1
3476
+ AND state IN ('committed', 'delete_pending')
3477
+ AND hash > $2
3478
+ ORDER BY hash
3479
+ LIMIT $3`,
3480
+ [tenant, cursor ?? "", this.#batchSize]
3481
+ );
3482
+ for (const manifest of page.rows) {
3483
+ const observed = await this.#objectStorage.inspectObject(
3484
+ tenant,
3485
+ manifest.hash
3486
+ );
3487
+ if (manifest.state === "delete_pending") {
3488
+ if (observed === void 0) {
3489
+ try {
3490
+ const released = await this.#settleDeleted(
3491
+ client,
3492
+ tenant,
3493
+ manifest.hash
3494
+ );
3495
+ if (released !== void 0) {
3496
+ counts.objectsDeleted += 1n;
3497
+ counts.objectReleasedBytes += released;
3498
+ }
3499
+ } catch {
3500
+ counts.operationErrors += 1n;
3501
+ }
3502
+ }
3503
+ continue;
3504
+ }
3505
+ if (observed === void 0) {
3506
+ counts.missingObjects += 1n;
3507
+ } else if (observed.observedByteSize !== manifest.byte_size || observed.observedContentType !== manifest.content_type) {
3508
+ counts.shapeDrift += 1n;
3509
+ }
3510
+ }
3511
+ await this.#advanceLexicalCursor(
3512
+ client,
3513
+ tenant,
3514
+ "manifest",
3515
+ page.rows.at(-1)?.hash,
3516
+ page.rows.length
3517
+ );
3518
+ }
3519
+ async #reconcileR2(client, tenant, counts) {
3520
+ const cursor = await this.#readCursor(client, tenant, "r2");
3521
+ const page = await this.#objectStorage.listTenantObjects(
3522
+ tenant,
3523
+ cursor ?? void 0,
3524
+ this.#batchSize
3525
+ );
3526
+ for (const object of page.objects) {
3527
+ if (object.hash === void 0) {
3528
+ counts.invalidObjectKeys += 1n;
3529
+ continue;
3530
+ }
3531
+ const manifest = await client.query(
3532
+ "SELECT state FROM object_manifest WHERE tenant_id = $1 AND hash = $2",
3533
+ [tenant, object.hash]
3534
+ );
3535
+ const state = manifest.rows[0]?.state;
3536
+ if (state !== void 0 && state !== "deleted") continue;
3537
+ const observed = await this.#objectStorage.inspectObject(tenant, object.hash);
3538
+ if (observed === void 0) continue;
3539
+ const witnessed = await client.query(
3540
+ `INSERT INTO object_manifest (
3541
+ tenant_id, hash, byte_size, content_type, state, ref_count,
3542
+ created_at, updated_at, delete_pending_at,
3543
+ gc_accounted_bytes, gc_accounted_object
3544
+ ) VALUES ($1, $2, $3, $4, 'pending', 0, $5, $5, NULL, NULL, NULL)
3545
+ ON CONFLICT (tenant_id, hash) DO UPDATE
3546
+ SET byte_size = EXCLUDED.byte_size,
3547
+ content_type = EXCLUDED.content_type,
3548
+ state = 'pending', ref_count = 0,
3549
+ created_at = EXCLUDED.created_at,
3550
+ updated_at = EXCLUDED.updated_at,
3551
+ delete_pending_at = NULL,
3552
+ gc_accounted_bytes = NULL,
3553
+ gc_accounted_object = NULL
3554
+ WHERE object_manifest.state = 'deleted'
3555
+ RETURNING 1`,
3556
+ [
3557
+ tenant,
3558
+ object.hash,
3559
+ observed.observedByteSize,
3560
+ observed.observedContentType,
3561
+ this.#now()
3562
+ ]
3563
+ );
3564
+ counts.orphanWitnessesCreated += BigInt(witnessed.rowCount ?? 0);
3565
+ }
3566
+ if (page.nextContinuationToken === void 0) {
3567
+ await this.#clearCursor(client, tenant, "r2");
3568
+ } else {
3569
+ await this.#writeCursor(client, tenant, "r2", page.nextContinuationToken);
3570
+ }
3571
+ }
3572
+ async #advanceLexicalCursor(client, tenant, kind, lastValue, rowCount) {
3573
+ if (lastValue === void 0 || rowCount < this.#batchSize) {
3574
+ await this.#clearCursor(client, tenant, kind);
3575
+ } else {
3576
+ await this.#writeCursor(client, tenant, kind, lastValue);
3577
+ }
3578
+ }
3579
+ async #readCursor(client, tenant, kind) {
3580
+ const result = await client.query(
3581
+ "SELECT cursor_value FROM gc_cursor WHERE tenant_id = $1 AND cursor_kind = $2",
3582
+ [tenant, kind]
3583
+ );
3584
+ return result.rows[0]?.cursor_value ?? null;
3585
+ }
3586
+ async #writeCursor(client, tenant, kind, value) {
3587
+ await client.query(
3588
+ `INSERT INTO gc_cursor (tenant_id, cursor_kind, cursor_value, updated_at)
3589
+ VALUES ($1, $2, $3, $4)
3590
+ ON CONFLICT (tenant_id, cursor_kind) DO UPDATE
3591
+ SET cursor_value = EXCLUDED.cursor_value, updated_at = EXCLUDED.updated_at`,
3592
+ [tenant, kind, value, this.#now()]
3593
+ );
3594
+ }
3595
+ async #clearCursor(client, tenant, kind) {
3596
+ await client.query(
3597
+ "DELETE FROM gc_cursor WHERE tenant_id = $1 AND cursor_kind = $2",
3598
+ [tenant, kind]
3599
+ );
3600
+ }
3601
+ async #finishJob(client, tenant, jobId, state, counts) {
3602
+ const finished = await client.query(
3603
+ `UPDATE cleanup_job SET
3604
+ state = $3, finished_at = $4,
3605
+ mailbox_deleted_count = $5, mailbox_expired_count = $6,
3606
+ mailbox_released_bytes = $7, reservations_expired = $8,
3607
+ ttl_rows_deleted = $9,
3608
+ objects_tombstoned = $10, objects_deleted = $11,
3609
+ object_released_bytes = $12, orphan_witnesses_created = $13,
3610
+ missing_objects = $14, shape_drift = $15,
3611
+ invalid_object_keys = $16, operation_errors = $17,
3612
+ error_message = NULL
3613
+ WHERE tenant_id = $1 AND job_id = $2
3614
+ RETURNING ${JOB_COLUMNS}`,
3615
+ [
3616
+ tenant,
3617
+ jobId,
3618
+ state,
3619
+ this.#now(),
3620
+ counts.mailboxDeletedCount,
3621
+ counts.mailboxExpiredCount,
3622
+ counts.mailboxReleasedBytes,
3623
+ counts.reservationsExpired,
3624
+ counts.ttlRowsDeleted,
3625
+ counts.objectsTombstoned,
3626
+ counts.objectsDeleted,
3627
+ counts.objectReleasedBytes,
3628
+ counts.orphanWitnessesCreated,
3629
+ counts.missingObjects,
3630
+ counts.shapeDrift,
3631
+ counts.invalidObjectKeys,
3632
+ counts.operationErrors
3633
+ ]
3634
+ );
3635
+ return toCleanupResult(finished.rows[0]);
3636
+ }
3637
+ async #failJob(client, tenant, jobId, cause) {
3638
+ const message = (cause instanceof Error ? cause.message : String(cause)).slice(0, 2e3);
3639
+ await client.query(
3640
+ `UPDATE cleanup_job
3641
+ SET state = 'failed', finished_at = $3, error_message = $4
3642
+ WHERE tenant_id = $1 AND job_id = $2`,
3643
+ [tenant, jobId, this.#now(), message]
3644
+ );
3645
+ }
3646
+ async #readJob(client, tenant, jobId) {
3647
+ const result = await client.query(
3648
+ `SELECT ${JOB_COLUMNS} FROM cleanup_job WHERE tenant_id = $1 AND job_id = $2`,
3649
+ [tenant, jobId]
3650
+ );
3651
+ return result.rows[0];
3652
+ }
3653
+ #now() {
3654
+ return this.#clock.now().toISOString();
3655
+ }
3656
+ };
3657
+ function createPostgresCloudMaintenance(options) {
3658
+ const objectStorage = new R2ObjectMaintenanceStore(options.objectStorage);
3659
+ return new PostgresCloudCleanup({
3660
+ pool: options.pool,
3661
+ clock: options.clock,
3662
+ objectStorage,
3663
+ ...options.batchSize === void 0 ? {} : { batchSize: options.batchSize }
3664
+ });
3665
+ }
3666
+ var JOB_COLUMNS = [
3667
+ "tenant_id",
3668
+ "job_id",
3669
+ "state",
3670
+ "started_at",
3671
+ "finished_at",
3672
+ "mailbox_deleted_count",
3673
+ "mailbox_expired_count",
3674
+ "mailbox_released_bytes",
3675
+ "reservations_expired",
3676
+ "ttl_rows_deleted",
3677
+ "objects_tombstoned",
3678
+ "objects_deleted",
3679
+ "object_released_bytes",
3680
+ "orphan_witnesses_created",
3681
+ "missing_objects",
3682
+ "shape_drift",
3683
+ "invalid_object_keys",
3684
+ "operation_errors",
3685
+ "error_message"
3686
+ ].join(", ");
3687
+ function emptyCounts() {
3688
+ return {
3689
+ mailboxDeletedCount: 0n,
3690
+ mailboxExpiredCount: 0n,
3691
+ mailboxReleasedBytes: 0n,
3692
+ reservationsExpired: 0n,
3693
+ ttlRowsDeleted: 0n,
3694
+ objectsTombstoned: 0n,
3695
+ objectsDeleted: 0n,
3696
+ objectReleasedBytes: 0n,
3697
+ orphanWitnessesCreated: 0n,
3698
+ missingObjects: 0n,
3699
+ shapeDrift: 0n,
3700
+ invalidObjectKeys: 0n,
3701
+ operationErrors: 0n
3702
+ };
3703
+ }
3704
+ function toPolicy(row) {
3705
+ return {
3706
+ tenantId: tenantId(row.tenant_id),
3707
+ policyId: row.policy_id,
3708
+ mailboxAckedRetentionMs: row.mailbox_acked_retention_ms,
3709
+ mailboxUnackedRetentionMs: row.mailbox_unacked_retention_ms,
3710
+ requestReceiptRetentionMs: row.request_receipt_retention_ms,
3711
+ objectOrphanGraceMs: row.object_orphan_grace_ms,
3712
+ updatedAt: row.updated_at
3713
+ };
3714
+ }
3715
+ function toCleanupResult(row) {
3716
+ return {
3717
+ tenantId: tenantId(row.tenant_id),
3718
+ jobId: row.job_id,
3719
+ state: row.state,
3720
+ startedAt: row.started_at,
3721
+ ...row.finished_at === null ? {} : { finishedAt: row.finished_at },
3722
+ mailboxDeletedCount: row.mailbox_deleted_count,
3723
+ mailboxExpiredCount: row.mailbox_expired_count,
3724
+ mailboxReleasedBytes: row.mailbox_released_bytes,
3725
+ reservationsExpired: row.reservations_expired,
3726
+ ttlRowsDeleted: row.ttl_rows_deleted,
3727
+ objectsTombstoned: row.objects_tombstoned,
3728
+ objectsDeleted: row.objects_deleted,
3729
+ objectReleasedBytes: row.object_released_bytes,
3730
+ orphanWitnessesCreated: row.orphan_witnesses_created,
3731
+ missingObjects: row.missing_objects,
3732
+ shapeDrift: row.shape_drift,
3733
+ invalidObjectKeys: row.invalid_object_keys,
3734
+ operationErrors: row.operation_errors,
3735
+ ...row.error_message === null ? {} : { errorMessage: row.error_message }
3736
+ };
3737
+ }
3738
+ function toMailboxMessage(row) {
3739
+ return {
3740
+ tenantId: tenantId(row.tenant_id),
3741
+ deviceId: row.device_id,
3742
+ seq: Number(row.seq),
3743
+ messageId: row.message_id,
3744
+ body: row.body,
3745
+ bodyHash: row.body_hash,
3746
+ byteSize: row.byte_size,
3747
+ state: row.state,
3748
+ appendedAt: row.appended_at
3749
+ };
3750
+ }
3751
+ function materializeReplayBody(original, seq) {
3752
+ try {
3753
+ const envelope = decodeEnvelope(original.body);
3754
+ if (!isServerToDaemonType(envelope.type)) {
3755
+ throw new Error(`Envelope type ${envelope.type} is not server-to-daemon.`);
3756
+ }
3757
+ const rebound = EnvelopeSchema.parse({ ...envelope, seq });
3758
+ const body = encodeEnvelope(rebound);
3759
+ const bytes = new TextEncoder().encode(body);
3760
+ return {
3761
+ body,
3762
+ bodyHash: contentHash(`sha256:${createHash("sha256").update(bytes).digest("hex")}`),
3763
+ byteSize: BigInt(bytes.length)
3764
+ };
3765
+ } catch (cause) {
3766
+ throw new CloudCleanupError(
3767
+ "cleanup_invalid_input",
3768
+ `Dead letter ${original.device_id}/${String(original.seq)} is not a replayable server-to-daemon envelope.`,
3769
+ { cause }
3770
+ );
3771
+ }
3772
+ }
3773
+ function replayMatches(row, original) {
3774
+ if (row.replay_source_seq !== original.seq) return false;
3775
+ const expected = materializeReplayBody(original, Number(row.seq));
3776
+ return row.body === expected.body && row.body_hash === expected.bodyHash && row.byte_size === expected.byteSize;
3777
+ }
3778
+ function assertPolicy(input) {
3779
+ assertIdentifier(input.policyId, "policyId");
3780
+ for (const [field, value] of [
3781
+ ["mailboxAckedRetentionMs", input.mailboxAckedRetentionMs],
3782
+ ["mailboxUnackedRetentionMs", input.mailboxUnackedRetentionMs],
3783
+ ["requestReceiptRetentionMs", input.requestReceiptRetentionMs],
3784
+ ["objectOrphanGraceMs", input.objectOrphanGraceMs]
3785
+ ]) {
3786
+ if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) {
3787
+ throw new CloudCleanupError(
3788
+ "cleanup_invalid_input",
3789
+ `${field} must be a non-negative duration no larger than Number.MAX_SAFE_INTEGER milliseconds.`
3790
+ );
3791
+ }
3792
+ }
3793
+ }
3794
+ function assertBatchSize(value) {
3795
+ if (!Number.isInteger(value) || value < 1 || value > MAX_BATCH_SIZE) {
3796
+ throw new CloudCleanupError(
3797
+ "cleanup_invalid_input",
3798
+ `batchSize/limit must be a whole number in [1, ${String(MAX_BATCH_SIZE)}].`
3799
+ );
3800
+ }
3801
+ return value;
3802
+ }
3803
+ function assertIdentifier(value, field) {
3804
+ if (value.length === 0 || value.length > 256 || value.trim() !== value) {
3805
+ throw new CloudCleanupError(
3806
+ "cleanup_invalid_input",
3807
+ `${field} must be a non-empty, unpadded string no longer than 256 characters.`
3808
+ );
3809
+ }
3810
+ }
3811
+ function assertDeadLetterRef(ref) {
3812
+ assertIdentifier(ref.deviceId, "deviceId");
3813
+ if (!Number.isSafeInteger(ref.seq) || ref.seq < 1) {
3814
+ throw new CloudCleanupError(
3815
+ "cleanup_invalid_input",
3816
+ "A dead-letter seq must be a positive safe integer."
3817
+ );
3818
+ }
3819
+ }
3820
+ function cutoff(now, durationMs) {
3821
+ return new Date(now.getTime() - Number(durationMs)).toISOString();
3822
+ }
3823
+ function deadLetterMissing(ref) {
3824
+ return new CloudCleanupError(
3825
+ "cleanup_dead_letter_not_found",
3826
+ `Expired mailbox row ${ref.deviceId}/${String(ref.seq)} was not found.`
3827
+ );
3828
+ }
3829
+ 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";
3830
+ var RECEIPT_COLUMNS = "tenant_id, device_id, request_id, operation, resource, body_sha256, body_size, response_status, response_body, recorded_at";
3831
+ function toBody2(row) {
3832
+ return row.body_kind === "inline" ? { kind: "inline", body: row.body_inline ?? "" } : { kind: "object", hash: row.body_object_hash ?? "" };
3833
+ }
3834
+ function toRecord3(tenant, row) {
3835
+ return {
3836
+ tenantId: tenant,
3837
+ kind: row.kind,
3838
+ recordKey: row.subject_id,
3839
+ rev: row.rev,
3840
+ contentHash: row.content_hash,
3841
+ byteSize: row.byte_size,
3842
+ body: toBody2(row),
3843
+ ...row.label === null ? {} : { label: row.label },
3844
+ ...row.request_id === null ? {} : { requestId: row.request_id },
3845
+ writtenAt: row.written_at
3846
+ };
3847
+ }
3848
+ function toReceipt3(tenant, row) {
3849
+ return {
3850
+ tenantId: tenant,
3851
+ deviceId: row.device_id,
3852
+ requestId: row.request_id,
3853
+ operation: row.operation,
3854
+ resource: row.resource,
3855
+ bodySha256: row.body_sha256,
3856
+ bodySize: row.body_size,
3857
+ responseStatus: row.response_status,
3858
+ responseBody: row.response_body,
3859
+ recordedAt: row.recorded_at.toISOString()
3860
+ };
3861
+ }
3862
+ function sameBinding(receipt, input) {
3863
+ return receipt.operation === input.operation && receipt.resource === input.resource && receipt.bodySha256 === input.proofBodySha256 && receipt.bodySize === input.proofBodySize;
3864
+ }
3865
+ function bodyColumns2(body) {
3866
+ return body.kind === "inline" ? ["inline", body.body, null] : ["object", null, body.hash];
3867
+ }
3868
+ function writeKey(write) {
3869
+ return `${write.kind}\0${write.recordKey}`;
3870
+ }
3871
+ function referenceId(write) {
3872
+ return `${write.kind}:${write.recordKey}`;
3873
+ }
3874
+ var PostgresTruthCommitter = class {
3875
+ #pool;
3876
+ #clock;
3877
+ #crypto;
3878
+ #truth;
3879
+ constructor(options) {
3880
+ this.#pool = options.pool;
3881
+ this.#clock = options.clock;
3882
+ this.#crypto = options.crypto;
3883
+ this.#truth = new PostgresTruthStore(options.pool, options.clock);
3884
+ }
3885
+ getRecord(tenant, selector) {
3886
+ return this.#truth.getRecord(tenant, selector);
3887
+ }
3888
+ listManifest(tenant, query) {
3889
+ return this.#truth.listManifest(tenant, query);
3890
+ }
3891
+ async commit(tenant, input) {
3892
+ await this.#validateInput(input);
3893
+ const client = await this.#pool.connect();
3894
+ try {
3895
+ await client.query("BEGIN");
3896
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
3897
+ JSON.stringify(["truth-receipt", tenant, input.deviceId, input.requestId])
3898
+ ]);
3899
+ const replay = await this.#readReceipt(client, tenant, input.deviceId, input.requestId);
3900
+ if (replay !== void 0) {
3901
+ if (!sameBinding(replay, input)) {
3902
+ throw new TruthCommitError(
3903
+ "proof_request_conflict",
3904
+ `Request ${input.requestId} was already used with a different binding.`
3905
+ );
3906
+ }
3907
+ const response2 = TruthCommitResponseSchema.parse(JSON.parse(replay.responseBody));
3908
+ await client.query("COMMIT");
3909
+ return { response: response2, replayed: true };
3910
+ }
3911
+ const before = await this.#lockCurrentRecords(client, tenant, input.writes);
3912
+ this.#assertWritePreconditions(input.writes, before);
3913
+ await this.#lockAndVerifyObjects(client, tenant, input.writes, before);
3914
+ const inlineAffected = input.writes.some((write) => {
3915
+ const current = before.get(writeKey(write));
3916
+ if (write.kind === "task.terminal" && current !== void 0) return false;
3917
+ return current?.body.kind === "inline" || write.body.kind === "inline";
3918
+ });
3919
+ const inlineDelta = inlineAffected ? await this.#prepareInlineAccounting(client, tenant, input.writes, before) : 0n;
3920
+ const applied = await this.#applyWrites(client, tenant, input, before);
3921
+ await this.#replaceObjectReferences(client, tenant, applied);
3922
+ await this.#settleInlineAccounting(client, tenant, inlineDelta);
3923
+ const response = {
3924
+ primary: truthRecordMetadata(applied[0].record),
3925
+ snapshots: applied.slice(1).map((entry) => truthRecordMetadata(entry.record))
3926
+ };
3927
+ await client.query(
3928
+ `INSERT INTO proof_request_receipt (${RECEIPT_COLUMNS})
3929
+ VALUES ($1, $2, $3, $4, $5, $6, $7::bigint, 200, $8, $9)`,
3930
+ [
3931
+ tenant,
3932
+ input.deviceId,
3933
+ input.requestId,
3934
+ input.operation,
3935
+ input.resource,
3936
+ input.proofBodySha256,
3937
+ input.proofBodySize,
3938
+ JSON.stringify(response),
3939
+ this.#now()
3940
+ ]
3941
+ );
3942
+ await client.query("COMMIT");
3943
+ return { response, replayed: false };
3944
+ } catch (error) {
3945
+ await client.query("ROLLBACK").catch(() => void 0);
3946
+ throw error;
3947
+ } finally {
3948
+ client.release();
3949
+ }
3950
+ }
3951
+ async #validateInput(input) {
3952
+ if (input.requestId.length === 0 || input.requestId.length > TRUTH_REQUEST_ID_MAX_LENGTH) {
3953
+ throw new TruthCommitError("proof_request_conflict", "Request id is outside the record contract.");
3954
+ }
3955
+ const seen = /* @__PURE__ */ new Set();
3956
+ const objectSizes = /* @__PURE__ */ new Map();
3957
+ for (const write of input.writes) {
3958
+ const key = writeKey(write);
3959
+ if (seen.has(key)) {
3960
+ throw new TruthCommitError("proof_request_conflict", `Duplicate truth write ${key}.`);
3961
+ }
3962
+ seen.add(key);
3963
+ if (write.body.kind === "inline") {
3964
+ const bytes = new TextEncoder().encode(write.body.body);
3965
+ if (BigInt(bytes.byteLength) !== write.byteSize) {
3966
+ throw new ByokCoreError("storage_integrity_mismatch", "Inline byte size disagrees with its content.");
3967
+ }
3968
+ if (await this.#crypto.sha256(bytes) !== write.contentHash) {
3969
+ throw new ByokCoreError("storage_integrity_mismatch", "Inline hash disagrees with its content.");
3970
+ }
3971
+ } else if (write.body.hash !== write.contentHash) {
3972
+ throw new ByokCoreError("storage_integrity_mismatch", "Object body hash disagrees with record hash.");
3973
+ } else {
3974
+ const priorSize = objectSizes.get(write.body.hash);
3975
+ if (priorSize !== void 0 && priorSize !== write.byteSize) {
3976
+ throw new ByokCoreError(
3977
+ "storage_integrity_mismatch",
3978
+ `Object ${write.body.hash} was declared with inconsistent byte sizes.`
3979
+ );
3980
+ }
3981
+ objectSizes.set(write.body.hash, write.byteSize);
3982
+ }
3983
+ }
3984
+ }
3985
+ async #readReceipt(client, tenant, deviceId, requestId) {
3986
+ const result = await client.query(
3987
+ `SELECT ${RECEIPT_COLUMNS} FROM proof_request_receipt
3988
+ WHERE tenant_id = $1 AND device_id = $2 AND request_id = $3`,
3989
+ [tenant, deviceId, requestId]
3990
+ );
3991
+ const row = result.rows[0];
3992
+ return row === void 0 ? void 0 : toReceipt3(tenant, row);
3993
+ }
3994
+ async #lockCurrentRecords(client, tenant, writes) {
3995
+ const current = /* @__PURE__ */ new Map();
3996
+ const ordered = [...writes].sort((a, b) => writeKey(a).localeCompare(writeKey(b)));
3997
+ for (const write of ordered) {
3998
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
3999
+ JSON.stringify(["truth-record", tenant, write.kind, write.recordKey])
4000
+ ]);
4001
+ }
4002
+ for (const write of ordered) {
4003
+ const result = await client.query(
4004
+ `SELECT ${RECORD_COLUMNS2} FROM attested_record
4005
+ WHERE tenant_id = $1 AND kind = $2 AND subject_id = $3
4006
+ FOR UPDATE`,
4007
+ [tenant, write.kind, write.recordKey]
4008
+ );
4009
+ current.set(
4010
+ writeKey(write),
4011
+ result.rows[0] === void 0 ? void 0 : toRecord3(tenant, result.rows[0])
4012
+ );
4013
+ }
4014
+ return current;
4015
+ }
4016
+ #assertWritePreconditions(writes, current) {
4017
+ for (const write of writes) {
4018
+ const before = current.get(writeKey(write));
4019
+ if (write.kind === "task.terminal") {
4020
+ if (before !== void 0 && before.contentHash !== write.contentHash) {
4021
+ throw new CoreConflictError(
4022
+ "terminal_conflict",
4023
+ `Task ${write.recordKey} already has a different immutable terminal.`,
4024
+ before,
4025
+ this.#now()
4026
+ );
4027
+ }
4028
+ } else if ((before?.rev ?? 0) !== write.expectedRev) {
4029
+ throw new CoreConflictError(
4030
+ "truth_revision_conflict",
4031
+ `${write.kind}/${write.recordKey} is at rev ${before?.rev ?? 0}, not ${write.expectedRev}.`,
4032
+ before,
4033
+ this.#now()
4034
+ );
4035
+ }
4036
+ }
4037
+ }
4038
+ async #lockAndVerifyObjects(client, tenant, writes, current) {
4039
+ const requested = /* @__PURE__ */ new Map();
4040
+ const affected = /* @__PURE__ */ new Set();
4041
+ for (const write of writes) {
4042
+ const before = current.get(writeKey(write));
4043
+ if (write.kind === "task.terminal" && before !== void 0) continue;
4044
+ if (before?.body.kind === "object") affected.add(before.body.hash);
4045
+ if (write.body.kind === "object") {
4046
+ const existing = requested.get(write.body.hash);
4047
+ if (existing !== void 0 && existing !== write.byteSize) {
4048
+ throw new ByokCoreError(
4049
+ "storage_integrity_mismatch",
4050
+ `Object ${write.body.hash} was declared with inconsistent byte sizes.`
4051
+ );
4052
+ }
4053
+ requested.set(write.body.hash, write.byteSize);
4054
+ affected.add(write.body.hash);
4055
+ }
4056
+ }
4057
+ for (const hash of [...affected].sort()) {
4058
+ const result = await client.query(
4059
+ `SELECT hash, byte_size, state FROM object_manifest
4060
+ WHERE tenant_id = $1 AND hash = $2 FOR UPDATE`,
4061
+ [tenant, hash]
4062
+ );
4063
+ const manifest = result.rows[0];
4064
+ const byteSize = requested.get(hash);
4065
+ if (manifest === void 0 || byteSize !== void 0 && (manifest.state !== "committed" || manifest.byte_size !== byteSize)) {
4066
+ throw new TruthCommitError(
4067
+ "truth_object_not_committed",
4068
+ `Object ${hash} is not a committed matching manifest.`
4069
+ );
4070
+ }
4071
+ }
4072
+ }
4073
+ async #prepareInlineAccounting(client, tenant, writes, current) {
4074
+ const entitlementResult = await client.query(
4075
+ `SELECT hard_limit_bytes, max_inline_bytes, downgrade_grace_until
4076
+ FROM storage_entitlement WHERE tenant_id = $1 FOR UPDATE`,
4077
+ [tenant]
4078
+ );
4079
+ const entitlement = entitlementResult.rows[0];
4080
+ if (entitlement === void 0) {
4081
+ throw new ByokCoreError("storage_entitlement_missing", "Tenant has no storage entitlement.");
4082
+ }
4083
+ const now = this.#now();
4084
+ await client.query(
4085
+ `UPDATE storage_reservation SET state = 'expired', settled_at = $2
4086
+ WHERE tenant_id = $1 AND state = 'reserved' AND expires_at <= $2`,
4087
+ [tenant, now]
4088
+ );
4089
+ const usageResult = await client.query(
4090
+ `SELECT u.committed_object_bytes, u.committed_inline_bytes,
4091
+ COALESCE((SELECT SUM(expected_bytes) FROM storage_reservation r
4092
+ WHERE r.tenant_id = $1 AND r.state = 'reserved'), 0)::bigint AS reserved_bytes
4093
+ FROM storage_usage u WHERE u.tenant_id = $1 FOR UPDATE`,
4094
+ [tenant]
4095
+ );
4096
+ const usage = usageResult.rows[0];
4097
+ if (usage === void 0) throw new Error(`storage usage for ${tenant} is missing`);
4098
+ const affectedHashes = /* @__PURE__ */ new Set();
4099
+ const sizes = /* @__PURE__ */ new Map();
4100
+ for (const write of writes) {
4101
+ const before = current.get(writeKey(write));
4102
+ if (write.kind === "task.terminal" && before !== void 0) continue;
4103
+ if (before?.body.kind === "inline") {
4104
+ affectedHashes.add(before.contentHash);
4105
+ sizes.set(before.contentHash, before.byteSize);
4106
+ }
4107
+ if (write.body.kind !== "inline") continue;
4108
+ if (write.byteSize > entitlement.max_inline_bytes) {
4109
+ throw new ByokCoreError(
4110
+ "storage_object_too_large",
4111
+ `Inline truth ${write.kind}/${write.recordKey} exceeds maxInlineBytes.`
4112
+ );
4113
+ }
4114
+ const knownSize = sizes.get(write.contentHash);
4115
+ if (knownSize !== void 0 && knownSize !== write.byteSize) {
4116
+ throw new ByokCoreError(
4117
+ "storage_integrity_mismatch",
4118
+ `Inline hash ${write.contentHash} was declared with inconsistent byte sizes.`
4119
+ );
4120
+ }
4121
+ affectedHashes.add(write.contentHash);
4122
+ sizes.set(write.contentHash, write.byteSize);
4123
+ }
4124
+ const hashes = [...affectedHashes].sort();
4125
+ const baseline = new Map(hashes.map((hash) => [hash, 0n]));
4126
+ const existing = await client.query(
4127
+ `SELECT content_hash, byte_size, count(*)::bigint AS ref_count
4128
+ FROM attested_record
4129
+ WHERE tenant_id = $1 AND body_kind = 'inline' AND content_hash = ANY($2::text[])
4130
+ GROUP BY content_hash, byte_size`,
4131
+ [tenant, hashes]
4132
+ );
4133
+ for (const row of existing.rows) {
4134
+ const knownSize = sizes.get(row.content_hash);
4135
+ if (knownSize !== void 0 && knownSize !== row.byte_size) {
4136
+ throw new ByokCoreError(
4137
+ "storage_integrity_mismatch",
4138
+ `Stored inline hash ${row.content_hash} disagrees on byte size.`
4139
+ );
4140
+ }
4141
+ if ((baseline.get(row.content_hash) ?? 0n) !== 0n) {
4142
+ throw new ByokCoreError(
4143
+ "storage_integrity_mismatch",
4144
+ `Stored inline hash ${row.content_hash} has multiple byte sizes.`
4145
+ );
4146
+ }
4147
+ baseline.set(row.content_hash, row.ref_count);
4148
+ sizes.set(row.content_hash, row.byte_size);
4149
+ }
4150
+ const projected = new Map(baseline);
4151
+ for (const write of writes) {
4152
+ const before = current.get(writeKey(write));
4153
+ if (write.kind === "task.terminal" && before !== void 0) continue;
4154
+ if (before?.body.kind === "inline") {
4155
+ projected.set(before.contentHash, (projected.get(before.contentHash) ?? 0n) - 1n);
4156
+ }
4157
+ if (write.body.kind === "inline") {
4158
+ projected.set(write.contentHash, (projected.get(write.contentHash) ?? 0n) + 1n);
4159
+ }
4160
+ }
4161
+ let delta = 0n;
4162
+ let newlyCommitted = 0n;
4163
+ for (const hash of hashes) {
4164
+ const before = baseline.get(hash) ?? 0n;
4165
+ const after = projected.get(hash) ?? 0n;
4166
+ if (after < 0n) throw new Error(`inline reference count for ${hash} would become negative`);
4167
+ const byteSize = sizes.get(hash);
4168
+ if (byteSize === void 0) throw new Error(`inline byte size for ${hash} is missing`);
4169
+ if (before === 0n && after > 0n) {
4170
+ delta += byteSize;
4171
+ newlyCommitted += byteSize;
4172
+ } else if (before > 0n && after === 0n) {
4173
+ delta -= byteSize;
4174
+ }
4175
+ }
4176
+ const used = usage.committed_object_bytes + usage.committed_inline_bytes + usage.reserved_bytes;
4177
+ if (newlyCommitted > 0n && used >= entitlement.hard_limit_bytes && entitlement.downgrade_grace_until !== null && entitlement.downgrade_grace_until <= now) {
4178
+ throw new ByokCoreError("storage_write_suspended", "Durable writes are suspended.");
4179
+ }
4180
+ if (used + delta > entitlement.hard_limit_bytes) {
4181
+ throw new ByokCoreError("storage_quota_exceeded", "Final inline truth usage exceeds quota.");
4182
+ }
4183
+ return delta;
4184
+ }
4185
+ async #applyWrites(client, tenant, input, current) {
4186
+ const applied = [];
4187
+ for (const write of input.writes) {
4188
+ const before = current.get(writeKey(write));
4189
+ if (write.kind === "task.terminal" && before !== void 0) {
4190
+ applied.push({ input: write, before, record: before, mutated: false });
4191
+ continue;
4192
+ }
4193
+ const [bodyKind, bodyInline, bodyObjectHash] = bodyColumns2(write.body);
4194
+ const values = [
4195
+ tenant,
4196
+ write.kind,
4197
+ write.recordKey,
4198
+ write.contentHash,
4199
+ write.byteSize,
4200
+ bodyKind,
4201
+ bodyInline,
4202
+ bodyObjectHash,
4203
+ write.label ?? null,
4204
+ input.requestId,
4205
+ this.#now()
4206
+ ];
4207
+ const result = before === void 0 ? await client.query(
4208
+ `INSERT INTO attested_record (${RECORD_COLUMNS2})
4209
+ VALUES ($1, $2, $3, 1, $4, $5, $6, $7, $8, $9, $10, $11)
4210
+ RETURNING ${RECORD_COLUMNS2}`,
4211
+ values
4212
+ ) : await client.query(
4213
+ `UPDATE attested_record
4214
+ SET rev = rev + 1, content_hash = $4, byte_size = $5,
4215
+ body_kind = $6, body_inline = $7, body_object_hash = $8,
4216
+ label = $9, request_id = $10, written_at = $11
4217
+ WHERE tenant_id = $1 AND kind = $2 AND subject_id = $3
4218
+ RETURNING ${RECORD_COLUMNS2}`,
4219
+ values
4220
+ );
4221
+ applied.push({
4222
+ input: write,
4223
+ before,
4224
+ record: toRecord3(tenant, result.rows[0]),
4225
+ mutated: true
4226
+ });
4227
+ }
4228
+ return applied;
4229
+ }
4230
+ async #replaceObjectReferences(client, tenant, applied) {
4231
+ const affected = /* @__PURE__ */ new Set();
4232
+ for (const entry of applied) {
4233
+ if (!entry.mutated) continue;
4234
+ const refId = referenceId(entry.input);
4235
+ if (entry.input.body.kind === "object") {
4236
+ affected.add(entry.input.body.hash);
4237
+ await client.query(
4238
+ `INSERT INTO object_reference (tenant_id, hash, ref_kind, ref_id, created_at)
4239
+ VALUES ($1, $2, 'truth', $3, $4)
4240
+ ON CONFLICT (tenant_id, hash, ref_kind, ref_id) DO NOTHING`,
4241
+ [tenant, entry.input.body.hash, refId, this.#now()]
4242
+ );
4243
+ }
4244
+ if (entry.before?.body.kind === "object" && (entry.input.body.kind !== "object" || entry.input.body.hash !== entry.before.body.hash)) {
4245
+ affected.add(entry.before.body.hash);
4246
+ await client.query(
4247
+ `DELETE FROM object_reference
4248
+ WHERE tenant_id = $1 AND hash = $2 AND ref_kind = 'truth' AND ref_id = $3`,
4249
+ [tenant, entry.before.body.hash, refId]
4250
+ );
4251
+ }
4252
+ }
4253
+ for (const hash of [...affected].sort()) {
4254
+ await client.query(
4255
+ `UPDATE object_manifest
4256
+ SET ref_count = (SELECT count(*) FROM object_reference r
4257
+ WHERE r.tenant_id = $1 AND r.hash = $2),
4258
+ updated_at = $3
4259
+ WHERE tenant_id = $1 AND hash = $2`,
4260
+ [tenant, hash, this.#now()]
4261
+ );
4262
+ }
4263
+ }
4264
+ async #settleInlineAccounting(client, tenant, delta) {
4265
+ if (delta !== 0n) {
4266
+ const updated = await client.query(
4267
+ `UPDATE storage_usage
4268
+ SET committed_inline_bytes = committed_inline_bytes + $2::bigint,
4269
+ updated_at = $3
4270
+ WHERE tenant_id = $1 AND committed_inline_bytes + $2::bigint >= 0
4271
+ RETURNING 1`,
4272
+ [tenant, delta, this.#now()]
4273
+ );
4274
+ if (updated.rowCount !== 1) throw new Error("inline accounting would become negative");
4275
+ }
4276
+ }
4277
+ #now() {
4278
+ return this.#clock.now().toISOString();
4279
+ }
4280
+ };
4281
+
4282
+ export { CLOUD_CLEANUP_ERROR_CODES, CloudCleanupError, DEFAULT_MAX_ATTEMPTS, DEFAULT_PRESIGN_TTL_SECONDS, DEFAULT_RETRY_DELAY_MS, MAX_PRESIGN_TTL_SECONDS, MIN_PRESIGN_TTL_SECONDS, MigrationChecksumMismatchError, MigrationFilenameError, ObjectStoreRequestError, PostgresActivityStore, PostgresBoardStore, PostgresCloudCleanup, PostgresDeviceDirectory, PostgresInboundDedupStore, PostgresMailboxStore, PostgresNonceStore, PostgresObjectStore, PostgresPairingCodeStore, PostgresPresenceStore, PostgresQuotaStore, PostgresRequestReceiptStore, PostgresTaskAttemptStore, PostgresTruthCommitter, PostgresTruthStore, R2BlobStoreError, R2CloudBlobStore, R2ObjectMaintenanceStore, R2_BLOB_ERROR_CODES, createByokPool, createPostgresCloudMaintenance, createPostgresCloudStores, createPostgresCoreStores, migrate, migrationsDir, readMigrationFiles };
4283
+ //# sourceMappingURL=index.js.map
4284
+ //# sourceMappingURL=index.js.map