@byok-sdk/cloud-dataplane 0.5.0 → 0.6.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,9 +1,9 @@
1
1
  import pg from 'pg';
2
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';
3
+ import { contentHash, ByokCoreError, assertCanonicalTimestamp, isContentHash, tenantObjectKey, objectKeyPrefix, CoreConflictError, isLegalBoardTransition, checkSkillPackManifest, checkSkillPackEntry, SKILL_PACK_ENTRY_PATH, tenantId, SKILL_PACK_MANIFEST_SCHEMA_ID } from '@byok-sdk/core';
4
4
  import { AwsClient } from 'aws4fetch';
5
5
  import { XMLParser } from 'fast-xml-parser';
6
- import { createHash } from 'crypto';
6
+ import { createHash, randomUUID } from 'crypto';
7
7
  import { readdir, readFile } from 'fs/promises';
8
8
  import { join } from 'path';
9
9
  import { fileURLToPath } from 'url';
@@ -596,7 +596,7 @@ var R2ObjectMaintenanceStore = class {
596
596
  `A ListObjectsV2 page limit of ${String(limit)} is not a whole number in [1, 1000].`
597
597
  );
598
598
  }
599
- const prefix = `${tenant}/sha256/`;
599
+ const prefix = tenantObjectKey(tenant, LIST_PREFIX_HASH, this.#keyPrefix).slice(0, -LIST_HASH_HEX_LENGTH);
600
600
  const url = new URL(`${this.#origin}/${this.#bucket}`);
601
601
  url.searchParams.set("list-type", "2");
602
602
  url.searchParams.set("prefix", prefix);
@@ -780,6 +780,8 @@ function parseListObjectsV2(xml, prefix, attempts) {
780
780
  return { objects, nextContinuationToken };
781
781
  }
782
782
  var HASH_KEY_SUFFIX = /^[0-9a-f]{64}$/;
783
+ var LIST_HASH_HEX_LENGTH = 64;
784
+ var LIST_PREFIX_HASH = contentHash(`sha256:${"0".repeat(LIST_HASH_HEX_LENGTH)}`);
783
785
  function asRecord(value) {
784
786
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
785
787
  }
@@ -791,8 +793,6 @@ function requiredText(record, field, attempts) {
791
793
  attempts
792
794
  );
793
795
  }
