@byok-sdk/cloud-dataplane 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import pg from 'pg';
2
+ import { DEDUP_RING_CAPACITY, NONCE_TTL_MS, validateActivityAppend, projectTimelineEvents, ByokCloudError, validateApprovalTimelineAppend, ApprovalObservationSchema, AllowAllRateLimiter, TruthCommitError, TruthCommitResponseSchema, truthRecordMetadata, TRUTH_REQUEST_ID_MAX_LENGTH, parseTimelineEvents, activityCursor, parseApprovalObservations, approvalTimelineCursor } from '@byok-sdk/cloud';
3
+ import { ByokCoreError, assertCanonicalTimestamp, contentHash, isContentHash, tenantObjectKey, objectKeyPrefix, CoreConflictError, isLegalBoardTransition, checkSkillPackManifest, checkSkillPackEntry, SKILL_PACK_ENTRY_PATH, tenantId, SKILL_PACK_MANIFEST_SCHEMA_ID } from '@byok-sdk/core';
4
+ import { AwsClient } from 'aws4fetch';
5
+ import { XMLParser } from 'fast-xml-parser';
2
6
  import { createHash } from 'crypto';
3
7
  import { readdir, readFile } from 'fs/promises';
4
8
  import { join } from 'path';
5
9
  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
10
  import { decodeEnvelope, isServerToDaemonType, EnvelopeSchema, encodeEnvelope } from '@byok-sdk/protocol';
11
11
 
12
12
  // src/pool.ts
@@ -29,123 +29,6 @@ function createByokPool(options) {
29
29
  });
30
30
  return pool;
31
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
32
  var DEFAULT_LIST_LIMIT = 100;
150
33
  var MANIFEST_COLUMNS = "tenant_id, hash, byte_size, content_type, state, ref_count, created_at, updated_at, delete_pending_at";
151
34
  function toEntry(row) {
@@ -1270,11 +1153,246 @@ var PostgresTaskAttemptStore = class {
1270
1153
  return this.#clock.now().toISOString();
1271
1154
  }
1272
1155
  };
1156
+ function toTail(row) {
1157
+ const entries = parseTimelineEvents(row.entries);
1158
+ const cursor = activityCursor(entries);
1159
+ return {
1160
+ tenantId: row.tenant_id,
1161
+ taskId: row.task_id,
1162
+ entries,
1163
+ ...cursor === void 0 ? {} : { cursor },
1164
+ dropped: row.dropped,
1165
+ capacity: row.capacity,
1166
+ expiresAt: row.expires_at
1167
+ };
1168
+ }
1169
+ var PostgresActivityStore = class {
1170
+ constructor(pool, clock) {
1171
+ this.pool = pool;
1172
+ this.clock = clock;
1173
+ }
1174
+ pool;
1175
+ clock;
1176
+ async append(tenant, input) {
1177
+ const capacity = validateActivityAppend(input);
1178
+ const now = this.clock.now();
1179
+ const receivedAt = now.toISOString();
1180
+ const incoming = projectTimelineEvents(input, receivedAt);
1181
+ const expiresAt = new Date(now.getTime() + input.ttlMs).toISOString();
1182
+ const result = await this.pool.query(
1183
+ `WITH incoming AS (
1184
+ SELECT entry
1185
+ FROM jsonb_array_elements($4::jsonb) AS element(entry)
1186
+ ), trimmed AS (
1187
+ SELECT COALESCE(jsonb_agg(entry ORDER BY
1188
+ (entry->>'batchSeq')::bigint,
1189
+ (entry->>'eventIndex')::bigint), '[]'::jsonb) AS entries,
1190
+ $5::integer + GREATEST(jsonb_array_length($4::jsonb) - $6, 0) AS dropped
1191
+ FROM (
1192
+ SELECT entry
1193
+ FROM incoming
1194
+ ORDER BY (entry->>'batchSeq')::bigint DESC,
1195
+ (entry->>'eventIndex')::bigint DESC
1196
+ LIMIT $6
1197
+ ) retained
1198
+ )
1199
+ INSERT INTO activity_tail (tenant_id, task_id, entries, dropped, capacity, expires_at)
1200
+ SELECT $1, $2, trimmed.entries, trimmed.dropped, $6, $7 FROM trimmed
1201
+ ON CONFLICT (tenant_id, task_id) DO UPDATE
1202
+ SET entries = (
1203
+ SELECT COALESCE(jsonb_agg(entry ORDER BY
1204
+ (entry->>'batchSeq')::bigint,
1205
+ (entry->>'eventIndex')::bigint), '[]'::jsonb)
1206
+ FROM (
1207
+ SELECT entry
1208
+ FROM jsonb_array_elements(
1209
+ (CASE WHEN activity_tail.expires_at > $3
1210
+ THEN activity_tail.entries ELSE '[]'::jsonb END) || $4::jsonb
1211
+ ) AS element(entry)
1212
+ ORDER BY (entry->>'batchSeq')::bigint DESC,
1213
+ (entry->>'eventIndex')::bigint DESC
1214
+ LIMIT $6
1215
+ ) retained
1216
+ ),
1217
+ dropped = (CASE WHEN activity_tail.expires_at > $3
1218
+ THEN activity_tail.dropped ELSE 0 END)
1219
+ + $5::integer
1220
+ + GREATEST(
1221
+ jsonb_array_length(
1222
+ (CASE WHEN activity_tail.expires_at > $3
1223
+ THEN activity_tail.entries ELSE '[]'::jsonb END) || $4::jsonb
1224
+ ) - $6,
1225
+ 0
1226
+ ),
1227
+ capacity = EXCLUDED.capacity,
1228
+ expires_at = EXCLUDED.expires_at
1229
+ WHERE activity_tail.expires_at <= $3
1230
+ OR (
1231
+ NOT EXISTS (
1232
+ SELECT 1
1233
+ FROM jsonb_array_elements(activity_tail.entries) AS stored(entry)
1234
+ WHERE jsonb_typeof(entry) <> 'object'
1235
+ OR NOT (entry ?& ARRAY[
1236
+ 'taskId', 'sourceEnvelopeId', 'batchSeq',
1237
+ 'eventIndex', 'receivedAt', 'event'
1238
+ ])
1239
+ )
1240
+ AND NOT EXISTS (
1241
+ SELECT 1
1242
+ FROM jsonb_array_elements(activity_tail.entries) AS old_element(entry)
1243
+ CROSS JOIN jsonb_array_elements($4::jsonb) AS new_element(candidate)
1244
+ WHERE (entry->>'batchSeq')::bigint = (candidate->>'batchSeq')::bigint
1245
+ AND (entry->>'eventIndex')::bigint = (candidate->>'eventIndex')::bigint
1246
+ AND entry->>'sourceEnvelopeId' <> candidate->>'sourceEnvelopeId'
1247
+ )
1248
+ )
1249
+ RETURNING tenant_id, task_id, entries, dropped, capacity, expires_at`,
1250
+ [tenant, input.taskId, receivedAt, JSON.stringify(incoming), input.dropped, capacity, expiresAt]
1251
+ );
1252
+ const row = result.rows[0];
1253
+ if (row === void 0) {
1254
+ throw new ByokCloudError(
1255
+ "coordination_input_invalid",
1256
+ `Activity batch ${input.batchSeq} conflicts with the existing typed tail authority.`
1257
+ );
1258
+ }
1259
+ return toTail(row);
1260
+ }
1261
+ async read(tenant, taskId) {
1262
+ const result = await this.pool.query(
1263
+ `SELECT tenant_id, task_id, entries, dropped, capacity, expires_at
1264
+ FROM activity_tail
1265
+ WHERE tenant_id = $1 AND task_id = $2 AND expires_at > $3`,
1266
+ [tenant, taskId, this.clock.now().toISOString()]
1267
+ );
1268
+ const row = result.rows[0];
1269
+ return row === void 0 ? void 0 : toTail(row);
1270
+ }
1271
+ };
1272
+ function toTail2(row) {
1273
+ const entries = parseApprovalObservations(row.entries);
1274
+ const cursor = approvalTimelineCursor(entries);
1275
+ return {
1276
+ tenantId: row.tenant_id,
1277
+ taskId: row.task_id,
1278
+ entries,
1279
+ ...cursor === void 0 ? {} : { cursor },
1280
+ dropped: row.dropped,
1281
+ capacity: row.capacity,
1282
+ expiresAt: row.expires_at
1283
+ };
1284
+ }
1285
+ async function readLocked(client, tenant, taskId) {
1286
+ const result = await client.query(
1287
+ `SELECT tenant_id, task_id, entries, next_revision, dropped, capacity, expires_at
1288
+ FROM approval_timeline_tail
1289
+ WHERE tenant_id = $1 AND task_id = $2
1290
+ FOR UPDATE`,
1291
+ [tenant, taskId]
1292
+ );
1293
+ return result.rows[0];
1294
+ }
1295
+ var PostgresApprovalTimelineStore = class {
1296
+ constructor(pool, clock) {
1297
+ this.pool = pool;
1298
+ this.clock = clock;
1299
+ }
1300
+ pool;
1301
+ clock;
1302
+ async append(tenant, input) {
1303
+ const { capacity, ttlMs, event } = validateApprovalTimelineAppend(input);
1304
+ const now = this.clock.now();
1305
+ const receivedAt = now.toISOString();
1306
+ const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
1307
+ const client = await this.pool.connect();
1308
+ try {
1309
+ await client.query("BEGIN");
1310
+ await client.query(
1311
+ `SELECT pg_advisory_xact_lock(hashtextextended($1 || E'\\x1f' || $2, 0))`,
1312
+ [tenant, input.taskId]
1313
+ );
1314
+ const stored = await readLocked(client, tenant, input.taskId);
1315
+ const live = stored !== void 0 && receivedAt < stored.expires_at ? toTail2(stored) : void 0;
1316
+ const duplicate = live?.entries.find(
1317
+ (entry) => entry.sourceEnvelopeId === input.sourceEnvelopeId
1318
+ );
1319
+ if (duplicate !== void 0) {
1320
+ if (JSON.stringify(duplicate.event) !== JSON.stringify(event)) {
1321
+ throw new ByokCloudError(
1322
+ "coordination_input_invalid",
1323
+ "Approval source envelope identity already belongs to another lifecycle event."
1324
+ );
1325
+ }
1326
+ await client.query("COMMIT");
1327
+ return live;
1328
+ }
1329
+ const revision = live === void 0 ? 1 : Number(stored.next_revision);
1330
+ const expectedRevision = (live?.cursor ?? 0) + 1;
1331
+ if (!Number.isSafeInteger(revision) || revision <= 0 || revision !== expectedRevision) {
1332
+ throw new ByokCloudError(
1333
+ "coordination_input_invalid",
1334
+ "Approval timeline revision authority is malformed."
1335
+ );
1336
+ }
1337
+ const observation = ApprovalObservationSchema.parse({
1338
+ taskId: input.taskId,
1339
+ sourceEnvelopeId: input.sourceEnvelopeId,
1340
+ revision,
1341
+ receivedAt,
1342
+ event
1343
+ });
1344
+ const allEntries = [...live?.entries ?? [], observation];
1345
+ const evicted = Math.max(allEntries.length - capacity, 0);
1346
+ const entries = allEntries.slice(evicted);
1347
+ const dropped = (live?.dropped ?? 0) + evicted;
1348
+ const result = await client.query(
1349
+ `INSERT INTO approval_timeline_tail
1350
+ (tenant_id, task_id, entries, next_revision, dropped, capacity, expires_at)
1351
+ VALUES ($1, $2, $3::jsonb, $4, $5, $6, $7)
1352
+ ON CONFLICT (tenant_id, task_id) DO UPDATE
1353
+ SET entries = EXCLUDED.entries,
1354
+ next_revision = EXCLUDED.next_revision,
1355
+ dropped = EXCLUDED.dropped,
1356
+ capacity = EXCLUDED.capacity,
1357
+ expires_at = EXCLUDED.expires_at
1358
+ RETURNING tenant_id, task_id, entries, next_revision, dropped, capacity, expires_at`,
1359
+ [
1360
+ tenant,
1361
+ input.taskId,
1362
+ JSON.stringify(entries),
1363
+ revision + 1,
1364
+ dropped,
1365
+ capacity,
1366
+ expiresAt
1367
+ ]
1368
+ );
1369
+ await client.query("COMMIT");
1370
+ return toTail2(result.rows[0]);
1371
+ } catch (caught) {
1372
+ await client.query("ROLLBACK");
1373
+ throw caught;
1374
+ } finally {
1375
+ client.release();
1376
+ }
1377
+ }
1378
+ async read(tenant, taskId) {
1379
+ const result = await this.pool.query(
1380
+ `SELECT tenant_id, task_id, entries, next_revision, dropped, capacity, expires_at
1381
+ FROM approval_timeline_tail
1382
+ WHERE tenant_id = $1 AND task_id = $2 AND expires_at > $3`,
1383
+ [tenant, taskId, this.clock.now().toISOString()]
1384
+ );
1385
+ const row = result.rows[0];
1386
+ return row === void 0 ? void 0 : toTail2(row);
1387
+ }
1388
+ };
1273
1389
 
