@byok-sdk/cloud-dataplane 0.4.2 → 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/README.md +81 -5
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1090 -289
- package/dist/index.js.map +1 -1
- package/dist/migrate.d.ts +42 -0
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.js +493 -247
- package/dist/runtime.js.map +1 -1
- package/dist/sql/0008_device_assertion_replay.sql +17 -0
- package/dist/sql/0009_task_cancellation.sql +6 -0
- package/dist/sql/0010_tenant_readiness.sql +21 -0
- package/dist/sql/0011_tenant_erasure.sql +46 -0
- package/dist/stores/core/mailbox.d.ts +13 -0
- package/dist/stores/device-assertion-replay.d.ts +10 -0
- package/dist/stores/devices.d.ts +3 -2
- package/dist/stores/index.d.ts +2 -0
- package/dist/stores/r2-blobs.d.ts +1 -1
- package/dist/stores/task-attempts.d.ts +14 -0
- package/dist/stores/task-cancellations.d.ts +9 -0
- package/dist/tenant-erasure.d.ts +77 -0
- package/package.json +4 -4
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,
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1086
|
-
function
|
|
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 ${
|
|
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
|
|
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 ${
|
|
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 :
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 ?
|
|
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);
|
|
@@ -1387,17 +1791,67 @@ var PostgresApprovalTimelineStore = class {
|
|
|
1387
1791
|
}
|
|
1388
1792
|
};
|
|
1389
1793
|
|
|
1794
|
+
// src/stores/device-assertion-replay.ts
|
|
1795
|
+
var PostgresDeviceAssertionReplayAuthority = class {
|
|
1796
|
+
#pool;
|
|
1797
|
+
constructor(pool) {
|
|
1798
|
+
this.#pool = pool;
|
|
1799
|
+
}
|
|
1800
|
+
async consume(input) {
|
|
1801
|
+
if (!Number.isFinite(Date.parse(input.expiresAt))) {
|
|
1802
|
+
throw new Error("device assertion replay expiry is invalid");
|
|
1803
|
+
}
|
|
1804
|
+
const result = await this.#pool.query(
|
|
1805
|
+
`INSERT INTO device_assertion_replay (
|
|
1806
|
+
tenant_id, issuer, product_id, device_id, audience, jti, expires_at
|
|
1807
|
+
)
|
|
1808
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
1809
|
+
ON CONFLICT (tenant_id, issuer, product_id, device_id, audience, jti) DO NOTHING
|
|
1810
|
+
RETURNING jti`,
|
|
1811
|
+
[
|
|
1812
|
+
input.tenantId,
|
|
1813
|
+
input.issuer,
|
|
1814
|
+
input.productId,
|
|
1815
|
+
input.deviceId,
|
|
1816
|
+
input.audience,
|
|
1817
|
+
input.jti,
|
|
1818
|
+
input.expiresAt
|
|
1819
|
+
]
|
|
1820
|
+
);
|
|
1821
|
+
return result.rowCount === 1;
|
|
1822
|
+
}
|
|
1823
|
+
/** Bounded retention cleanup; callers choose cadence and batch size. */
|
|
1824
|
+
async deleteExpired(before, limit) {
|
|
1825
|
+
if (!Number.isFinite(before.getTime()) || !Number.isSafeInteger(limit) || limit <= 0) {
|
|
1826
|
+
throw new Error("device assertion replay cleanup bounds are invalid");
|
|
1827
|
+
}
|
|
1828
|
+
const result = await this.#pool.query(
|
|
1829
|
+
`DELETE FROM device_assertion_replay
|
|
1830
|
+
WHERE ctid IN (
|
|
1831
|
+
SELECT ctid
|
|
1832
|
+
FROM device_assertion_replay
|
|
1833
|
+
WHERE expires_at <= $1
|
|
1834
|
+
ORDER BY expires_at
|
|
1835
|
+
LIMIT $2
|
|
1836
|
+
)`,
|
|
1837
|
+
[before.toISOString(), limit]
|
|
1838
|
+
);
|
|
1839
|
+
return result.rowCount ?? 0;
|
|
1840
|
+
}
|
|
1841
|
+
};
|
|
1842
|
+
|
|
1390
1843
|
// src/stores/index.ts
|
|
1391
1844
|
function createPostgresCloudStores(options) {
|
|
1392
1845
|
const { pool, clock, crypto } = options;
|
|
1393
1846
|
return {
|
|
1394
1847
|
activity: new PostgresActivityStore(pool, clock),
|
|
1395
1848
|
approvals: new PostgresApprovalTimelineStore(pool, clock),
|
|
1396
|
-
devices: new PostgresDeviceDirectory(pool),
|
|
1849
|
+
devices: new PostgresDeviceDirectory(pool, clock),
|
|
1397
1850
|
pairingCodes: new PostgresPairingCodeStore(pool, clock),
|
|
1398
1851
|
nonces: new PostgresNonceStore(pool, clock, crypto),
|
|
1399
1852
|
dedup: new PostgresInboundDedupStore(pool),
|
|
1400
1853
|
tasks: new PostgresTaskAttemptStore(pool, clock),
|
|
1854
|
+
cancellations: new PostgresTaskCancellationStore(pool, clock),
|
|
1401
1855
|
receipts: new PostgresRequestReceiptStore(pool, clock),
|
|
1402
1856
|
proofReceipts: new PostgresProofRequestReceiptStore(pool, clock),
|
|
1403
1857
|
// A second `PostgresObjectStore` instance, not a shared one: it is a
|
|
@@ -1411,7 +1865,7 @@ function createPostgresCloudStores(options) {
|
|
|
1411
1865
|
rateLimiter: new AllowAllRateLimiter()
|
|
1412
1866
|
};
|
|
1413
1867
|
}
|
|
1414
|
-
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";
|
|
1415
1869
|
function toHint(row) {
|
|
1416
1870
|
return {
|
|
1417
1871
|
tenantId: row.tenant_id,
|
|
@@ -1419,6 +1873,19 @@ function toHint(row) {
|
|
|
1419
1873
|
level: row.level,
|
|
1420
1874
|
...row.detail === null ? {} : { detail: row.detail },
|
|
1421
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
|
+
},
|
|
1422
1889
|
observedAt: row.observed_at,
|
|
1423
1890
|
expiresAt: row.expires_at
|
|
1424
1891
|
};
|
|
@@ -1454,15 +1921,18 @@ var PostgresPresenceStore = class {
|
|
|
1454
1921
|
const allowedBefore = new Date(now.getTime() - input.minimumIntervalMs).toISOString();
|
|
1455
1922
|
const result = await this.#pool.query(
|
|
1456
1923
|
`INSERT INTO device_presence (${PRESENCE_COLUMNS})
|
|
1457
|
-
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)
|
|
1924
|
+
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8::jsonb, $9, $10)
|
|
1458
1925
|
ON CONFLICT (tenant_id, device_id) DO UPDATE
|
|
1459
1926
|
SET level = EXCLUDED.level,
|
|
1460
1927
|
detail = EXCLUDED.detail,
|
|
1461
1928
|
configured_toolsets = EXCLUDED.configured_toolsets,
|
|
1929
|
+
client_version = EXCLUDED.client_version,
|
|
1930
|
+
protocol_versions = EXCLUDED.protocol_versions,
|
|
1931
|
+
runtimes = EXCLUDED.runtimes,
|
|
1462
1932
|
observed_at = EXCLUDED.observed_at,
|
|
1463
1933
|
expires_at = EXCLUDED.expires_at
|
|
1464
1934
|
WHERE device_presence.expires_at <= EXCLUDED.observed_at
|
|
1465
|
-
OR device_presence.observed_at <= $
|
|
1935
|
+
OR device_presence.observed_at <= $11
|
|
1466
1936
|
RETURNING ${PRESENCE_COLUMNS}`,
|
|
1467
1937
|
[
|
|
1468
1938
|
tenant,
|
|
@@ -1470,6 +1940,9 @@ var PostgresPresenceStore = class {
|
|
|
1470
1940
|
input.level,
|
|
1471
1941
|
input.detail ?? null,
|
|
1472
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),
|
|
1473
1946
|
observedAt,
|
|
1474
1947
|
new Date(now.getTime() + input.ttlMs).toISOString(),
|
|
1475
1948
|
allowedBefore
|
|
@@ -1699,266 +2172,39 @@ var PostgresBoardStore = class {
|
|
|
1699
2172
|
throw new CoreConflictError(
|
|
1700
2173
|
"board_transition_invalid",
|
|
1701
2174
|
`${input.expectedStatus} to ${input.status} is not a legal board transition.`,
|
|
1702
|
-
current,
|
|
1703
|
-
this.#now()
|
|
1704
|
-
);
|
|
1705
|
-
}
|
|
1706
|
-
throw this.#statusConflict(input.itemId, current, input.expectedStatus);
|
|
1707
|
-
}
|
|
1708
|
-
/**
|
|
1709
|
-
* One statement, its own transaction, lock released immediately. See the file
|
|
1710
|
-
* header for why this is not a CTE inside the write it feeds.
|
|
1711
|
-
*/
|
|
1712
|
-
async #allocateSeq(tenant) {
|
|
1713
|
-
const result = await this.#pool.query(
|
|
1714
|
-
`INSERT INTO tenant_stream (tenant_id, board_seq)
|
|
1715
|
-
VALUES ($1, 1)
|
|
1716
|
-
ON CONFLICT (tenant_id) DO UPDATE SET board_seq = tenant_stream.board_seq + 1
|
|
1717
|
-
RETURNING board_seq`,
|
|
1718
|
-
[tenant]
|
|
1719
|
-
);
|
|
1720
|
-
return result.rows[0].board_seq;
|
|
1721
|
-
}
|
|
1722
|
-
#itemNotFound(itemId) {
|
|
1723
|
-
return new ByokCoreError(
|
|
1724
|
-
"board_item_not_found",
|
|
1725
|
-
`Board item ${itemId} does not exist in this tenant.`
|
|
1726
|
-
);
|
|
1727
|
-
}
|
|
1728
|
-
#statusConflict(itemId, current, expected) {
|
|
1729
|
-
return new CoreConflictError(
|
|
1730
|
-
"board_status_conflict",
|
|
1731
|
-
`Board item ${itemId} is ${current.status}, not ${expected}.`,
|
|
1732
|
-
current,
|
|
1733
|
-
this.#now()
|
|
1734
|
-
);
|
|
1735
|
-
}
|
|
1736
|
-
#now() {
|
|
1737
|
-
return this.#clock.now().toISOString();
|
|
1738
|
-
}
|
|
1739
|
-
};
|
|
1740
|
-
|
|
1741
|
-
// src/stores/core/mailbox-sequence.ts
|
|
1742
|
-
async function allocateMailboxSequence(client, tenant, deviceId, now) {
|
|
1743
|
-
const allocation = await client.query(
|
|
1744
|
-
`INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
|
|
1745
|
-
VALUES ($1, $2, 2, 0, $3)
|
|
1746
|
-
ON CONFLICT (tenant_id, device_id) DO UPDATE
|
|
1747
|
-
SET next_seq = device_stream.next_seq + 1
|
|
1748
|
-
RETURNING next_seq - 1 AS seq`,
|
|
1749
|
-
[tenant, deviceId, now]
|
|
1750
|
-
);
|
|
1751
|
-
return Number(allocation.rows[0].seq);
|
|
1752
|
-
}
|
|
1753
|
-
|
|
1754
|
-
// src/stores/core/mailbox.ts
|
|
1755
|
-
var DEFAULT_READ_LIMIT = 50;
|
|
1756
|
-
var OUTBOX_COLUMNS = "tenant_id, device_id, seq, message_id, body, body_hash, byte_size, state, appended_at";
|
|
1757
|
-
function toMessage(row) {
|
|
1758
|
-
return {
|
|
1759
|
-
tenantId: row.tenant_id,
|
|
1760
|
-
deviceId: row.device_id,
|
|
1761
|
-
// `seq` is bigint in the column and `number` on the port, because it is the
|
|
1762
|
-
// envelope `seq` on the wire. The column is wide so the counter cannot wrap
|
|
1763
|
-
// into a redelivery bug; the narrowing happens once, here.
|
|
1764
|
-
seq: Number(row.seq),
|
|
1765
|
-
messageId: row.message_id,
|
|
1766
|
-
body: row.body,
|
|
1767
|
-
bodyHash: row.body_hash,
|
|
1768
|
-
byteSize: row.byte_size,
|
|
1769
|
-
state: row.state,
|
|
1770
|
-
appendedAt: row.appended_at
|
|
1771
|
-
};
|
|
1772
|
-
}
|
|
1773
|
-
var PostgresMailboxStore = class {
|
|
1774
|
-
#pool;
|
|
1775
|
-
#clock;
|
|
1776
|
-
constructor(pool, clock) {
|
|
1777
|
-
this.#pool = pool;
|
|
1778
|
-
this.#clock = clock;
|
|
1779
|
-
}
|
|
1780
|
-
async append(tenant, input) {
|
|
1781
|
-
this.#requireDeviceId(input.deviceId);
|
|
1782
|
-
const client = await this.#pool.connect();
|
|
1783
|
-
try {
|
|
1784
|
-
await client.query("BEGIN");
|
|
1785
|
-
const existing = await client.query(
|
|
1786
|
-
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1787
|
-
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
1788
|
-
[tenant, input.deviceId, input.messageId]
|
|
1789
|
-
);
|
|
1790
|
-
const replayed = existing.rows[0];
|
|
1791
|
-
if (replayed !== void 0) {
|
|
1792
|
-
await client.query("COMMIT");
|
|
1793
|
-
return toMessage(replayed);
|
|
1794
|
-
}
|
|
1795
|
-
const seq = await allocateMailboxSequence(client, tenant, input.deviceId, this.#now());
|
|
1796
|
-
const serializedExisting = await client.query(
|
|
1797
|
-
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1798
|
-
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
1799
|
-
[tenant, input.deviceId, input.messageId]
|
|
1800
|
-
);
|
|
1801
|
-
const winnerAfterLock = serializedExisting.rows[0];
|
|
1802
|
-
if (winnerAfterLock !== void 0) {
|
|
1803
|
-
await client.query("ROLLBACK");
|
|
1804
|
-
return toMessage(winnerAfterLock);
|
|
1805
|
-
}
|
|
1806
|
-
const materialized = await input.materialize(seq);
|
|
1807
|
-
const now = this.#now();
|
|
1808
|
-
const inserted = await client.query(
|
|
1809
|
-
`INSERT INTO outbox (${OUTBOX_COLUMNS})
|
|
1810
|
-
VALUES ($1, $2, $3::bigint, $4, $5, $6, $7::bigint, 'pending', $8)
|
|
1811
|
-
ON CONFLICT (tenant_id, device_id, message_id) DO NOTHING
|
|
1812
|
-
RETURNING ${OUTBOX_COLUMNS}`,
|
|
1813
|
-
[
|
|
1814
|
-
tenant,
|
|
1815
|
-
input.deviceId,
|
|
1816
|
-
seq,
|
|
1817
|
-
input.messageId,
|
|
1818
|
-
materialized.body,
|
|
1819
|
-
materialized.bodyHash,
|
|
1820
|
-
materialized.byteSize,
|
|
1821
|
-
now
|
|
1822
|
-
]
|
|
1823
|
-
);
|
|
1824
|
-
const row = inserted.rows[0];
|
|
1825
|
-
if (row !== void 0) {
|
|
1826
|
-
await client.query("COMMIT");
|
|
1827
|
-
return toMessage(row);
|
|
1828
|
-
}
|
|
1829
|
-
const winner = await client.query(
|
|
1830
|
-
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1831
|
-
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
1832
|
-
[tenant, input.deviceId, input.messageId]
|
|
1833
|
-
);
|
|
1834
|
-
await client.query("ROLLBACK");
|
|
1835
|
-
const won = winner.rows[0];
|
|
1836
|
-
if (won === void 0) {
|
|
1837
|
-
throw new ByokCoreError(
|
|
1838
|
-
"mailbox_message_not_found",
|
|
1839
|
-
`Message ${input.messageId} vanished during an idempotent append.`
|
|
1840
|
-
);
|
|
1841
|
-
}
|
|
1842
|
-
return toMessage(won);
|
|
1843
|
-
} catch (cause) {
|
|
1844
|
-
await client.query("ROLLBACK").catch(() => {
|
|
1845
|
-
});
|
|
1846
|
-
throw cause;
|
|
1847
|
-
} finally {
|
|
1848
|
-
client.release();
|
|
1849
|
-
}
|
|
1850
|
-
}
|
|
1851
|
-
async readAfter(tenant, query) {
|
|
1852
|
-
const limit = query.limit ?? DEFAULT_READ_LIMIT;
|
|
1853
|
-
const result = await this.#pool.query(
|
|
1854
|
-
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1855
|
-
WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending' AND seq > $3::bigint
|
|
1856
|
-
ORDER BY seq
|
|
1857
|
-
LIMIT $4`,
|
|
1858
|
-
[tenant, query.deviceId, query.afterSeq, limit + 1]
|
|
1859
|
-
);
|
|
1860
|
-
const page = result.rows.slice(0, limit).map(toMessage);
|
|
1861
|
-
return {
|
|
1862
|
-
messages: page,
|
|
1863
|
-
// Nothing above was mutated, so an identical call replays the same page.
|
|
1864
|
-
// The returned position is a READ cursor and moves no ack.
|
|
1865
|
-
nextSeq: page.at(-1)?.seq ?? query.afterSeq,
|
|
1866
|
-
hasMore: result.rows.length > page.length
|
|
1867
|
-
};
|
|
1868
|
-
}
|
|
1869
|
-
async advanceCursor(tenant, input) {
|
|
1870
|
-
this.#requireDeviceId(input.deviceId);
|
|
1871
|
-
const now = this.#now();
|
|
1872
|
-
const moved = await this.#pool.query(
|
|
1873
|
-
`WITH moved AS (
|
|
1874
|
-
INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
|
|
1875
|
-
VALUES ($1, $2, 1, $3::bigint, $4)
|
|
1876
|
-
ON CONFLICT (tenant_id, device_id) DO UPDATE
|
|
1877
|
-
SET acked_seq = EXCLUDED.acked_seq, acked_at = EXCLUDED.acked_at
|
|
1878
|
-
WHERE device_stream.acked_seq <= EXCLUDED.acked_seq
|
|
1879
|
-
RETURNING acked_seq, acked_at
|
|
1880
|
-
), marked AS (
|
|
1881
|
-
UPDATE outbox
|
|
1882
|
-
SET state = 'acked'
|
|
1883
|
-
WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending'
|
|
1884
|
-
AND seq <= (SELECT acked_seq FROM moved)
|
|
1885
|
-
RETURNING 1
|
|
1886
|
-
)
|
|
1887
|
-
SELECT acked_seq, acked_at FROM moved`,
|
|
1888
|
-
[tenant, input.deviceId, input.ackedSeq, now]
|
|
1889
|
-
);
|
|
1890
|
-
const row = moved.rows[0];
|
|
1891
|
-
if (row !== void 0) {
|
|
1892
|
-
return {
|
|
1893
|
-
tenantId: tenant,
|
|
1894
|
-
deviceId: input.deviceId,
|
|
1895
|
-
ackedSeq: Number(row.acked_seq),
|
|
1896
|
-
updatedAt: row.acked_at ?? now
|
|
1897
|
-
};
|
|
1898
|
-
}
|
|
1899
|
-
const current = await this.readCursor(tenant, input.deviceId);
|
|
1900
|
-
throw new CoreConflictError(
|
|
1901
|
-
"mailbox_cursor_regression",
|
|
1902
|
-
`Cursor for device ${input.deviceId} is at ${current.ackedSeq}; refusing to move it back to ${input.ackedSeq}.`,
|
|
1903
|
-
current,
|
|
1904
|
-
this.#now()
|
|
1905
|
-
);
|
|
2175
|
+
current,
|
|
2176
|
+
this.#now()
|
|
2177
|
+
);
|
|
2178
|
+
}
|
|
2179
|
+
throw this.#statusConflict(input.itemId, current, input.expectedStatus);
|
|
1906
2180
|
}
|
|
1907
|
-
|
|
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) {
|
|
1908
2186
|
const result = await this.#pool.query(
|
|
1909
|
-
`
|
|
1910
|
-
|
|
1911
|
-
|
|
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]
|
|
1912
2192
|
);
|
|
1913
|
-
|
|
1914
|
-
return {
|
|
1915
|
-
tenantId: tenant,
|
|
1916
|
-
deviceId,
|
|
1917
|
-
ackedSeq: row === void 0 ? 0 : Number(row.acked_seq),
|
|
1918
|
-
updatedAt: row?.acked_at ?? this.#now()
|
|
1919
|
-
};
|
|
2193
|
+
return result.rows[0].board_seq;
|
|
1920
2194
|
}
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
`WITH deleted AS (
|
|
1926
|
-
DELETE FROM outbox
|
|
1927
|
-
WHERE tenant_id = $1
|
|
1928
|
-
AND ($2::text IS NULL OR device_id = $2::text)
|
|
1929
|
-
AND state = 'acked'
|
|
1930
|
-
AND appended_at < $3
|
|
1931
|
-
RETURNING byte_size
|
|
1932
|
-
), expired AS (
|
|
1933
|
-
UPDATE outbox
|
|
1934
|
-
SET state = 'expired'
|
|
1935
|
-
WHERE tenant_id = $1
|
|
1936
|
-
AND ($2::text IS NULL OR device_id = $2::text)
|
|
1937
|
-
AND state = 'pending'
|
|
1938
|
-
AND appended_at < $4
|
|
1939
|
-
RETURNING 1
|
|
1940
|
-
)
|
|
1941
|
-
SELECT (SELECT count(*) FROM deleted) AS deleted_count,
|
|
1942
|
-
(SELECT count(*) FROM expired) AS expired_count,
|
|
1943
|
-
(SELECT COALESCE(SUM(byte_size), 0) FROM deleted)::bigint AS released_bytes`,
|
|
1944
|
-
[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.`
|
|
1945
2199
|
);
|
|
1946
|
-
const row = swept.rows[0];
|
|
1947
|
-
return {
|
|
1948
|
-
deletedCount: Number(row.deleted_count),
|
|
1949
|
-
expiredCount: Number(row.expired_count),
|
|
1950
|
-
releasedBytes: row.released_bytes
|
|
1951
|
-
};
|
|
1952
2200
|
}
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
throw new ByokCoreError("mailbox_message_not_found", "Device id must not be empty.");
|
|
1961
|
-
}
|
|
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
|
+
);
|
|
1962
2208
|
}
|
|
1963
2209
|
#now() {
|
|
1964
2210
|
return this.#clock.now().toISOString();
|
|
@@ -3308,6 +3554,11 @@ var PostgresTruthCommitter = class {
|
|
|
3308
3554
|
return this.#clock.now().toISOString();
|
|
3309
3555
|
}
|
|
3310
3556
|
};
|
|
3557
|
+
function migrationsDir() {
|
|
3558
|
+
return fileURLToPath(new URL("./sql", import.meta.url));
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
// src/migrate.ts
|
|
3311
3562
|
var MIGRATION_ADVISORY_LOCK_KEY = "4021960801";
|
|
3312
3563
|
var MIGRATION_FILENAME_PATTERN = /^(\d{4})[_-].+\.sql$/;
|
|
3313
3564
|
var LEDGER_DDL = `
|
|
@@ -3316,6 +3567,28 @@ CREATE TABLE IF NOT EXISTS byok_schema_migration (
|
|
|
3316
3567
|
checksum text NOT NULL,
|
|
3317
3568
|
applied_at timestamptz NOT NULL
|
|
3318
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
|
+
};
|
|
3319
3592
|
var MigrationChecksumMismatchError = class extends Error {
|
|
3320
3593
|
version;
|
|
3321
3594
|
expectedChecksum;
|
|
@@ -3374,11 +3647,65 @@ async function readMigrationFiles(directory) {
|
|
|
3374
3647
|
}
|
|
3375
3648
|
return files;
|
|
3376
3649
|
}
|
|
3650
|
+
async function readLedgerRows(client) {
|
|
3651
|
+
const result = await client.query(LEDGER_READ_SQL);
|
|
3652
|
+
return result.rows;
|
|
3653
|
+
}
|
|
3377
3654
|
async function readLedger(client) {
|
|
3378
|
-
const
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
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
|
+
}
|
|
3382
3709
|
}
|
|
3383
3710
|
async function migrate(pool, directory) {
|
|
3384
3711
|
const files = await readMigrationFiles(directory);
|
|
@@ -3422,9 +3749,6 @@ async function migrate(pool, directory) {
|
|
|
3422
3749
|
client.release();
|
|
3423
3750
|
}
|
|
3424
3751
|
}
|
|
3425
|
-
function migrationsDir() {
|
|
3426
|
-
return fileURLToPath(new URL("./sql", import.meta.url));
|
|
3427
|
-
}
|
|
3428
3752
|
var DEFAULT_BATCH_SIZE = 100;
|
|
3429
3753
|
var MAX_BATCH_SIZE = 1e3;
|
|
3430
3754
|
var ADVISORY_LOCK_NAMESPACE = 1106736963;
|
|
@@ -3595,7 +3919,7 @@ var PostgresCloudCleanup = class {
|
|
|
3595
3919
|
]
|
|
3596
3920
|
);
|
|
3597
3921
|
return {
|
|
3598
|
-
messages: listed.rows.slice(0, limit).map(
|
|
3922
|
+
messages: listed.rows.slice(0, limit).map(toMailboxMessage2),
|
|
3599
3923
|
hasMore: listed.rows.length > limit
|
|
3600
3924
|
};
|
|
3601
3925
|
}
|
|
@@ -3633,7 +3957,7 @@ var PostgresCloudCleanup = class {
|
|
|
3633
3957
|
`Replay id ${input.replayMessageId} already binds a different replay delivery.`
|
|
3634
3958
|
);
|
|
3635
3959
|
} else {
|
|
3636
|
-
result =
|
|
3960
|
+
result = toMailboxMessage2(existing);
|
|
3637
3961
|
}
|
|
3638
3962
|
} else {
|
|
3639
3963
|
const entitlement = await client.query(
|
|
@@ -3658,7 +3982,7 @@ var PostgresCloudCleanup = class {
|
|
|
3658
3982
|
`Replay id ${input.replayMessageId} already binds a different replay delivery.`
|
|
3659
3983
|
);
|
|
3660
3984
|
} else {
|
|
3661
|
-
result =
|
|
3985
|
+
result = toMailboxMessage2(winner);
|
|
3662
3986
|
}
|
|
3663
3987
|
} else if (capacity === void 0) {
|
|
3664
3988
|
rejection = new CloudCleanupError(
|
|
@@ -3686,7 +4010,7 @@ var PostgresCloudCleanup = class {
|
|
|
3686
4010
|
`Replay id ${input.replayMessageId} already binds a different replay delivery.`
|
|
3687
4011
|
);
|
|
3688
4012
|
} else {
|
|
3689
|
-
result =
|
|
4013
|
+
result = toMailboxMessage2(appendWinner);
|
|
3690
4014
|
}
|
|
3691
4015
|
} else {
|
|
3692
4016
|
const rebound = materializeReplayBody(original, seq);
|
|
@@ -3718,7 +4042,7 @@ var PostgresCloudCleanup = class {
|
|
|
3718
4042
|
WHERE tenant_id = $1`,
|
|
3719
4043
|
[tenant, rebound.byteSize, this.#now()]
|
|
3720
4044
|
);
|
|
3721
|
-
result =
|
|
4045
|
+
result = toMailboxMessage2(inserted.rows[0]);
|
|
3722
4046
|
}
|
|
3723
4047
|
}
|
|
3724
4048
|
}
|
|
@@ -3789,7 +4113,7 @@ var PostgresCloudCleanup = class {
|
|
|
3789
4113
|
client.release();
|
|
3790
4114
|
}
|
|
3791
4115
|
if (rejection !== void 0) throw rejection;
|
|
3792
|
-
return
|
|
4116
|
+
return toMailboxMessage2(row);
|
|
3793
4117
|
}
|
|
3794
4118
|
/**
|
|
3795
4119
|
* Explicit recovery operation: rebuild object accounting from committed
|
|
@@ -4323,7 +4647,7 @@ function toCleanupResult(row) {
|
|
|
4323
4647
|
...row.error_message === null ? {} : { errorMessage: row.error_message }
|
|
4324
4648
|
};
|
|
4325
4649
|
}
|
|
4326
|
-
function
|
|
4650
|
+
function toMailboxMessage2(row) {
|
|
4327
4651
|
return {
|
|
4328
4652
|
tenantId: tenantId(row.tenant_id),
|
|
4329
4653
|
deviceId: row.device_id,
|
|
@@ -4414,7 +4738,484 @@ function deadLetterMissing(ref) {
|
|
|
4414
4738
|
`Expired mailbox row ${ref.deviceId}/${String(ref.seq)} was not found.`
|
|
4415
4739
|
);
|
|
4416
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
|
+
}
|
|
4417
5218
|
|
|
4418
|
-
export { CLOUD_CLEANUP_ERROR_CODES, CloudCleanupError, DEFAULT_MAX_ATTEMPTS, DEFAULT_PRESIGN_TTL_SECONDS, DEFAULT_RETRY_DELAY_MS, MAX_PRESIGN_TTL_SECONDS, MIN_PRESIGN_TTL_SECONDS, MigrationChecksumMismatchError, MigrationFilenameError, ObjectStoreRequestError, PostgresActivityStore, PostgresApprovalTimelineStore, PostgresBoardStore, PostgresCloudCleanup, PostgresDeviceDirectory, PostgresInboundDedupStore, PostgresMailboxStore, PostgresNonceStore, PostgresObjectStore, PostgresPairingCodeStore, PostgresPresenceStore, PostgresProofRequestReceiptStore, PostgresQuotaStore, PostgresRequestReceiptStore, PostgresSkillPackStore, PostgresTaskAttemptStore, PostgresTruthCommitter, PostgresTruthStore, R2BlobStoreError, R2CloudBlobStore, R2ObjectMaintenanceStore, R2_BLOB_ERROR_CODES, createByokPool, createPostgresCloudMaintenance, createPostgresCloudStores, createPostgresCoreStores, migrate, migrationsDir, readMigrationFiles };
|
|
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 };
|
|
4419
5220
|
//# sourceMappingURL=index.js.map
|
|
4420
5221
|
//# sourceMappingURL=index.js.map
|