794
-
795
- // src/stores/devices.ts
796
796
  function toRecord(row) {
797
797
  return {
798
798
  tenantId: row.tenant_id,
@@ -808,8 +808,10 @@ function toRecord(row) {
808
808
  var SELECT_COLUMNS = "tenant_id, device_id, product_id, device_name, device_public_key, proof_key_id, proof_key_epoch, revoked";
809
809
  var PostgresDeviceDirectory = class {
810
810
  #pool;
811
- constructor(pool) {
811
+ #clock;
812
+ constructor(pool, clock) {
812
813
  this.#pool = pool;
814
+ this.#clock = clock;
813
815
  }
814
816
  async register(tenant, input) {
815
817
  const result = await this.#pool.query(
@@ -859,6 +861,73 @@ var PostgresDeviceDirectory = class {
859
861
  );
860
862
  return result.rows.map(toRecord);
861
863
  }
864
+ async readiness(tenant, _presence) {
865
+ const result = await this.#pool.query(
866
+ `SELECT
867
+ d.device_id,
868
+ d.product_id,
869
+ d.device_name,
870
+ d.revoked,
871
+ CASE WHEN NOT d.revoked THEN p.level END AS presence_level,
872
+ CASE WHEN NOT d.revoked THEN p.detail END AS presence_detail,
873
+ CASE WHEN NOT d.revoked THEN p.configured_toolsets END AS presence_configured_toolsets,
874
+ CASE WHEN NOT d.revoked THEN p.client_version END AS presence_client_version,
875
+ CASE WHEN NOT d.revoked THEN p.protocol_versions END AS presence_protocol_versions,
876
+ CASE WHEN NOT d.revoked THEN p.runtimes END AS presence_runtimes,
877
+ CASE WHEN NOT d.revoked THEN p.observed_at END AS presence_observed_at,
878
+ CASE WHEN NOT d.revoked THEN p.expires_at END AS presence_expires_at,
879
+ (COUNT(*) FILTER (WHERE NOT d.revoked) OVER ())::int AS active_paired_device_count,
880
+ (COUNT(*) FILTER (WHERE d.revoked) OVER ())::int AS revoked_device_count,
881
+ (COUNT(*) FILTER (WHERE NOT d.revoked AND p.device_id IS NOT NULL) OVER ())::int AS observed_presence_count,
882
+ (COUNT(*) FILTER (WHERE NOT d.revoked AND p.level = 'online') OVER ())::int AS observed_online_count,
883
+ (COUNT(*) FILTER (WHERE NOT d.revoked AND p.level = 'thinking') OVER ())::int AS observed_thinking_count,
884
+ (COUNT(*) FILTER (WHERE NOT d.revoked AND p.level = 'working') OVER ())::int AS observed_working_count,
885
+ (COUNT(*) FILTER (WHERE NOT d.revoked AND p.level = 'error') OVER ())::int AS observed_error_count,
886
+ (COUNT(*) FILTER (WHERE NOT d.revoked AND p.level = 'offline') OVER ())::int AS observed_offline_count
887
+ FROM device d
888
+ LEFT JOIN device_presence p
889
+ ON p.tenant_id = d.tenant_id
890
+ AND p.device_id = d.device_id
891
+ AND p.expires_at > $2
892
+ WHERE d.tenant_id = $1
893
+ ORDER BY d.device_id`,
894
+ [tenant, this.#clock?.now().toISOString() ?? (/* @__PURE__ */ new Date()).toISOString()]
895
+ );
896
+ const row = result.rows[0];
897
+ const count = (value) => Number(value);
898
+ const devices = result.rows.map((device) => ({
899
+ deviceId: device.device_id,
900
+ productId: device.product_id,
901
+ deviceName: device.device_name,
902
+ revoked: device.revoked,
903
+ ...device.presence_level === null ? {} : {
904
+ presence: {
905
+ level: device.presence_level,
906
+ ...device.presence_detail === null ? {} : { detail: device.presence_detail },
907
+ ...device.presence_configured_toolsets === null ? {} : { configuredToolsets: Object.freeze([...device.presence_configured_toolsets]) },
908
+ ...device.presence_client_version === null ? {} : { clientVersion: device.presence_client_version },
909
+ ...device.presence_protocol_versions === null ? {} : { protocolVersions: Object.freeze([...device.presence_protocol_versions]) },
910
+ ...device.presence_runtimes === null ? {} : { runtimes: Object.freeze(device.presence_runtimes.map((runtime) => Object.freeze({ ...runtime }))) },
911
+ observedAt: device.presence_observed_at,
912
+ expiresAt: device.presence_expires_at
913
+ }
914
+ }
915
+ }));
916
+ return {
917
+ tenantId: tenant,
918
+ activePairedDeviceCount: count(row?.active_paired_device_count ?? 0),
919
+ revokedDeviceCount: count(row?.revoked_device_count ?? 0),
920
+ observedPresenceCount: count(row?.observed_presence_count ?? 0),
921
+ observedPresenceByLevel: {
922
+ online: count(row?.observed_online_count ?? 0),
923
+ thinking: count(row?.observed_thinking_count ?? 0),
924
+ working: count(row?.observed_working_count ?? 0),
925
+ error: count(row?.observed_error_count ?? 0),
926
+ offline: count(row?.observed_offline_count ?? 0)
927
+ },
928
+ devices
929
+ };
930
+ }
862
931
  async resolveByDeviceId(deviceId) {
863
932
  const result = await this.#pool.query(
864
933
  `SELECT ${SELECT_COLUMNS} FROM device WHERE device_id = $1`,
@@ -1082,8 +1151,8 @@ var PostgresProofRequestReceiptStore = class {
1082
1151
  };
1083
1152
 
1084
1153
  // src/stores/task-attempts.ts
1085
- var SELECT_COLUMNS4 = "tenant_id, task_id, device_id, owner_device_id, status, updated_at";
1086
- function toAttempt(row) {
1154
+ var TASK_SELECT_COLUMNS = "tenant_id, task_id, device_id, owner_device_id, status, cancel_requested_at, cancel_reason, cancel_message_id, updated_at";
1155
+ function taskRowToAttempt(row) {
1087
1156
  return {
1088
1157
  tenantId: row.tenant_id,
1089
1158
  taskId: row.task_id,
@@ -1094,6 +1163,12 @@ function toAttempt(row) {
1094
1163
  // former.
1095
1164
  ...row.owner_device_id === null ? {} : { ownerDeviceId: row.owner_device_id },
1096
1165
  status: row.status,
1166
+ ...row.cancel_requested_at === null ? {} : {
1167
+ cancellation: {
1168
+ requestedAt: row.cancel_requested_at.toISOString(),
1169
+ ...row.cancel_reason === null ? {} : { reason: row.cancel_reason }
1170
+ }
1171
+ },
1097
1172
  updatedAt: row.updated_at.toISOString()
1098
1173
  };
1099
1174
  }
@@ -1109,33 +1184,42 @@ var PostgresTaskAttemptStore = class {
1109
1184
  `INSERT INTO task (tenant_id, task_id, device_id, owner_device_id, status, updated_at)
1110
1185
  VALUES ($1, $2, $3, NULL, 'offered', $4)
1111
1186
  ON CONFLICT (tenant_id, task_id) DO NOTHING
1112
- RETURNING ${SELECT_COLUMNS4}`,
1187
+ RETURNING ${TASK_SELECT_COLUMNS}`,
1113
1188
  [tenant, input.taskId, input.deviceId, this.#now()]
1114
1189
  );
1115
1190
  const created = inserted.rows[0];
1116
- if (created !== void 0) return toAttempt(created);
1191
+ if (created !== void 0) return taskRowToAttempt(created);
1117
1192
  const existing = await this.get(tenant, input.taskId);
1118
1193
  if (existing === void 0) throw new Error(`task ${input.taskId} vanished during open`);
1119
1194
  return existing;
1120
1195
  }
1121
1196
  async get(tenant, taskId) {
1122
1197
  const result = await this.#pool.query(
1123
- `SELECT ${SELECT_COLUMNS4} FROM task WHERE tenant_id = $1 AND task_id = $2`,
1198
+ `SELECT ${TASK_SELECT_COLUMNS} FROM task WHERE tenant_id = $1 AND task_id = $2`,
1124
1199
  [tenant, taskId]
1125
1200
  );
1126
1201
  const row = result.rows[0];
1127
- return row === void 0 ? void 0 : toAttempt(row);
1202
+ return row === void 0 ? void 0 : taskRowToAttempt(row);
1203
+ }
1204
+ async getMany(tenant, taskIds) {
1205
+ if (taskIds.length === 0) return [];
1206
+ const result = await this.#pool.query(
1207
+ `SELECT ${TASK_SELECT_COLUMNS} FROM task WHERE tenant_id = $1 AND task_id = ANY($2::text[])`,
1208
+ [tenant, [...new Set(taskIds)]]
1209
+ );
1210
+ return result.rows.map(taskRowToAttempt);
1128
1211
  }
1129
1212
  async claim(tenant, input) {
1130
1213
  const claimed = await this.#pool.query(
1131
1214
  `UPDATE task
1132
1215
  SET owner_device_id = $3, status = 'claimed', updated_at = $4
1133
1216
  WHERE tenant_id = $1 AND task_id = $2 AND owner_device_id IS NULL
1134
- RETURNING ${SELECT_COLUMNS4}`,
1217
+ AND cancel_requested_at IS NULL AND status = 'offered'
1218
+ RETURNING ${TASK_SELECT_COLUMNS}`,
1135
1219
  [tenant, input.taskId, input.deviceId, this.#now()]
1136
1220
  );
1137
1221
  const won = claimed.rows[0];
1138
- if (won !== void 0) return toAttempt(won);
1222
+ if (won !== void 0) return taskRowToAttempt(won);
1139
1223
  return this.get(tenant, input.taskId);
1140
1224
  }
1141
1225
  async recordStatus(tenant, input) {
@@ -1143,16 +1227,336 @@ var PostgresTaskAttemptStore = class {
1143
1227
  `UPDATE task
1144
1228
  SET status = $3, updated_at = $4
1145
1229
  WHERE tenant_id = $1 AND task_id = $2
1146
- RETURNING ${SELECT_COLUMNS4}`,
1230
+ AND (
1231
+ (cancel_requested_at IS NULL AND status NOT IN ('complete', 'failed', 'cancelled'))
1232
+ OR (cancel_requested_at IS NOT NULL AND $3 = 'cancelled' AND status <> 'cancelled')
1233
+ )
1234
+ RETURNING ${TASK_SELECT_COLUMNS}`,
1147
1235
  [tenant, input.taskId, input.status, this.#now()]
1148
1236
  );
1149
1237
  const row = result.rows[0];
1150
- return row === void 0 ? void 0 : toAttempt(row);
1238
+ return row === void 0 ? this.get(tenant, input.taskId) : taskRowToAttempt(row);
1151
1239
  }
1152
1240
  #now() {
1153
1241
  return this.#clock.now().toISOString();
1154
1242
  }
1155
1243
  };
1244
+
1245
+ // src/stores/core/mailbox-sequence.ts
1246
+ async function allocateMailboxSequence(client, tenant, deviceId, now) {
1247
+ const allocation = await client.query(
1248
+ `INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
1249
+ VALUES ($1, $2, 2, 0, $3)
1250
+ ON CONFLICT (tenant_id, device_id) DO UPDATE
1251
+ SET next_seq = device_stream.next_seq + 1
1252
+ RETURNING next_seq - 1 AS seq`,
1253
+ [tenant, deviceId, now]
1254
+ );
1255
+ return Number(allocation.rows[0].seq);
1256
+ }
1257
+
1258
+ // src/stores/core/mailbox.ts
1259
+ var DEFAULT_READ_LIMIT = 50;
1260
+ var OUTBOX_COLUMNS = "tenant_id, device_id, seq, message_id, body, body_hash, byte_size, state, appended_at";
1261
+ function toMailboxMessage(row) {
1262
+ return {
1263
+ tenantId: row.tenant_id,
1264
+ deviceId: row.device_id,
1265
+ // `seq` is bigint in the column and `number` on the port, because it is the
1266
+ // envelope `seq` on the wire. The column is wide so the counter cannot wrap
1267
+ // into a redelivery bug; the narrowing happens once, here.
1268
+ seq: Number(row.seq),
1269
+ messageId: row.message_id,
1270
+ body: row.body,
1271
+ bodyHash: row.body_hash,
1272
+ byteSize: row.byte_size,
1273
+ state: row.state,
1274
+ appendedAt: row.appended_at
1275
+ };
1276
+ }
1277
+ var PostgresMailboxStore = class {
1278
+ #pool;
1279
+ #clock;
1280
+ constructor(pool, clock) {
1281
+ this.#pool = pool;
1282
+ this.#clock = clock;
1283
+ }
1284
+ async append(tenant, input) {
1285
+ this.#requireDeviceId(input.deviceId);
1286
+ const client = await this.#pool.connect();
1287
+ try {
1288
+ await client.query("BEGIN");
1289
+ const existing = await client.query(
1290
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1291
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1292
+ [tenant, input.deviceId, input.messageId]
1293
+ );
1294
+ const replayed = existing.rows[0];
1295
+ if (replayed !== void 0) {
1296
+ await client.query("COMMIT");
1297
+ return toMailboxMessage(replayed);
1298
+ }
1299
+ const seq = await allocateMailboxSequence(client, tenant, input.deviceId, this.#now());
1300
+ const serializedExisting = await client.query(
1301
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1302
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1303
+ [tenant, input.deviceId, input.messageId]
1304
+ );
1305
+ const winnerAfterLock = serializedExisting.rows[0];
1306
+ if (winnerAfterLock !== void 0) {
1307
+ await client.query("ROLLBACK");
1308
+ return toMailboxMessage(winnerAfterLock);
1309
+ }
1310
+ const materialized = await input.materialize(seq);
1311
+ const now = this.#now();
1312
+ const inserted = await client.query(
1313
+ `INSERT INTO outbox (${OUTBOX_COLUMNS})
1314
+ VALUES ($1, $2, $3::bigint, $4, $5, $6, $7::bigint, 'pending', $8)
1315
+ ON CONFLICT (tenant_id, device_id, message_id) DO NOTHING
1316
+ RETURNING ${OUTBOX_COLUMNS}`,
1317
+ [
1318
+ tenant,
1319
+ input.deviceId,
1320
+ seq,
1321
+ input.messageId,
1322
+ materialized.body,
1323
+ materialized.bodyHash,
1324
+ materialized.byteSize,
1325
+ now
1326
+ ]
1327
+ );
1328
+ const row = inserted.rows[0];
1329
+ if (row !== void 0) {
1330
+ await client.query("COMMIT");
1331
+ return toMailboxMessage(row);
1332
+ }
1333
+ const winner = await client.query(
1334
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1335
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1336
+ [tenant, input.deviceId, input.messageId]
1337
+ );
1338
+ await client.query("ROLLBACK");
1339
+ const won = winner.rows[0];
1340
+ if (won === void 0) {
1341
+ throw new ByokCoreError(
1342
+ "mailbox_message_not_found",
1343
+ `Message ${input.messageId} vanished during an idempotent append.`
1344
+ );
1345
+ }
1346
+ return toMailboxMessage(won);
1347
+ } catch (cause) {
1348
+ await client.query("ROLLBACK").catch(() => {
1349
+ });
1350
+ throw cause;
1351
+ } finally {
1352
+ client.release();
1353
+ }
1354
+ }
1355
+ async readAfter(tenant, query) {
1356
+ const limit = query.limit ?? DEFAULT_READ_LIMIT;
1357
+ const result = await this.#pool.query(
1358
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1359
+ WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending' AND seq > $3::bigint
1360
+ ORDER BY seq
1361
+ LIMIT $4`,
1362
+ [tenant, query.deviceId, query.afterSeq, limit + 1]
1363
+ );
1364
+ const page = result.rows.slice(0, limit).map(toMailboxMessage);
1365
+ return {
1366
+ messages: page,
1367
+ // Nothing above was mutated, so an identical call replays the same page.
1368
+ // The returned position is a READ cursor and moves no ack.
1369
+ nextSeq: page.at(-1)?.seq ?? query.afterSeq,
1370
+ hasMore: result.rows.length > page.length
1371
+ };
1372
+ }
1373
+ async advanceCursor(tenant, input) {
1374
+ this.#requireDeviceId(input.deviceId);
1375
+ const now = this.#now();
1376
+ const moved = await this.#pool.query(
1377
+ `WITH moved AS (
1378
+ INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
1379
+ VALUES ($1, $2, 1, $3::bigint, $4)
1380
+ ON CONFLICT (tenant_id, device_id) DO UPDATE
1381
+ SET acked_seq = EXCLUDED.acked_seq, acked_at = EXCLUDED.acked_at
1382
+ WHERE device_stream.acked_seq <= EXCLUDED.acked_seq
1383
+ RETURNING acked_seq, acked_at
1384
+ ), marked AS (
1385
+ UPDATE outbox
1386
+ SET state = 'acked'
1387
+ WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending'
1388
+ AND seq <= (SELECT acked_seq FROM moved)
1389
+ RETURNING 1
1390
+ )
1391
+ SELECT acked_seq, acked_at FROM moved`,
1392
+ [tenant, input.deviceId, input.ackedSeq, now]
1393
+ );
1394
+ const row = moved.rows[0];
1395
+ if (row !== void 0) {
1396
+ return {
1397
+ tenantId: tenant,
1398
+ deviceId: input.deviceId,
1399
+ ackedSeq: Number(row.acked_seq),
1400
+ updatedAt: row.acked_at ?? now
1401
+ };
1402
+ }
1403
+ const current = await this.readCursor(tenant, input.deviceId);
1404
+ throw new CoreConflictError(
1405
+ "mailbox_cursor_regression",
1406
+ `Cursor for device ${input.deviceId} is at ${current.ackedSeq}; refusing to move it back to ${input.ackedSeq}.`,
1407
+ current,
1408
+ this.#now()
1409
+ );
1410
+ }
1411
+ async readCursor(tenant, deviceId) {
1412
+ const result = await this.#pool.query(
1413
+ `SELECT acked_seq, acked_at FROM device_stream
1414
+ WHERE tenant_id = $1 AND device_id = $2`,
1415
+ [tenant, deviceId]
1416
+ );
1417
+ const row = result.rows[0];
1418
+ return {
1419
+ tenantId: tenant,
1420
+ deviceId,
1421
+ ackedSeq: row === void 0 ? 0 : Number(row.acked_seq),
1422
+ updatedAt: row?.acked_at ?? this.#now()
1423
+ };
1424
+ }
1425
+ async collectRetired(tenant, input) {
1426
+ assertCanonicalTimestamp(input.ackedBefore, "ackedBefore");
1427
+ assertCanonicalTimestamp(input.expireUnackedBefore, "expireUnackedBefore");
1428
+ const swept = await this.#pool.query(
1429
+ `WITH deleted AS (
1430
+ DELETE FROM outbox
1431
+ WHERE tenant_id = $1
1432
+ AND ($2::text IS NULL OR device_id = $2::text)
1433
+ AND state = 'acked'
1434
+ AND appended_at < $3
1435
+ RETURNING byte_size
1436
+ ), expired AS (
1437
+ UPDATE outbox
1438
+ SET state = 'expired'
1439
+ WHERE tenant_id = $1
1440
+ AND ($2::text IS NULL OR device_id = $2::text)
1441
+ AND state = 'pending'
1442
+ AND appended_at < $4
1443
+ RETURNING 1
1444
+ )
1445
+ SELECT (SELECT count(*) FROM deleted) AS deleted_count,
1446
+ (SELECT count(*) FROM expired) AS expired_count,
1447
+ (SELECT COALESCE(SUM(byte_size), 0) FROM deleted)::bigint AS released_bytes`,
1448
+ [tenant, input.deviceId ?? null, input.ackedBefore, input.expireUnackedBefore]
1449
+ );
1450
+ const row = swept.rows[0];
1451
+ return {
1452
+ deletedCount: Number(row.deleted_count),
1453
+ expiredCount: Number(row.expired_count),
1454
+ releasedBytes: row.released_bytes
1455
+ };
1456
+ }
1457
+ /**
1458
+ * The in-memory reference refuses an empty device id rather than opening a
1459
+ * mailbox nothing can address. Kept here so the two compositions answer the
1460
+ * same way; the table itself would happily store the row.
1461
+ */
1462
+ #requireDeviceId(deviceId) {
1463
+ if (deviceId.length === 0) {
1464
+ throw new ByokCoreError("mailbox_message_not_found", "Device id must not be empty.");
1465
+ }
1466
+ }
1467
+ #now() {
1468
+ return this.#clock.now().toISOString();
1469
+ }
1470
+ };
1471
+
1472
+ // src/stores/task-cancellations.ts
1473
+ var PostgresTaskCancellationStore = class {
1474
+ #pool;
1475
+ #clock;
1476
+ constructor(pool, clock) {
1477
+ this.#pool = pool;
1478
+ this.#clock = clock;
1479
+ }
1480
+ async request(tenant, input) {
1481
+ const client = await this.#pool.connect();
1482
+ try {
1483
+ await client.query("BEGIN");
1484
+ const selected = await client.query(
1485
+ `SELECT ${TASK_SELECT_COLUMNS} FROM task
1486
+ WHERE tenant_id = $1 AND task_id = $2
1487
+ FOR UPDATE`,
1488
+ [tenant, input.taskId]
1489
+ );
1490
+ const current = selected.rows[0];
1491
+ if (current === void 0) {
1492
+ await client.query("ROLLBACK");
1493
+ return void 0;
1494
+ }
1495
+ if (current.cancel_requested_at === null && (current.status === "complete" || current.status === "failed" || current.status === "cancelled")) {
1496
+ await client.query("COMMIT");
1497
+ return { attempt: taskRowToAttempt(current) };
1498
+ }
1499
+ if (current.cancel_message_id !== null) {
1500
+ const replayed = await client.query(
1501
+ `SELECT ${OUTBOX_COLUMNS} FROM outbox
1502
+ WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1503
+ [tenant, current.device_id, current.cancel_message_id]
1504
+ );
1505
+ const message = replayed.rows[0];
1506
+ if (message === void 0) {
1507
+ if (current.status === "cancelled") {
1508
+ await client.query("COMMIT");
1509
+ return { attempt: taskRowToAttempt(current) };
1510
+ }
1511
+ throw new Error(`Cancellation delivery ${current.cancel_message_id} is missing for task ${input.taskId}`);
1512
+ }
1513
+ await client.query("COMMIT");
1514
+ return { attempt: taskRowToAttempt(current), message: toMailboxMessage(message) };
1515
+ }
1516
+ const now = this.#clock.now().toISOString();
1517
+ const messageId = input.proposedMessageId;
1518
+ const seq = await allocateMailboxSequence(client, tenant, current.device_id, now);
1519
+ const materialized = await input.materialize(seq, messageId);
1520
+ const insertedMessage = await client.query(
1521
+ `INSERT INTO outbox (${OUTBOX_COLUMNS})
1522
+ VALUES ($1, $2, $3::bigint, $4, $5, $6, $7::bigint, 'pending', $8)
1523
+ RETURNING ${OUTBOX_COLUMNS}`,
1524
+ [
1525
+ tenant,
1526
+ current.device_id,
1527
+ seq,
1528
+ messageId,
1529
+ materialized.body,
1530
+ materialized.bodyHash,
1531
+ materialized.byteSize,
1532
+ now
1533
+ ]
1534
+ );
1535
+ const updated = await client.query(
1536
+ `UPDATE task
1537
+ SET status = CASE WHEN owner_device_id IS NULL THEN 'cancelled' ELSE 'cancel_requested' END,
1538
+ cancel_requested_at = $3,
1539
+ cancel_reason = $4,
1540
+ cancel_message_id = $5,
1541
+ updated_at = $3
1542
+ WHERE tenant_id = $1 AND task_id = $2
1543
+ RETURNING ${TASK_SELECT_COLUMNS}`,
1544
+ [tenant, input.taskId, now, input.reason ?? null, messageId]
1545
+ );
1546
+ await client.query("COMMIT");
1547
+ return {
1548
+ attempt: taskRowToAttempt(updated.rows[0]),
1549
+ message: toMailboxMessage(insertedMessage.rows[0])
1550
+ };
1551
+ } catch (cause) {
1552
+ await client.query("ROLLBACK").catch(() => {
1553
+ });
1554
+ throw cause;
1555
+ } finally {
1556
+ client.release();
1557
+ }
1558
+ }
1559
+ };
1156
1560
  function toTail(row) {
1157
1561
  const entries = parseTimelineEvents(row.entries);
1158
1562
  const cursor = activityCursor(entries);
@@ -1442,11 +1846,12 @@ function createPostgresCloudStores(options) {
1442
1846
  return {
1443
1847
  activity: new PostgresActivityStore(pool, clock),
1444
1848
  approvals: new PostgresApprovalTimelineStore(pool, clock),
1445
- devices: new PostgresDeviceDirectory(pool),
1849
+ devices: new PostgresDeviceDirectory(pool, clock),
1446
1850
  pairingCodes: new PostgresPairingCodeStore(pool, clock),
1447
1851
  nonces: new PostgresNonceStore(pool, clock, crypto),
1448
1852
  dedup: new PostgresInboundDedupStore(pool),
1449
1853
  tasks: new PostgresTaskAttemptStore(pool, clock),
1854
+ cancellations: new PostgresTaskCancellationStore(pool, clock),
1450
1855
  receipts: new PostgresRequestReceiptStore(pool, clock),
1451
1856
  proofReceipts: new PostgresProofRequestReceiptStore(pool, clock),
1452
1857
  // A second `PostgresObjectStore` instance, not a shared one: it is a
@@ -1460,7 +1865,7 @@ function createPostgresCloudStores(options) {
1460
1865
  rateLimiter: new AllowAllRateLimiter()
1461
1866
  };
1462
1867
  }
1463
- var PRESENCE_COLUMNS = "tenant_id, device_id, level, detail, configured_toolsets, observed_at, expires_at";
1868
+ var PRESENCE_COLUMNS = "tenant_id, device_id, level, detail, configured_toolsets, client_version, protocol_versions, runtimes, observed_at, expires_at";
1464
1869
  function toHint(row) {
1465
1870
  return {
1466
1871
  tenantId: row.tenant_id,
@@ -1468,6 +1873,19 @@ function toHint(row) {
1468
1873
  level: row.level,
1469
1874
  ...row.detail === null ? {} : { detail: row.detail },
1470
1875
  ...row.configured_toolsets === null ? {} : { configuredToolsets: Object.freeze([...row.configured_toolsets]) },
1876
+ ...row.client_version === null ? {} : { clientVersion: row.client_version },
1877
+ ...row.protocol_versions === null ? {} : { protocolVersions: Object.freeze([...row.protocol_versions]) },
1878
+ ...row.runtimes === null ? {} : {
1879
+ runtimes: Object.freeze(
1880
+ row.runtimes.map(
1881
+ (runtime) => Object.freeze({
1882
+ id: runtime.id,
1883
+ ...runtime.version === void 0 ? {} : { version: runtime.version },
1884
+ ...runtime.authPresent === void 0 ? {} : { authPresent: runtime.authPresent }
1885
+ })
1886
+ )
1887
+ )
1888
+ },
1471
1889
  observedAt: row.observed_at,
1472
1890
  expiresAt: row.expires_at
1473
1891
  };
@@ -1503,15 +1921,18 @@ var PostgresPresenceStore = class {
1503
1921
  const allowedBefore = new Date(now.getTime() - input.minimumIntervalMs).toISOString();
1504
1922
  const result = await this.#pool.query(
1505
1923
  `INSERT INTO device_presence (${PRESENCE_COLUMNS})
1506
- VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)
1924
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8::jsonb, $9, $10)
1507
1925
  ON CONFLICT (tenant_id, device_id) DO UPDATE
1508
1926
  SET level = EXCLUDED.level,
1509
1927
  detail = EXCLUDED.detail,
1510
1928
  configured_toolsets = EXCLUDED.configured_toolsets,
1929
+ client_version = EXCLUDED.client_version,
1930
+ protocol_versions = EXCLUDED.protocol_versions,
1931
+ runtimes = EXCLUDED.runtimes,
1511
1932
  observed_at = EXCLUDED.observed_at,
1512
1933
  expires_at = EXCLUDED.expires_at
1513
1934
  WHERE device_presence.expires_at <= EXCLUDED.observed_at
1514
- OR device_presence.observed_at <= $8
1935
+ OR device_presence.observed_at <= $11
1515
1936
  RETURNING ${PRESENCE_COLUMNS}`,
1516
1937
  [
1517
1938
  tenant,
@@ -1519,6 +1940,9 @@ var PostgresPresenceStore = class {
1519
1940
  input.level,
1520
1941
  input.detail ?? null,
1521
1942
  input.configuredToolsets === void 0 ? null : JSON.stringify(input.configuredToolsets),
1943
+ input.clientVersion ?? null,
1944
+ input.protocolVersions === void 0 ? null : JSON.stringify(input.protocolVersions),
1945
+ input.runtimes === void 0 ? null : JSON.stringify(input.runtimes),
1522
1946
  observedAt,
1523
1947
  new Date(now.getTime() + input.ttlMs).toISOString(),
1524
1948
  allowedBefore
@@ -1748,266 +2172,39 @@ var PostgresBoardStore = class {
1748
2172
  throw new CoreConflictError(
1749
2173
  "board_transition_invalid",
1750
2174
  `${input.expectedStatus} to ${input.status} is not a legal board transition.`,
1751
- current,
1752
- this.#now()
1753
- );
1754
- }
1755
- throw this.#statusConflict(input.itemId, current, input.expectedStatus);
1756
- }
1757
- /**
1758
- * One statement, its own transaction, lock released immediately. See the file
1759
- * header for why this is not a CTE inside the write it feeds.
1760
- */
1761
- async #allocateSeq(tenant) {
1762
- const result = await this.#pool.query(
1763
- `INSERT INTO tenant_stream (tenant_id, board_seq)
1764
- VALUES ($1, 1)
1765
- ON CONFLICT (tenant_id) DO UPDATE SET board_seq = tenant_stream.board_seq + 1
1766
- RETURNING board_seq`,
1767
- [tenant]
1768
- );
1769
- return result.rows[0].board_seq;
1770
- }
1771
- #itemNotFound(itemId) {
1772
- return new ByokCoreError(
1773
- "board_item_not_found",
1774
- `Board item ${itemId} does not exist in this tenant.`
1775
- );
1776
- }
1777
- #statusConflict(itemId, current, expected) {
1778
- return new CoreConflictError(
1779
- "board_status_conflict",
1780
- `Board item ${itemId} is ${current.status}, not ${expected}.`,
1781
- current,
1782
- this.#now()
1783
- );
1784
- }
1785
- #now() {
1786
- return this.#clock.now().toISOString();
1787
- }
1788
- };
1789
-
1790
- // src/stores/core/mailbox-sequence.ts
1791
- async function allocateMailboxSequence(client, tenant, deviceId, now) {
1792
- const allocation = await client.query(
1793
- `INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
1794
- VALUES ($1, $2, 2, 0, $3)
1795
- ON CONFLICT (tenant_id, device_id) DO UPDATE
1796
- SET next_seq = device_stream.next_seq + 1
1797
- RETURNING next_seq - 1 AS seq`,
1798
- [tenant, deviceId, now]
1799
- );
1800
- return Number(allocation.rows[0].seq);
1801
- }
1802
-
1803
- // src/stores/core/mailbox.ts
1804
- var DEFAULT_READ_LIMIT = 50;
1805
- var OUTBOX_COLUMNS = "tenant_id, device_id, seq, message_id, body, body_hash, byte_size, state, appended_at";
1806
- function toMessage(row) {
1807
- return {
1808
- tenantId: row.tenant_id,
1809
- deviceId: row.device_id,
1810
- // `seq` is bigint in the column and `number` on the port, because it is the
1811
- // envelope `seq` on the wire. The column is wide so the counter cannot wrap
1812
- // into a redelivery bug; the narrowing happens once, here.
1813
- seq: Number(row.seq),
1814
- messageId: row.message_id,
1815
- body: row.body,
1816
- bodyHash: row.body_hash,
1817
- byteSize: row.byte_size,
1818
- state: row.state,
1819
- appendedAt: row.appended_at
1820
- };
1821
- }
1822
- var PostgresMailboxStore = class {
1823
- #pool;
1824
- #clock;
1825
- constructor(pool, clock) {
1826
- this.#pool = pool;
1827
- this.#clock = clock;
1828
- }
1829
- async append(tenant, input) {
1830
- this.#requireDeviceId(input.deviceId);
1831
- const client = await this.#pool.connect();
1832
- try {
1833
- await client.query("BEGIN");
1834
- const existing = await client.query(
1835
- `SELECT ${OUTBOX_COLUMNS} FROM outbox
1836
- WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1837
- [tenant, input.deviceId, input.messageId]
1838
- );
1839
- const replayed = existing.rows[0];
1840
- if (replayed !== void 0) {
1841
- await client.query("COMMIT");
1842
- return toMessage(replayed);
1843
- }
1844
- const seq = await allocateMailboxSequence(client, tenant, input.deviceId, this.#now());
1845
- const serializedExisting = await client.query(
1846
- `SELECT ${OUTBOX_COLUMNS} FROM outbox
1847
- WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1848
- [tenant, input.deviceId, input.messageId]
1849
- );
1850
- const winnerAfterLock = serializedExisting.rows[0];
1851
- if (winnerAfterLock !== void 0) {
1852
- await client.query("ROLLBACK");
1853
- return toMessage(winnerAfterLock);
1854
- }
1855
- const materialized = await input.materialize(seq);
1856
- const now = this.#now();
1857
- const inserted = await client.query(
1858
- `INSERT INTO outbox (${OUTBOX_COLUMNS})
1859
- VALUES ($1, $2, $3::bigint, $4, $5, $6, $7::bigint, 'pending', $8)
1860
- ON CONFLICT (tenant_id, device_id, message_id) DO NOTHING
1861
- RETURNING ${OUTBOX_COLUMNS}`,
1862
- [
1863
- tenant,
1864
- input.deviceId,
1865
- seq,
1866
- input.messageId,
1867
- materialized.body,
1868
- materialized.bodyHash,
1869
- materialized.byteSize,
1870
- now
1871
- ]
1872
- );
1873
- const row = inserted.rows[0];
1874
- if (row !== void 0) {
1875
- await client.query("COMMIT");
1876
- return toMessage(row);
1877
- }
1878
- const winner = await client.query(
1879
- `SELECT ${OUTBOX_COLUMNS} FROM outbox
1880
- WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
1881
- [tenant, input.deviceId, input.messageId]
1882
- );
1883
- await client.query("ROLLBACK");
1884
- const won = winner.rows[0];
1885
- if (won === void 0) {
1886
- throw new ByokCoreError(
1887
- "mailbox_message_not_found",
1888
- `Message ${input.messageId} vanished during an idempotent append.`
1889
- );
1890
- }
1891
- return toMessage(won);
1892
- } catch (cause) {
1893
- await client.query("ROLLBACK").catch(() => {
1894
- });
1895
- throw cause;
1896
- } finally {
1897
- client.release();
1898
- }
1899
- }
1900
- async readAfter(tenant, query) {
1901
- const limit = query.limit ?? DEFAULT_READ_LIMIT;
1902
- const result = await this.#pool.query(
1903
- `SELECT ${OUTBOX_COLUMNS} FROM outbox
1904
- WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending' AND seq > $3::bigint
1905
- ORDER BY seq
1906
- LIMIT $4`,
1907
- [tenant, query.deviceId, query.afterSeq, limit + 1]
1908
- );
1909
- const page = result.rows.slice(0, limit).map(toMessage);
1910
- return {
1911
- messages: page,
1912
- // Nothing above was mutated, so an identical call replays the same page.
1913
- // The returned position is a READ cursor and moves no ack.
1914
- nextSeq: page.at(-1)?.seq ?? query.afterSeq,
1915
- hasMore: result.rows.length > page.length
1916
- };
1917
- }
1918
- async advanceCursor(tenant, input) {
1919
- this.#requireDeviceId(input.deviceId);
1920
- const now = this.#now();
1921
- const moved = await this.#pool.query(
1922
- `WITH moved AS (
1923
- INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
1924
- VALUES ($1, $2, 1, $3::bigint, $4)
1925
- ON CONFLICT (tenant_id, device_id) DO UPDATE
1926
- SET acked_seq = EXCLUDED.acked_seq, acked_at = EXCLUDED.acked_at
1927
- WHERE device_stream.acked_seq <= EXCLUDED.acked_seq
1928
- RETURNING acked_seq, acked_at
1929
- ), marked AS (
1930
- UPDATE outbox
1931
- SET state = 'acked'
1932
- WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending'
1933
- AND seq <= (SELECT acked_seq FROM moved)
1934
- RETURNING 1
1935
- )
1936
- SELECT acked_seq, acked_at FROM moved`,
1937
- [tenant, input.deviceId, input.ackedSeq, now]
1938
- );
1939
- const row = moved.rows[0];
1940
- if (row !== void 0) {
1941
- return {
1942
- tenantId: tenant,
1943
- deviceId: input.deviceId,
1944
- ackedSeq: Number(row.acked_seq),
1945
- updatedAt: row.acked_at ?? now
1946
- };
1947
- }
1948
- const current = await this.readCursor(tenant, input.deviceId);
1949
- throw new CoreConflictError(
1950
- "mailbox_cursor_regression",
1951
- `Cursor for device ${input.deviceId} is at ${current.ackedSeq}; refusing to move it back to ${input.ackedSeq}.`,
1952
- current,
1953
- this.#now()
1954
- );
2175
+ current,
2176
+ this.#now()
2177
+ );
2178
+ }
2179
+ throw this.#statusConflict(input.itemId, current, input.expectedStatus);
1955
2180
  }