1274
1390
  // src/stores/index.ts
1275
1391
  function createPostgresCloudStores(options) {
1276
1392
  const { pool, clock, crypto } = options;
1277
1393
  return {
1394
+ activity: new PostgresActivityStore(pool, clock),
1395
+ approvals: new PostgresApprovalTimelineStore(pool, clock),
1278
1396
  devices: new PostgresDeviceDirectory(pool),
1279
1397
  pairingCodes: new PostgresPairingCodeStore(pool, clock),
1280
1398
  nonces: new PostgresNonceStore(pool, clock, crypto),
@@ -1305,16 +1423,6 @@ function toHint(row) {
1305
1423
  expiresAt: row.expires_at
1306
1424
  };
1307
1425
  }
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
1426
  function assertTtl(ttlMs) {
1319
1427
  if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
1320
1428
  throw new ByokCoreError(
@@ -1401,144 +1509,51 @@ var PostgresPresenceStore = class {
1401
1509
  return this.#clock.now().toISOString();
1402
1510
  }
1403
1511
  };
1404
- var PostgresActivityStore = class {
1512
+ var DEFAULT_LIST_LIMIT2 = 50;
1513
+ var BOARD_COLUMNS = "tenant_id, item_id, channel, title, status, holder_id, held_since, board_seq, created_at, updated_at";
1514
+ function toItem(row) {
1515
+ return {
1516
+ tenantId: row.tenant_id,
1517
+ itemId: row.item_id,
1518
+ channel: row.channel,
1519
+ title: row.title,
1520
+ status: row.status,
1521
+ // An unheld item has NO assignee rather than an assignee with an empty
1522
+ // holder, which is what lets `expect(item.assignee).toBeUndefined()` mean
1523
+ // "nobody holds this" in both compositions.
1524
+ ...row.holder_id === null || row.held_since === null ? {} : { assignee: { holderId: row.holder_id, heldSince: row.held_since } },
1525
+ boardSeq: Number(row.board_seq),
1526
+ createdAt: row.created_at,
1527
+ updatedAt: row.updated_at
1528
+ };
1529
+ }
1530
+ var PostgresBoardStore = class {
1405
1531
  #pool;
1406
1532
  #clock;
1407
1533
  constructor(pool, clock) {
1408
1534
  this.#pool = pool;
1409
1535
  this.#clock = clock;
1410
1536
  }
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) {
1537
+ async create(tenant, input) {
1538
+ const now = this.#now();
1539
+ const boardSeq = await this.#allocateSeq(tenant);
1540
+ const inserted = await this.#pool.query(
1541
+ `INSERT INTO board_item (${BOARD_COLUMNS})
1542
+ VALUES ($1, $2, $3, $4, $5, NULL, NULL, $6::bigint, $7, $7)
1543
+ ON CONFLICT (tenant_id, item_id) DO NOTHING
1544
+ RETURNING ${BOARD_COLUMNS}`,
1545
+ [tenant, input.itemId, input.channel, input.title, input.status ?? "todo", boardSeq, now]
1546
+ );
1547
+ const row = inserted.rows[0];
1548
+ if (row === void 0) {
1421
1549
  throw new ByokCoreError(
1422
- "activity_batch_invalid",
1423
- "Activity batches require at least one detail and a non-negative integer dropped count."
1550
+ "board_item_exists",
1551
+ `Board item ${input.itemId} already exists in this tenant.`
1424
1552
  );
1425
1553
  }
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) {
1554
+ return toItem(row);
1555
+ }
1556
+ async get(tenant, itemId) {
1542
1557
  const result = await this.#pool.query(
1543
1558
  `SELECT ${BOARD_COLUMNS} FROM board_item WHERE tenant_id = $1 AND item_id = $2`,
1544
1559
  [tenant, itemId]
@@ -2834,7 +2849,6 @@ function createPostgresCoreStores(options) {
2834
2849
  board: new PostgresBoardStore(pool, clock),
2835
2850
  truth: new PostgresTruthStore(pool, clock),
2836
2851
  presence: new PostgresPresenceStore(pool, clock),
2837
- activity: new PostgresActivityStore(pool, clock),
2838
2852
  objects: new PostgresObjectStore(pool, clock),
2839
2853
  quota: new PostgresQuotaStore(pool, clock),
2840
2854
  // No clock: a skill-pack manifest carries no timestamp, so this store reads
@@ -2842,1443 +2856,1565 @@ function createPostgresCoreStores(options) {
2842
2856
  skillPacks: new PostgresSkillPackStore(pool)
2843
2857
  };
2844
2858
  }
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 {
2859
+ 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";
2860
+ var RECEIPT_COLUMNS = "tenant_id, device_id, request_id, operation, resource, body_sha256, body_size, response_status, response_body, recorded_at";
2861
+ function toBody2(row) {
2862
+ return row.body_kind === "inline" ? { kind: "inline", body: row.body_inline ?? "" } : { kind: "object", hash: row.body_object_hash ?? "" };
2863
+ }
2864
+ function toRecord3(tenant, row) {
2865
+ return {
2866
+ tenantId: tenant,
2867
+ kind: row.kind,
2868
+ recordKey: row.subject_id,
2869
+ rev: row.rev,
2870
+ contentHash: row.content_hash,
2871
+ byteSize: row.byte_size,
2872
+ body: toBody2(row),
2873
+ ...row.label === null ? {} : { label: row.label },
2874
+ ...row.request_id === null ? {} : { requestId: row.request_id },
2875
+ writtenAt: row.written_at
2876
+ };
2877
+ }
2878
+ function toReceipt3(tenant, row) {
2879
+ return {
2880
+ tenantId: tenant,
2881
+ deviceId: row.device_id,
2882
+ requestId: row.request_id,
2883
+ operation: row.operation,
2884
+ resource: row.resource,
2885
+ bodySha256: row.body_sha256,
2886
+ bodySize: row.body_size,
2887
+ responseStatus: row.response_status,
2888
+ responseBody: row.response_body,
2889
+ recordedAt: row.recorded_at.toISOString()
2890
+ };
2891
+ }
2892
+ function sameBinding(receipt, input) {
2893
+ return receipt.operation === input.operation && receipt.resource === input.resource && receipt.bodySha256 === input.proofBodySha256 && receipt.bodySize === input.proofBodySize;
2894
+ }
2895
+ function bodyColumns2(body) {
2896
+ return body.kind === "inline" ? ["inline", body.body, null] : ["object", null, body.hash];
2897
+ }
2898
+ function writeKey(write) {
2899
+ return `${write.kind}\0${write.recordKey}`;
2900
+ }
2901
+ function referenceId(write) {
2902
+ return `${write.kind}:${write.recordKey}`;
2903
+ }
2904
+ var PostgresTruthCommitter = class {
2865
2905
  #pool;
2866
2906
  #clock;
2867
- #objectStorage;
2868
- #batchSize;
2907
+ #crypto;
2908
+ #truth;
2869
2909
  constructor(options) {
2870
2910
  this.#pool = options.pool;
2871
2911
  this.#clock = options.clock;
2872
- this.#objectStorage = options.objectStorage;
2873
- this.#batchSize = assertBatchSize(options.batchSize ?? DEFAULT_BATCH_SIZE);
2912
+ this.#crypto = options.crypto;
2913
+ this.#truth = new PostgresTruthStore(options.pool, options.clock);
2874
2914
  }
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]);
2915
+ getRecord(tenant, selector) {
2916
+ return this.#truth.getRecord(tenant, selector);
2903
2917
  }
2904
- async readRetentionPolicy(tenant) {
2905
- return this.#readRetentionPolicy(this.#pool, tenant);
2918
+ listManifest(tenant, query) {
2919
+ return this.#truth.listManifest(tenant, query);
2906
2920
  }
2907
- /** Run one bounded tenant maintenance cycle. Completed job ids are replay-safe. */
2908
- async runTenant(tenant, jobId) {
2909
- assertIdentifier(jobId, "jobId");
2921
+ async commit(tenant, input) {
2922
+ await this.#validateInput(input);
2910
2923
  const client = await this.#pool.connect();
2911
- let jobStarted = false;
2912
2924
  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;
2925
+ await client.query("BEGIN");
2926
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
2927
+ JSON.stringify(["truth-receipt", tenant, input.deviceId, input.requestId])
2928
+ ]);
2929
+ const replay = await this.#readReceipt(client, tenant, input.deviceId, input.requestId);
2930
+ if (replay !== void 0) {
2931
+ if (!sameBinding(replay, input)) {
2932
+ throw new TruthCommitError(
2933
+ "proof_request_conflict",
2934
+ `Request ${input.requestId} was already used with a different binding.`
2935
+ );
2960
2936
  }
2937
+ const response2 = TruthCommitResponseSchema.parse(JSON.parse(replay.responseBody));
2938
+ await client.query("COMMIT");
2939
+ return { response: response2, replayed: true };
2961
2940
  }
2962
- await this.#advanceLexicalCursor(
2963
- client,
2964
- tenant,
2965
- "delete",
2966
- pending.rows.at(-1)?.hash,
2967
- pending.rows.length
2941
+ const before = await this.#lockCurrentRecords(client, tenant, input.writes);
2942
+ this.#assertWritePreconditions(input.writes, before);
2943
+ await this.#lockAndVerifyObjects(client, tenant, input.writes, before);
2944
+ const inlineAffected = input.writes.some((write) => {
2945
+ const current = before.get(writeKey(write));
2946
+ if (write.kind === "task.terminal" && current !== void 0) return false;
2947
+ return current?.body.kind === "inline" || write.body.kind === "inline";
2948
+ });
2949
+ const inlineDelta = inlineAffected ? await this.#prepareInlineAccounting(client, tenant, input.writes, before) : 0n;
2950
+ const applied = await this.#applyWrites(client, tenant, input, before);
2951
+ await this.#replaceObjectReferences(client, tenant, applied);
2952
+ await this.#settleInlineAccounting(client, tenant, inlineDelta);
2953
+ const response = {
2954
+ primary: truthRecordMetadata(applied[0].record),
2955
+ snapshots: applied.slice(1).map((entry) => truthRecordMetadata(entry.record))
2956
+ };
2957
+ await client.query(
2958
+ `INSERT INTO proof_request_receipt (${RECEIPT_COLUMNS})
2959
+ VALUES ($1, $2, $3, $4, $5, $6, $7::bigint, 200, $8, $9)`,
2960
+ [
2961
+ tenant,
2962
+ input.deviceId,
2963
+ input.requestId,
2964
+ input.operation,
2965
+ input.resource,
2966
+ input.proofBodySha256,
2967
+ input.proofBodySize,
2968
+ JSON.stringify(response),
2969
+ this.#now()
2970
+ ]
2968
2971
  );
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;
2972
+ await client.query("COMMIT");
2973
+ return { response, replayed: false };
2974
+ } catch (error) {
2975
+ await client.query("ROLLBACK").catch(() => void 0);
2976
+ throw error;
2979
2977
  } finally {
2980
- await client.query("SELECT pg_advisory_unlock(hashtextextended($1, $2))", [
2981
- tenant,
2982
- ADVISORY_LOCK_NAMESPACE
2983
- ]).catch(() => {
2984
- });
2985
2978
  client.release();
2986
2979
  }
2987
2980
  }
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
- );
2981
+ async #validateInput(input) {
2982
+ if (input.requestId.length === 0 || input.requestId.length > TRUTH_REQUEST_ID_MAX_LENGTH) {
2983
+ throw new TruthCommitError("proof_request_conflict", "Request id is outside the record contract.");
2997
2984
  }
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);
2985
+ const seen = /* @__PURE__ */ new Set();
2986
+ const objectSizes = /* @__PURE__ */ new Map();
2987
+ for (const write of input.writes) {
2988
+ const key = writeKey(write);
2989
+ if (seen.has(key)) {
2990
+ throw new TruthCommitError("proof_request_conflict", `Duplicate truth write ${key}.`);
2991
+ }
2992
+ seen.add(key);
2993
+ if (write.body.kind === "inline") {
2994
+ const bytes = new TextEncoder().encode(write.body.body);
2995
+ if (BigInt(bytes.byteLength) !== write.byteSize) {
2996
+ throw new ByokCoreError("storage_integrity_mismatch", "Inline byte size disagrees with its content.");
2997
+ }
2998
+ if (await this.#crypto.sha256(bytes) !== write.contentHash) {
2999
+ throw new ByokCoreError("storage_integrity_mismatch", "Inline hash disagrees with its content.");
3000
+ }
3001
+ } else if (write.body.hash !== write.contentHash) {
3002
+ throw new ByokCoreError("storage_integrity_mismatch", "Object body hash disagrees with record hash.");
3039
3003
  } 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]
3004
+ const priorSize = objectSizes.get(write.body.hash);
3005
+ if (priorSize !== void 0 && priorSize !== write.byteSize) {
3006
+ throw new ByokCoreError(
3007
+ "storage_integrity_mismatch",
3008
+ `Object ${write.body.hash} was declared with inconsistent byte sizes.`
3069
3009
  );
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
3010
  }
3011
+ objectSizes.set(write.body.hash, write.byteSize);
3143
3012
  }
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
3013
  }
