@byok-sdk/cloud-dataplane 0.5.0 → 0.6.1
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 +74 -5
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1199 -303
- 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 +603 -262
- package/dist/runtime.js.map +1 -1
- 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/sql/0012_agent_home_contract.sql +41 -0
- package/dist/sql/0013_agent_egress_contract.sql +47 -0
- package/dist/stores/agent-egress.d.ts +14 -0
- package/dist/stores/core/mailbox.d.ts +13 -0
- package/dist/stores/devices.d.ts +7 -2
- package/dist/stores/index.d.ts +3 -1
- package/dist/stores/r2-blobs.d.ts +1 -1
- package/dist/stores/task-attempts.d.ts +29 -1
- 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,13 +1,13 @@
|
|
|
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 {
|
|
6
|
+
import { decodeEnvelope, isServerToDaemonType, EnvelopeSchema, encodeEnvelope, AgentEgressReliablePayloadSchema } from '@byok-sdk/protocol';
|
|
7
|
+
import { createHash, randomUUID } from 'crypto';
|
|
7
8
|
import { readdir, readFile } from 'fs/promises';
|
|
8
9
|
import { join } from 'path';
|
|
9
10
|
import { fileURLToPath } from 'url';
|
|
10
|
-
import { decodeEnvelope, isServerToDaemonType, EnvelopeSchema, encodeEnvelope } from '@byok-sdk/protocol';
|
|
11
11
|
|
|
12
12
|
// src/pool.ts
|
|
13
13
|
var defaultTypeParser = pg.types.getTypeParser;
|
|
@@ -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,
|
|
@@ -802,14 +802,17 @@ function toRecord(row) {
|
|
|
802
802
|
devicePublicKey: row.device_public_key,
|
|
803
803
|
proofKeyId: row.proof_key_id,
|
|
804
804
|
proofKeyEpoch: row.proof_key_epoch,
|
|
805
|
-
revoked: row.revoked
|
|
805
|
+
revoked: row.revoked,
|
|
806
|
+
...row.capabilities == null ? {} : { capabilities: Object.freeze([...row.capabilities]) }
|
|
806
807
|
};
|
|
807
808
|
}
|
|
808
|
-
var SELECT_COLUMNS = "tenant_id, device_id, product_id, device_name, device_public_key, proof_key_id, proof_key_epoch, revoked";
|
|
809
|
+
var SELECT_COLUMNS = "tenant_id, device_id, product_id, device_name, device_public_key, proof_key_id, proof_key_epoch, revoked, capabilities";
|
|
809
810
|
var PostgresDeviceDirectory = class {
|
|
810
811
|
#pool;
|
|
811
|
-
|
|
812
|
+
#clock;
|
|
813
|
+
constructor(pool, clock) {
|
|
812
814
|
this.#pool = pool;
|
|
815
|
+
this.#clock = clock;
|
|
813
816
|
}
|
|
814
817
|
async register(tenant, input) {
|
|
815
818
|
const result = await this.#pool.query(
|
|
@@ -824,7 +827,8 @@ var PostgresDeviceDirectory = class {
|
|
|
824
827
|
device_public_key = EXCLUDED.device_public_key,
|
|
825
828
|
proof_key_id = EXCLUDED.proof_key_id,
|
|
826
829
|
proof_key_epoch = EXCLUDED.proof_key_epoch,
|
|
827
|
-
revoked = false
|
|
830
|
+
revoked = false,
|
|
831
|
+
capabilities = NULL
|
|
828
832
|
RETURNING ${SELECT_COLUMNS}`,
|
|
829
833
|
[
|
|
830
834
|
tenant,
|
|
@@ -852,6 +856,17 @@ var PostgresDeviceDirectory = class {
|
|
|
852
856
|
deviceId
|
|
853
857
|
]);
|
|
854
858
|
}
|
|
859
|
+
async recordCapabilities(tenant, input) {
|
|
860
|
+
const result = await this.#pool.query(
|
|
861
|
+
`UPDATE device
|
|
862
|
+
SET capabilities = $3::jsonb
|
|
863
|
+
WHERE tenant_id = $1 AND device_id = $2 AND revoked = false
|
|
864
|
+
RETURNING ${SELECT_COLUMNS}`,
|
|
865
|
+
[tenant, input.deviceId, JSON.stringify([...input.capabilities])]
|
|
866
|
+
);
|
|
867
|
+
const row = result.rows[0];
|
|
868
|
+
return row === void 0 ? void 0 : toRecord(row);
|
|
869
|
+
}
|
|
855
870
|
async list(tenant) {
|
|
856
871
|
const result = await this.#pool.query(
|
|
857
872
|
`SELECT ${SELECT_COLUMNS} FROM device WHERE tenant_id = $1 ORDER BY device_id`,
|
|
@@ -859,6 +874,73 @@ var PostgresDeviceDirectory = class {
|
|
|
859
874
|
);
|
|
860
875
|
return result.rows.map(toRecord);
|
|
861
876
|
}
|
|
877
|
+
async readiness(tenant, _presence) {
|
|
878
|
+
const result = await this.#pool.query(
|
|
879
|
+
`SELECT
|
|
880
|
+
d.device_id,
|
|
881
|
+
d.product_id,
|
|
882
|
+
d.device_name,
|
|
883
|
+
d.revoked,
|
|
884
|
+
CASE WHEN NOT d.revoked THEN p.level END AS presence_level,
|
|
885
|
+
CASE WHEN NOT d.revoked THEN p.detail END AS presence_detail,
|
|
886
|
+
CASE WHEN NOT d.revoked THEN p.configured_toolsets END AS presence_configured_toolsets,
|
|
887
|
+
CASE WHEN NOT d.revoked THEN p.client_version END AS presence_client_version,
|
|
888
|
+
CASE WHEN NOT d.revoked THEN p.protocol_versions END AS presence_protocol_versions,
|
|
889
|
+
CASE WHEN NOT d.revoked THEN p.runtimes END AS presence_runtimes,
|
|
890
|
+
CASE WHEN NOT d.revoked THEN p.observed_at END AS presence_observed_at,
|
|
891
|
+
CASE WHEN NOT d.revoked THEN p.expires_at END AS presence_expires_at,
|
|
892
|
+
(COUNT(*) FILTER (WHERE NOT d.revoked) OVER ())::int AS active_paired_device_count,
|
|
893
|
+
(COUNT(*) FILTER (WHERE d.revoked) OVER ())::int AS revoked_device_count,
|
|
894
|
+
(COUNT(*) FILTER (WHERE NOT d.revoked AND p.device_id IS NOT NULL) OVER ())::int AS observed_presence_count,
|
|
895
|
+
(COUNT(*) FILTER (WHERE NOT d.revoked AND p.level = 'online') OVER ())::int AS observed_online_count,
|
|
896
|
+
(COUNT(*) FILTER (WHERE NOT d.revoked AND p.level = 'thinking') OVER ())::int AS observed_thinking_count,
|
|
897
|
+
(COUNT(*) FILTER (WHERE NOT d.revoked AND p.level = 'working') OVER ())::int AS observed_working_count,
|
|
898
|
+
(COUNT(*) FILTER (WHERE NOT d.revoked AND p.level = 'error') OVER ())::int AS observed_error_count,
|
|
899
|
+
(COUNT(*) FILTER (WHERE NOT d.revoked AND p.level = 'offline') OVER ())::int AS observed_offline_count
|
|
900
|
+
FROM device d
|
|
901
|
+
LEFT JOIN device_presence p
|
|
902
|
+
ON p.tenant_id = d.tenant_id
|
|
903
|
+
AND p.device_id = d.device_id
|
|
904
|
+
AND p.expires_at > $2
|
|
905
|
+
WHERE d.tenant_id = $1
|
|
906
|
+
ORDER BY d.device_id`,
|
|
907
|
+
[tenant, this.#clock?.now().toISOString() ?? (/* @__PURE__ */ new Date()).toISOString()]
|
|
908
|
+
);
|
|
909
|
+
const row = result.rows[0];
|
|
910
|
+
const count = (value) => Number(value);
|
|
911
|
+
const devices = result.rows.map((device) => ({
|
|
912
|
+
deviceId: device.device_id,
|
|
913
|
+
productId: device.product_id,
|
|
914
|
+
deviceName: device.device_name,
|
|
915
|
+
revoked: device.revoked,
|
|
916
|
+
...device.presence_level === null ? {} : {
|
|
917
|
+
presence: {
|
|
918
|
+
level: device.presence_level,
|
|
919
|
+
...device.presence_detail === null ? {} : { detail: device.presence_detail },
|
|
920
|
+
...device.presence_configured_toolsets === null ? {} : { configuredToolsets: Object.freeze([...device.presence_configured_toolsets]) },
|
|
921
|
+
...device.presence_client_version === null ? {} : { clientVersion: device.presence_client_version },
|
|
922
|
+
...device.presence_protocol_versions === null ? {} : { protocolVersions: Object.freeze([...device.presence_protocol_versions]) },
|
|
923
|
+
...device.presence_runtimes === null ? {} : { runtimes: Object.freeze(device.presence_runtimes.map((runtime) => Object.freeze({ ...runtime }))) },
|
|
924
|
+
observedAt: device.presence_observed_at,
|
|
925
|
+
expiresAt: device.presence_expires_at
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}));
|
|
929
|
+
return {
|
|
930
|
+
tenantId: tenant,
|
|
931
|
+
activePairedDeviceCount: count(row?.active_paired_device_count ?? 0),
|
|
932
|
+
revokedDeviceCount: count(row?.revoked_device_count ?? 0),
|
|
933
|
+
observedPresenceCount: count(row?.observed_presence_count ?? 0),
|
|
934
|
+
observedPresenceByLevel: {
|
|
935
|
+
online: count(row?.observed_online_count ?? 0),
|
|
936
|
+
thinking: count(row?.observed_thinking_count ?? 0),
|
|
937
|
+
working: count(row?.observed_working_count ?? 0),
|
|
938
|
+
error: count(row?.observed_error_count ?? 0),
|
|
939
|
+
offline: count(row?.observed_offline_count ?? 0)
|
|
940
|
+
},
|
|
941
|
+
devices
|
|
942
|
+
};
|
|
943
|
+
}
|
|
862
944
|
async resolveByDeviceId(deviceId) {
|
|
863
945
|
const result = await this.#pool.query(
|
|
864
946
|
`SELECT ${SELECT_COLUMNS} FROM device WHERE device_id = $1`,
|
|
@@ -1082,18 +1164,31 @@ var PostgresProofRequestReceiptStore = class {
|
|
|
1082
1164
|
};
|
|
1083
1165
|
|
|
1084
1166
|
// src/stores/task-attempts.ts
|
|
1085
|
-
var
|
|
1086
|
-
function
|
|
1167
|
+
var TASK_SELECT_COLUMNS = "tenant_id, task_id, device_id, agent_id, agent_profile_revision, owner_device_id, status, terminal_cause, cancel_requested_at, cancel_reason, cancel_message_id, updated_at";
|
|
1168
|
+
function taskRowToAttempt(row) {
|
|
1087
1169
|
return {
|
|
1088
1170
|
tenantId: row.tenant_id,
|
|
1089
1171
|
taskId: row.task_id,
|
|
1090
1172
|
deviceId: row.device_id,
|
|
1173
|
+
...row.agent_id == null || row.agent_profile_revision == null ? {} : {
|
|
1174
|
+
agentRef: {
|
|
1175
|
+
agentId: row.agent_id,
|
|
1176
|
+
profileRevision: row.agent_profile_revision
|
|
1177
|
+
}
|
|
1178
|
+
},
|
|
1091
1179
|
// `exactOptionalPropertyTypes` is off here, but an explicit absent key is
|
|
1092
1180
|
// still what the in-memory reference produces for an unclaimed attempt, and
|
|
1093
1181
|
// `toEqual` in the suite treats `undefined` and absent alike only for the
|
|
1094
1182
|
// former.
|
|
1095
1183
|
...row.owner_device_id === null ? {} : { ownerDeviceId: row.owner_device_id },
|
|
1096
1184
|
status: row.status,
|
|
1185
|
+
...row.terminal_cause == null ? {} : { terminalCause: row.terminal_cause },
|
|
1186
|
+
...row.cancel_requested_at === null ? {} : {
|
|
1187
|
+
cancellation: {
|
|
1188
|
+
requestedAt: row.cancel_requested_at.toISOString(),
|
|
1189
|
+
...row.cancel_reason === null ? {} : { reason: row.cancel_reason }
|
|
1190
|
+
}
|
|
1191
|
+
},
|
|
1097
1192
|
updatedAt: row.updated_at.toISOString()
|
|
1098
1193
|
};
|
|
1099
1194
|
}
|
|
@@ -1106,53 +1201,423 @@ var PostgresTaskAttemptStore = class {
|
|
|
1106
1201
|
}
|
|
1107
1202
|
async open(tenant, input) {
|
|
1108
1203
|
const inserted = await this.#pool.query(
|
|
1109
|
-
`INSERT INTO task (
|
|
1110
|
-
|
|
1204
|
+
`INSERT INTO task (
|
|
1205
|
+
tenant_id, task_id, device_id, agent_id, agent_profile_revision,
|
|
1206
|
+
owner_device_id, status, updated_at
|
|
1207
|
+
)
|
|
1208
|
+
VALUES ($1, $2, $3, $4, $5, NULL, 'offered', $6)
|
|
1111
1209
|
ON CONFLICT (tenant_id, task_id) DO NOTHING
|
|
1112
|
-
RETURNING ${
|
|
1113
|
-
[
|
|
1210
|
+
RETURNING ${TASK_SELECT_COLUMNS}`,
|
|
1211
|
+
[
|
|
1212
|
+
tenant,
|
|
1213
|
+
input.taskId,
|
|
1214
|
+
input.deviceId,
|
|
1215
|
+
input.agentRef?.agentId ?? null,
|
|
1216
|
+
input.agentRef?.profileRevision ?? null,
|
|
1217
|
+
this.#now()
|
|
1218
|
+
]
|
|
1114
1219
|
);
|
|
1115
1220
|
const created = inserted.rows[0];
|
|
1116
|
-
if (created !== void 0) return
|
|
1221
|
+
if (created !== void 0) return taskRowToAttempt(created);
|
|
1117
1222
|
const existing = await this.get(tenant, input.taskId);
|
|
1118
1223
|
if (existing === void 0) throw new Error(`task ${input.taskId} vanished during open`);
|
|
1119
1224
|
return existing;
|
|
1120
1225
|
}
|
|
1226
|
+
async reserveAgentOffer(tenant, input) {
|
|
1227
|
+
const inserted = await this.#pool.query(
|
|
1228
|
+
`INSERT INTO task (
|
|
1229
|
+
tenant_id, task_id, device_id, agent_id, agent_profile_revision,
|
|
1230
|
+
owner_device_id, status, updated_at
|
|
1231
|
+
)
|
|
1232
|
+
VALUES ($1, $2, $3, $4, $5, NULL, 'offered', $6)
|
|
1233
|
+
ON CONFLICT (tenant_id, task_id) DO NOTHING
|
|
1234
|
+
RETURNING ${TASK_SELECT_COLUMNS}`,
|
|
1235
|
+
[tenant, input.taskId, input.deviceId, input.agentRef.agentId, input.agentRef.profileRevision, this.#now()]
|
|
1236
|
+
);
|
|
1237
|
+
const created = inserted.rows[0];
|
|
1238
|
+
if (created !== void 0) return { attempt: taskRowToAttempt(created), created: true };
|
|
1239
|
+
const existing = await this.get(tenant, input.taskId);
|
|
1240
|
+
if (existing === void 0) throw new Error(`task ${input.taskId} vanished during Agent offer reservation`);
|
|
1241
|
+
return { attempt: existing, created: false };
|
|
1242
|
+
}
|
|
1121
1243
|
async get(tenant, taskId) {
|
|
1122
1244
|
const result = await this.#pool.query(
|
|
1123
|
-
`SELECT ${
|
|
1245
|
+
`SELECT ${TASK_SELECT_COLUMNS} FROM task WHERE tenant_id = $1 AND task_id = $2`,
|
|
1124
1246
|
[tenant, taskId]
|
|
1125
1247
|
);
|
|
1126
1248
|
const row = result.rows[0];
|
|
1127
|
-
return row === void 0 ? void 0 :
|
|
1249
|
+
return row === void 0 ? void 0 : taskRowToAttempt(row);
|
|
1250
|
+
}
|
|
1251
|
+
async getMany(tenant, taskIds) {
|
|
1252
|
+
if (taskIds.length === 0) return [];
|
|
1253
|
+
const result = await this.#pool.query(
|
|
1254
|
+
`SELECT ${TASK_SELECT_COLUMNS} FROM task WHERE tenant_id = $1 AND task_id = ANY($2::text[])`,
|
|
1255
|
+
[tenant, [...new Set(taskIds)]]
|
|
1256
|
+
);
|
|
1257
|
+
return result.rows.map(taskRowToAttempt);
|
|
1128
1258
|
}
|
|
1129
1259
|
async claim(tenant, input) {
|
|
1130
1260
|
const claimed = await this.#pool.query(
|
|
1131
1261
|
`UPDATE task
|
|
1132
1262
|
SET owner_device_id = $3, status = 'claimed', updated_at = $4
|
|
1133
1263
|
WHERE tenant_id = $1 AND task_id = $2 AND owner_device_id IS NULL
|
|
1134
|
-
|
|
1264
|
+
AND cancel_requested_at IS NULL AND status = 'offered'
|
|
1265
|
+
RETURNING ${TASK_SELECT_COLUMNS}`,
|
|
1135
1266
|
[tenant, input.taskId, input.deviceId, this.#now()]
|
|
1136
1267
|
);
|
|
1137
1268
|
const won = claimed.rows[0];
|
|
1138
|
-
if (won !== void 0) return
|
|
1269
|
+
if (won !== void 0) return taskRowToAttempt(won);
|
|
1139
1270
|
return this.get(tenant, input.taskId);
|
|
1140
1271
|
}
|
|
1141
1272
|
async recordStatus(tenant, input) {
|
|
1142
1273
|
const result = await this.#pool.query(
|
|
1143
1274
|
`UPDATE task
|
|
1144
|
-
SET status = $3,
|
|
1275
|
+
SET status = $3,
|
|
1276
|
+
terminal_cause = COALESCE($7, terminal_cause),
|
|
1277
|
+
updated_at = $4
|
|
1145
1278
|
WHERE tenant_id = $1 AND task_id = $2
|
|
1146
|
-
|
|
1147
|
-
|
|
1279
|
+
AND (
|
|
1280
|
+
(agent_id IS NULL AND agent_profile_revision IS NULL AND $5::text IS NULL AND $6::text IS NULL)
|
|
1281
|
+
OR (agent_id = $5 AND agent_profile_revision = $6)
|
|
1282
|
+
)
|
|
1283
|
+
AND (
|
|
1284
|
+
(cancel_requested_at IS NULL AND status NOT IN ('complete', 'failed', 'cancelled'))
|
|
1285
|
+
OR (cancel_requested_at IS NOT NULL AND $3 = 'cancelled' AND status <> 'cancelled')
|
|
1286
|
+
)
|
|
1287
|
+
RETURNING ${TASK_SELECT_COLUMNS}`,
|
|
1288
|
+
[
|
|
1289
|
+
tenant,
|
|
1290
|
+
input.taskId,
|
|
1291
|
+
input.status,
|
|
1292
|
+
this.#now(),
|
|
1293
|
+
input.agentRef?.agentId ?? null,
|
|
1294
|
+
input.agentRef?.profileRevision ?? null,
|
|
1295
|
+
input.terminalCause ?? null
|
|
1296
|
+
]
|
|
1297
|
+
);
|
|
1298
|
+
const row = result.rows[0];
|
|
1299
|
+
return row === void 0 ? this.get(tenant, input.taskId) : taskRowToAttempt(row);
|
|
1300
|
+
}
|
|
1301
|
+
#now() {
|
|
1302
|
+
return this.#clock.now().toISOString();
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1305
|
+
|
|
1306
|
+
// src/stores/core/mailbox-sequence.ts
|
|
1307
|
+
async function allocateMailboxSequence(client, tenant, deviceId, now) {
|
|
1308
|
+
const allocation = await client.query(
|
|
1309
|
+
`INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
|
|
1310
|
+
VALUES ($1, $2, 2, 0, $3)
|
|
1311
|
+
ON CONFLICT (tenant_id, device_id) DO UPDATE
|
|
1312
|
+
SET next_seq = device_stream.next_seq + 1
|
|
1313
|
+
RETURNING next_seq - 1 AS seq`,
|
|
1314
|
+
[tenant, deviceId, now]
|
|
1315
|
+
);
|
|
1316
|
+
return Number(allocation.rows[0].seq);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// src/stores/core/mailbox.ts
|
|
1320
|
+
var DEFAULT_READ_LIMIT = 50;
|
|
1321
|
+
var OUTBOX_COLUMNS = "tenant_id, device_id, seq, message_id, body, body_hash, byte_size, state, appended_at";
|
|
1322
|
+
function toMailboxMessage(row) {
|
|
1323
|
+
return {
|
|
1324
|
+
tenantId: row.tenant_id,
|
|
1325
|
+
deviceId: row.device_id,
|
|
1326
|
+
// `seq` is bigint in the column and `number` on the port, because it is the
|
|
1327
|
+
// envelope `seq` on the wire. The column is wide so the counter cannot wrap
|
|
1328
|
+
// into a redelivery bug; the narrowing happens once, here.
|
|
1329
|
+
seq: Number(row.seq),
|
|
1330
|
+
messageId: row.message_id,
|
|
1331
|
+
body: row.body,
|
|
1332
|
+
bodyHash: row.body_hash,
|
|
1333
|
+
byteSize: row.byte_size,
|
|
1334
|
+
state: row.state,
|
|
1335
|
+
appendedAt: row.appended_at
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
var PostgresMailboxStore = class {
|
|
1339
|
+
#pool;
|
|
1340
|
+
#clock;
|
|
1341
|
+
constructor(pool, clock) {
|
|
1342
|
+
this.#pool = pool;
|
|
1343
|
+
this.#clock = clock;
|
|
1344
|
+
}
|
|
1345
|
+
async append(tenant, input) {
|
|
1346
|
+
this.#requireDeviceId(input.deviceId);
|
|
1347
|
+
const client = await this.#pool.connect();
|
|
1348
|
+
try {
|
|
1349
|
+
await client.query("BEGIN");
|
|
1350
|
+
const existing = await client.query(
|
|
1351
|
+
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1352
|
+
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
1353
|
+
[tenant, input.deviceId, input.messageId]
|
|
1354
|
+
);
|
|
1355
|
+
const replayed = existing.rows[0];
|
|
1356
|
+
if (replayed !== void 0) {
|
|
1357
|
+
await client.query("COMMIT");
|
|
1358
|
+
return toMailboxMessage(replayed);
|
|
1359
|
+
}
|
|
1360
|
+
const seq = await allocateMailboxSequence(client, tenant, input.deviceId, this.#now());
|
|
1361
|
+
const serializedExisting = await client.query(
|
|
1362
|
+
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1363
|
+
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
1364
|
+
[tenant, input.deviceId, input.messageId]
|
|
1365
|
+
);
|
|
1366
|
+
const winnerAfterLock = serializedExisting.rows[0];
|
|
1367
|
+
if (winnerAfterLock !== void 0) {
|
|
1368
|
+
await client.query("ROLLBACK");
|
|
1369
|
+
return toMailboxMessage(winnerAfterLock);
|
|
1370
|
+
}
|
|
1371
|
+
const materialized = await input.materialize(seq);
|
|
1372
|
+
const now = this.#now();
|
|
1373
|
+
const inserted = await client.query(
|
|
1374
|
+
`INSERT INTO outbox (${OUTBOX_COLUMNS})
|
|
1375
|
+
VALUES ($1, $2, $3::bigint, $4, $5, $6, $7::bigint, 'pending', $8)
|
|
1376
|
+
ON CONFLICT (tenant_id, device_id, message_id) DO NOTHING
|
|
1377
|
+
RETURNING ${OUTBOX_COLUMNS}`,
|
|
1378
|
+
[
|
|
1379
|
+
tenant,
|
|
1380
|
+
input.deviceId,
|
|
1381
|
+
seq,
|
|
1382
|
+
input.messageId,
|
|
1383
|
+
materialized.body,
|
|
1384
|
+
materialized.bodyHash,
|
|
1385
|
+
materialized.byteSize,
|
|
1386
|
+
now
|
|
1387
|
+
]
|
|
1388
|
+
);
|
|
1389
|
+
const row = inserted.rows[0];
|
|
1390
|
+
if (row !== void 0) {
|
|
1391
|
+
await client.query("COMMIT");
|
|
1392
|
+
return toMailboxMessage(row);
|
|
1393
|
+
}
|
|
1394
|
+
const winner = await client.query(
|
|
1395
|
+
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1396
|
+
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
1397
|
+
[tenant, input.deviceId, input.messageId]
|
|
1398
|
+
);
|
|
1399
|
+
await client.query("ROLLBACK");
|
|
1400
|
+
const won = winner.rows[0];
|
|
1401
|
+
if (won === void 0) {
|
|
1402
|
+
throw new ByokCoreError(
|
|
1403
|
+
"mailbox_message_not_found",
|
|
1404
|
+
`Message ${input.messageId} vanished during an idempotent append.`
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
return toMailboxMessage(won);
|
|
1408
|
+
} catch (cause) {
|
|
1409
|
+
await client.query("ROLLBACK").catch(() => {
|
|
1410
|
+
});
|
|
1411
|
+
throw cause;
|
|
1412
|
+
} finally {
|
|
1413
|
+
client.release();
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
async readAfter(tenant, query) {
|
|
1417
|
+
const limit = query.limit ?? DEFAULT_READ_LIMIT;
|
|
1418
|
+
const result = await this.#pool.query(
|
|
1419
|
+
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1420
|
+
WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending' AND seq > $3::bigint
|
|
1421
|
+
ORDER BY seq
|
|
1422
|
+
LIMIT $4`,
|
|
1423
|
+
[tenant, query.deviceId, query.afterSeq, limit + 1]
|
|
1424
|
+
);
|
|
1425
|
+
const page = result.rows.slice(0, limit).map(toMailboxMessage);
|
|
1426
|
+
return {
|
|
1427
|
+
messages: page,
|
|
1428
|
+
// Nothing above was mutated, so an identical call replays the same page.
|
|
1429
|
+
// The returned position is a READ cursor and moves no ack.
|
|
1430
|
+
nextSeq: page.at(-1)?.seq ?? query.afterSeq,
|
|
1431
|
+
hasMore: result.rows.length > page.length
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
async advanceCursor(tenant, input) {
|
|
1435
|
+
this.#requireDeviceId(input.deviceId);
|
|
1436
|
+
const now = this.#now();
|
|
1437
|
+
const moved = await this.#pool.query(
|
|
1438
|
+
`WITH moved AS (
|
|
1439
|
+
INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
|
|
1440
|
+
VALUES ($1, $2, 1, $3::bigint, $4)
|
|
1441
|
+
ON CONFLICT (tenant_id, device_id) DO UPDATE
|
|
1442
|
+
SET acked_seq = EXCLUDED.acked_seq, acked_at = EXCLUDED.acked_at
|
|
1443
|
+
WHERE device_stream.acked_seq <= EXCLUDED.acked_seq
|
|
1444
|
+
RETURNING acked_seq, acked_at
|
|
1445
|
+
), marked AS (
|
|
1446
|
+
UPDATE outbox
|
|
1447
|
+
SET state = 'acked'
|
|
1448
|
+
WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending'
|
|
1449
|
+
AND seq <= (SELECT acked_seq FROM moved)
|
|
1450
|
+
RETURNING 1
|
|
1451
|
+
)
|
|
1452
|
+
SELECT acked_seq, acked_at FROM moved`,
|
|
1453
|
+
[tenant, input.deviceId, input.ackedSeq, now]
|
|
1454
|
+
);
|
|
1455
|
+
const row = moved.rows[0];
|
|
1456
|
+
if (row !== void 0) {
|
|
1457
|
+
return {
|
|
1458
|
+
tenantId: tenant,
|
|
1459
|
+
deviceId: input.deviceId,
|
|
1460
|
+
ackedSeq: Number(row.acked_seq),
|
|
1461
|
+
updatedAt: row.acked_at ?? now
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
const current = await this.readCursor(tenant, input.deviceId);
|
|
1465
|
+
throw new CoreConflictError(
|
|
1466
|
+
"mailbox_cursor_regression",
|
|
1467
|
+
`Cursor for device ${input.deviceId} is at ${current.ackedSeq}; refusing to move it back to ${input.ackedSeq}.`,
|
|
1468
|
+
current,
|
|
1469
|
+
this.#now()
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
async readCursor(tenant, deviceId) {
|
|
1473
|
+
const result = await this.#pool.query(
|
|
1474
|
+
`SELECT acked_seq, acked_at FROM device_stream
|
|
1475
|
+
WHERE tenant_id = $1 AND device_id = $2`,
|
|
1476
|
+
[tenant, deviceId]
|
|
1148
1477
|
);
|
|
1149
1478
|
const row = result.rows[0];
|
|
1150
|
-
return
|
|
1479
|
+
return {
|
|
1480
|
+
tenantId: tenant,
|
|
1481
|
+
deviceId,
|
|
1482
|
+
ackedSeq: row === void 0 ? 0 : Number(row.acked_seq),
|
|
1483
|
+
updatedAt: row?.acked_at ?? this.#now()
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
async collectRetired(tenant, input) {
|
|
1487
|
+
assertCanonicalTimestamp(input.ackedBefore, "ackedBefore");
|
|
1488
|
+
assertCanonicalTimestamp(input.expireUnackedBefore, "expireUnackedBefore");
|
|
1489
|
+
const swept = await this.#pool.query(
|
|
1490
|
+
`WITH deleted AS (
|
|
1491
|
+
DELETE FROM outbox
|
|
1492
|
+
WHERE tenant_id = $1
|
|
1493
|
+
AND ($2::text IS NULL OR device_id = $2::text)
|
|
1494
|
+
AND state = 'acked'
|
|
1495
|
+
AND appended_at < $3
|
|
1496
|
+
RETURNING byte_size
|
|
1497
|
+
), expired AS (
|
|
1498
|
+
UPDATE outbox
|
|
1499
|
+
SET state = 'expired'
|
|
1500
|
+
WHERE tenant_id = $1
|
|
1501
|
+
AND ($2::text IS NULL OR device_id = $2::text)
|
|
1502
|
+
AND state = 'pending'
|
|
1503
|
+
AND appended_at < $4
|
|
1504
|
+
RETURNING 1
|
|
1505
|
+
)
|
|
1506
|
+
SELECT (SELECT count(*) FROM deleted) AS deleted_count,
|
|
1507
|
+
(SELECT count(*) FROM expired) AS expired_count,
|
|
1508
|
+
(SELECT COALESCE(SUM(byte_size), 0) FROM deleted)::bigint AS released_bytes`,
|
|
1509
|
+
[tenant, input.deviceId ?? null, input.ackedBefore, input.expireUnackedBefore]
|
|
1510
|
+
);
|
|
1511
|
+
const row = swept.rows[0];
|
|
1512
|
+
return {
|
|
1513
|
+
deletedCount: Number(row.deleted_count),
|
|
1514
|
+
expiredCount: Number(row.expired_count),
|
|
1515
|
+
releasedBytes: row.released_bytes
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
/**
|
|
1519
|
+
* The in-memory reference refuses an empty device id rather than opening a
|
|
1520
|
+
* mailbox nothing can address. Kept here so the two compositions answer the
|
|
1521
|
+
* same way; the table itself would happily store the row.
|
|
1522
|
+
*/
|
|
1523
|
+
#requireDeviceId(deviceId) {
|
|
1524
|
+
if (deviceId.length === 0) {
|
|
1525
|
+
throw new ByokCoreError("mailbox_message_not_found", "Device id must not be empty.");
|
|
1526
|
+
}
|
|
1151
1527
|
}
|
|
1152
1528
|
#now() {
|
|
1153
1529
|
return this.#clock.now().toISOString();
|
|
1154
1530
|
}
|
|
1155
1531
|
};
|
|
1532
|
+
|
|
1533
|
+
// src/stores/task-cancellations.ts
|
|
1534
|
+
var PostgresTaskCancellationStore = class {
|
|
1535
|
+
#pool;
|
|
1536
|
+
#clock;
|
|
1537
|
+
constructor(pool, clock) {
|
|
1538
|
+
this.#pool = pool;
|
|
1539
|
+
this.#clock = clock;
|
|
1540
|
+
}
|
|
1541
|
+
async request(tenant, input) {
|
|
1542
|
+
const client = await this.#pool.connect();
|
|
1543
|
+
try {
|
|
1544
|
+
await client.query("BEGIN");
|
|
1545
|
+
const selected = await client.query(
|
|
1546
|
+
`SELECT ${TASK_SELECT_COLUMNS} FROM task
|
|
1547
|
+
WHERE tenant_id = $1 AND task_id = $2
|
|
1548
|
+
FOR UPDATE`,
|
|
1549
|
+
[tenant, input.taskId]
|
|
1550
|
+
);
|
|
1551
|
+
const current = selected.rows[0];
|
|
1552
|
+
if (current === void 0) {
|
|
1553
|
+
await client.query("ROLLBACK");
|
|
1554
|
+
return void 0;
|
|
1555
|
+
}
|
|
1556
|
+
if (current.cancel_requested_at === null && (current.status === "complete" || current.status === "failed" || current.status === "cancelled")) {
|
|
1557
|
+
await client.query("COMMIT");
|
|
1558
|
+
return { attempt: taskRowToAttempt(current) };
|
|
1559
|
+
}
|
|
1560
|
+
if (current.cancel_message_id !== null) {
|
|
1561
|
+
const replayed = await client.query(
|
|
1562
|
+
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1563
|
+
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
1564
|
+
[tenant, current.device_id, current.cancel_message_id]
|
|
1565
|
+
);
|
|
1566
|
+
const message = replayed.rows[0];
|
|
1567
|
+
if (message === void 0) {
|
|
1568
|
+
if (current.status === "cancelled") {
|
|
1569
|
+
await client.query("COMMIT");
|
|
1570
|
+
return { attempt: taskRowToAttempt(current) };
|
|
1571
|
+
}
|
|
1572
|
+
throw new Error(`Cancellation delivery ${current.cancel_message_id} is missing for task ${input.taskId}`);
|
|
1573
|
+
}
|
|
1574
|
+
await client.query("COMMIT");
|
|
1575
|
+
return { attempt: taskRowToAttempt(current), message: toMailboxMessage(message) };
|
|
1576
|
+
}
|
|
1577
|
+
const now = this.#clock.now().toISOString();
|
|
1578
|
+
const messageId = input.proposedMessageId;
|
|
1579
|
+
const seq = await allocateMailboxSequence(client, tenant, current.device_id, now);
|
|
1580
|
+
const materialized = await input.materialize(seq, messageId);
|
|
1581
|
+
const insertedMessage = await client.query(
|
|
1582
|
+
`INSERT INTO outbox (${OUTBOX_COLUMNS})
|
|
1583
|
+
VALUES ($1, $2, $3::bigint, $4, $5, $6, $7::bigint, 'pending', $8)
|
|
1584
|
+
RETURNING ${OUTBOX_COLUMNS}`,
|
|
1585
|
+
[
|
|
1586
|
+
tenant,
|
|
1587
|
+
current.device_id,
|
|
1588
|
+
seq,
|
|
1589
|
+
messageId,
|
|
1590
|
+
materialized.body,
|
|
1591
|
+
materialized.bodyHash,
|
|
1592
|
+
materialized.byteSize,
|
|
1593
|
+
now
|
|
1594
|
+
]
|
|
1595
|
+
);
|
|
1596
|
+
const updated = await client.query(
|
|
1597
|
+
`UPDATE task
|
|
1598
|
+
SET status = CASE WHEN owner_device_id IS NULL THEN 'cancelled' ELSE 'cancel_requested' END,
|
|
1599
|
+
cancel_requested_at = $3,
|
|
1600
|
+
cancel_reason = $4,
|
|
1601
|
+
cancel_message_id = $5,
|
|
1602
|
+
updated_at = $3
|
|
1603
|
+
WHERE tenant_id = $1 AND task_id = $2
|
|
1604
|
+
RETURNING ${TASK_SELECT_COLUMNS}`,
|
|
1605
|
+
[tenant, input.taskId, now, input.reason ?? null, messageId]
|
|
1606
|
+
);
|
|
1607
|
+
await client.query("COMMIT");
|
|
1608
|
+
return {
|
|
1609
|
+
attempt: taskRowToAttempt(updated.rows[0]),
|
|
1610
|
+
message: toMailboxMessage(insertedMessage.rows[0])
|
|
1611
|
+
};
|
|
1612
|
+
} catch (cause) {
|
|
1613
|
+
await client.query("ROLLBACK").catch(() => {
|
|
1614
|
+
});
|
|
1615
|
+
throw cause;
|
|
1616
|
+
} finally {
|
|
1617
|
+
client.release();
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
};
|
|
1156
1621
|
function toTail(row) {
|
|
1157
1622
|
const entries = parseTimelineEvents(row.entries);
|
|
1158
1623
|
const cursor = activityCursor(entries);
|
|
@@ -1386,6 +1851,87 @@ var PostgresApprovalTimelineStore = class {
|
|
|
1386
1851
|
return row === void 0 ? void 0 : toTail2(row);
|
|
1387
1852
|
}
|
|
1388
1853
|
};
|
|
1854
|
+
var SELECT_COLUMNS4 = [
|
|
1855
|
+
"tenant_id",
|
|
1856
|
+
"device_id",
|
|
1857
|
+
"event_id",
|
|
1858
|
+
"agent_id",
|
|
1859
|
+
"agent_profile_revision",
|
|
1860
|
+
"session_ref",
|
|
1861
|
+
"policy_revision",
|
|
1862
|
+
"cursor",
|
|
1863
|
+
"payload_json",
|
|
1864
|
+
"content_hash",
|
|
1865
|
+
"byte_count",
|
|
1866
|
+
"receipt_id",
|
|
1867
|
+
"recorded_at"
|
|
1868
|
+
].join(", ");
|
|
1869
|
+
function toRecord2(row) {
|
|
1870
|
+
const payload = AgentEgressReliablePayloadSchema.parse({
|
|
1871
|
+
agentRef: { agentId: row.agent_id, profileRevision: row.agent_profile_revision },
|
|
1872
|
+
sessionRef: row.session_ref,
|
|
1873
|
+
policyRevision: row.policy_revision,
|
|
1874
|
+
eventId: row.event_id,
|
|
1875
|
+
cursor: Number(row.cursor),
|
|
1876
|
+
payload: row.payload_json,
|
|
1877
|
+
contentHash: row.content_hash,
|
|
1878
|
+
byteCount: row.byte_count
|
|
1879
|
+
});
|
|
1880
|
+
return {
|
|
1881
|
+
tenantId: row.tenant_id,
|
|
1882
|
+
deviceId: row.device_id,
|
|
1883
|
+
payload,
|
|
1884
|
+
receiptId: row.receipt_id,
|
|
1885
|
+
recordedAt: row.recorded_at.toISOString()
|
|
1886
|
+
};
|
|
1887
|
+
}
|
|
1888
|
+
var PostgresAgentEgressStore = class {
|
|
1889
|
+
constructor(pool, clock) {
|
|
1890
|
+
this.pool = pool;
|
|
1891
|
+
this.clock = clock;
|
|
1892
|
+
}
|
|
1893
|
+
pool;
|
|
1894
|
+
clock;
|
|
1895
|
+
async record(tenant, input) {
|
|
1896
|
+
const payload = AgentEgressReliablePayloadSchema.parse(input.payload);
|
|
1897
|
+
const inserted = await this.pool.query(
|
|
1898
|
+
`INSERT INTO agent_egress_event (${SELECT_COLUMNS4})
|
|
1899
|
+
VALUES ($1, $2, $3::uuid, $4, $5, $6, $7, $8::bigint, $9::jsonb, $10, $11::integer, $12::uuid, $13)
|
|
1900
|
+
ON CONFLICT (tenant_id, device_id, event_id) DO NOTHING
|
|
1901
|
+
RETURNING ${SELECT_COLUMNS4}`,
|
|
1902
|
+
[
|
|
1903
|
+
tenant,
|
|
1904
|
+
input.deviceId,
|
|
1905
|
+
payload.eventId,
|
|
1906
|
+
payload.agentRef.agentId,
|
|
1907
|
+
payload.agentRef.profileRevision,
|
|
1908
|
+
payload.sessionRef,
|
|
1909
|
+
payload.policyRevision,
|
|
1910
|
+
payload.cursor,
|
|
1911
|
+
JSON.stringify(payload.payload),
|
|
1912
|
+
payload.contentHash,
|
|
1913
|
+
payload.byteCount,
|
|
1914
|
+
input.receiptId,
|
|
1915
|
+
this.clock.now().toISOString()
|
|
1916
|
+
]
|
|
1917
|
+
);
|
|
1918
|
+
const row = inserted.rows[0];
|
|
1919
|
+
if (row !== void 0) return { record: toRecord2(row), created: true };
|
|
1920
|
+
const existing = await this.get(tenant, input.deviceId, payload.eventId);
|
|
1921
|
+
if (existing === void 0) throw new Error(`Agent egress ${payload.eventId} vanished during first-write record.`);
|
|
1922
|
+
return { record: existing, created: false };
|
|
1923
|
+
}
|
|
1924
|
+
async get(tenant, deviceId, eventId) {
|
|
1925
|
+
const result = await this.pool.query(
|
|
1926
|
+
`SELECT ${SELECT_COLUMNS4}
|
|
1927
|
+
FROM agent_egress_event
|
|
1928
|
+
WHERE tenant_id = $1 AND device_id = $2 AND event_id = $3::uuid`,
|
|
1929
|
+
[tenant, deviceId, eventId]
|
|
1930
|
+
);
|
|
1931
|
+
const row = result.rows[0];
|
|
1932
|
+
return row === void 0 ? void 0 : toRecord2(row);
|
|
1933
|
+
}
|
|
1934
|
+
};
|
|
1389
1935
|
|
|
1390
1936
|
// src/stores/device-assertion-replay.ts
|
|
1391
1937
|
var PostgresDeviceAssertionReplayAuthority = class {
|
|
@@ -1442,12 +1988,14 @@ function createPostgresCloudStores(options) {
|
|
|
1442
1988
|
return {
|
|
1443
1989
|
activity: new PostgresActivityStore(pool, clock),
|
|
1444
1990
|
approvals: new PostgresApprovalTimelineStore(pool, clock),
|
|
1445
|
-
devices: new PostgresDeviceDirectory(pool),
|
|
1991
|
+
devices: new PostgresDeviceDirectory(pool, clock),
|
|
1446
1992
|
pairingCodes: new PostgresPairingCodeStore(pool, clock),
|
|
1447
1993
|
nonces: new PostgresNonceStore(pool, clock, crypto),
|
|
1448
1994
|
dedup: new PostgresInboundDedupStore(pool),
|
|
1449
1995
|
tasks: new PostgresTaskAttemptStore(pool, clock),
|
|
1996
|
+
cancellations: new PostgresTaskCancellationStore(pool, clock),
|
|
1450
1997
|
receipts: new PostgresRequestReceiptStore(pool, clock),
|
|
1998
|
+
egress: new PostgresAgentEgressStore(pool, clock),
|
|
1451
1999
|
proofReceipts: new PostgresProofRequestReceiptStore(pool, clock),
|
|
1452
2000
|
// A second `PostgresObjectStore` instance, not a shared one: it is a
|
|
1453
2001
|
// stateless wrapper over the pool, so the two read and write the same rows
|
|
@@ -1460,7 +2008,7 @@ function createPostgresCloudStores(options) {
|
|
|
1460
2008
|
rateLimiter: new AllowAllRateLimiter()
|
|
1461
2009
|
};
|
|
1462
2010
|
}
|
|
1463
|
-
var PRESENCE_COLUMNS = "tenant_id, device_id, level, detail, configured_toolsets, observed_at, expires_at";
|
|
2011
|
+
var PRESENCE_COLUMNS = "tenant_id, device_id, level, detail, configured_toolsets, client_version, protocol_versions, runtimes, observed_at, expires_at";
|
|
1464
2012
|
function toHint(row) {
|
|
1465
2013
|
return {
|
|
1466
2014
|
tenantId: row.tenant_id,
|
|
@@ -1468,6 +2016,19 @@ function toHint(row) {
|
|
|
1468
2016
|
level: row.level,
|
|
1469
2017
|
...row.detail === null ? {} : { detail: row.detail },
|
|
1470
2018
|
...row.configured_toolsets === null ? {} : { configuredToolsets: Object.freeze([...row.configured_toolsets]) },
|
|
2019
|
+
...row.client_version === null ? {} : { clientVersion: row.client_version },
|
|
2020
|
+
...row.protocol_versions === null ? {} : { protocolVersions: Object.freeze([...row.protocol_versions]) },
|
|
2021
|
+
...row.runtimes === null ? {} : {
|
|
2022
|
+
runtimes: Object.freeze(
|
|
2023
|
+
row.runtimes.map(
|
|
2024
|
+
(runtime) => Object.freeze({
|
|
2025
|
+
id: runtime.id,
|
|
2026
|
+
...runtime.version === void 0 ? {} : { version: runtime.version },
|
|
2027
|
+
...runtime.authPresent === void 0 ? {} : { authPresent: runtime.authPresent }
|
|
2028
|
+
})
|
|
2029
|
+
)
|
|
2030
|
+
)
|
|
2031
|
+
},
|
|
1471
2032
|
observedAt: row.observed_at,
|
|
1472
2033
|
expiresAt: row.expires_at
|
|
1473
2034
|
};
|
|
@@ -1503,15 +2064,18 @@ var PostgresPresenceStore = class {
|
|
|
1503
2064
|
const allowedBefore = new Date(now.getTime() - input.minimumIntervalMs).toISOString();
|
|
1504
2065
|
const result = await this.#pool.query(
|
|
1505
2066
|
`INSERT INTO device_presence (${PRESENCE_COLUMNS})
|
|
1506
|
-
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)
|
|
2067
|
+
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8::jsonb, $9, $10)
|
|
1507
2068
|
ON CONFLICT (tenant_id, device_id) DO UPDATE
|
|
1508
2069
|
SET level = EXCLUDED.level,
|
|
1509
2070
|
detail = EXCLUDED.detail,
|
|
1510
2071
|
configured_toolsets = EXCLUDED.configured_toolsets,
|
|
2072
|
+
client_version = EXCLUDED.client_version,
|
|
2073
|
+
protocol_versions = EXCLUDED.protocol_versions,
|
|
2074
|
+
runtimes = EXCLUDED.runtimes,
|
|
1511
2075
|
observed_at = EXCLUDED.observed_at,
|
|
1512
2076
|
expires_at = EXCLUDED.expires_at
|
|
1513
2077
|
WHERE device_presence.expires_at <= EXCLUDED.observed_at
|
|
1514
|
-
OR device_presence.observed_at <= $
|
|
2078
|
+
OR device_presence.observed_at <= $11
|
|
1515
2079
|
RETURNING ${PRESENCE_COLUMNS}`,
|
|
1516
2080
|
[
|
|
1517
2081
|
tenant,
|
|
@@ -1519,6 +2083,9 @@ var PostgresPresenceStore = class {
|
|
|
1519
2083
|
input.level,
|
|
1520
2084
|
input.detail ?? null,
|
|
1521
2085
|
input.configuredToolsets === void 0 ? null : JSON.stringify(input.configuredToolsets),
|
|
2086
|
+
input.clientVersion ?? null,
|
|
2087
|
+
input.protocolVersions === void 0 ? null : JSON.stringify(input.protocolVersions),
|
|
2088
|
+
input.runtimes === void 0 ? null : JSON.stringify(input.runtimes),
|
|
1522
2089
|
observedAt,
|
|
1523
2090
|
new Date(now.getTime() + input.ttlMs).toISOString(),
|
|
1524
2091
|
allowedBefore
|
|
@@ -1750,264 +2317,37 @@ var PostgresBoardStore = class {
|
|
|
1750
2317
|
`${input.expectedStatus} to ${input.status} is not a legal board transition.`,
|
|
1751
2318
|
current,
|
|
1752
2319
|
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
|
-
);
|
|
2320
|
+
);
|
|
2321
|
+
}
|
|
2322
|
+
throw this.#statusConflict(input.itemId, current, input.expectedStatus);
|
|
1955
2323
|
}
|
|
1956
|
-
|
|
2324
|
+
/**
|
|
2325
|
+
* One statement, its own transaction, lock released immediately. See the file
|
|
2326
|
+
* header for why this is not a CTE inside the write it feeds.
|
|
2327
|
+
*/
|
|
2328
|
+
async #allocateSeq(tenant) {
|
|
1957
2329
|
const result = await this.#pool.query(
|
|
1958
|
-
`
|
|
1959
|
-
|
|
1960
|
-
|
|
2330
|
+
`INSERT INTO tenant_stream (tenant_id, board_seq)
|
|
2331
|
+
VALUES ($1, 1)
|
|
2332
|
+
ON CONFLICT (tenant_id) DO UPDATE SET board_seq = tenant_stream.board_seq + 1
|
|
2333
|
+
RETURNING board_seq`,
|
|
2334
|
+
[tenant]
|
|
1961
2335
|
);
|
|
1962
|
-
|
|
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
|
-
};
|
|
2336
|
+
return result.rows[0].board_seq;
|
|
1969
2337
|
}
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
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]
|
|
2338
|
+
#itemNotFound(itemId) {
|
|
2339
|
+
return new ByokCoreError(
|
|
2340
|
+
"board_item_not_found",
|
|
2341
|
+
`Board item ${itemId} does not exist in this tenant.`
|
|
1994
2342
|
);
|
|
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
2343
|
}
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
throw new ByokCoreError("mailbox_message_not_found", "Device id must not be empty.");
|
|
2010
|
-
}
|
|
2344
|
+
#statusConflict(itemId, current, expected) {
|
|
2345
|
+
return new CoreConflictError(
|
|
2346
|
+
"board_status_conflict",
|
|
2347
|
+
`Board item ${itemId} is ${current.status}, not ${expected}.`,
|
|
2348
|
+
current,
|
|
2349
|
+
this.#now()
|
|
2350
|
+
);
|
|
2011
2351
|
}
|
|
2012
2352
|
#now() {
|
|
2013
2353
|
return this.#clock.now().toISOString();
|
|
@@ -2732,7 +3072,7 @@ var RECORD_COLUMNS = "tenant_id, kind, subject_id, rev, content_hash, byte_size,
|
|
|
2732
3072
|
function toBody(row) {
|
|
2733
3073
|
return row.body_kind === "inline" ? { kind: "inline", body: row.body_inline ?? "" } : { kind: "object", hash: row.body_object_hash ?? "" };
|
|
2734
3074
|
}
|
|
2735
|
-
function
|
|
3075
|
+
function toRecord3(row) {
|
|
2736
3076
|
return {
|
|
2737
3077
|
tenantId: row.tenant_id,
|
|
2738
3078
|
kind: row.kind,
|
|
@@ -2777,7 +3117,7 @@ var PostgresTruthStore = class {
|
|
|
2777
3117
|
]
|
|
2778
3118
|
);
|
|
2779
3119
|
const row = inserted.rows[0];
|
|
2780
|
-
if (row !== void 0) return
|
|
3120
|
+
if (row !== void 0) return toRecord3(row);
|
|
2781
3121
|
const existing = await this.getRecord(tenant, {
|
|
2782
3122
|
kind: "task.terminal",
|
|
2783
3123
|
recordKey: input.taskId
|
|
@@ -2843,7 +3183,7 @@ var PostgresTruthStore = class {
|
|
|
2843
3183
|
]
|
|
2844
3184
|
);
|
|
2845
3185
|
const row = written.rows[0];
|
|
2846
|
-
if (row !== void 0) return
|
|
3186
|
+
if (row !== void 0) return toRecord3(row);
|
|
2847
3187
|
const current = await this.getRecord(tenant, {
|
|
2848
3188
|
kind: input.kind,
|
|
2849
3189
|
recordKey: input.recordKey
|
|
@@ -2862,7 +3202,7 @@ var PostgresTruthStore = class {
|
|
|
2862
3202
|
[tenant, selector.kind, selector.recordKey]
|
|
2863
3203
|
);
|
|
2864
3204
|
const row = result.rows[0];
|
|
2865
|
-
return row === void 0 ? void 0 :
|
|
3205
|
+
return row === void 0 ? void 0 : toRecord3(row);
|
|
2866
3206
|
}
|
|
2867
3207
|
async listManifest(tenant, query) {
|
|
2868
3208
|
const result = await this.#pool.query(
|
|
@@ -2910,7 +3250,7 @@ var RECEIPT_COLUMNS = "tenant_id, device_id, request_id, operation, resource, bo
|
|
|
2910
3250
|
function toBody2(row) {
|
|
2911
3251
|
return row.body_kind === "inline" ? { kind: "inline", body: row.body_inline ?? "" } : { kind: "object", hash: row.body_object_hash ?? "" };
|
|
2912
3252
|
}
|
|
2913
|
-
function
|
|
3253
|
+
function toRecord4(tenant, row) {
|
|
2914
3254
|
return {
|
|
2915
3255
|
tenantId: tenant,
|
|
2916
3256
|
kind: row.kind,
|
|
@@ -3087,7 +3427,7 @@ var PostgresTruthCommitter = class {
|
|
|
3087
3427
|
);
|
|
3088
3428
|
current.set(
|
|
3089
3429
|
writeKey(write),
|
|
3090
|
-
result.rows[0] === void 0 ? void 0 :
|
|
3430
|
+
result.rows[0] === void 0 ? void 0 : toRecord4(tenant, result.rows[0])
|
|
3091
3431
|
);
|
|
3092
3432
|
}
|
|
3093
3433
|
return current;
|
|
@@ -3300,7 +3640,7 @@ var PostgresTruthCommitter = class {
|
|
|
3300
3640
|
applied.push({
|
|
3301
3641
|
input: write,
|
|
3302
3642
|
before,
|
|
3303
|
-
record:
|
|
3643
|
+
record: toRecord4(tenant, result.rows[0]),
|
|
3304
3644
|
mutated: true
|
|
3305
3645
|
});
|
|
3306
3646
|
}
|
|
@@ -3357,6 +3697,11 @@ var PostgresTruthCommitter = class {
|
|
|
3357
3697
|
return this.#clock.now().toISOString();
|
|
3358
3698
|
}
|
|
3359
3699
|
};
|
|
3700
|
+
function migrationsDir() {
|
|
3701
|
+
return fileURLToPath(new URL("./sql", import.meta.url));
|
|
3702
|
+
}
|
|
3703
|
+
|
|
3704
|
+
// src/migrate.ts
|
|
3360
3705
|
var MIGRATION_ADVISORY_LOCK_KEY = "4021960801";
|
|
3361
3706
|
var MIGRATION_FILENAME_PATTERN = /^(\d{4})[_-].+\.sql$/;
|
|
3362
3707
|
var LEDGER_DDL = `
|
|
@@ -3365,6 +3710,28 @@ CREATE TABLE IF NOT EXISTS byok_schema_migration (
|
|
|
3365
3710
|
checksum text NOT NULL,
|
|
3366
3711
|
applied_at timestamptz NOT NULL
|
|
3367
3712
|
)`;
|
|
3713
|
+
var MIGRATION_LEDGER_TABLE = "byok_schema_migration";
|
|
3714
|
+
var LEDGER_READ_SQL = "SELECT version, checksum FROM byok_schema_migration";
|
|
3715
|
+
var MigrationStateMismatchError = class extends Error {
|
|
3716
|
+
issues;
|
|
3717
|
+
constructor(issues, options) {
|
|
3718
|
+
const detail = issues.map((issue) => {
|
|
3719
|
+
switch (issue.kind) {
|
|
3720
|
+
case "missing":
|
|
3721
|
+
return `missing ${issue.version}`;
|
|
3722
|
+
case "unexpected":
|
|
3723
|
+
return `unexpected ${issue.version}`;
|
|
3724
|
+
case "checksum_mismatch":
|
|
3725
|
+
return `checksum mismatch ${issue.version}`;
|
|
3726
|
+
case "ledger_missing":
|
|
3727
|
+
return `ledger table missing ${issue.table}`;
|
|
3728
|
+
}
|
|
3729
|
+
}).join("; ");
|
|
3730
|
+
super(`Migration state does not match package files: ${detail}`, options);
|
|
3731
|
+
this.name = "MigrationStateMismatchError";
|
|
3732
|
+
this.issues = Object.freeze([...issues]);
|
|
3733
|
+
}
|
|
3734
|
+
};
|
|
3368
3735
|
var MigrationChecksumMismatchError = class extends Error {
|
|
3369
3736
|
version;
|
|
3370
3737
|
expectedChecksum;
|
|
@@ -3423,11 +3790,65 @@ async function readMigrationFiles(directory) {
|
|
|
3423
3790
|
}
|
|
3424
3791
|
return files;
|
|
3425
3792
|
}
|
|
3793
|
+
async function readLedgerRows(client) {
|
|
3794
|
+
const result = await client.query(LEDGER_READ_SQL);
|
|
3795
|
+
return result.rows;
|
|
3796
|
+
}
|
|
3426
3797
|
async function readLedger(client) {
|
|
3427
|
-
const
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3798
|
+
const rows = await readLedgerRows(client);
|
|
3799
|
+
return new Map(rows.map((row) => [row.version, row.checksum]));
|
|
3800
|
+
}
|
|
3801
|
+
function isMissingLedgerTable(error) {
|
|
3802
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "42P01";
|
|
3803
|
+
}
|
|
3804
|
+
function compareVersions(left, right) {
|
|
3805
|
+
if (left < right) return -1;
|
|
3806
|
+
if (left > right) return 1;
|
|
3807
|
+
return 0;
|
|
3808
|
+
}
|
|
3809
|
+
async function verifyMigrations(pool, directory = migrationsDir()) {
|
|
3810
|
+
const files = await readMigrationFiles(directory);
|
|
3811
|
+
const client = await pool.connect();
|
|
3812
|
+
try {
|
|
3813
|
+
let ledgerRows;
|
|
3814
|
+
try {
|
|
3815
|
+
ledgerRows = await readLedgerRows(client);
|
|
3816
|
+
} catch (error) {
|
|
3817
|
+
if (!isMissingLedgerTable(error)) throw error;
|
|
3818
|
+
throw new MigrationStateMismatchError(
|
|
3819
|
+
[{ kind: "ledger_missing", table: MIGRATION_LEDGER_TABLE }],
|
|
3820
|
+
{ cause: error }
|
|
3821
|
+
);
|
|
3822
|
+
}
|
|
3823
|
+
const ledger = new Map(ledgerRows.map((row) => [row.version, row.checksum]));
|
|
3824
|
+
const expectedVersions = new Set(files.map((file) => file.version));
|
|
3825
|
+
const issues = [];
|
|
3826
|
+
for (const file of files) {
|
|
3827
|
+
const actualChecksum = ledger.get(file.version);
|
|
3828
|
+
if (actualChecksum === void 0) {
|
|
3829
|
+
issues.push({
|
|
3830
|
+
kind: "missing",
|
|
3831
|
+
version: file.version,
|
|
3832
|
+
expectedChecksum: file.checksum
|
|
3833
|
+
});
|
|
3834
|
+
} else if (actualChecksum !== file.checksum) {
|
|
3835
|
+
issues.push({
|
|
3836
|
+
kind: "checksum_mismatch",
|
|
3837
|
+
version: file.version,
|
|
3838
|
+
expectedChecksum: file.checksum,
|
|
3839
|
+
actualChecksum
|
|
3840
|
+
});
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
const unexpectedRows = ledgerRows.filter((row) => !expectedVersions.has(row.version)).slice().sort((left, right) => compareVersions(left.version, right.version));
|
|
3844
|
+
for (const row of unexpectedRows) {
|
|
3845
|
+
issues.push({ kind: "unexpected", version: row.version, actualChecksum: row.checksum });
|
|
3846
|
+
}
|
|
3847
|
+
if (issues.length > 0) throw new MigrationStateMismatchError(issues);
|
|
3848
|
+
return files.map(({ version, checksum }) => ({ version, checksum }));
|
|
3849
|
+
} finally {
|
|
3850
|
+
client.release();
|
|
3851
|
+
}
|
|
3431
3852
|
}
|
|
3432
3853
|
async function migrate(pool, directory) {
|
|
3433
3854
|
const files = await readMigrationFiles(directory);
|
|
@@ -3471,9 +3892,6 @@ async function migrate(pool, directory) {
|
|
|
3471
3892
|
client.release();
|
|
3472
3893
|
}
|
|
3473
3894
|
}
|
|
3474
|
-
function migrationsDir() {
|
|
3475
|
-
return fileURLToPath(new URL("./sql", import.meta.url));
|
|
3476
|
-
}
|
|
3477
3895
|
var DEFAULT_BATCH_SIZE = 100;
|
|
3478
3896
|
var MAX_BATCH_SIZE = 1e3;
|
|
3479
3897
|
var ADVISORY_LOCK_NAMESPACE = 1106736963;
|
|
@@ -3644,7 +4062,7 @@ var PostgresCloudCleanup = class {
|
|
|
3644
4062
|
]
|
|
3645
4063
|
);
|
|
3646
4064
|
return {
|
|
3647
|
-
messages: listed.rows.slice(0, limit).map(
|
|
4065
|
+
messages: listed.rows.slice(0, limit).map(toMailboxMessage2),
|
|
3648
4066
|
hasMore: listed.rows.length > limit
|
|
3649
4067
|
};
|
|
3650
4068
|
}
|
|
@@ -3682,7 +4100,7 @@ var PostgresCloudCleanup = class {
|
|
|
3682
4100
|
`Replay id ${input.replayMessageId} already binds a different replay delivery.`
|
|
3683
4101
|
);
|
|
3684
4102
|
} else {
|
|
3685
|
-
result =
|
|
4103
|
+
result = toMailboxMessage2(existing);
|
|
3686
4104
|
}
|
|
3687
4105
|
} else {
|
|
3688
4106
|
const entitlement = await client.query(
|
|
@@ -3707,7 +4125,7 @@ var PostgresCloudCleanup = class {
|
|
|
3707
4125
|
`Replay id ${input.replayMessageId} already binds a different replay delivery.`
|
|
3708
4126
|
);
|
|
3709
4127
|
} else {
|
|
3710
|
-
result =
|
|
4128
|
+
result = toMailboxMessage2(winner);
|
|
3711
4129
|
}
|
|
3712
4130
|
} else if (capacity === void 0) {
|
|
3713
4131
|
rejection = new CloudCleanupError(
|
|
@@ -3735,7 +4153,7 @@ var PostgresCloudCleanup = class {
|
|
|
3735
4153
|
`Replay id ${input.replayMessageId} already binds a different replay delivery.`
|
|
3736
4154
|
);
|
|
3737
4155
|
} else {
|
|
3738
|
-
result =
|
|
4156
|
+
result = toMailboxMessage2(appendWinner);
|
|
3739
4157
|
}
|
|
3740
4158
|
} else {
|
|
3741
4159
|
const rebound = materializeReplayBody(original, seq);
|
|
@@ -3767,7 +4185,7 @@ var PostgresCloudCleanup = class {
|
|
|
3767
4185
|
WHERE tenant_id = $1`,
|
|
3768
4186
|
[tenant, rebound.byteSize, this.#now()]
|
|
3769
4187
|
);
|
|
3770
|
-
result =
|
|
4188
|
+
result = toMailboxMessage2(inserted.rows[0]);
|
|
3771
4189
|
}
|
|
3772
4190
|
}
|
|
3773
4191
|
}
|
|
@@ -3838,7 +4256,7 @@ var PostgresCloudCleanup = class {
|
|
|
3838
4256
|
client.release();
|
|
3839
4257
|
}
|
|
3840
4258
|
if (rejection !== void 0) throw rejection;
|
|
3841
|
-
return
|
|
4259
|
+
return toMailboxMessage2(row);
|
|
3842
4260
|
}
|
|
3843
4261
|
/**
|
|
3844
4262
|
* Explicit recovery operation: rebuild object accounting from committed
|
|
@@ -4372,7 +4790,7 @@ function toCleanupResult(row) {
|
|
|
4372
4790
|
...row.error_message === null ? {} : { errorMessage: row.error_message }
|
|
4373
4791
|
};
|
|
4374
4792
|
}
|
|
4375
|
-
function
|
|
4793
|
+
function toMailboxMessage2(row) {
|
|
4376
4794
|
return {
|
|
4377
4795
|
tenantId: tenantId(row.tenant_id),
|
|
4378
4796
|
deviceId: row.device_id,
|
|
@@ -4463,7 +4881,485 @@ function deadLetterMissing(ref) {
|
|
|
4463
4881
|
`Expired mailbox row ${ref.deviceId}/${String(ref.seq)} was not found.`
|
|
4464
4882
|
);
|
|
4465
4883
|
}
|
|
4884
|
+
var DEFAULT_BATCH_SIZE2 = 100;
|
|
4885
|
+
var DEFAULT_MAX_PAGES_PER_RUN = 10;
|
|
4886
|
+
var DEFAULT_LEASE_MS = 3e4;
|
|
4887
|
+
var MAX_BATCH_SIZE2 = 1e3;
|
|
4888
|
+
var MAX_PAGES_PER_RUN = 100;
|
|
4889
|
+
var MAX_LEASE_MS = 5 * 6e4;
|
|
4890
|
+
var TENANT_ERASURE_TABLES = [
|
|
4891
|
+
"object_reference",
|
|
4892
|
+
"object_manifest",
|
|
4893
|
+
"storage_reservation",
|
|
4894
|
+
"storage_usage",
|
|
4895
|
+
"storage_entitlement",
|
|
4896
|
+
"gc_cursor",
|
|
4897
|
+
"cleanup_job",
|
|
4898
|
+
"tenant_retention_policy",
|
|
4899
|
+
"skill_pack_file",
|
|
4900
|
+
"skill_pack",
|
|
4901
|
+
"approval_timeline_tail",
|
|
4902
|
+
"activity_tail",
|
|
4903
|
+
"attested_record",
|
|
4904
|
+
"board_item",
|
|
4905
|
+
"tenant_stream",
|
|
4906
|
+
"outbox",
|
|
4907
|
+
"agent_egress_event",
|
|
4908
|
+
"device_request_receipts",
|
|
4909
|
+
"proof_request_receipt",
|
|
4910
|
+
"task",
|
|
4911
|
+
"device_presence",
|
|
4912
|
+
"device_assertion_replay",
|
|
4913
|
+
"device_stream",
|
|
4914
|
+
"inbound_dedup",
|
|
4915
|
+
"auth_nonce",
|
|
4916
|
+
"pairing_code",
|
|
4917
|
+
"device"
|
|
4918
|
+
];
|
|
4919
|
+
var TENANT_ERASURE_TABLE_SET = new Set(TENANT_ERASURE_TABLES);
|
|
4920
|
+
var TENANT_ERASURE_ERROR_CODES = {
|
|
4921
|
+
tenant_erasure_invalid_input: "tenant_erasure_invalid_input",
|
|
4922
|
+
tenant_erasure_schema_drift: "tenant_erasure_schema_drift",
|
|
4923
|
+
tenant_erasure_object_key_invalid: "tenant_erasure_object_key_invalid",
|
|
4924
|
+
tenant_erasure_storage_failure: "tenant_erasure_storage_failure",
|
|
4925
|
+
tenant_erasure_database_failure: "tenant_erasure_database_failure",
|
|
4926
|
+
tenant_erasure_cas_lost: "tenant_erasure_cas_lost"
|
|
4927
|
+
};
|
|
4928
|
+
var TenantErasureError = class extends Error {
|
|
4929
|
+
code;
|
|
4930
|
+
constructor(code, message, options) {
|
|
4931
|
+
super(message, options);
|
|
4932
|
+
this.name = "TenantErasureError";
|
|
4933
|
+
this.code = code;
|
|
4934
|
+
}
|
|
4935
|
+
};
|
|
4936
|
+
var OPERATION_COLUMNS = [
|
|
4937
|
+
"tenant_id",
|
|
4938
|
+
"operation_id",
|
|
4939
|
+
"state",
|
|
4940
|
+
"revision",
|
|
4941
|
+
"lease_token",
|
|
4942
|
+
"lease_expires_at",
|
|
4943
|
+
"r2_cursor",
|
|
4944
|
+
"r2_complete",
|
|
4945
|
+
"sql_table_index",
|
|
4946
|
+
"r2_objects_deleted",
|
|
4947
|
+
"sql_rows_deleted",
|
|
4948
|
+
"started_at",
|
|
4949
|
+
"updated_at",
|
|
4950
|
+
"completed_at",
|
|
4951
|
+
"last_error_code"
|
|
4952
|
+
].join(", ");
|
|
4953
|
+
var PostgresTenantErasure = class {
|
|
4954
|
+
#pool;
|
|
4955
|
+
#clock;
|
|
4956
|
+
#objectStorage;
|
|
4957
|
+
#batchSize;
|
|
4958
|
+
#maxPagesPerRun;
|
|
4959
|
+
#leaseMs;
|
|
4960
|
+
constructor(options) {
|
|
4961
|
+
this.#pool = options.pool;
|
|
4962
|
+
this.#clock = options.clock;
|
|
4963
|
+
this.#objectStorage = options.objectStorage;
|
|
4964
|
+
this.#batchSize = assertBoundedWhole(options.batchSize ?? DEFAULT_BATCH_SIZE2, "batchSize", MAX_BATCH_SIZE2);
|
|
4965
|
+
this.#maxPagesPerRun = assertBoundedWhole(
|
|
4966
|
+
options.maxPagesPerRun ?? DEFAULT_MAX_PAGES_PER_RUN,
|
|
4967
|
+
"maxPagesPerRun",
|
|
4968
|
+
MAX_PAGES_PER_RUN
|
|
4969
|
+
);
|
|
4970
|
+
this.#leaseMs = assertBoundedWhole(options.leaseMs ?? DEFAULT_LEASE_MS, "leaseMs", MAX_LEASE_MS);
|
|
4971
|
+
}
|
|
4972
|
+
/** Read a durable operation receipt without advancing it. */
|
|
4973
|
+
async readTenantErasure(tenant, operationId) {
|
|
4974
|
+
assertOperationId(operationId);
|
|
4975
|
+
const row = await this.#readOperation(tenant, operationId);
|
|
4976
|
+
return row === void 0 ? void 0 : toReadback(row);
|
|
4977
|
+
}
|
|
4978
|
+
/**
|
|
4979
|
+
* Advance one bounded operation slice. Calls with a completed id replay its
|
|
4980
|
+
* receipt; another running id for the same tenant gets a typed conflict.
|
|
4981
|
+
*/
|
|
4982
|
+
async eraseTenant(tenant, operationId) {
|
|
4983
|
+
assertOperationId(operationId);
|
|
4984
|
+
let existing;
|
|
4985
|
+
try {
|
|
4986
|
+
existing = await this.#readOperation(tenant, operationId);
|
|
4987
|
+
} catch (cause) {
|
|
4988
|
+
throw databaseFailure("reading tenant erasure operation", cause);
|
|
4989
|
+
}
|
|
4990
|
+
if (existing?.state === "completed") return toReadback(existing);
|
|
4991
|
+
await this.#assertSchemaInventory();
|
|
4992
|
+
let row;
|
|
4993
|
+
try {
|
|
4994
|
+
const opened = await this.#openOperation(tenant, operationId);
|
|
4995
|
+
if ("status" in opened) return opened;
|
|
4996
|
+
row = opened;
|
|
4997
|
+
} catch (cause) {
|
|
4998
|
+
throw databaseFailure("opening tenant erasure operation", cause);
|
|
4999
|
+
}
|
|
5000
|
+
if (row.state === "completed") return toReadback(row);
|
|
5001
|
+
const leaseToken = randomUUID();
|
|
5002
|
+
let claimed;
|
|
5003
|
+
try {
|
|
5004
|
+
claimed = await this.#claim(tenant, operationId, row.revision, leaseToken);
|
|
5005
|
+
if (claimed === void 0) return await this.#readConflictOrReceipt(tenant, operationId);
|
|
5006
|
+
for (let page = 0; page < this.#maxPagesPerRun; page += 1) {
|
|
5007
|
+
if (!claimed.r2_complete) {
|
|
5008
|
+
claimed = await this.#eraseR2Page(tenant, claimed, leaseToken);
|
|
5009
|
+
continue;
|
|
5010
|
+
}
|
|
5011
|
+
if (claimed.sql_table_index < TENANT_ERASURE_TABLES.length) {
|
|
5012
|
+
claimed = await this.#eraseSqlPage(tenant, claimed, leaseToken);
|
|
5013
|
+
continue;
|
|
5014
|
+
}
|
|
5015
|
+
claimed = await this.#verifyAndComplete(tenant, claimed, leaseToken);
|
|
5016
|
+
if (claimed.state === "completed") return toReadback(claimed);
|
|
5017
|
+
}
|
|
5018
|
+
return toReadback(await this.#release(tenant, operationId, claimed.revision, leaseToken));
|
|
5019
|
+
} catch (cause) {
|
|
5020
|
+
if (claimed === void 0) throw databaseFailure("claiming tenant erasure operation", cause);
|
|
5021
|
+
const code = cause instanceof TenantErasureError ? cause.code : TENANT_ERASURE_ERROR_CODES.tenant_erasure_database_failure;
|
|
5022
|
+
try {
|
|
5023
|
+
return toReadback(await this.#recordPartial(tenant, operationId, claimed.revision, leaseToken, code));
|
|
5024
|
+
} catch (recordCause) {
|
|
5025
|
+
throw databaseFailure("recording tenant erasure partial outcome", recordCause);
|
|
5026
|
+
}
|
|
5027
|
+
}
|
|
5028
|
+
}
|
|
5029
|
+
async #assertSchemaInventory() {
|
|
5030
|
+
const found = await this.#pool.query(
|
|
5031
|
+
`SELECT t.relname
|
|
5032
|
+
FROM pg_class t
|
|
5033
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
5034
|
+
WHERE n.nspname = current_schema()
|
|
5035
|
+
AND t.relkind = 'r'
|
|
5036
|
+
AND t.relname NOT IN ('byok_schema_migration', 'tenant_erasure_operation')
|
|
5037
|
+
ORDER BY t.relname`
|
|
5038
|
+
);
|
|
5039
|
+
const actual = new Set(found.rows.map((row) => row.relname));
|
|
5040
|
+
const missing = TENANT_ERASURE_TABLES.filter((name) => !actual.has(name));
|
|
5041
|
+
const unexpected = [...actual].filter((name) => !TENANT_ERASURE_TABLE_SET.has(name));
|
|
5042
|
+
if (missing.length === 0 && unexpected.length === 0 && actual.size === TENANT_ERASURE_TABLES.length) return;
|
|
5043
|
+
throw new TenantErasureError(
|
|
5044
|
+
"tenant_erasure_schema_drift",
|
|
5045
|
+
`Tenant erasure inventory drift: missing=[${missing.join(",")}], unexpected=[${unexpected.join(",")}].`
|
|
5046
|
+
);
|
|
5047
|
+
}
|
|
5048
|
+
async #openOperation(tenant, operationId) {
|
|
5049
|
+
const now = this.#clock.now();
|
|
5050
|
+
try {
|
|
5051
|
+
await this.#pool.query(
|
|
5052
|
+
`INSERT INTO tenant_erasure_operation (
|
|
5053
|
+
tenant_id, operation_id, state, started_at, updated_at
|
|
5054
|
+
) VALUES ($1, $2, 'running', $3, $3)
|
|
5055
|
+
ON CONFLICT (tenant_id, operation_id) DO NOTHING`,
|
|
5056
|
+
[tenant, operationId, now]
|
|
5057
|
+
);
|
|
5058
|
+
} catch (cause) {
|
|
5059
|
+
if (postgresCode(cause) !== "23505") throw cause;
|
|
5060
|
+
return this.#conflict(tenant, operationId);
|
|
5061
|
+
}
|
|
5062
|
+
const own = await this.#readOperation(tenant, operationId);
|
|
5063
|
+
if (own !== void 0) return own;
|
|
5064
|
+
return this.#conflict(tenant, operationId);
|
|
5065
|
+
}
|
|
5066
|
+
async #claim(tenant, operationId, revision, leaseToken) {
|
|
5067
|
+
const now = this.#clock.now();
|
|
5068
|
+
const leaseExpiresAt = new Date(now.getTime() + this.#leaseMs);
|
|
5069
|
+
const result = await this.#pool.query(
|
|
5070
|
+
`UPDATE tenant_erasure_operation
|
|
5071
|
+
SET lease_token = $1,
|
|
5072
|
+
lease_expires_at = $2,
|
|
5073
|
+
revision = revision + 1,
|
|
5074
|
+
updated_at = $3,
|
|
5075
|
+
last_error_code = NULL
|
|
5076
|
+
WHERE tenant_id = $4
|
|
5077
|
+
AND operation_id = $5
|
|
5078
|
+
AND state = 'running'
|
|
5079
|
+
AND revision = $6::bigint
|
|
5080
|
+
AND (lease_token IS NULL OR lease_expires_at <= $3)
|
|
5081
|
+
RETURNING ${OPERATION_COLUMNS}`,
|
|
5082
|
+
[leaseToken, leaseExpiresAt, now, tenant, operationId, revision]
|
|
5083
|
+
);
|
|
5084
|
+
return result.rows[0];
|
|
5085
|
+
}
|
|
5086
|
+
async #eraseR2Page(tenant, row, leaseToken) {
|
|
5087
|
+
let page;
|
|
5088
|
+
try {
|
|
5089
|
+
page = await this.#objectStorage.listTenantObjects(
|
|
5090
|
+
tenant,
|
|
5091
|
+
row.r2_cursor ?? void 0,
|
|
5092
|
+
this.#batchSize
|
|
5093
|
+
);
|
|
5094
|
+
} catch (cause) {
|
|
5095
|
+
throw storageFailure("listing the tenant R2 namespace", cause);
|
|
5096
|
+
}
|
|
5097
|
+
for (const object of page.objects) {
|
|
5098
|
+
if (object.hash === void 0) {
|
|
5099
|
+
throw new TenantErasureError(
|
|
5100
|
+
"tenant_erasure_object_key_invalid",
|
|
5101
|
+
"Tenant R2 namespace contains a non-canonical object key; erasure refused before SQL deletion."
|
|
5102
|
+
);
|
|
5103
|
+
}
|
|
5104
|
+
try {
|
|
5105
|
+
await this.#objectStorage.deleteObject(tenant, object.hash);
|
|
5106
|
+
} catch (cause) {
|
|
5107
|
+
throw storageFailure("deleting a tenant R2 object", cause);
|
|
5108
|
+
}
|
|
5109
|
+
}
|
|
5110
|
+
return this.#casUpdate(
|
|
5111
|
+
tenant,
|
|
5112
|
+
row.operation_id,
|
|
5113
|
+
row.revision,
|
|
5114
|
+
leaseToken,
|
|
5115
|
+
`r2_cursor = $1,
|
|
5116
|
+
r2_complete = $2,
|
|
5117
|
+
r2_objects_deleted = r2_objects_deleted + $3::bigint`,
|
|
5118
|
+
[page.nextContinuationToken ?? null, page.nextContinuationToken === void 0, page.objects.length]
|
|
5119
|
+
);
|
|
5120
|
+
}
|
|
5121
|
+
async #eraseSqlPage(tenant, row, leaseToken) {
|
|
5122
|
+
const table = TENANT_ERASURE_TABLES[row.sql_table_index];
|
|
5123
|
+
if (table === void 0) {
|
|
5124
|
+
throw new TenantErasureError("tenant_erasure_cas_lost", "Tenant erasure SQL progress escaped its static inventory.");
|
|
5125
|
+
}
|
|
5126
|
+
let deleted;
|
|
5127
|
+
try {
|
|
5128
|
+
const result = await this.#pool.query(
|
|
5129
|
+
`WITH deleted AS (
|
|
5130
|
+
DELETE FROM ${table}
|
|
5131
|
+
WHERE ctid IN (
|
|
5132
|
+
SELECT ctid FROM ${table}
|
|
5133
|
+
WHERE tenant_id = $1
|
|
5134
|
+
LIMIT $2
|
|
5135
|
+
)
|
|
5136
|
+
RETURNING 1
|
|
5137
|
+
)
|
|
5138
|
+
SELECT count(*)::bigint AS deleted FROM deleted`,
|
|
5139
|
+
[tenant, this.#batchSize]
|
|
5140
|
+
);
|
|
5141
|
+
deleted = result.rows[0].deleted;
|
|
5142
|
+
} catch (cause) {
|
|
5143
|
+
throw databaseFailure(`deleting tenant rows from ${table}`, cause);
|
|
5144
|
+
}
|
|
5145
|
+
const nextTableIndex = deleted < BigInt(this.#batchSize) ? row.sql_table_index + 1 : row.sql_table_index;
|
|
5146
|
+
return this.#casUpdate(
|
|
5147
|
+
tenant,
|
|
5148
|
+
row.operation_id,
|
|
5149
|
+
row.revision,
|
|
5150
|
+
leaseToken,
|
|
5151
|
+
`sql_table_index = $1,
|
|
5152
|
+
sql_rows_deleted = sql_rows_deleted + $2::bigint`,
|
|
5153
|
+
[nextTableIndex, deleted]
|
|
5154
|
+
);
|
|
5155
|
+
}
|
|
5156
|
+
async #verifyAndComplete(tenant, row, leaseToken) {
|
|
5157
|
+
try {
|
|
5158
|
+
const r2 = await this.#objectStorage.listTenantObjects(tenant, void 0, 1);
|
|
5159
|
+
if (r2.objects.length > 0) {
|
|
5160
|
+
return this.#casUpdate(
|
|
5161
|
+
tenant,
|
|
5162
|
+
row.operation_id,
|
|
5163
|
+
row.revision,
|
|
5164
|
+
leaseToken,
|
|
5165
|
+
"r2_cursor = NULL, r2_complete = false",
|
|
5166
|
+
[]
|
|
5167
|
+
);
|
|
5168
|
+
}
|
|
5169
|
+
} catch (cause) {
|
|
5170
|
+
throw storageFailure("verifying the tenant R2 namespace is empty", cause);
|
|
5171
|
+
}
|
|
5172
|
+
for (const table of TENANT_ERASURE_TABLES) {
|
|
5173
|
+
let present;
|
|
5174
|
+
try {
|
|
5175
|
+
const result = await this.#pool.query(
|
|
5176
|
+
`SELECT EXISTS (SELECT 1 FROM ${table} WHERE tenant_id = $1) AS present`,
|
|
5177
|
+
[tenant]
|
|
5178
|
+
);
|
|
5179
|
+
present = result.rows[0].present;
|
|
5180
|
+
} catch (cause) {
|
|
5181
|
+
throw databaseFailure(`verifying tenant rows in ${table}`, cause);
|
|
5182
|
+
}
|
|
5183
|
+
if (present) {
|
|
5184
|
+
return this.#casUpdate(
|
|
5185
|
+
tenant,
|
|
5186
|
+
row.operation_id,
|
|
5187
|
+
row.revision,
|
|
5188
|
+
leaseToken,
|
|
5189
|
+
"sql_table_index = 0",
|
|
5190
|
+
[]
|
|
5191
|
+
);
|
|
5192
|
+
}
|
|
5193
|
+
}
|
|
5194
|
+
const now = this.#clock.now();
|
|
5195
|
+
return this.#casUpdate(
|
|
5196
|
+
tenant,
|
|
5197
|
+
row.operation_id,
|
|
5198
|
+
row.revision,
|
|
5199
|
+
leaseToken,
|
|
5200
|
+
`state = 'completed',
|
|
5201
|
+
completed_at = $1,
|
|
5202
|
+
lease_token = NULL,
|
|
5203
|
+
lease_expires_at = NULL,
|
|
5204
|
+
last_error_code = NULL`,
|
|
5205
|
+
[now],
|
|
5206
|
+
false
|
|
5207
|
+
);
|
|
5208
|
+
}
|
|
5209
|
+
async #release(tenant, operationId, revision, leaseToken) {
|
|
5210
|
+
return this.#casUpdate(
|
|
5211
|
+
tenant,
|
|
5212
|
+
operationId,
|
|
5213
|
+
revision,
|
|
5214
|
+
leaseToken,
|
|
5215
|
+
"lease_token = NULL, lease_expires_at = NULL",
|
|
5216
|
+
[],
|
|
5217
|
+
false
|
|
5218
|
+
);
|
|
5219
|
+
}
|
|
5220
|
+
async #recordPartial(tenant, operationId, revision, leaseToken, errorCode) {
|
|
5221
|
+
return this.#casUpdate(
|
|
5222
|
+
tenant,
|
|
5223
|
+
operationId,
|
|
5224
|
+
revision,
|
|
5225
|
+
leaseToken,
|
|
5226
|
+
`lease_token = NULL,
|
|
5227
|
+
lease_expires_at = NULL,
|
|
5228
|
+
last_error_code = $1`,
|
|
5229
|
+
[errorCode],
|
|
5230
|
+
false
|
|
5231
|
+
);
|
|
5232
|
+
}
|
|
5233
|
+
async #casUpdate(tenant, operationId, revision, leaseToken, setClause, setValues, refreshLease = true) {
|
|
5234
|
+
const now = this.#clock.now();
|
|
5235
|
+
const values = [...setValues];
|
|
5236
|
+
let refreshClause = "";
|
|
5237
|
+
if (refreshLease) {
|
|
5238
|
+
values.push(new Date(now.getTime() + this.#leaseMs));
|
|
5239
|
+
refreshClause = `, lease_expires_at = $${String(values.length)}`;
|
|
5240
|
+
}
|
|
5241
|
+
values.push(now, tenant, operationId, revision, leaseToken);
|
|
5242
|
+
const updatedAtIndex = values.length - 4;
|
|
5243
|
+
const tenantIndex = values.length - 3;
|
|
5244
|
+
const operationIndex = values.length - 2;
|
|
5245
|
+
const revisionIndex = values.length - 1;
|
|
5246
|
+
const leaseIndex = values.length;
|
|
5247
|
+
const result = await this.#pool.query(
|
|
5248
|
+
`UPDATE tenant_erasure_operation
|
|
5249
|
+
SET ${setClause}${refreshClause},
|
|
5250
|
+
revision = revision + 1,
|
|
5251
|
+
updated_at = $${String(updatedAtIndex)}
|
|
5252
|
+
WHERE tenant_id = $${String(tenantIndex)}
|
|
5253
|
+
AND operation_id = $${String(operationIndex)}
|
|
5254
|
+
AND state = 'running'
|
|
5255
|
+
AND revision = $${String(revisionIndex)}::bigint
|
|
5256
|
+
AND lease_token = $${String(leaseIndex)}
|
|
5257
|
+
RETURNING ${OPERATION_COLUMNS}`,
|
|
5258
|
+
values
|
|
5259
|
+
);
|
|
5260
|
+
const row = result.rows[0];
|
|
5261
|
+
if (row !== void 0) return row;
|
|
5262
|
+
throw new TenantErasureError(
|
|
5263
|
+
"tenant_erasure_cas_lost",
|
|
5264
|
+
"Tenant erasure operation no longer owns its durable progress lease."
|
|
5265
|
+
);
|
|
5266
|
+
}
|
|
5267
|
+
async #readOperation(tenant, operationId) {
|
|
5268
|
+
const result = await this.#pool.query(
|
|
5269
|
+
`SELECT ${OPERATION_COLUMNS}
|
|
5270
|
+
FROM tenant_erasure_operation
|
|
5271
|
+
WHERE tenant_id = $1 AND operation_id = $2`,
|
|
5272
|
+
[tenant, operationId]
|
|
5273
|
+
);
|
|
5274
|
+
return result.rows[0];
|
|
5275
|
+
}
|
|
5276
|
+
async #readConflictOrReceipt(tenant, operationId) {
|
|
5277
|
+
const own = await this.#readOperation(tenant, operationId);
|
|
5278
|
+
if (own?.state === "completed") return toReadback(own);
|
|
5279
|
+
if (own !== void 0 && own.lease_token !== null && own.lease_expires_at !== null && own.lease_expires_at > this.#clock.now()) {
|
|
5280
|
+
return { status: "conflict", tenantId: tenant, operationId, activeOperationId: own.operation_id };
|
|
5281
|
+
}
|
|
5282
|
+
if (own !== void 0) return toReadback(own);
|
|
5283
|
+
return this.#conflict(tenant, operationId);
|
|
5284
|
+
}
|
|
5285
|
+
async #conflict(tenant, operationId) {
|
|
5286
|
+
const active = await this.#pool.query(
|
|
5287
|
+
`SELECT operation_id
|
|
5288
|
+
FROM tenant_erasure_operation
|
|
5289
|
+
WHERE tenant_id = $1 AND state = 'running'`,
|
|
5290
|
+
[tenant]
|
|
5291
|
+
);
|
|
5292
|
+
const winner = active.rows[0];
|
|
5293
|
+
if (winner === void 0) {
|
|
5294
|
+
throw new TenantErasureError(
|
|
5295
|
+
"tenant_erasure_cas_lost",
|
|
5296
|
+
"Tenant erasure operation changed while acquiring its receipt; retry with the same operation id."
|
|
5297
|
+
);
|
|
5298
|
+
}
|
|
5299
|
+
return { status: "conflict", tenantId: tenant, operationId, activeOperationId: winner.operation_id };
|
|
5300
|
+
}
|
|
5301
|
+
};
|
|
5302
|
+
function createPostgresTenantErasure(options) {
|
|
5303
|
+
return new PostgresTenantErasure({
|
|
5304
|
+
pool: options.pool,
|
|
5305
|
+
clock: options.clock,
|
|
5306
|
+
objectStorage: new R2ObjectMaintenanceStore(options.objectStorage),
|
|
5307
|
+
...options.batchSize === void 0 ? {} : { batchSize: options.batchSize },
|
|
5308
|
+
...options.maxPagesPerRun === void 0 ? {} : { maxPagesPerRun: options.maxPagesPerRun },
|
|
5309
|
+
...options.leaseMs === void 0 ? {} : { leaseMs: options.leaseMs }
|
|
5310
|
+
});
|
|
5311
|
+
}
|
|
5312
|
+
function toReadback(row) {
|
|
5313
|
+
const status = row.state === "completed" ? "completed" : row.last_error_code === null ? "outstanding" : "partial";
|
|
5314
|
+
return {
|
|
5315
|
+
status,
|
|
5316
|
+
tenantId: tenantId(row.tenant_id),
|
|
5317
|
+
operationId: row.operation_id,
|
|
5318
|
+
startedAt: row.started_at.toISOString(),
|
|
5319
|
+
updatedAt: row.updated_at.toISOString(),
|
|
5320
|
+
...row.completed_at === null ? {} : { completedAt: row.completed_at.toISOString() },
|
|
5321
|
+
r2Complete: row.r2_complete,
|
|
5322
|
+
sqlTableIndex: row.sql_table_index,
|
|
5323
|
+
r2ObjectsDeleted: row.r2_objects_deleted,
|
|
5324
|
+
sqlRowsDeleted: row.sql_rows_deleted,
|
|
5325
|
+
...row.last_error_code === null ? {} : { errorCode: row.last_error_code }
|
|
5326
|
+
};
|
|
5327
|
+
}
|
|
5328
|
+
function assertOperationId(operationId) {
|
|
5329
|
+
if (operationId.length === 0 || operationId.length > 256 || operationId.trim() !== operationId) {
|
|
5330
|
+
throw new TenantErasureError(
|
|
5331
|
+
"tenant_erasure_invalid_input",
|
|
5332
|
+
"operationId must be a non-empty, unpadded string no longer than 256 characters."
|
|
5333
|
+
);
|
|
5334
|
+
}
|
|
5335
|
+
}
|
|
5336
|
+
function assertBoundedWhole(value, field, max) {
|
|
5337
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > max) {
|
|
5338
|
+
throw new TenantErasureError(
|
|
5339
|
+
"tenant_erasure_invalid_input",
|
|
5340
|
+
`${field} must be a whole number in [1, ${String(max)}].`
|
|
5341
|
+
);
|
|
5342
|
+
}
|
|
5343
|
+
return value;
|
|
5344
|
+
}
|
|
5345
|
+
function postgresCode(cause) {
|
|
5346
|
+
return typeof cause === "object" && cause !== null && "code" in cause && typeof cause.code === "string" ? cause.code : void 0;
|
|
5347
|
+
}
|
|
5348
|
+
function storageFailure(action, cause) {
|
|
5349
|
+
return new TenantErasureError(
|
|
5350
|
+
"tenant_erasure_storage_failure",
|
|
5351
|
+
`Tenant erasure could not finish ${action}; no SQL progress was advanced.`,
|
|
5352
|
+
{ cause }
|
|
5353
|
+
);
|
|
5354
|
+
}
|
|
5355
|
+
function databaseFailure(action, cause) {
|
|
5356
|
+
return cause instanceof TenantErasureError ? cause : new TenantErasureError(
|
|
5357
|
+
"tenant_erasure_database_failure",
|
|
5358
|
+
`Tenant erasure could not finish ${action}; retry with the same operation id.`,
|
|
5359
|
+
{ cause }
|
|
5360
|
+
);
|
|
5361
|
+
}
|
|
4466
5362
|
|
|
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 };
|
|
5363
|
+
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
5364
|
//# sourceMappingURL=index.js.map
|
|
4469
5365
|
//# sourceMappingURL=index.js.map
|