1956
- async readCursor(tenant, deviceId) {
2181
+ /**
2182
+ * One statement, its own transaction, lock released immediately. See the file
2183
+ * header for why this is not a CTE inside the write it feeds.
2184
+ */
2185
+ async #allocateSeq(tenant) {
1957
2186
  const result = await this.#pool.query(
1958
- `SELECT acked_seq, acked_at FROM device_stream
1959
- WHERE tenant_id = $1 AND device_id = $2`,
1960
- [tenant, deviceId]
2187
+ `INSERT INTO tenant_stream (tenant_id, board_seq)
2188
+ VALUES ($1, 1)
2189
+ ON CONFLICT (tenant_id) DO UPDATE SET board_seq = tenant_stream.board_seq + 1
2190
+ RETURNING board_seq`,
2191
+ [tenant]
1961
2192
  );
1962
- const row = result.rows[0];
1963
- return {
1964
- tenantId: tenant,
1965
- deviceId,
1966
- ackedSeq: row === void 0 ? 0 : Number(row.acked_seq),
1967
- updatedAt: row?.acked_at ?? this.#now()
1968
- };
2193
+ return result.rows[0].board_seq;
1969
2194
  }
1970
- async collectRetired(tenant, input) {
1971
- assertCanonicalTimestamp(input.ackedBefore, "ackedBefore");
1972
- assertCanonicalTimestamp(input.expireUnackedBefore, "expireUnackedBefore");
1973
- const swept = await this.#pool.query(
1974
- `WITH deleted AS (
1975
- DELETE FROM outbox
1976
- WHERE tenant_id = $1
1977
- AND ($2::text IS NULL OR device_id = $2::text)
1978
- AND state = 'acked'
1979
- AND appended_at < $3
1980
- RETURNING byte_size
1981
- ), expired AS (
1982
- UPDATE outbox
1983
- SET state = 'expired'
1984
- WHERE tenant_id = $1
1985
- AND ($2::text IS NULL OR device_id = $2::text)
1986
- AND state = 'pending'
1987
- AND appended_at < $4
1988
- RETURNING 1
1989
- )
1990
- SELECT (SELECT count(*) FROM deleted) AS deleted_count,
1991
- (SELECT count(*) FROM expired) AS expired_count,
1992
- (SELECT COALESCE(SUM(byte_size), 0) FROM deleted)::bigint AS released_bytes`,
1993
- [tenant, input.deviceId ?? null, input.ackedBefore, input.expireUnackedBefore]
2195
+ #itemNotFound(itemId) {
2196
+ return new ByokCoreError(
2197
+ "board_item_not_found",
2198
+ `Board item ${itemId} does not exist in this tenant.`
1994
2199
  );
1995
- const row = swept.rows[0];
1996
- return {
1997
- deletedCount: Number(row.deleted_count),
1998
- expiredCount: Number(row.expired_count),
1999
- releasedBytes: row.released_bytes
2000
- };
2001
2200
  }