3153
- if (rejection !== void 0) throw rejection;
3154
- return result;
3155
3014
  }
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]
3015
+ async #readReceipt(client, tenant, deviceId, requestId) {
3016
+ const result = await client.query(
3017
+ `SELECT ${RECEIPT_COLUMNS} FROM proof_request_receipt
3018
+ WHERE tenant_id = $1 AND device_id = $2 AND request_id = $3`,
3019
+ [tenant, deviceId, requestId]
3020
+ );
3021
+ const row = result.rows[0];
3022
+ return row === void 0 ? void 0 : toReceipt3(tenant, row);
3023
+ }
3024
+ async #lockCurrentRecords(client, tenant, writes) {
3025
+ const current = /* @__PURE__ */ new Map();
3026
+ const ordered = [...writes].sort((a, b) => writeKey(a).localeCompare(writeKey(b)));
3027
+ for (const write of ordered) {
3028
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
3029
+ JSON.stringify(["truth-record", tenant, write.kind, write.recordKey])
3030
+ ]);
3031
+ }
3032
+ for (const write of ordered) {
3033
+ const result = await client.query(
3034
+ `SELECT ${RECORD_COLUMNS2} FROM attested_record
3035
+ WHERE tenant_id = $1 AND kind = $2 AND subject_id = $3
3036
+ FOR UPDATE`,
3037
+ [tenant, write.kind, write.recordKey]
3175
3038
  );
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()]
3039
+ current.set(
3040
+ writeKey(write),
3041
+ result.rows[0] === void 0 ? void 0 : toRecord3(tenant, result.rows[0])
3042
+ );
3043
+ }
3044
+ return current;
3045
+ }
3046
+ #assertWritePreconditions(writes, current) {
3047
+ for (const write of writes) {
3048
+ const before = current.get(writeKey(write));
3049
+ if (write.kind === "task.terminal") {
3050
+ if (before !== void 0 && before.contentHash !== write.contentHash) {
3051
+ throw new CoreConflictError(
3052
+ "terminal_conflict",
3053
+ `Task ${write.recordKey} already has a different immutable terminal.`,
3054
+ before,
3055
+ this.#now()
3056
+ );
3057
+ }
3058
+ } else if ((before?.rev ?? 0) !== write.expectedRev) {
3059
+ throw new CoreConflictError(
3060
+ "truth_revision_conflict",
3061
+ `${write.kind}/${write.recordKey} is at rev ${before?.rev ?? 0}, not ${write.expectedRev}.`,
3062
+ before,
3063
+ this.#now()
3196
3064
  );
3197
- row = removed.rows[0];
3198
3065
  }
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
3066
  }
