@byok-sdk/cloud-dataplane 0.4.1 → 0.5.0

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