2002
- /**
2003
- * The in-memory reference refuses an empty device id rather than opening a
2004
- * mailbox nothing can address. Kept here so the two compositions answer the
2005
- * same way; the table itself would happily store the row.
2006
- */
2007
- #requireDeviceId(deviceId) {
2008
- if (deviceId.length === 0) {
2009
- throw new ByokCoreError("mailbox_message_not_found", "Device id must not be empty.");
2010
- }
2201
+ #statusConflict(itemId, current, expected) {
2202
+ return new CoreConflictError(
2203
+ "board_status_conflict",
2204
+ `Board item ${itemId} is ${current.status}, not ${expected}.`,
2205
+ current,
2206
+ this.#now()
2207
+ );
2011
2208
  }
2012
2209
  #now() {
2013
2210
  return this.#clock.now().toISOString();
@@ -3357,6 +3554,11 @@ var PostgresTruthCommitter = class {
3357
3554
  return this.#clock.now().toISOString();
3358
3555
  }
3359
3556
  };
3557
+ function migrationsDir() {
3558
+ return fileURLToPath(new URL("./sql", import.meta.url));
3559
+ }
3560
+
3561
+ // src/migrate.ts
3360
3562
  var MIGRATION_ADVISORY_LOCK_KEY = "4021960801";
3361
3563
  var MIGRATION_FILENAME_PATTERN = /^(\d{4})[_-].+\.sql$/;