3208
- if (rejection !== void 0) throw rejection;
3209
- return toMailboxMessage(row);
3210
3067
  }
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]
3068
+ async #lockAndVerifyObjects(client, tenant, writes, current) {
3069
+ const requested = /* @__PURE__ */ new Map();
3070
+ const affected = /* @__PURE__ */ new Set();
3071
+ for (const write of writes) {
3072
+ const before = current.get(writeKey(write));
3073
+ if (write.kind === "task.terminal" && before !== void 0) continue;
3074
+ if (before?.body.kind === "object") affected.add(before.body.hash);
3075
+ if (write.body.kind === "object") {
3076
+ const existing = requested.get(write.body.hash);
3077
+ if (existing !== void 0 && existing !== write.byteSize) {
3078
+ throw new ByokCoreError(
3079
+ "storage_integrity_mismatch",
3080
+ `Object ${write.body.hash} was declared with inconsistent byte sizes.`
3081
+ );
3082
+ }
3083
+ requested.set(write.body.hash, write.byteSize);
3084
+ affected.add(write.body.hash);
3085
+ }
3086
+ }
3087
+ for (const hash of [...affected].sort()) {
3088
+ const result = await client.query(
3089
+ `SELECT hash, byte_size, state FROM object_manifest
3090
+ WHERE tenant_id = $1 AND hash = $2 FOR UPDATE`,
3091
+ [tenant, hash]
3223
3092
  );
3224
- if (locked.rowCount === 0) {
3225
- throw new CloudCleanupError(
3226
- "cleanup_policy_missing",
3227
- `Tenant ${tenant} has no storage usage row to rebuild.`
3093
+ const manifest = result.rows[0];
3094
+ const byteSize = requested.get(hash);
3095
+ if (manifest === void 0 || byteSize !== void 0 && (manifest.state !== "committed" || manifest.byte_size !== byteSize)) {
3096
+ throw new TruthCommitError(
3097
+ "truth_object_not_committed",
3098
+ `Object ${hash} is not a committed matching manifest.`
3228
3099
  );
3229
3100
  }
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
3101
  }
3260
3102
  }
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`,
3103
+ async #prepareInlineAccounting(client, tenant, writes, current) {
3104
+ const entitlementResult = await client.query(
3105
+ `SELECT hard_limit_bytes, max_inline_bytes, downgrade_grace_until
3106
+ FROM storage_entitlement WHERE tenant_id = $1 FOR UPDATE`,
3270
3107
  [tenant]
3271
3108
  );
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
- );
3109
+ const entitlement = entitlementResult.rows[0];
3110
+ if (entitlement === void 0) {
3111
+ throw new ByokCoreError("storage_entitlement_missing", "Tenant has no storage entitlement.");
3278
3112
  }
3279
- const policy = toPolicy(row);
3280
- assertPolicy(policy);
3281
- return policy;
3282
- }
3283
- async #startJob(client, tenant, jobId) {
3284
3113
  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]
3114
+ await client.query(
3115
+ `UPDATE storage_reservation SET state = 'expired', settled_at = $2
3116
+ WHERE tenant_id = $1 AND state = 'reserved' AND expires_at <= $2`,
3117
+ [tenant, now]
3294
3118
  );
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}.`
3119
+ const usageResult = await client.query(
3120
+ `SELECT u.committed_object_bytes, u.committed_inline_bytes,
3121
+ COALESCE((SELECT SUM(expected_bytes) FROM storage_reservation r
3122
+ WHERE r.tenant_id = $1 AND r.state = 'reserved'), 0)::bigint AS reserved_bytes
3123
+ FROM storage_usage u WHERE u.tenant_id = $1 FOR UPDATE`,
3124
+ [tenant]
3125
+ );
3126
+ const usage = usageResult.rows[0];
3127
+ if (usage === void 0) throw new Error(`storage usage for ${tenant} is missing`);
3128
+ const affectedHashes = /* @__PURE__ */ new Set();
3129
+ const sizes = /* @__PURE__ */ new Map();
3130
+ for (const write of writes) {
3131
+ const before = current.get(writeKey(write));
3132
+ if (write.kind === "task.terminal" && before !== void 0) continue;
3133
+ if (before?.body.kind === "inline") {
3134
+ affectedHashes.add(before.contentHash);
3135
+ sizes.set(before.contentHash, before.byteSize);
3136
+ }
3137
+ if (write.body.kind !== "inline") continue;
3138
+ if (write.byteSize > entitlement.max_inline_bytes) {
3139
+ throw new ByokCoreError(
3140
+ "storage_object_too_large",
3141
+ `Inline truth ${write.kind}/${write.recordKey} exceeds maxInlineBytes.`
3366
3142
  );
3367
3143
  }
3368
- await client.query("COMMIT");
3369
- return result;
3370
- } catch (cause) {
3371
- await client.query("ROLLBACK").catch(() => {
3144
+ const knownSize = sizes.get(write.contentHash);
3145
+ if (knownSize !== void 0 && knownSize !== write.byteSize) {
3146
+ throw new ByokCoreError(
3147
+ "storage_integrity_mismatch",
3148
+ `Inline hash ${write.contentHash} was declared with inconsistent byte sizes.`
3149
+ );
3150
+ }
3151
+ affectedHashes.add(write.contentHash);
3152
+ sizes.set(write.contentHash, write.byteSize);
3153
+ }
3154
+ const hashes = [...affectedHashes].sort();
3155
+ const baseline = new Map(hashes.map((hash) => [hash, 0n]));
3156
+ const existing = await client.query(
3157
+ `SELECT content_hash, byte_size, count(*)::bigint AS ref_count
3158
+ FROM attested_record
3159
+ WHERE tenant_id = $1 AND body_kind = 'inline' AND content_hash = ANY($2::text[])
3160
+ GROUP BY content_hash, byte_size`,
3161
+ [tenant, hashes]
3162
+ );
3163
+ for (const row of existing.rows) {
3164
+ const knownSize = sizes.get(row.content_hash);
3165
+ if (knownSize !== void 0 && knownSize !== row.byte_size) {
3166
+ throw new ByokCoreError(
3167
+ "storage_integrity_mismatch",
3168
+ `Stored inline hash ${row.content_hash} disagrees on byte size.`
3169
+ );
3170
+ }
3171
+ if ((baseline.get(row.content_hash) ?? 0n) !== 0n) {
3172
+ throw new ByokCoreError(
3173
+ "storage_integrity_mismatch",
3174
+ `Stored inline hash ${row.content_hash} has multiple byte sizes.`
3175
+ );
3176
+ }
3177
+ baseline.set(row.content_hash, row.ref_count);
3178
+ sizes.set(row.content_hash, row.byte_size);
3179
+ }
3180
+ const projected = new Map(baseline);
3181
+ for (const write of writes) {
3182
+ const before = current.get(writeKey(write));
3183
+ if (write.kind === "task.terminal" && before !== void 0) continue;
3184
+ if (before?.body.kind === "inline") {
3185
+ projected.set(before.contentHash, (projected.get(before.contentHash) ?? 0n) - 1n);
3186
+ }
3187
+ if (write.body.kind === "inline") {
3188
+ projected.set(write.contentHash, (projected.get(write.contentHash) ?? 0n) + 1n);
3189
+ }
3190
+ }
3191
+ let delta = 0n;
3192
+ let newlyCommitted = 0n;
3193
+ for (const hash of hashes) {
3194
+ const before = baseline.get(hash) ?? 0n;
3195
+ const after = projected.get(hash) ?? 0n;
3196
+ if (after < 0n) throw new Error(`inline reference count for ${hash} would become negative`);
3197
+ const byteSize = sizes.get(hash);
3198
+ if (byteSize === void 0) throw new Error(`inline byte size for ${hash} is missing`);
3199
+ if (before === 0n && after > 0n) {
3200
+ delta += byteSize;
3201
+ newlyCommitted += byteSize;
3202
+ } else if (before > 0n && after === 0n) {
3203
+ delta -= byteSize;
3204
+ }
3205
+ }
3206
+ const used = usage.committed_object_bytes + usage.committed_inline_bytes + usage.reserved_bytes;
3207
+ if (newlyCommitted > 0n && used >= entitlement.hard_limit_bytes && entitlement.downgrade_grace_until !== null && entitlement.downgrade_grace_until <= now) {
3208
+ throw new ByokCoreError("storage_write_suspended", "Durable writes are suspended.");
3209
+ }
3210
+ if (used + delta > entitlement.hard_limit_bytes) {
3211
+ throw new ByokCoreError("storage_quota_exceeded", "Final inline truth usage exceeds quota.");
3212
+ }
3213
+ return delta;
3214
+ }
3215
+ async #applyWrites(client, tenant, input, current) {
3216
+ const applied = [];
3217
+ for (const write of input.writes) {
3218
+ const before = current.get(writeKey(write));
3219
+ if (write.kind === "task.terminal" && before !== void 0) {
3220
+ applied.push({ input: write, before, record: before, mutated: false });
3221
+ continue;
3222
+ }
3223
+ const [bodyKind, bodyInline, bodyObjectHash] = bodyColumns2(write.body);
3224
+ const values = [
3225
+ tenant,
3226
+ write.kind,
3227
+ write.recordKey,
3228
+ write.contentHash,
3229
+ write.byteSize,
3230
+ bodyKind,
3231
+ bodyInline,
3232
+ bodyObjectHash,
3233
+ write.label ?? null,
3234
+ input.requestId,
3235
+ this.#now()
3236
+ ];
3237
+ const result = before === void 0 ? await client.query(
3238
+ `INSERT INTO attested_record (${RECORD_COLUMNS2})
3239
+ VALUES ($1, $2, $3, 1, $4, $5, $6, $7, $8, $9, $10, $11)
3240
+ RETURNING ${RECORD_COLUMNS2}`,
3241
+ values
3242
+ ) : await client.query(
3243
+ `UPDATE attested_record
3244
+ SET rev = rev + 1, content_hash = $4, byte_size = $5,
3245
+ body_kind = $6, body_inline = $7, body_object_hash = $8,
3246
+ label = $9, request_id = $10, written_at = $11
3247
+ WHERE tenant_id = $1 AND kind = $2 AND subject_id = $3
3248
+ RETURNING ${RECORD_COLUMNS2}`,
3249
+ values
3250
+ );
3251
+ applied.push({
3252
+ input: write,
3253
+ before,
3254
+ record: toRecord3(tenant, result.rows[0]),
3255
+ mutated: true
3372
3256
  });
3373
- throw cause;
3374
3257
  }
3258
+ return applied;
3375
3259
  }
3376
- async #markTombstones(client, tenant, orphanCutoff) {
3377
- try {
3378
- await client.query("BEGIN");
3260
+ async #replaceObjectReferences(client, tenant, applied) {
3261
+ const affected = /* @__PURE__ */ new Set();
3262
+ for (const entry of applied) {
3263
+ if (!entry.mutated) continue;
3264
+ const refId = referenceId(entry.input);
3265
+ if (entry.input.body.kind === "object") {
3266
+ affected.add(entry.input.body.hash);
3267
+ await client.query(
3268
+ `INSERT INTO object_reference (tenant_id, hash, ref_kind, ref_id, created_at)
3269
+ VALUES ($1, $2, 'truth', $3, $4)
3270
+ ON CONFLICT (tenant_id, hash, ref_kind, ref_id) DO NOTHING`,
3271
+ [tenant, entry.input.body.hash, refId, this.#now()]
3272
+ );
3273
+ }
3274
+ if (entry.before?.body.kind === "object" && (entry.input.body.kind !== "object" || entry.input.body.hash !== entry.before.body.hash)) {
3275
+ affected.add(entry.before.body.hash);
3276
+ await client.query(
3277
+ `DELETE FROM object_reference
3278
+ WHERE tenant_id = $1 AND hash = $2 AND ref_kind = 'truth' AND ref_id = $3`,
3279
+ [tenant, entry.before.body.hash, refId]
3280
+ );
3281
+ }
3282
+ }
3283
+ for (const hash of [...affected].sort()) {
3379
3284
  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()]
3285
+ `UPDATE object_manifest
3286
+ SET ref_count = (SELECT count(*) FROM object_reference r
3287
+ WHERE r.tenant_id = $1 AND r.hash = $2),
3288
+ updated_at = $3
3289
+ WHERE tenant_id = $1 AND hash = $2`,
3290
+ [tenant, hash, this.#now()]
3412
3291
  );
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
3292
  }
3420
3293
  }
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,
3294
+ async #settleInlineAccounting(client, tenant, delta) {
3295
+ if (delta !== 0n) {
3296
+ const updated = await client.query(
3297
+ `UPDATE storage_usage
3298
+ SET committed_inline_bytes = committed_inline_bytes + $2::bigint,
3449
3299
  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'
3300
+ WHERE tenant_id = $1 AND committed_inline_bytes + $2::bigint >= 0
3555
3301
  RETURNING 1`,
3556
- [
3557
- tenant,
3558
- object.hash,
3559
- observed.observedByteSize,
3560
- observed.observedContentType,
3561
- this.#now()
3562
- ]
3302
+ [tenant, delta, this.#now()]
3563
3303
  );
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);
3304
+ if (updated.rowCount !== 1) throw new Error("inline accounting would become negative");
3577
3305
  }
3578
3306
  }
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
- );
3307
+ #now() {
3308
+ return this.#clock.now().toISOString();
3645
3309
  }
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]
3310
+ };
3311
+ var MIGRATION_ADVISORY_LOCK_KEY = "4021960801";
3312
+ var MIGRATION_FILENAME_PATTERN = /^(\d{4})[_-].+\.sql$/;
3313
+ var LEDGER_DDL = `
3314
+ CREATE TABLE IF NOT EXISTS byok_schema_migration (
3315
+ version text PRIMARY KEY,
3316
+ checksum text NOT NULL,
3317
+ applied_at timestamptz NOT NULL
3318
+ )`;
3319
+ var MigrationChecksumMismatchError = class extends Error {
3320
+ version;
3321
+ expectedChecksum;
3322
+ actualChecksum;
3323
+ constructor(version, expectedChecksum, actualChecksum) {
3324
+ super(
3325
+ `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.`
3650
3326
  );
3651
- return result.rows[0];
3327
+ this.name = "MigrationChecksumMismatchError";
3328
+ this.version = version;
3329
+ this.expectedChecksum = expectedChecksum;
3330
+ this.actualChecksum = actualChecksum;
3652
3331
  }
3653
- #now() {
3654
- return this.#clock.now().toISOString();
3332
+ };
3333
+ var MigrationFilenameError = class extends Error {
3334
+ filename;
3335
+ constructor(filename, reason) {
3336
+ super(`Migration file ${filename} is not usable: ${reason}`);
3337
+ this.name = "MigrationFilenameError";
3338
+ this.filename = filename;
3655
3339
  }
3656
3340
  };
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
- });
3341
+ function sha256(text) {
3342
+ return createHash("sha256").update(text, "utf8").digest("hex");
3665
3343
  }
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
- };
3344
+ async function readMigrationFiles(directory) {
3345
+ const entries = await readdir(directory, { withFileTypes: true });
3346
+ const files = [];
3347
+ for (const entry of entries) {
3348
+ if (!entry.isFile() || !entry.name.endsWith(".sql")) continue;
3349
+ const match = MIGRATION_FILENAME_PATTERN.exec(entry.name);
3350
+ if (match === null) {
3351
+ throw new MigrationFilenameError(
3352
+ entry.name,
3353
+ "expected a four-digit prefix, e.g. 0001_cloud_local.sql"
3354
+ );
3355
+ }
3356
+ const sql = await readFile(join(directory, entry.name), "utf8");
3357
+ files.push({
3358
+ version: entry.name,
3359
+ ordinal: Number.parseInt(match[1], 10),
3360
+ checksum: sha256(sql),
3361
+ sql
3362
+ });
3363
+ }
3364
+ files.sort((left, right) => left.ordinal - right.ordinal);
3365
+ for (let index = 1; index < files.length; index += 1) {
3366
+ const previous = files[index - 1];
3367
+ const current = files[index];
3368
+ if (previous.ordinal === current.ordinal) {
3369
+ throw new MigrationFilenameError(
3370
+ current.version,
3371
+ `duplicate prefix ${String(current.ordinal).padStart(4, "0")}, already used by ${previous.version}`
3372
+ );
3373
+ }
3374
+ }
3375
+ return files;
3737
3376
  }
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
- };
3377
+ async function readLedger(client) {
3378
+ const result = await client.query(
3379
+ "SELECT version, checksum FROM byok_schema_migration"
3380
+ );
3381
+ return new Map(result.rows.map((row) => [row.version, row.checksum]));
3750
3382
  }
3751
- function materializeReplayBody(original, seq) {
3383
+ async function migrate(pool, directory) {
3384
+ const files = await readMigrationFiles(directory);
3385
+ const client = await pool.connect();
3752
3386
  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.`);
3387
+ await client.query("SELECT pg_advisory_lock($1)", [MIGRATION_ADVISORY_LOCK_KEY]);
3388
+ try {
3389
+ await client.query(LEDGER_DDL);
3390
+ const ledger = await readLedger(client);
3391
+ const applied = [];
3392
+ const alreadyApplied = [];
3393
+ for (const file of files) {
3394
+ const recordedChecksum = ledger.get(file.version);
3395
+ if (recordedChecksum !== void 0) {
3396
+ if (recordedChecksum !== file.checksum) {
3397
+ throw new MigrationChecksumMismatchError(file.version, recordedChecksum, file.checksum);
3398
+ }
3399
+ alreadyApplied.push(file.version);
3400
+ continue;
3401
+ }
3402
+ await client.query("BEGIN");
3403
+ try {
3404
+ await client.query(file.sql);
3405
+ await client.query(
3406
+ "INSERT INTO byok_schema_migration (version, checksum, applied_at) VALUES ($1, $2, now())",
3407
+ [file.version, file.checksum]
3408
+ );
3409
+ await client.query("COMMIT");
3410
+ } catch (error) {
3411
+ await client.query("ROLLBACK").catch(() => {
3412
+ });
3413
+ throw error;
3414
+ }
3415
+ applied.push(file.version);
3416
+ }
3417
+ return { applied, alreadyApplied };
3418
+ } finally {
3419
+ await client.query("SELECT pg_advisory_unlock($1)", [MIGRATION_ADVISORY_LOCK_KEY]);
3756
3420
  }
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
- );
3421
+ } finally {
3422
+ client.release();
3771
3423
  }
3772
3424
  }
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;
3425
+ function migrationsDir() {
3426
+ return fileURLToPath(new URL("./sql", import.meta.url));
3777
3427
  }
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
- }
3428
+ var DEFAULT_BATCH_SIZE = 100;
3429
+ var MAX_BATCH_SIZE = 1e3;
3430
+ var ADVISORY_LOCK_NAMESPACE = 1106736963;
3431
+ var OUTBOX_COLUMNS2 = "tenant_id, device_id, seq, message_id, body, body_hash, byte_size, state, appended_at, replay_source_seq";
3432
+ var CLOUD_CLEANUP_ERROR_CODES = {
3433
+ cleanup_invalid_input: "cleanup_invalid_input",
3434
+ cleanup_policy_missing: "cleanup_policy_missing",
3435
+ cleanup_job_running: "cleanup_job_running",
3436
+ cleanup_dead_letter_not_found: "cleanup_dead_letter_not_found",
3437
+ cleanup_accounting_drift: "cleanup_accounting_drift"
3438
+ };
3439
+ var CloudCleanupError = class extends Error {
3440
+ code;
3441
+ constructor(code, message, options) {
3442
+ super(message, options);
3443
+ this.name = "CloudCleanupError";
3444
+ this.code = code;
3792
3445
  }
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
- );
3446
+ };
3447
+ var PostgresCloudCleanup = class {
3448
+ #pool;
3449
+ #clock;
3450
+ #objectStorage;
3451
+ #batchSize;
3452
+ constructor(options) {
3453
+ this.#pool = options.pool;
3454
+ this.#clock = options.clock;
3455
+ this.#objectStorage = options.objectStorage;
3456
+ this.#batchSize = assertBatchSize(options.batchSize ?? DEFAULT_BATCH_SIZE);
3800
3457
  }
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.`
3458
+ async writeRetentionPolicy(tenant, input) {
3459
+ assertPolicy(input);
3460
+ const written = await this.#pool.query(
3461
+ `INSERT INTO tenant_retention_policy (
3462
+ tenant_id, policy_id, mailbox_acked_retention_ms,
3463
+ mailbox_unacked_retention_ms, request_receipt_retention_ms,
3464
+ object_orphan_grace_ms, updated_at
3465
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7)
3466
+ ON CONFLICT (tenant_id, policy_id) DO UPDATE
3467
+ SET mailbox_acked_retention_ms = EXCLUDED.mailbox_acked_retention_ms,
3468
+ mailbox_unacked_retention_ms = EXCLUDED.mailbox_unacked_retention_ms,
3469
+ request_receipt_retention_ms = EXCLUDED.request_receipt_retention_ms,
3470
+ object_orphan_grace_ms = EXCLUDED.object_orphan_grace_ms,
3471
+ updated_at = EXCLUDED.updated_at
3472
+ RETURNING tenant_id, policy_id, mailbox_acked_retention_ms,
3473
+ mailbox_unacked_retention_ms, request_receipt_retention_ms,
3474
+ object_orphan_grace_ms, updated_at`,
3475
+ [
3476
+ tenant,
3477
+ input.policyId,
3478
+ input.mailboxAckedRetentionMs,
3479
+ input.mailboxUnackedRetentionMs,
3480
+ input.requestReceiptRetentionMs,
3481
+ input.objectOrphanGraceMs,
3482
+ this.#now()
3483
+ ]
3808
3484
  );
3485
+ return toPolicy(written.rows[0]);
3809
3486
  }
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);
3487
+ async readRetentionPolicy(tenant) {
3488
+ return this.#readRetentionPolicy(this.#pool, tenant);
3890
3489
  }
3891
- async commit(tenant, input) {
3892
- await this.#validateInput(input);
3490
+ /** Run one bounded tenant maintenance cycle. Completed job ids are replay-safe. */
3491
+ async runTenant(tenant, jobId) {
3492
+ assertIdentifier(jobId, "jobId");
3893
3493
  const client = await this.#pool.connect();
3494
+ let jobStarted = false;
3894
3495
  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
- );
3496
+ const lock = await client.query(
3497
+ "SELECT pg_try_advisory_lock(hashtextextended($1, $2)) AS locked",
3498
+ [tenant, ADVISORY_LOCK_NAMESPACE]
3499
+ );
3500
+ if (lock.rows[0]?.locked !== true) {
3501
+ throw new CloudCleanupError(
3502
+ "cleanup_job_running",
3503
+ `A cleanup job is already running for tenant ${tenant}.`
3504
+ );
3505
+ }
3506
+ const replay = await this.#startJob(client, tenant, jobId);
3507
+ if (replay !== void 0) return replay;
3508
+ jobStarted = true;
3509
+ const policy = await this.#readRetentionPolicy(client, tenant);
3510
+ const counts = emptyCounts();
3511
+ const retention = await this.#runRetention(client, tenant, policy);
3512
+ counts.mailboxDeletedCount = retention.mailbox_deleted_count;
3513
+ counts.mailboxExpiredCount = retention.mailbox_expired_count;
3514
+ counts.mailboxReleasedBytes = retention.mailbox_released_bytes;
3515
+ counts.reservationsExpired = retention.reservations_expired;
3516
+ counts.ttlRowsDeleted = retention.ttl_rows_deleted;
3517
+ const orphanCutoff = cutoff(this.#clock.now(), policy.objectOrphanGraceMs);
3518
+ counts.objectsTombstoned = await this.#markTombstones(client, tenant, orphanCutoff);
3519
+ const deleteCursor = await this.#readCursor(client, tenant, "delete");
3520
+ const pending = await client.query(
3521
+ `SELECT hash, byte_size, content_type, state,
3522
+ gc_accounted_bytes, gc_accounted_object
3523
+ FROM object_manifest
3524
+ WHERE tenant_id = $1
3525
+ AND state = 'delete_pending'
3526
+ AND gc_accounted_bytes IS NOT NULL
3527
+ AND gc_accounted_object IS NOT NULL
3528
+ AND hash > $2
3529
+ ORDER BY hash
3530
+ LIMIT $3`,
3531
+ [tenant, deleteCursor ?? "", this.#batchSize]
3532
+ );
3533
+ for (const manifest of pending.rows) {
3534
+ try {
3535
+ await this.#objectStorage.deleteObject(tenant, manifest.hash);
3536
+ const released = await this.#settleDeleted(client, tenant, manifest.hash);
3537
+ if (released !== void 0) {
3538
+ counts.objectsDeleted += 1n;
3539
+ counts.objectReleasedBytes += released;
3540
+ }
3541
+ } catch {
3542
+ counts.operationErrors += 1n;
3906
3543
  }
3907
- const response2 = TruthCommitResponseSchema.parse(JSON.parse(replay.responseBody));
3908
- await client.query("COMMIT");
3909
- return { response: response2, replayed: true };
3910
3544
  }
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
- ]
3545
+ await this.#advanceLexicalCursor(
3546
+ client,
3547
+ tenant,
3548
+ "delete",
3549
+ pending.rows.at(-1)?.hash,
3550
+ pending.rows.length
3941
3551
  );
3942
- await client.query("COMMIT");
3943
- return { response, replayed: false };
3944
- } catch (error) {
3945
- await client.query("ROLLBACK").catch(() => void 0);
3946
- throw error;
3552
+ await this.#reconcileManifests(client, tenant, counts);
3553
+ await this.#reconcileR2(client, tenant, counts);
3554
+ const state = counts.operationErrors === 0n ? "completed" : "completed_with_errors";
3555
+ return this.#finishJob(client, tenant, jobId, state, counts);
3556
+ } catch (cause) {
3557
+ if (jobStarted) {
3558
+ await this.#failJob(client, tenant, jobId, cause).catch(() => {
3559
+ });
3560
+ }
3561
+ throw cause;
3947
3562
  } finally {
3563
+ await client.query("SELECT pg_advisory_unlock(hashtextextended($1, $2))", [
3564
+ tenant,
3565
+ ADVISORY_LOCK_NAMESPACE
3566
+ ]).catch(() => {
3567
+ });
3948
3568
  client.release();
3949
3569
  }
3950
3570
  }
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
- ]);
3571
+ async listDeadLetters(tenant, query = {}) {
3572
+ const limit = assertBatchSize(query.limit ?? DEFAULT_BATCH_SIZE);
3573
+ if (query.deviceId !== void 0) assertIdentifier(query.deviceId, "deviceId");
3574
+ if (query.after !== void 0) assertDeadLetterRef(query.after);
3575
+ if (query.deviceId !== void 0 && query.after !== void 0 && query.deviceId !== query.after.deviceId) {
3576
+ throw new CloudCleanupError(
3577
+ "cleanup_invalid_input",
3578
+ "A device-scoped dead-letter cursor must belong to the same device."
3579
+ );
4001
3580
  }
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
3581
+ const listed = await this.#pool.query(
3582
+ `SELECT ${OUTBOX_COLUMNS2} FROM outbox
3583
+ WHERE tenant_id = $1
3584
+ AND state = 'expired'
3585
+ AND ($2::text IS NULL OR device_id = $2::text)
3586
+ AND (device_id > $3 OR (device_id = $3 AND seq > $4::bigint))
3587
+ ORDER BY device_id, seq
3588
+ LIMIT $5`,
3589
+ [
3590
+ tenant,
3591
+ query.deviceId ?? null,
3592
+ query.after?.deviceId ?? "",
3593
+ query.after?.seq ?? 0,
3594
+ limit + 1
3595
+ ]
3596
+ );
3597
+ return {
3598
+ messages: listed.rows.slice(0, limit).map(toMailboxMessage),
3599
+ hasMore: listed.rows.length > limit
3600
+ };
3601
+ }
3602
+ /** Clone an expired row to a new monotonic seq. The original remains evidence. */
3603
+ async replayDeadLetter(tenant, input) {
3604
+ assertDeadLetterRef(input);
3605
+ assertIdentifier(input.replayMessageId, "replayMessageId");
3606
+ const client = await this.#pool.connect();
3607
+ let result;
3608
+ let rejection;
3609
+ let rollbackAllocation = false;
3610
+ try {
3611
+ await client.query("BEGIN");
3612
+ const originalResult = await client.query(
3613
+ `SELECT ${OUTBOX_COLUMNS2} FROM outbox
3614
+ WHERE tenant_id = $1 AND device_id = $2 AND seq = $3::bigint
3615
+ AND state = 'expired'
4006
3616
  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])
3617
+ [tenant, input.deviceId, input.seq]
4012
3618
  );
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()
3619
+ const original = originalResult.rows[0];
3620
+ if (original === void 0) {
3621
+ rejection = deadLetterMissing(input);
3622
+ } else {
3623
+ const existingResult = await client.query(
3624
+ `SELECT ${OUTBOX_COLUMNS2} FROM outbox
3625
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
3626
+ [tenant, input.deviceId, input.replayMessageId]
4034
3627
  );
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.`
3628
+ const existing = existingResult.rows[0];
3629
+ if (existing !== void 0) {
3630
+ if (!replayMatches(existing, original)) {
3631
+ rejection = new CloudCleanupError(
3632
+ "cleanup_invalid_input",
3633
+ `Replay id ${input.replayMessageId} already binds a different replay delivery.`
3634
+ );
3635
+ } else {
3636
+ result = toMailboxMessage(existing);
3637
+ }
3638
+ } else {
3639
+ const entitlement = await client.query(
3640
+ `SELECT e.mailbox_limit_bytes, u.mailbox_bytes
3641
+ FROM storage_entitlement e
3642
+ JOIN storage_usage u ON u.tenant_id = e.tenant_id
3643
+ WHERE e.tenant_id = $1
3644
+ FOR UPDATE OF e, u`,
3645
+ [tenant]
3646
+ );
3647
+ const capacity = entitlement.rows[0];
3648
+ const serializedExisting = await client.query(
3649
+ `SELECT ${OUTBOX_COLUMNS2} FROM outbox
3650
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
3651
+ [tenant, input.deviceId, input.replayMessageId]
4051
3652
  );
3653
+ const winner = serializedExisting.rows[0];
3654
+ if (winner !== void 0) {
3655
+ if (!replayMatches(winner, original)) {
3656
+ rejection = new CloudCleanupError(
3657
+ "cleanup_invalid_input",
3658
+ `Replay id ${input.replayMessageId} already binds a different replay delivery.`
3659
+ );
3660
+ } else {
3661
+ result = toMailboxMessage(winner);
3662
+ }
3663
+ } else if (capacity === void 0) {
3664
+ rejection = new CloudCleanupError(
3665
+ "cleanup_policy_missing",
3666
+ `Tenant ${tenant} has no storage entitlement/usage row.`
3667
+ );
3668
+ } else {
3669
+ const seq = await allocateMailboxSequence(
3670
+ client,
3671
+ tenant,
3672
+ input.deviceId,
3673
+ this.#now()
3674
+ );
3675
+ const afterAllocation = await client.query(
3676
+ `SELECT ${OUTBOX_COLUMNS2} FROM outbox
3677
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
3678
+ [tenant, input.deviceId, input.replayMessageId]
3679
+ );
3680
+ const appendWinner = afterAllocation.rows[0];
3681
+ if (appendWinner !== void 0) {
3682
+ rollbackAllocation = true;
3683
+ if (!replayMatches(appendWinner, original)) {
3684
+ rejection = new CloudCleanupError(
3685
+ "cleanup_invalid_input",
3686
+ `Replay id ${input.replayMessageId} already binds a different replay delivery.`
3687
+ );
3688
+ } else {
3689
+ result = toMailboxMessage(appendWinner);
3690
+ }
3691
+ } else {
3692
+ const rebound = materializeReplayBody(original, seq);
3693
+ if (capacity.mailbox_bytes + rebound.byteSize > capacity.mailbox_limit_bytes) {
3694
+ rejection = new ByokCoreError(
3695
+ "storage_quota_exceeded",
3696
+ `Replaying the dead letter would exceed tenant ${tenant}'s mailbox limit.`
3697
+ );
3698
+ } else {
3699
+ const inserted = await client.query(
3700
+ `INSERT INTO outbox (${OUTBOX_COLUMNS2})
3701
+ VALUES ($1, $2, $3::bigint, $4, $5, $6, $7::bigint, 'pending', $8, $9)
3702
+ RETURNING ${OUTBOX_COLUMNS2}`,
3703
+ [
3704
+ tenant,
3705
+ input.deviceId,
3706
+ seq,
3707
+ input.replayMessageId,
3708
+ rebound.body,
3709
+ rebound.bodyHash,
3710
+ rebound.byteSize,
3711
+ this.#now(),
3712
+ original.seq
3713
+ ]
3714
+ );
3715
+ await client.query(
3716
+ `UPDATE storage_usage
3717
+ SET mailbox_bytes = mailbox_bytes + $2::bigint, updated_at = $3
3718
+ WHERE tenant_id = $1`,
3719
+ [tenant, rebound.byteSize, this.#now()]
3720
+ );
3721
+ result = toMailboxMessage(inserted.rows[0]);
3722
+ }
3723
+ }
3724
+ }
4052
3725
  }