3362
3564
  var LEDGER_DDL = `
@@ -3365,6 +3567,28 @@ CREATE TABLE IF NOT EXISTS byok_schema_migration (
3365
3567
  checksum text NOT NULL,
3366
3568
  applied_at timestamptz NOT NULL
3367
3569
  )`;
3570
+ var MIGRATION_LEDGER_TABLE = "byok_schema_migration";
3571
+ var LEDGER_READ_SQL = "SELECT version, checksum FROM byok_schema_migration";
3572
+ var MigrationStateMismatchError = class extends Error {
3573
+ issues;
3574
+ constructor(issues, options) {
3575
+ const detail = issues.map((issue) => {
3576
+ switch (issue.kind) {
3577
+ case "missing":
3578
+ return `missing ${issue.version}`;
3579
+ case "unexpected":
3580
+ return `unexpected ${issue.version}`;
3581
+ case "checksum_mismatch":
3582
+ return `checksum mismatch ${issue.version}`;
3583
+ case "ledger_missing":
3584
+ return `ledger table missing ${issue.table}`;
3585
+ }
3586
+ }).join("; ");
3587
+ super(`Migration state does not match package files: ${detail}`, options);
3588
+ this.name = "MigrationStateMismatchError";
3589
+ this.issues = Object.freeze([...issues]);
3590
+ }
3591
+ };
3368
3592
  var MigrationChecksumMismatchError = class extends Error {
3369
3593
  version;
3370
3594
  expectedChecksum;
@@ -3423,11 +3647,65 @@ async function readMigrationFiles(directory) {
3423
3647
  }
3424
3648
  return files;
3425
3649
  }
3650
+ async function readLedgerRows(client) {
3651
+ const result = await client.query(LEDGER_READ_SQL);
3652
+ return result.rows;
3653
+ }
3426
3654
  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]));