4053
- requested.set(write.body.hash, write.byteSize);
4054
- affected.add(write.body.hash);
4055
3726
  }
3727
+ if (rejection === void 0 && !rollbackAllocation) await client.query("COMMIT");
3728
+ else await client.query("ROLLBACK");
3729
+ } catch (cause) {
3730
+ await client.query("ROLLBACK").catch(() => {
3731
+ });
3732
+ throw cause;
3733
+ } finally {
3734
+ client.release();
4056
3735
  }
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]
3736
+ if (rejection !== void 0) throw rejection;
3737
+ return result;
3738
+ }
3739
+ /** Explicit operator discard. Automatic retention never deletes dead letters. */
3740
+ async discardDeadLetter(tenant, ref) {
3741
+ assertDeadLetterRef(ref);
3742
+ const client = await this.#pool.connect();
3743
+ let row;
3744
+ let rejection;
3745
+ try {
3746
+ await client.query("BEGIN");
3747
+ const existing = await client.query(
3748
+ `SELECT ${OUTBOX_COLUMNS2} FROM outbox
3749
+ WHERE tenant_id = $1 AND device_id = $2 AND seq = $3::bigint
3750
+ AND state = 'expired'
3751
+ FOR UPDATE`,
3752
+ [tenant, ref.deviceId, ref.seq]
4062
3753
  );
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.`
3754
+ const deadLetter = existing.rows[0];
3755
+ const usage = await client.query(
3756
+ "SELECT mailbox_bytes FROM storage_usage WHERE tenant_id = $1 FOR UPDATE",
3757
+ [tenant]
3758
+ );
3759
+ if (deadLetter === void 0) {
3760
+ rejection = deadLetterMissing(ref);
3761
+ } else if (usage.rows[0] === void 0 || usage.rows[0].mailbox_bytes < deadLetter.byte_size) {
3762
+ rejection = new CloudCleanupError(
3763
+ "cleanup_accounting_drift",
3764
+ `Mailbox accounting cannot release dead letter ${ref.deviceId}/${String(ref.seq)}.`
3765
+ );
3766
+ } else {
3767
+ const removed = await client.query(
3768
+ `DELETE FROM outbox
3769
+ WHERE tenant_id = $1 AND device_id = $2 AND seq = $3::bigint
3770
+ AND state = 'expired'
3771
+ RETURNING ${OUTBOX_COLUMNS2}`,
3772
+ [tenant, ref.deviceId, ref.seq]
3773
+ );
3774
+ await client.query(
3775
+ `UPDATE storage_usage
3776
+ SET mailbox_bytes = mailbox_bytes - $2::bigint, updated_at = $3
3777
+ WHERE tenant_id = $1`,
3778
+ [tenant, deadLetter.byte_size, this.#now()]
4069
3779
  );
3780
+ row = removed.rows[0];
4070
3781
  }
3782
+ if (rejection === void 0) await client.query("COMMIT");
3783
+ else await client.query("ROLLBACK");
3784
+ } catch (cause) {
3785
+ await client.query("ROLLBACK").catch(() => {
3786
+ });
3787
+ throw cause;
3788
+ } finally {
3789
+ client.release();
4071
3790
  }
3791
+ if (rejection !== void 0) throw rejection;
3792
+ return toMailboxMessage(row);
4072
3793
  }
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;
3794
+ /**
3795
+ * Explicit recovery operation: rebuild object accounting from committed
3796
+ * Postgres manifests. Reconciliation must run first; R2 LIST is never used as
3797
+ * billing authority and inline/mailbox usage is left untouched.
3798
+ */
3799
+ async rebuildObjectUsage(tenant) {
3800
+ const client = await this.#pool.connect();
3801
+ try {
3802
+ await client.query("BEGIN");
3803
+ const locked = await client.query(
3804
+ "SELECT 1 FROM storage_usage WHERE tenant_id = $1 FOR UPDATE",
3805
+ [tenant]
3806
+ );
3807
+ if (locked.rowCount === 0) {
3808
+ throw new CloudCleanupError(
3809
+ "cleanup_policy_missing",
3810
+ `Tenant ${tenant} has no storage usage row to rebuild.`
3811
+ );
4192
3812
  }
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
3813
+ const rebuilt = await client.query(
3814
+ `WITH authority AS MATERIALIZED (
3815
+ SELECT COALESCE(SUM(byte_size), 0)::bigint AS committed_object_bytes,
3816
+ count(*)::bigint AS object_count
3817
+ FROM object_manifest
3818
+ WHERE tenant_id = $1 AND state = 'committed'
3819
+ )
3820
+ UPDATE storage_usage u
3821
+ SET committed_object_bytes = authority.committed_object_bytes,
3822
+ object_count = authority.object_count,
3823
+ updated_at = $2
3824
+ FROM authority
3825
+ WHERE u.tenant_id = $1
3826
+ RETURNING u.committed_object_bytes, u.object_count, u.updated_at`,
3827
+ [tenant, this.#now()]
4220
3828
  );
4221
- applied.push({
4222
- input: write,
4223
- before,
4224
- record: toRecord3(tenant, result.rows[0]),
4225
- mutated: true
3829
+ await client.query("COMMIT");
3830
+ const row = rebuilt.rows[0];
3831
+ return {
3832
+ committedObjectBytes: row.committed_object_bytes,
3833
+ objectCount: row.object_count,
3834
+ updatedAt: row.updated_at
3835
+ };
3836
+ } catch (cause) {
3837
+ await client.query("ROLLBACK").catch(() => {
4226
3838
  });
3839
+ throw cause;
3840
+ } finally {
3841
+ client.release();
4227
3842
  }
4228
- return applied;
4229
3843
  }
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()]
3844
+ async #readRetentionPolicy(queryable, tenant) {
3845
+ const result = await queryable.query(
3846
+ `SELECT p.tenant_id, p.policy_id, p.mailbox_acked_retention_ms,
3847
+ p.mailbox_unacked_retention_ms, p.request_receipt_retention_ms,
3848
+ p.object_orphan_grace_ms, p.updated_at
3849
+ FROM storage_entitlement e
3850
+ JOIN tenant_retention_policy p
3851
+ ON p.tenant_id = e.tenant_id AND p.policy_id = e.retention_policy_id
3852
+ WHERE e.tenant_id = $1`,
3853
+ [tenant]
3854
+ );
3855
+ const row = result.rows[0];
3856
+ if (row === void 0) {
3857
+ throw new CloudCleanupError(
3858
+ "cleanup_policy_missing",
3859
+ `Tenant ${tenant} has no retention policy matching its entitlement.`
4261
3860
  );
4262
3861
  }
3862
+ const policy = toPolicy(row);
3863
+ assertPolicy(policy);
3864
+ return policy;
4263
3865
  }
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,
3866
+ async #startJob(client, tenant, jobId) {
3867
+ const now = this.#now();
3868
+ const started = await client.query(
3869
+ `INSERT INTO cleanup_job (tenant_id, job_id, kind, state, started_at)
3870
+ VALUES ($1, $2, 'tenant_cleanup', 'running', $3)
3871
+ ON CONFLICT (tenant_id, job_id) DO UPDATE
3872
+ SET state = 'running', started_at = EXCLUDED.started_at,
3873
+ finished_at = NULL, error_message = NULL
3874
+ WHERE cleanup_job.state IN ('running', 'failed')
3875
+ RETURNING ${JOB_COLUMNS}`,
3876
+ [tenant, jobId, now]
3877
+ );
3878
+ if (started.rows[0] !== void 0) return void 0;
3879
+ const existing = await this.#readJob(client, tenant, jobId);
3880
+ return toCleanupResult(existing);
3881
+ }
3882
+ async #runRetention(client, tenant, policy) {
3883
+ const ackedBefore = cutoff(this.#clock.now(), policy.mailboxAckedRetentionMs);
3884
+ const expireBefore = cutoff(this.#clock.now(), policy.mailboxUnackedRetentionMs);
3885
+ const receiptBefore = cutoff(this.#clock.now(), policy.requestReceiptRetentionMs);
3886
+ const now = this.#now();
3887
+ try {
3888
+ await client.query("BEGIN");
3889
+ const swept = await client.query(
3890
+ `WITH deleted AS (
3891
+ DELETE FROM outbox
3892
+ WHERE tenant_id = $1 AND state = 'acked' AND appended_at < $2
3893
+ RETURNING byte_size
3894
+ ), released AS MATERIALIZED (
3895
+ SELECT COALESCE(SUM(byte_size), 0)::bigint AS bytes FROM deleted
3896
+ ), expired AS (
3897
+ UPDATE outbox SET state = 'expired'
3898
+ WHERE tenant_id = $1 AND state = 'pending' AND appended_at < $3
3899
+ RETURNING 1
3900
+ ), reservations AS (
3901
+ UPDATE storage_reservation SET state = 'expired', settled_at = $4
3902
+ WHERE tenant_id = $1 AND state = 'reserved' AND expires_at <= $4
3903
+ RETURNING 1
3904
+ ), nonces AS (
3905
+ DELETE FROM auth_nonce
3906
+ WHERE tenant_id = $1 AND (used OR expires_at <= $4::timestamptz)
3907
+ RETURNING 1
3908
+ ), pairing_codes AS (
3909
+ DELETE FROM pairing_code
3910
+ WHERE tenant_id = $1
3911
+ AND (redeemed_at IS NOT NULL OR expires_at <= $4::timestamptz)
3912
+ RETURNING 1
3913
+ ), receipts AS (
3914
+ DELETE FROM device_request_receipts
3915
+ WHERE tenant_id = $1 AND recorded_at < $5::timestamptz
3916
+ RETURNING 1
3917
+ ), presence AS (
3918
+ DELETE FROM device_presence
3919
+ WHERE tenant_id = $1 AND expires_at <= $4
3920
+ RETURNING 1
3921
+ ), activity AS (
3922
+ DELETE FROM activity_tail
3923
+ WHERE tenant_id = $1 AND expires_at <= $4
3924
+ RETURNING 1
3925
+ ), approvals AS (
3926
+ DELETE FROM approval_timeline_tail
3927
+ WHERE tenant_id = $1 AND expires_at <= $4
3928
+ RETURNING 1
3929
+ ), accounted AS (
3930
+ UPDATE storage_usage u
3931
+ SET mailbox_bytes = u.mailbox_bytes - released.bytes, updated_at = $4
3932
+ FROM released
3933
+ WHERE u.tenant_id = $1 AND u.mailbox_bytes >= released.bytes
3934
+ RETURNING released.bytes
3935
+ )
3936
+ SELECT (SELECT count(*) FROM deleted)::bigint AS mailbox_deleted_count,
3937
+ (SELECT count(*) FROM expired)::bigint AS mailbox_expired_count,
3938
+ (SELECT bytes FROM released)::bigint AS mailbox_released_bytes,
3939
+ (SELECT count(*) FROM accounted)::bigint AS usage_accounted,
3940
+ (SELECT count(*) FROM reservations)::bigint AS reservations_expired,
3941
+ ((SELECT count(*) FROM nonces)
3942
+ + (SELECT count(*) FROM pairing_codes)
3943
+ + (SELECT count(*) FROM receipts)
3944
+ + (SELECT count(*) FROM presence)
3945
+ + (SELECT count(*) FROM activity)
3946
+ + (SELECT count(*) FROM approvals))::bigint AS ttl_rows_deleted`,
3947
+ [tenant, ackedBefore, expireBefore, now, receiptBefore]
3948
+ );
3949
+ const result = swept.rows[0];
3950
+ if (result.usage_accounted !== 1n) {
3951
+ throw new CloudCleanupError(
3952
+ "cleanup_accounting_drift",
3953
+ `Mailbox accounting cannot release ${String(result.mailbox_released_bytes)} deleted bytes for tenant ${tenant}.`
3954
+ );
3955
+ }
3956
+ await client.query("COMMIT");
3957
+ return result;
3958
+ } catch (cause) {
3959
+ await client.query("ROLLBACK").catch(() => {
3960
+ });
3961
+ throw cause;
3962
+ }
3963
+ }
3964
+ async #markTombstones(client, tenant, orphanCutoff) {
3965
+ try {
3966
+ await client.query("BEGIN");
3967
+ await client.query(
3968
+ "SELECT 1 FROM storage_entitlement WHERE tenant_id = $1 FOR UPDATE",
3969
+ [tenant]
3970
+ );
3971
+ const marked = await client.query(
3972
+ `WITH candidates AS MATERIALIZED (
3973
+ SELECT m.tenant_id, m.hash
3974
+ FROM object_manifest m
3975
+ WHERE m.tenant_id = $1
3976
+ AND m.state IN ('pending', 'committed')
3977
+ AND m.ref_count = 0
3978
+ AND m.updated_at < $2
3979
+ AND NOT EXISTS (
3980
+ SELECT 1 FROM object_reference r
3981
+ WHERE r.tenant_id = m.tenant_id AND r.hash = m.hash
3982
+ )
3983
+ AND NOT EXISTS (
3984
+ SELECT 1 FROM storage_reservation s
3985
+ WHERE s.tenant_id = m.tenant_id AND s.content_hash = m.hash
3986
+ AND s.state = 'reserved'
3987
+ )
3988
+ ORDER BY m.updated_at, m.hash
3989
+ LIMIT $3
3990
+ FOR UPDATE OF m SKIP LOCKED
3991
+ )
3992
+ UPDATE object_manifest m
3993
+ SET gc_accounted_bytes = CASE WHEN m.state = 'committed' THEN m.byte_size ELSE 0 END,
3994
+ gc_accounted_object = (m.state = 'committed'),
3995
+ state = 'delete_pending', delete_pending_at = $4, updated_at = $4
3996
+ FROM candidates c
3997
+ WHERE m.tenant_id = c.tenant_id AND m.hash = c.hash
3998
+ RETURNING m.hash`,
3999
+ [tenant, orphanCutoff, this.#batchSize, this.#now()]
4000
+ );
4001
+ await client.query("COMMIT");
4002
+ return BigInt(marked.rowCount ?? 0);
4003
+ } catch (cause) {
4004
+ await client.query("ROLLBACK").catch(() => {
4005
+ });
4006
+ throw cause;
4007
+ }
4008
+ }
4009
+ async #settleDeleted(client, tenant, hash) {
4010
+ const settled = await client.query(
4011
+ `WITH candidate AS MATERIALIZED (
4012
+ SELECT m.gc_accounted_bytes, m.gc_accounted_object
4013
+ FROM object_manifest m
4014
+ JOIN storage_usage u ON u.tenant_id = m.tenant_id
4015
+ WHERE m.tenant_id = $1 AND m.hash = $2
4016
+ AND m.state = 'delete_pending'
4017
+ AND m.ref_count = 0
4018
+ AND m.gc_accounted_bytes IS NOT NULL
4019
+ AND m.gc_accounted_object IS NOT NULL
4020
+ AND NOT EXISTS (
4021
+ SELECT 1 FROM object_reference r
4022
+ WHERE r.tenant_id = m.tenant_id AND r.hash = m.hash
4023
+ )
4024
+ AND u.committed_object_bytes >= m.gc_accounted_bytes
4025
+ AND u.object_count >= CASE WHEN m.gc_accounted_object THEN 1 ELSE 0 END
4026
+ FOR UPDATE OF m, u
4027
+ ), moved AS (
4028
+ UPDATE object_manifest m
4029
+ SET state = 'deleted', updated_at = $3
4030
+ FROM candidate c
4031
+ WHERE m.tenant_id = $1 AND m.hash = $2 AND m.state = 'delete_pending'
4032
+ RETURNING c.gc_accounted_bytes, c.gc_accounted_object
4033
+ ), accounted AS (
4034
+ UPDATE storage_usage u
4035
+ SET committed_object_bytes = u.committed_object_bytes - moved.gc_accounted_bytes,
4036
+ object_count = u.object_count - CASE WHEN moved.gc_accounted_object THEN 1 ELSE 0 END,
4269
4037
  updated_at = $3
4270
- WHERE tenant_id = $1 AND committed_inline_bytes + $2::bigint >= 0
4038
+ FROM moved
4039
+ WHERE u.tenant_id = $1
4040
+ RETURNING moved.gc_accounted_bytes
4041
+ )
4042
+ SELECT gc_accounted_bytes FROM accounted`,
4043
+ [tenant, hash, this.#now()]
4044
+ );
4045
+ const row = settled.rows[0];
4046
+ if (row !== void 0) return row.gc_accounted_bytes;
4047
+ const current = await client.query(
4048
+ "SELECT state FROM object_manifest WHERE tenant_id = $1 AND hash = $2",
4049
+ [tenant, hash]
4050
+ );
4051
+ if (current.rows[0]?.state === "deleted") return void 0;
4052
+ throw new CloudCleanupError(
4053
+ "cleanup_accounting_drift",
4054
+ `Object ${hash} could not settle its delete tombstone against storage usage.`
4055
+ );
4056
+ }
4057
+ async #reconcileManifests(client, tenant, counts) {
4058
+ const cursor = await this.#readCursor(client, tenant, "manifest");
4059
+ const page = await client.query(
4060
+ `SELECT hash, byte_size, content_type, state,
4061
+ gc_accounted_bytes, gc_accounted_object
4062
+ FROM object_manifest
4063
+ WHERE tenant_id = $1
4064
+ AND state IN ('committed', 'delete_pending')
4065
+ AND hash > $2
4066
+ ORDER BY hash
4067
+ LIMIT $3`,
4068
+ [tenant, cursor ?? "", this.#batchSize]
4069
+ );
4070
+ for (const manifest of page.rows) {
4071
+ const observed = await this.#objectStorage.inspectObject(
4072
+ tenant,
4073
+ manifest.hash
4074
+ );
4075
+ if (manifest.state === "delete_pending") {
4076
+ if (observed === void 0) {
4077
+ try {
4078
+ const released = await this.#settleDeleted(
4079
+ client,
4080
+ tenant,
4081
+ manifest.hash
4082
+ );
4083
+ if (released !== void 0) {
4084
+ counts.objectsDeleted += 1n;
4085
+ counts.objectReleasedBytes += released;
4086
+ }
4087
+ } catch {
4088
+ counts.operationErrors += 1n;
4089
+ }
4090
+ }
4091
+ continue;
4092
+ }
4093
+ if (observed === void 0) {
4094
+ counts.missingObjects += 1n;
4095
+ } else if (observed.observedByteSize !== manifest.byte_size || observed.observedContentType !== manifest.content_type) {
4096
+ counts.shapeDrift += 1n;
4097
+ }
4098
+ }
4099
+ await this.#advanceLexicalCursor(
4100
+ client,
4101
+ tenant,
4102
+ "manifest",
4103
+ page.rows.at(-1)?.hash,
4104
+ page.rows.length
4105
+ );
4106
+ }
4107
+ async #reconcileR2(client, tenant, counts) {
4108
+ const cursor = await this.#readCursor(client, tenant, "r2");
4109
+ const page = await this.#objectStorage.listTenantObjects(
4110
+ tenant,
4111
+ cursor ?? void 0,
4112
+ this.#batchSize
4113
+ );
4114
+ for (const object of page.objects) {
4115
+ if (object.hash === void 0) {
4116
+ counts.invalidObjectKeys += 1n;
4117
+ continue;
4118
+ }
4119
+ const manifest = await client.query(
4120
+ "SELECT state FROM object_manifest WHERE tenant_id = $1 AND hash = $2",
4121
+ [tenant, object.hash]
4122
+ );
4123
+ const state = manifest.rows[0]?.state;
4124
+ if (state !== void 0 && state !== "deleted") continue;
4125
+ const observed = await this.#objectStorage.inspectObject(tenant, object.hash);
4126
+ if (observed === void 0) continue;
4127
+ const witnessed = await client.query(
4128
+ `INSERT INTO object_manifest (
4129
+ tenant_id, hash, byte_size, content_type, state, ref_count,
4130
+ created_at, updated_at, delete_pending_at,
4131
+ gc_accounted_bytes, gc_accounted_object
4132
+ ) VALUES ($1, $2, $3, $4, 'pending', 0, $5, $5, NULL, NULL, NULL)
4133
+ ON CONFLICT (tenant_id, hash) DO UPDATE
4134
+ SET byte_size = EXCLUDED.byte_size,
4135
+ content_type = EXCLUDED.content_type,
4136
+ state = 'pending', ref_count = 0,
4137
+ created_at = EXCLUDED.created_at,
4138
+ updated_at = EXCLUDED.updated_at,
4139
+ delete_pending_at = NULL,
4140
+ gc_accounted_bytes = NULL,
4141
+ gc_accounted_object = NULL
4142
+ WHERE object_manifest.state = 'deleted'
4271
4143
  RETURNING 1`,
4272
- [tenant, delta, this.#now()]
4144
+ [
4145
+ tenant,
4146
+ object.hash,
4147
+ observed.observedByteSize,
4148
+ observed.observedContentType,
4149
+ this.#now()
4150
+ ]
4273
4151
  );
4274
- if (updated.rowCount !== 1) throw new Error("inline accounting would become negative");
4152
+ counts.orphanWitnessesCreated += BigInt(witnessed.rowCount ?? 0);
4153
+ }
4154
+ if (page.nextContinuationToken === void 0) {
4155
+ await this.#clearCursor(client, tenant, "r2");
4156
+ } else {
4157
+ await this.#writeCursor(client, tenant, "r2", page.nextContinuationToken);
4158
+ }
4159
+ }
4160
+ async #advanceLexicalCursor(client, tenant, kind, lastValue, rowCount) {
4161
+ if (lastValue === void 0 || rowCount < this.#batchSize) {
4162
+ await this.#clearCursor(client, tenant, kind);
4163
+ } else {
4164
+ await this.#writeCursor(client, tenant, kind, lastValue);
4275
4165
  }
4276
4166
  }
4167
+ async #readCursor(client, tenant, kind) {
4168
+ const result = await client.query(
4169
+ "SELECT cursor_value FROM gc_cursor WHERE tenant_id = $1 AND cursor_kind = $2",
4170
+ [tenant, kind]
4171
+ );
4172
+ return result.rows[0]?.cursor_value ?? null;
4173
+ }
4174
+ async #writeCursor(client, tenant, kind, value) {
4175
+ await client.query(
4176
+ `INSERT INTO gc_cursor (tenant_id, cursor_kind, cursor_value, updated_at)
4177
+ VALUES ($1, $2, $3, $4)
4178
+ ON CONFLICT (tenant_id, cursor_kind) DO UPDATE
4179
+ SET cursor_value = EXCLUDED.cursor_value, updated_at = EXCLUDED.updated_at`,
4180
+ [tenant, kind, value, this.#now()]
4181
+ );
4182
+ }
4183
+ async #clearCursor(client, tenant, kind) {
4184
+ await client.query(
4185
+ "DELETE FROM gc_cursor WHERE tenant_id = $1 AND cursor_kind = $2",
4186
+ [tenant, kind]
4187
+ );
4188
+ }
4189
+ async #finishJob(client, tenant, jobId, state, counts) {
4190
+ const finished = await client.query(
4191
+ `UPDATE cleanup_job SET
4192
+ state = $3, finished_at = $4,
4193
+ mailbox_deleted_count = $5, mailbox_expired_count = $6,
4194
+ mailbox_released_bytes = $7, reservations_expired = $8,
4195
+ ttl_rows_deleted = $9,
4196
+ objects_tombstoned = $10, objects_deleted = $11,
4197
+ object_released_bytes = $12, orphan_witnesses_created = $13,
4198
+ missing_objects = $14, shape_drift = $15,
4199
+ invalid_object_keys = $16, operation_errors = $17,
4200
+ error_message = NULL
4201
+ WHERE tenant_id = $1 AND job_id = $2
4202
+ RETURNING ${JOB_COLUMNS}`,
4203
+ [
4204
+ tenant,
4205
+ jobId,
4206
+ state,
4207
+ this.#now(),
4208
+ counts.mailboxDeletedCount,
4209
+ counts.mailboxExpiredCount,
4210
+ counts.mailboxReleasedBytes,
4211
+ counts.reservationsExpired,
4212
+ counts.ttlRowsDeleted,
4213
+ counts.objectsTombstoned,
4214
+ counts.objectsDeleted,
4215
+ counts.objectReleasedBytes,
4216
+ counts.orphanWitnessesCreated,
4217
+ counts.missingObjects,
4218
+ counts.shapeDrift,
4219
+ counts.invalidObjectKeys,
4220
+ counts.operationErrors
4221
+ ]
4222
+ );
4223
+ return toCleanupResult(finished.rows[0]);
4224
+ }
4225
+ async #failJob(client, tenant, jobId, cause) {
4226
+ const message = (cause instanceof Error ? cause.message : String(cause)).slice(0, 2e3);
4227
+ await client.query(
4228
+ `UPDATE cleanup_job
4229
+ SET state = 'failed', finished_at = $3, error_message = $4
4230
+ WHERE tenant_id = $1 AND job_id = $2`,
4231
+ [tenant, jobId, this.#now(), message]
4232
+ );
4233
+ }
4234
+ async #readJob(client, tenant, jobId) {
4235
+ const result = await client.query(
4236
+ `SELECT ${JOB_COLUMNS} FROM cleanup_job WHERE tenant_id = $1 AND job_id = $2`,
4237
+ [tenant, jobId]
4238
+ );
4239
+ return result.rows[0];
4240
+ }
4277
4241
  #now() {
4278
4242
  return this.#clock.now().toISOString();
4279
4243
  }
4280
4244
  };
4245
+ function createPostgresCloudMaintenance(options) {
4246
+ const objectStorage = new R2ObjectMaintenanceStore(options.objectStorage);
4247
+ return new PostgresCloudCleanup({
4248
+ pool: options.pool,
4249
+ clock: options.clock,
4250
+ objectStorage,
4251
+ ...options.batchSize === void 0 ? {} : { batchSize: options.batchSize }
4252
+ });
4253
+ }
4254
+ var JOB_COLUMNS = [
4255
+ "tenant_id",
4256
+ "job_id",
4257
+ "state",
4258
+ "started_at",
4259
+ "finished_at",
4260
+ "mailbox_deleted_count",
4261
+ "mailbox_expired_count",
4262
+ "mailbox_released_bytes",
4263
+ "reservations_expired",
4264
+ "ttl_rows_deleted",
4265
+ "objects_tombstoned",
4266
+ "objects_deleted",
4267
+ "object_released_bytes",
4268
+ "orphan_witnesses_created",
4269
+ "missing_objects",
4270
+ "shape_drift",
4271
+ "invalid_object_keys",
4272
+ "operation_errors",
4273
+ "error_message"
4274
+ ].join(", ");
4275
+ function emptyCounts() {
4276
+ return {
4277
+ mailboxDeletedCount: 0n,
4278
+ mailboxExpiredCount: 0n,
4279
+ mailboxReleasedBytes: 0n,
4280
+ reservationsExpired: 0n,
4281
+ ttlRowsDeleted: 0n,
4282
+ objectsTombstoned: 0n,
4283
+ objectsDeleted: 0n,
4284
+ objectReleasedBytes: 0n,
4285
+ orphanWitnessesCreated: 0n,
4286
+ missingObjects: 0n,
4287
+ shapeDrift: 0n,
4288
+ invalidObjectKeys: 0n,
4289
+ operationErrors: 0n
4290
+ };
4291
+ }
4292
+ function toPolicy(row) {
4293
+ return {
4294
+ tenantId: tenantId(row.tenant_id),
4295
+ policyId: row.policy_id,
4296
+ mailboxAckedRetentionMs: row.mailbox_acked_retention_ms,
4297
+ mailboxUnackedRetentionMs: row.mailbox_unacked_retention_ms,
4298
+ requestReceiptRetentionMs: row.request_receipt_retention_ms,
4299
+ objectOrphanGraceMs: row.object_orphan_grace_ms,
4300
+ updatedAt: row.updated_at
4301
+ };
4302
+ }
4303
+ function toCleanupResult(row) {
4304
+ return {
4305
+ tenantId: tenantId(row.tenant_id),
4306
+ jobId: row.job_id,
4307
+ state: row.state,
4308
+ startedAt: row.started_at,
4309
+ ...row.finished_at === null ? {} : { finishedAt: row.finished_at },
4310
+ mailboxDeletedCount: row.mailbox_deleted_count,
4311
+ mailboxExpiredCount: row.mailbox_expired_count,
4312
+ mailboxReleasedBytes: row.mailbox_released_bytes,
4313
+ reservationsExpired: row.reservations_expired,
4314
+ ttlRowsDeleted: row.ttl_rows_deleted,
4315
+ objectsTombstoned: row.objects_tombstoned,
4316
+ objectsDeleted: row.objects_deleted,
4317
+ objectReleasedBytes: row.object_released_bytes,
4318
+ orphanWitnessesCreated: row.orphan_witnesses_created,
4319
+ missingObjects: row.missing_objects,
4320
+ shapeDrift: row.shape_drift,
4321
+ invalidObjectKeys: row.invalid_object_keys,
4322
+ operationErrors: row.operation_errors,
4323
+ ...row.error_message === null ? {} : { errorMessage: row.error_message }
4324
+ };
4325
+ }
4326
+ function toMailboxMessage(row) {
4327
+ return {
4328
+ tenantId: tenantId(row.tenant_id),
4329
+ deviceId: row.device_id,
4330
+ seq: Number(row.seq),
4331
+ messageId: row.message_id,
4332
+ body: row.body,
4333
+ bodyHash: row.body_hash,
4334
+ byteSize: row.byte_size,
4335
+ state: row.state,
4336
+ appendedAt: row.appended_at
4337
+ };
4338
+ }
4339
+ function materializeReplayBody(original, seq) {
4340
+ try {
4341
+ const envelope = decodeEnvelope(original.body);
4342
+ if (!isServerToDaemonType(envelope.type)) {
4343
+ throw new Error(`Envelope type ${envelope.type} is not server-to-daemon.`);
4344
+ }
4345
+ const rebound = EnvelopeSchema.parse({ ...envelope, seq });
4346
+ const body = encodeEnvelope(rebound);
4347
+ const bytes = new TextEncoder().encode(body);
4348
+ return {
4349
+ body,
4350
+ bodyHash: contentHash(`sha256:${createHash("sha256").update(bytes).digest("hex")}`),
4351
+ byteSize: BigInt(bytes.length)
4352
+ };
4353
+ } catch (cause) {
4354
+ throw new CloudCleanupError(
4355
+ "cleanup_invalid_input",
4356
+ `Dead letter ${original.device_id}/${String(original.seq)} is not a replayable server-to-daemon envelope.`,
4357
+ { cause }
4358
+ );
4359
+ }
4360
+ }
4361
+ function replayMatches(row, original) {
4362
+ if (row.replay_source_seq !== original.seq) return false;
4363
+ const expected = materializeReplayBody(original, Number(row.seq));
4364
+ return row.body === expected.body && row.body_hash === expected.bodyHash && row.byte_size === expected.byteSize;
4365
+ }
4366
+ function assertPolicy(input) {
4367
+ assertIdentifier(input.policyId, "policyId");
4368
+ for (const [field, value] of [
4369
+ ["mailboxAckedRetentionMs", input.mailboxAckedRetentionMs],
4370
+ ["mailboxUnackedRetentionMs", input.mailboxUnackedRetentionMs],
4371
+ ["requestReceiptRetentionMs", input.requestReceiptRetentionMs],
4372
+ ["objectOrphanGraceMs", input.objectOrphanGraceMs]
4373
+ ]) {
4374
+ if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) {
4375
+ throw new CloudCleanupError(
4376
+ "cleanup_invalid_input",
4377
+ `${field} must be a non-negative duration no larger than Number.MAX_SAFE_INTEGER milliseconds.`
4378
+ );
4379
+ }
4380
+ }
4381
+ }
4382
+ function assertBatchSize(value) {
4383
+ if (!Number.isInteger(value) || value < 1 || value > MAX_BATCH_SIZE) {
4384
+ throw new CloudCleanupError(
4385
+ "cleanup_invalid_input",
4386
+ `batchSize/limit must be a whole number in [1, ${String(MAX_BATCH_SIZE)}].`
4387
+ );
4388
+ }
4389
+ return value;
4390
+ }
4391
+ function assertIdentifier(value, field) {
4392
+ if (value.length === 0 || value.length > 256 || value.trim() !== value) {
4393
+ throw new CloudCleanupError(
4394
+ "cleanup_invalid_input",
4395
+ `${field} must be a non-empty, unpadded string no longer than 256 characters.`
4396
+ );
4397
+ }
4398
+ }
4399
+ function assertDeadLetterRef(ref) {
4400
+ assertIdentifier(ref.deviceId, "deviceId");
4401
+ if (!Number.isSafeInteger(ref.seq) || ref.seq < 1) {
4402
+ throw new CloudCleanupError(
4403
+ "cleanup_invalid_input",
4404
+ "A dead-letter seq must be a positive safe integer."
4405
+ );
4406
+ }
4407
+ }
4408
+ function cutoff(now, durationMs) {
4409
+ return new Date(now.getTime() - Number(durationMs)).toISOString();
4410
+ }
4411
+ function deadLetterMissing(ref) {
4412
+ return new CloudCleanupError(
4413
+ "cleanup_dead_letter_not_found",
4414
+ `Expired mailbox row ${ref.deviceId}/${String(ref.seq)} was not found.`
4415
+ );
4416
+ }
4281
4417
 
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 };
4418
+ 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, PostgresApprovalTimelineStore, PostgresBoardStore, PostgresCloudCleanup, PostgresDeviceDirectory, PostgresInboundDedupStore, PostgresMailboxStore, PostgresNonceStore, PostgresObjectStore, PostgresPairingCodeStore, PostgresPresenceStore, PostgresProofRequestReceiptStore, PostgresQuotaStore, PostgresRequestReceiptStore, PostgresSkillPackStore, PostgresTaskAttemptStore, PostgresTruthCommitter, PostgresTruthStore, R2BlobStoreError, R2CloudBlobStore, R2ObjectMaintenanceStore, R2_BLOB_ERROR_CODES, createByokPool, createPostgresCloudMaintenance, createPostgresCloudStores, createPostgresCoreStores, migrate, migrationsDir, readMigrationFiles };
4283
4419
  //# sourceMappingURL=index.js.map
4284
4420
  //# sourceMappingURL=index.js.map