3655
+ const rows = await readLedgerRows(client);
3656
+ return new Map(rows.map((row) => [row.version, row.checksum]));
3657
+ }
3658
+ function isMissingLedgerTable(error) {
3659
+ return typeof error === "object" && error !== null && "code" in error && error.code === "42P01";
3660
+ }
3661
+ function compareVersions(left, right) {
3662
+ if (left < right) return -1;
3663
+ if (left > right) return 1;
3664
+ return 0;
3665
+ }
3666
+ async function verifyMigrations(pool, directory = migrationsDir()) {
3667
+ const files = await readMigrationFiles(directory);
3668
+ const client = await pool.connect();
3669
+ try {
3670
+ let ledgerRows;
3671
+ try {
3672
+ ledgerRows = await readLedgerRows(client);
3673
+ } catch (error) {
3674
+ if (!isMissingLedgerTable(error)) throw error;
3675
+ throw new MigrationStateMismatchError(
3676
+ [{ kind: "ledger_missing", table: MIGRATION_LEDGER_TABLE }],
3677
+ { cause: error }
3678
+ );
3679
+ }
3680
+ const ledger = new Map(ledgerRows.map((row) => [row.version, row.checksum]));
3681
+ const expectedVersions = new Set(files.map((file) => file.version));
3682
+ const issues = [];
3683
+ for (const file of files) {
3684
+ const actualChecksum = ledger.get(file.version);
3685
+ if (actualChecksum === void 0) {
3686
+ issues.push({
3687
+ kind: "missing",
3688
+ version: file.version,
3689
+ expectedChecksum: file.checksum
3690
+ });
3691
+ } else if (actualChecksum !== file.checksum) {
3692
+ issues.push({
3693
+ kind: "checksum_mismatch",
3694
+ version: file.version,
3695
+ expectedChecksum: file.checksum,
3696
+ actualChecksum
3697
+ });
3698
+ }
3699
+ }
3700
+ const unexpectedRows = ledgerRows.filter((row) => !expectedVersions.has(row.version)).slice().sort((left, right) => compareVersions(left.version, right.version));
3701
+ for (const row of unexpectedRows) {
3702
+ issues.push({ kind: "unexpected", version: row.version, actualChecksum: row.checksum });
3703
+ }
3704
+ if (issues.length > 0) throw new MigrationStateMismatchError(issues);
3705
+ return files.map(({ version, checksum }) => ({ version, checksum }));
3706
+ } finally {
3707
+ client.release();
3708
+ }
3431
3709
  }
3432
3710
  async function migrate(pool, directory) {
3433
3711
  const files = await readMigrationFiles(directory);
@@ -3471,9 +3749,6 @@ async function migrate(pool, directory) {
3471
3749
  client.release();
3472
3750
  }
3473
3751
  }
3474
- function migrationsDir() {
3475
- return fileURLToPath(new URL("./sql", import.meta.url));
3476
- }
3477
3752
  var DEFAULT_BATCH_SIZE = 100;
3478
3753
  var MAX_BATCH_SIZE = 1e3;
3479
3754
  var ADVISORY_LOCK_NAMESPACE = 1106736963;
@@ -3644,7 +3919,7 @@ var PostgresCloudCleanup = class {
3644
3919
  ]
3645
3920
  );
3646
3921
  return {
3647
- messages: listed.rows.slice(0, limit).map(toMailboxMessage),
3922
+ messages: listed.rows.slice(0, limit).map(toMailboxMessage2),
3648
3923
  hasMore: listed.rows.length > limit
3649
3924
  };
3650
3925
  }
@@ -3682,7 +3957,7 @@ var PostgresCloudCleanup = class {
3682
3957
  `Replay id ${input.replayMessageId} already binds a different replay delivery.`
3683
3958
  );
3684
3959
  } else {
3685
- result = toMailboxMessage(existing);
3960
+ result = toMailboxMessage2(existing);
3686
3961
  }
3687
3962
  } else {
3688
3963
  const entitlement = await client.query(
@@ -3707,7 +3982,7 @@ var PostgresCloudCleanup = class {
3707
3982
  `Replay id ${input.replayMessageId} already binds a different replay delivery.`
3708
3983
  );
3709
3984
  } else {
3710
- result = toMailboxMessage(winner);
3985
+ result = toMailboxMessage2(winner);
3711
3986
  }
3712
3987
  } else if (capacity === void 0) {
3713
3988
  rejection = new CloudCleanupError(
@@ -3735,7 +4010,7 @@ var PostgresCloudCleanup = class {
3735
4010
  `Replay id ${input.replayMessageId} already binds a different replay delivery.`
3736
4011
  );
3737
4012
  } else {
3738
- result = toMailboxMessage(appendWinner);
4013
+ result = toMailboxMessage2(appendWinner);
3739
4014
  }
3740
4015
  } else {
3741
4016
  const rebound = materializeReplayBody(original, seq);
@@ -3767,7 +4042,7 @@ var PostgresCloudCleanup = class {
3767
4042
  WHERE tenant_id = $1`,
3768
4043
  [tenant, rebound.byteSize, this.#now()]
3769
4044
  );
3770
- result = toMailboxMessage(inserted.rows[0]);
4045
+ result = toMailboxMessage2(inserted.rows[0]);
3771
4046
  }
3772
4047
  }
3773
4048
  }
@@ -3838,7 +4113,7 @@ var PostgresCloudCleanup = class {
3838
4113
  client.release();
3839
4114
  }
3840
4115
  if (rejection !== void 0) throw rejection;
3841
- return toMailboxMessage(row);
4116
+ return toMailboxMessage2(row);
3842
4117
  }
3843
4118
  /**
3844
4119
  * Explicit recovery operation: rebuild object accounting from committed
@@ -4372,7 +4647,7 @@ function toCleanupResult(row) {
4372
4647
  ...row.error_message === null ? {} : { errorMessage: row.error_message }
4373
4648
  };
4374
4649
  }
4375
- function toMailboxMessage(row) {
4650
+ function toMailboxMessage2(row) {
4376
4651
  return {
4377
4652
  tenantId: tenantId(row.tenant_id),
4378
4653
  deviceId: row.device_id,
@@ -4463,7 +4738,484 @@ function deadLetterMissing(ref) {
4463
4738
  `Expired mailbox row ${ref.deviceId}/${String(ref.seq)} was not found.`
4464
4739
  );
4465
4740
  }
4741
+ var DEFAULT_BATCH_SIZE2 = 100;
4742
+ var DEFAULT_MAX_PAGES_PER_RUN = 10;
4743
+ var DEFAULT_LEASE_MS = 3e4;
4744
+ var MAX_BATCH_SIZE2 = 1e3;
4745
+ var MAX_PAGES_PER_RUN = 100;
4746
+ var MAX_LEASE_MS = 5 * 6e4;
4747
+ var TENANT_ERASURE_TABLES = [
4748
+ "object_reference",
4749
+ "object_manifest",
4750
+ "storage_reservation",
4751
+ "storage_usage",
4752
+ "storage_entitlement",
4753
+ "gc_cursor",
4754
+ "cleanup_job",
4755
+ "tenant_retention_policy",
4756
+ "skill_pack_file",
4757
+ "skill_pack",
4758
+ "approval_timeline_tail",
4759
+ "activity_tail",
4760
+ "attested_record",
4761
+ "board_item",
4762
+ "tenant_stream",
4763
+ "outbox",
4764
+ "device_request_receipts",
4765
+ "proof_request_receipt",
4766
+ "task",
4767
+ "device_presence",
4768
+ "device_assertion_replay",
4769
+ "device_stream",
4770
+ "inbound_dedup",
4771
+ "auth_nonce",
4772
+ "pairing_code",
4773
+ "device"
4774
+ ];
4775
+ var TENANT_ERASURE_TABLE_SET = new Set(TENANT_ERASURE_TABLES);
4776
+ var TENANT_ERASURE_ERROR_CODES = {
4777
+ tenant_erasure_invalid_input: "tenant_erasure_invalid_input",
4778
+ tenant_erasure_schema_drift: "tenant_erasure_schema_drift",
4779
+ tenant_erasure_object_key_invalid: "tenant_erasure_object_key_invalid",
4780
+ tenant_erasure_storage_failure: "tenant_erasure_storage_failure",
4781
+ tenant_erasure_database_failure: "tenant_erasure_database_failure",
4782
+ tenant_erasure_cas_lost: "tenant_erasure_cas_lost"
4783
+ };
4784
+ var TenantErasureError = class extends Error {
4785
+ code;
4786
+ constructor(code, message, options) {
4787
+ super(message, options);
4788
+ this.name = "TenantErasureError";
4789
+ this.code = code;
4790
+ }
4791
+ };
4792
+ var OPERATION_COLUMNS = [
4793
+ "tenant_id",
4794
+ "operation_id",
4795
+ "state",
4796
+ "revision",
4797
+ "lease_token",
4798
+ "lease_expires_at",
4799
+ "r2_cursor",
4800
+ "r2_complete",
4801
+ "sql_table_index",
4802
+ "r2_objects_deleted",
4803
+ "sql_rows_deleted",
4804
+ "started_at",
4805
+ "updated_at",
4806
+ "completed_at",
4807
+ "last_error_code"
4808
+ ].join(", ");
4809
+ var PostgresTenantErasure = class {
4810
+ #pool;
4811
+ #clock;
4812
+ #objectStorage;
4813
+ #batchSize;
4814
+ #maxPagesPerRun;
4815
+ #leaseMs;
4816
+ constructor(options) {
4817
+ this.#pool = options.pool;
4818
+ this.#clock = options.clock;
4819
+ this.#objectStorage = options.objectStorage;
4820
+ this.#batchSize = assertBoundedWhole(options.batchSize ?? DEFAULT_BATCH_SIZE2, "batchSize", MAX_BATCH_SIZE2);
4821
+ this.#maxPagesPerRun = assertBoundedWhole(
4822
+ options.maxPagesPerRun ?? DEFAULT_MAX_PAGES_PER_RUN,
4823
+ "maxPagesPerRun",
4824
+ MAX_PAGES_PER_RUN
4825
+ );
4826
+ this.#leaseMs = assertBoundedWhole(options.leaseMs ?? DEFAULT_LEASE_MS, "leaseMs", MAX_LEASE_MS);
4827
+ }
4828
+ /** Read a durable operation receipt without advancing it. */
4829
+ async readTenantErasure(tenant, operationId) {
4830
+ assertOperationId(operationId);
4831
+ const row = await this.#readOperation(tenant, operationId);
4832
+ return row === void 0 ? void 0 : toReadback(row);
4833
+ }
4834
+ /**
4835
+ * Advance one bounded operation slice. Calls with a completed id replay its
4836
+ * receipt; another running id for the same tenant gets a typed conflict.
4837
+ */
4838
+ async eraseTenant(tenant, operationId) {
4839
+ assertOperationId(operationId);
4840
+ let existing;
4841
+ try {
4842
+ existing = await this.#readOperation(tenant, operationId);
4843
+ } catch (cause) {
4844
+ throw databaseFailure("reading tenant erasure operation", cause);
4845
+ }
4846
+ if (existing?.state === "completed") return toReadback(existing);
4847
+ await this.#assertSchemaInventory();
4848
+ let row;
4849
+ try {
4850
+ const opened = await this.#openOperation(tenant, operationId);
4851
+ if ("status" in opened) return opened;
4852
+ row = opened;
4853
+ } catch (cause) {
4854
+ throw databaseFailure("opening tenant erasure operation", cause);
4855
+ }
4856
+ if (row.state === "completed") return toReadback(row);
4857
+ const leaseToken = randomUUID();
4858
+ let claimed;
4859
+ try {
4860
+ claimed = await this.#claim(tenant, operationId, row.revision, leaseToken);
4861
+ if (claimed === void 0) return await this.#readConflictOrReceipt(tenant, operationId);
4862
+ for (let page = 0; page < this.#maxPagesPerRun; page += 1) {
4863
+ if (!claimed.r2_complete) {
4864
+ claimed = await this.#eraseR2Page(tenant, claimed, leaseToken);
4865
+ continue;
4866
+ }
4867
+ if (claimed.sql_table_index < TENANT_ERASURE_TABLES.length) {
4868
+ claimed = await this.#eraseSqlPage(tenant, claimed, leaseToken);
4869
+ continue;
4870
+ }
4871
+ claimed = await this.#verifyAndComplete(tenant, claimed, leaseToken);
4872
+ if (claimed.state === "completed") return toReadback(claimed);
4873
+ }
4874
+ return toReadback(await this.#release(tenant, operationId, claimed.revision, leaseToken));
4875
+ } catch (cause) {
4876
+ if (claimed === void 0) throw databaseFailure("claiming tenant erasure operation", cause);
4877
+ const code = cause instanceof TenantErasureError ? cause.code : TENANT_ERASURE_ERROR_CODES.tenant_erasure_database_failure;
4878
+ try {
4879
+ return toReadback(await this.#recordPartial(tenant, operationId, claimed.revision, leaseToken, code));
4880
+ } catch (recordCause) {
4881
+ throw databaseFailure("recording tenant erasure partial outcome", recordCause);
4882
+ }
4883
+ }
4884
+ }
4885
+ async #assertSchemaInventory() {
4886
+ const found = await this.#pool.query(
4887
+ `SELECT t.relname
4888
+ FROM pg_class t
4889
+ JOIN pg_namespace n ON n.oid = t.relnamespace
4890
+ WHERE n.nspname = current_schema()
4891
+ AND t.relkind = 'r'
4892
+ AND t.relname NOT IN ('byok_schema_migration', 'tenant_erasure_operation')
4893
+ ORDER BY t.relname`
4894
+ );
4895
+ const actual = new Set(found.rows.map((row) => row.relname));
4896
+ const missing = TENANT_ERASURE_TABLES.filter((name) => !actual.has(name));
4897
+ const unexpected = [...actual].filter((name) => !TENANT_ERASURE_TABLE_SET.has(name));
4898
+ if (missing.length === 0 && unexpected.length === 0 && actual.size === TENANT_ERASURE_TABLES.length) return;
4899
+ throw new TenantErasureError(
4900
+ "tenant_erasure_schema_drift",
4901
+ `Tenant erasure inventory drift: missing=[${missing.join(",")}], unexpected=[${unexpected.join(",")}].`
4902
+ );
4903
+ }
4904
+ async #openOperation(tenant, operationId) {
4905
+ const now = this.#clock.now();
4906
+ try {
4907
+ await this.#pool.query(
4908
+ `INSERT INTO tenant_erasure_operation (
4909
+ tenant_id, operation_id, state, started_at, updated_at
4910
+ ) VALUES ($1, $2, 'running', $3, $3)
4911
+ ON CONFLICT (tenant_id, operation_id) DO NOTHING`,
4912
+ [tenant, operationId, now]
4913
+ );
4914
+ } catch (cause) {
4915
+ if (postgresCode(cause) !== "23505") throw cause;
4916
+ return this.#conflict(tenant, operationId);
4917
+ }
4918
+ const own = await this.#readOperation(tenant, operationId);
4919
+ if (own !== void 0) return own;
4920
+ return this.#conflict(tenant, operationId);
4921
+ }
4922
+ async #claim(tenant, operationId, revision, leaseToken) {
4923
+ const now = this.#clock.now();
4924
+ const leaseExpiresAt = new Date(now.getTime() + this.#leaseMs);
4925
+ const result = await this.#pool.query(
4926
+ `UPDATE tenant_erasure_operation
4927
+ SET lease_token = $1,
4928
+ lease_expires_at = $2,
4929
+ revision = revision + 1,
4930
+ updated_at = $3,
4931
+ last_error_code = NULL
4932
+ WHERE tenant_id = $4
4933
+ AND operation_id = $5
4934
+ AND state = 'running'
4935
+ AND revision = $6::bigint
4936
+ AND (lease_token IS NULL OR lease_expires_at <= $3)
4937
+ RETURNING ${OPERATION_COLUMNS}`,
4938
+ [leaseToken, leaseExpiresAt, now, tenant, operationId, revision]
4939
+ );
4940
+ return result.rows[0];
4941
+ }
4942
+ async #eraseR2Page(tenant, row, leaseToken) {
4943
+ let page;
4944
+ try {
4945
+ page = await this.#objectStorage.listTenantObjects(
4946
+ tenant,
4947
+ row.r2_cursor ?? void 0,
4948
+ this.#batchSize
4949
+ );
4950
+ } catch (cause) {
4951
+ throw storageFailure("listing the tenant R2 namespace", cause);
4952
+ }
4953
+ for (const object of page.objects) {
4954
+ if (object.hash === void 0) {
4955
+ throw new TenantErasureError(
4956
+ "tenant_erasure_object_key_invalid",
4957
+ "Tenant R2 namespace contains a non-canonical object key; erasure refused before SQL deletion."
4958
+ );
4959
+ }
4960
+ try {
4961
+ await this.#objectStorage.deleteObject(tenant, object.hash);
4962
+ } catch (cause) {
4963
+ throw storageFailure("deleting a tenant R2 object", cause);
4964
+ }
4965
+ }
4966
+ return this.#casUpdate(
4967
+ tenant,
4968
+ row.operation_id,
4969
+ row.revision,
4970
+ leaseToken,
4971
+ `r2_cursor = $1,
4972
+ r2_complete = $2,
4973
+ r2_objects_deleted = r2_objects_deleted + $3::bigint`,
4974
+ [page.nextContinuationToken ?? null, page.nextContinuationToken === void 0, page.objects.length]
4975
+ );
4976
+ }
4977
+ async #eraseSqlPage(tenant, row, leaseToken) {
4978
+ const table = TENANT_ERASURE_TABLES[row.sql_table_index];
4979
+ if (table === void 0) {
4980
+ throw new TenantErasureError("tenant_erasure_cas_lost", "Tenant erasure SQL progress escaped its static inventory.");
4981
+ }
4982
+ let deleted;
4983
+ try {
4984
+ const result = await this.#pool.query(
4985
+ `WITH deleted AS (
4986
+ DELETE FROM ${table}
4987
+ WHERE ctid IN (
4988
+ SELECT ctid FROM ${table}
4989
+ WHERE tenant_id = $1
4990
+ LIMIT $2
4991
+ )
4992
+ RETURNING 1
4993
+ )
4994
+ SELECT count(*)::bigint AS deleted FROM deleted`,
4995
+ [tenant, this.#batchSize]
4996
+ );
4997
+ deleted = result.rows[0].deleted;
4998
+ } catch (cause) {
4999
+ throw databaseFailure(`deleting tenant rows from ${table}`, cause);
5000
+ }
5001
+ const nextTableIndex = deleted < BigInt(this.#batchSize) ? row.sql_table_index + 1 : row.sql_table_index;
5002
+ return this.#casUpdate(
5003
+ tenant,
5004
+ row.operation_id,
5005
+ row.revision,
5006
+ leaseToken,
5007
+ `sql_table_index = $1,
5008
+ sql_rows_deleted = sql_rows_deleted + $2::bigint`,
5009
+ [nextTableIndex, deleted]
5010
+ );
5011
+ }
5012
+ async #verifyAndComplete(tenant, row, leaseToken) {
5013
+ try {
5014
+ const r2 = await this.#objectStorage.listTenantObjects(tenant, void 0, 1);
5015
+ if (r2.objects.length > 0) {
5016
+ return this.#casUpdate(
5017
+ tenant,
5018
+ row.operation_id,
5019
+ row.revision,
5020
+ leaseToken,
5021
+ "r2_cursor = NULL, r2_complete = false",
5022
+ []
5023
+ );
5024
+ }
5025
+ } catch (cause) {
5026
+ throw storageFailure("verifying the tenant R2 namespace is empty", cause);
5027
+ }
5028
+ for (const table of TENANT_ERASURE_TABLES) {
5029
+ let present;
5030
+ try {
5031
+ const result = await this.#pool.query(
5032
+ `SELECT EXISTS (SELECT 1 FROM ${table} WHERE tenant_id = $1) AS present`,
5033
+ [tenant]
5034
+ );
5035
+ present = result.rows[0].present;
5036
+ } catch (cause) {
5037
+ throw databaseFailure(`verifying tenant rows in ${table}`, cause);
5038
+ }
5039
+ if (present) {
5040
+ return this.#casUpdate(
5041
+ tenant,
5042
+ row.operation_id,
5043
+ row.revision,
5044
+ leaseToken,
5045
+ "sql_table_index = 0",
5046
+ []
5047
+ );
5048
+ }
5049
+ }
5050
+ const now = this.#clock.now();
5051
+ return this.#casUpdate(
5052
+ tenant,
5053
+ row.operation_id,
5054
+ row.revision,
5055
+ leaseToken,
5056
+ `state = 'completed',
5057
+ completed_at = $1,
5058
+ lease_token = NULL,
5059
+ lease_expires_at = NULL,
5060
+ last_error_code = NULL`,
5061
+ [now],
5062
+ false
5063
+ );
5064
+ }
5065
+ async #release(tenant, operationId, revision, leaseToken) {
5066
+ return this.#casUpdate(
5067
+ tenant,
5068
+ operationId,
5069
+ revision,
5070
+ leaseToken,
5071
+ "lease_token = NULL, lease_expires_at = NULL",
5072
+ [],
5073
+ false
5074
+ );
5075
+ }
5076
+ async #recordPartial(tenant, operationId, revision, leaseToken, errorCode) {
5077
+ return this.#casUpdate(
5078
+ tenant,
5079
+ operationId,
5080
+ revision,
5081
+ leaseToken,
5082
+ `lease_token = NULL,
5083
+ lease_expires_at = NULL,
5084
+ last_error_code = $1`,
5085
+ [errorCode],
5086
+ false
5087
+ );
5088
+ }
5089
+ async #casUpdate(tenant, operationId, revision, leaseToken, setClause, setValues, refreshLease = true) {
5090
+ const now = this.#clock.now();
5091
+ const values = [...setValues];
5092
+ let refreshClause = "";
5093
+ if (refreshLease) {
5094
+ values.push(new Date(now.getTime() + this.#leaseMs));
5095
+ refreshClause = `, lease_expires_at = $${String(values.length)}`;
5096
+ }
5097
+ values.push(now, tenant, operationId, revision, leaseToken);
5098
+ const updatedAtIndex = values.length - 4;
5099
+ const tenantIndex = values.length - 3;
5100
+ const operationIndex = values.length - 2;
5101
+ const revisionIndex = values.length - 1;
5102
+ const leaseIndex = values.length;
5103
+ const result = await this.#pool.query(
5104
+ `UPDATE tenant_erasure_operation
5105
+ SET ${setClause}${refreshClause},
5106
+ revision = revision + 1,
5107
+ updated_at = $${String(updatedAtIndex)}
5108
+ WHERE tenant_id = $${String(tenantIndex)}
5109
+ AND operation_id = $${String(operationIndex)}
5110
+ AND state = 'running'
5111
+ AND revision = $${String(revisionIndex)}::bigint
5112
+ AND lease_token = $${String(leaseIndex)}
5113
+ RETURNING ${OPERATION_COLUMNS}`,
5114
+ values
5115
+ );
5116
+ const row = result.rows[0];
5117
+ if (row !== void 0) return row;
5118
+ throw new TenantErasureError(
5119
+ "tenant_erasure_cas_lost",
5120
+ "Tenant erasure operation no longer owns its durable progress lease."
5121
+ );
5122
+ }
5123
+ async #readOperation(tenant, operationId) {
5124
+ const result = await this.#pool.query(
5125
+ `SELECT ${OPERATION_COLUMNS}
5126
+ FROM tenant_erasure_operation
5127
+ WHERE tenant_id = $1 AND operation_id = $2`,
5128
+ [tenant, operationId]
5129
+ );
5130
+ return result.rows[0];
5131
+ }
5132
+ async #readConflictOrReceipt(tenant, operationId) {
5133
+ const own = await this.#readOperation(tenant, operationId);
5134
+ if (own?.state === "completed") return toReadback(own);
5135
+ if (own !== void 0 && own.lease_token !== null && own.lease_expires_at !== null && own.lease_expires_at > this.#clock.now()) {
5136
+ return { status: "conflict", tenantId: tenant, operationId, activeOperationId: own.operation_id };
5137
+ }
5138
+ if (own !== void 0) return toReadback(own);
5139
+ return this.#conflict(tenant, operationId);
5140
+ }
5141
+ async #conflict(tenant, operationId) {
5142
+ const active = await this.#pool.query(
5143
+ `SELECT operation_id
5144
+ FROM tenant_erasure_operation
5145
+ WHERE tenant_id = $1 AND state = 'running'`,
5146
+ [tenant]
5147
+ );
5148
+ const winner = active.rows[0];
5149
+ if (winner === void 0) {
5150
+ throw new TenantErasureError(
5151
+ "tenant_erasure_cas_lost",
5152
+ "Tenant erasure operation changed while acquiring its receipt; retry with the same operation id."
5153
+ );
5154
+ }
5155
+ return { status: "conflict", tenantId: tenant, operationId, activeOperationId: winner.operation_id };
5156
+ }
5157
+ };
5158
+ function createPostgresTenantErasure(options) {
5159
+ return new PostgresTenantErasure({
5160
+ pool: options.pool,
5161
+ clock: options.clock,
5162
+ objectStorage: new R2ObjectMaintenanceStore(options.objectStorage),
5163
+ ...options.batchSize === void 0 ? {} : { batchSize: options.batchSize },
5164
+ ...options.maxPagesPerRun === void 0 ? {} : { maxPagesPerRun: options.maxPagesPerRun },
5165
+ ...options.leaseMs === void 0 ? {} : { leaseMs: options.leaseMs }
5166
+ });
5167
+ }
5168
+ function toReadback(row) {
5169
+ const status = row.state === "completed" ? "completed" : row.last_error_code === null ? "outstanding" : "partial";
5170
+ return {
5171
+ status,
5172
+ tenantId: tenantId(row.tenant_id),
5173
+ operationId: row.operation_id,
5174
+ startedAt: row.started_at.toISOString(),
5175
+ updatedAt: row.updated_at.toISOString(),
5176
+ ...row.completed_at === null ? {} : { completedAt: row.completed_at.toISOString() },
5177
+ r2Complete: row.r2_complete,
5178
+ sqlTableIndex: row.sql_table_index,
5179
+ r2ObjectsDeleted: row.r2_objects_deleted,
5180
+ sqlRowsDeleted: row.sql_rows_deleted,
5181
+ ...row.last_error_code === null ? {} : { errorCode: row.last_error_code }
5182
+ };
5183
+ }
5184
+ function assertOperationId(operationId) {
5185
+ if (operationId.length === 0 || operationId.length > 256 || operationId.trim() !== operationId) {
5186
+ throw new TenantErasureError(
5187
+ "tenant_erasure_invalid_input",
5188
+ "operationId must be a non-empty, unpadded string no longer than 256 characters."
5189
+ );
5190
+ }
5191
+ }
5192
+ function assertBoundedWhole(value, field, max) {
5193
+ if (!Number.isSafeInteger(value) || value < 1 || value > max) {
5194
+ throw new TenantErasureError(
5195
+ "tenant_erasure_invalid_input",
5196
+ `${field} must be a whole number in [1, ${String(max)}].`
5197
+ );
5198
+ }
5199
+ return value;
5200
+ }
5201
+ function postgresCode(cause) {
5202
+ return typeof cause === "object" && cause !== null && "code" in cause && typeof cause.code === "string" ? cause.code : void 0;
5203
+ }
5204
+ function storageFailure(action, cause) {
5205
+ return new TenantErasureError(
5206
+ "tenant_erasure_storage_failure",
5207
+ `Tenant erasure could not finish ${action}; no SQL progress was advanced.`,
5208
+ { cause }
5209
+ );
5210
+ }
5211
+ function databaseFailure(action, cause) {
5212
+ return cause instanceof TenantErasureError ? cause : new TenantErasureError(
5213
+ "tenant_erasure_database_failure",
5214
+ `Tenant erasure could not finish ${action}; retry with the same operation id.`,
5215
+ { cause }
5216
+ );
5217
+ }
4466
5218
 
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 };
5219
+ 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, MigrationStateMismatchError, ObjectStoreRequestError, PostgresActivityStore, PostgresApprovalTimelineStore, PostgresBoardStore, PostgresCloudCleanup, PostgresDeviceAssertionReplayAuthority, PostgresDeviceDirectory, PostgresInboundDedupStore, PostgresMailboxStore, PostgresNonceStore, PostgresObjectStore, PostgresPairingCodeStore, PostgresPresenceStore, PostgresProofRequestReceiptStore, PostgresQuotaStore, PostgresRequestReceiptStore, PostgresSkillPackStore, PostgresTaskAttemptStore, PostgresTaskCancellationStore, PostgresTenantErasure, PostgresTruthCommitter, PostgresTruthStore, R2BlobStoreError, R2CloudBlobStore, R2ObjectMaintenanceStore, R2_BLOB_ERROR_CODES, TENANT_ERASURE_ERROR_CODES, TENANT_ERASURE_TABLES, TenantErasureError, createByokPool, createPostgresCloudMaintenance, createPostgresCloudStores, createPostgresCoreStores, createPostgresTenantErasure, migrate, migrationsDir, readMigrationFiles, verifyMigrations };
4468
5220
  //# sourceMappingURL=index.js.map
4469
5221
  //# sourceMappingURL=index.js.map