@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/runtime.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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, SKILL_PACK_MANIFEST_SCHEMA_ID } from '@byok-sdk/core';
|
|
4
4
|
import { AwsClient } from 'aws4fetch';
|
|
5
5
|
import { XMLParser } from 'fast-xml-parser';
|
|
6
6
|
|
|
@@ -591,7 +591,7 @@ var R2ObjectMaintenanceStore = class {
|
|
|
591
591
|
`A ListObjectsV2 page limit of ${String(limit)} is not a whole number in [1, 1000].`
|
|
592
592
|
);
|
|
593
593
|
}
|
|
594
|
-
const prefix =
|
|
594
|
+
const prefix = tenantObjectKey(tenant, LIST_PREFIX_HASH, this.#keyPrefix).slice(0, -LIST_HASH_HEX_LENGTH);
|
|
595
595
|
const url = new URL(`${this.#origin}/${this.#bucket}`);
|
|
596
596
|
url.searchParams.set("list-type", "2");
|
|
597
597
|
url.searchParams.set("prefix", prefix);
|
|
@@ -775,6 +775,8 @@ function parseListObjectsV2(xml, prefix, attempts) {
|
|
|
775
775
|
return { objects, nextContinuationToken };
|
|
776
776
|
}
|
|
777
777
|
var HASH_KEY_SUFFIX = /^[0-9a-f]{64}$/;
|
|
778
|
+
var LIST_HASH_HEX_LENGTH = 64;
|
|
779
|
+
var LIST_PREFIX_HASH = contentHash(`sha256:${"0".repeat(LIST_HASH_HEX_LENGTH)}`);
|
|
778
780
|
function asRecord(value) {
|
|
779
781
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
780
782
|
}
|
|
@@ -786,8 +788,6 @@ function requiredText(record, field, attempts) {
|
|
|
786
788
|
attempts
|
|
787
789
|
);
|
|
788
790
|
}
|
|
789
|
-
|
|
790
|
-
// src/stores/devices.ts
|
|
791
791
|
function toRecord(row) {
|
|
792
792
|
return {
|
|
793
793
|
tenantId: row.tenant_id,
|
|
@@ -803,8 +803,10 @@ function toRecord(row) {
|
|
|
803
803
|
var SELECT_COLUMNS = "tenant_id, device_id, product_id, device_name, device_public_key, proof_key_id, proof_key_epoch, revoked";
|
|
804
804
|
var PostgresDeviceDirectory = class {
|
|
805
805
|
#pool;
|
|
806
|
-
|
|
806
|
+
#clock;
|
|
807
|
+
constructor(pool, clock) {
|
|
807
808
|
this.#pool = pool;
|
|
809
|
+
this.#clock = clock;
|
|
808
810
|
}
|
|
809
811
|
async register(tenant, input) {
|
|
810
812
|
const result = await this.#pool.query(
|
|
@@ -854,6 +856,73 @@ var PostgresDeviceDirectory = class {
|
|
|
854
856
|
);
|
|
855
857
|
return result.rows.map(toRecord);
|
|
856
858
|
}
|
|
859
|
+
async readiness(tenant, _presence) {
|
|
860
|
+
const result = await this.#pool.query(
|
|
861
|
+
`SELECT
|
|
862
|
+
d.device_id,
|
|
863
|
+
d.product_id,
|
|
864
|
+
d.device_name,
|
|
865
|
+
d.revoked,
|
|
866
|
+
CASE WHEN NOT d.revoked THEN p.level END AS presence_level,
|
|
867
|
+
CASE WHEN NOT d.revoked THEN p.detail END AS presence_detail,
|
|
868
|
+
CASE WHEN NOT d.revoked THEN p.configured_toolsets END AS presence_configured_toolsets,
|
|
869
|
+
CASE WHEN NOT d.revoked THEN p.client_version END AS presence_client_version,
|
|
870
|
+
CASE WHEN NOT d.revoked THEN p.protocol_versions END AS presence_protocol_versions,
|
|
871
|
+
CASE WHEN NOT d.revoked THEN p.runtimes END AS presence_runtimes,
|
|
872
|
+
CASE WHEN NOT d.revoked THEN p.observed_at END AS presence_observed_at,
|
|
873
|
+
CASE WHEN NOT d.revoked THEN p.expires_at END AS presence_expires_at,
|
|
874
|
+
(COUNT(*) FILTER (WHERE NOT d.revoked) OVER ())::int AS active_paired_device_count,
|
|
875
|
+
(COUNT(*) FILTER (WHERE d.revoked) OVER ())::int AS revoked_device_count,
|
|
876
|
+
(COUNT(*) FILTER (WHERE NOT d.revoked AND p.device_id IS NOT NULL) OVER ())::int AS observed_presence_count,
|
|
877
|
+
(COUNT(*) FILTER (WHERE NOT d.revoked AND p.level = 'online') OVER ())::int AS observed_online_count,
|
|
878
|
+
(COUNT(*) FILTER (WHERE NOT d.revoked AND p.level = 'thinking') OVER ())::int AS observed_thinking_count,
|
|
879
|
+
(COUNT(*) FILTER (WHERE NOT d.revoked AND p.level = 'working') OVER ())::int AS observed_working_count,
|
|
880
|
+
(COUNT(*) FILTER (WHERE NOT d.revoked AND p.level = 'error') OVER ())::int AS observed_error_count,
|
|
881
|
+
(COUNT(*) FILTER (WHERE NOT d.revoked AND p.level = 'offline') OVER ())::int AS observed_offline_count
|
|
882
|
+
FROM device d
|
|
883
|
+
LEFT JOIN device_presence p
|
|
884
|
+
ON p.tenant_id = d.tenant_id
|
|
885
|
+
AND p.device_id = d.device_id
|
|
886
|
+
AND p.expires_at > $2
|
|
887
|
+
WHERE d.tenant_id = $1
|
|
888
|
+
ORDER BY d.device_id`,
|
|
889
|
+
[tenant, this.#clock?.now().toISOString() ?? (/* @__PURE__ */ new Date()).toISOString()]
|
|
890
|
+
);
|
|
891
|
+
const row = result.rows[0];
|
|
892
|
+
const count = (value) => Number(value);
|
|
893
|
+
const devices = result.rows.map((device) => ({
|
|
894
|
+
deviceId: device.device_id,
|
|
895
|
+
productId: device.product_id,
|
|
896
|
+
deviceName: device.device_name,
|
|
897
|
+
revoked: device.revoked,
|
|
898
|
+
...device.presence_level === null ? {} : {
|
|
899
|
+
presence: {
|
|
900
|
+
level: device.presence_level,
|
|
901
|
+
...device.presence_detail === null ? {} : { detail: device.presence_detail },
|
|
902
|
+
...device.presence_configured_toolsets === null ? {} : { configuredToolsets: Object.freeze([...device.presence_configured_toolsets]) },
|
|
903
|
+
...device.presence_client_version === null ? {} : { clientVersion: device.presence_client_version },
|
|
904
|
+
...device.presence_protocol_versions === null ? {} : { protocolVersions: Object.freeze([...device.presence_protocol_versions]) },
|
|
905
|
+
...device.presence_runtimes === null ? {} : { runtimes: Object.freeze(device.presence_runtimes.map((runtime) => Object.freeze({ ...runtime }))) },
|
|
906
|
+
observedAt: device.presence_observed_at,
|
|
907
|
+
expiresAt: device.presence_expires_at
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}));
|
|
911
|
+
return {
|
|
912
|
+
tenantId: tenant,
|
|
913
|
+
activePairedDeviceCount: count(row?.active_paired_device_count ?? 0),
|
|
914
|
+
revokedDeviceCount: count(row?.revoked_device_count ?? 0),
|
|
915
|
+
observedPresenceCount: count(row?.observed_presence_count ?? 0),
|
|
916
|
+
observedPresenceByLevel: {
|
|
917
|
+
online: count(row?.observed_online_count ?? 0),
|
|
918
|
+
thinking: count(row?.observed_thinking_count ?? 0),
|
|
919
|
+
working: count(row?.observed_working_count ?? 0),
|
|
920
|
+
error: count(row?.observed_error_count ?? 0),
|
|
921
|
+
offline: count(row?.observed_offline_count ?? 0)
|
|
922
|
+
},
|
|
923
|
+
devices
|
|
924
|
+
};
|
|
925
|
+
}
|
|
857
926
|
async resolveByDeviceId(deviceId) {
|
|
858
927
|
const result = await this.#pool.query(
|
|
859
928
|
`SELECT ${SELECT_COLUMNS} FROM device WHERE device_id = $1`,
|
|
@@ -1077,8 +1146,8 @@ var PostgresProofRequestReceiptStore = class {
|
|
|
1077
1146
|
};
|
|
1078
1147
|
|
|
1079
1148
|
// src/stores/task-attempts.ts
|
|
1080
|
-
var
|
|
1081
|
-
function
|
|
1149
|
+
var TASK_SELECT_COLUMNS = "tenant_id, task_id, device_id, owner_device_id, status, cancel_requested_at, cancel_reason, cancel_message_id, updated_at";
|
|
1150
|
+
function taskRowToAttempt(row) {
|
|
1082
1151
|
return {
|
|
1083
1152
|
tenantId: row.tenant_id,
|
|
1084
1153
|
taskId: row.task_id,
|
|
@@ -1089,6 +1158,12 @@ function toAttempt(row) {
|
|
|
1089
1158
|
// former.
|
|
1090
1159
|
...row.owner_device_id === null ? {} : { ownerDeviceId: row.owner_device_id },
|
|
1091
1160
|
status: row.status,
|
|
1161
|
+
...row.cancel_requested_at === null ? {} : {
|
|
1162
|
+
cancellation: {
|
|
1163
|
+
requestedAt: row.cancel_requested_at.toISOString(),
|
|
1164
|
+
...row.cancel_reason === null ? {} : { reason: row.cancel_reason }
|
|
1165
|
+
}
|
|
1166
|
+
},
|
|
1092
1167
|
updatedAt: row.updated_at.toISOString()
|
|
1093
1168
|
};
|
|
1094
1169
|
}
|
|
@@ -1104,33 +1179,42 @@ var PostgresTaskAttemptStore = class {
|
|
|
1104
1179
|
`INSERT INTO task (tenant_id, task_id, device_id, owner_device_id, status, updated_at)
|
|
1105
1180
|
VALUES ($1, $2, $3, NULL, 'offered', $4)
|
|
1106
1181
|
ON CONFLICT (tenant_id, task_id) DO NOTHING
|
|
1107
|
-
RETURNING ${
|
|
1182
|
+
RETURNING ${TASK_SELECT_COLUMNS}`,
|
|
1108
1183
|
[tenant, input.taskId, input.deviceId, this.#now()]
|
|
1109
1184
|
);
|
|
1110
1185
|
const created = inserted.rows[0];
|
|
1111
|
-
if (created !== void 0) return
|
|
1186
|
+
if (created !== void 0) return taskRowToAttempt(created);
|
|
1112
1187
|
const existing = await this.get(tenant, input.taskId);
|
|
1113
1188
|
if (existing === void 0) throw new Error(`task ${input.taskId} vanished during open`);
|
|
1114
1189
|
return existing;
|
|
1115
1190
|
}
|
|
1116
1191
|
async get(tenant, taskId) {
|
|
1117
1192
|
const result = await this.#pool.query(
|
|
1118
|
-
`SELECT ${
|
|
1193
|
+
`SELECT ${TASK_SELECT_COLUMNS} FROM task WHERE tenant_id = $1 AND task_id = $2`,
|
|
1119
1194
|
[tenant, taskId]
|
|
1120
1195
|
);
|
|
1121
1196
|
const row = result.rows[0];
|
|
1122
|
-
return row === void 0 ? void 0 :
|
|
1197
|
+
return row === void 0 ? void 0 : taskRowToAttempt(row);
|
|
1198
|
+
}
|
|
1199
|
+
async getMany(tenant, taskIds) {
|
|
1200
|
+
if (taskIds.length === 0) return [];
|
|
1201
|
+
const result = await this.#pool.query(
|
|
1202
|
+
`SELECT ${TASK_SELECT_COLUMNS} FROM task WHERE tenant_id = $1 AND task_id = ANY($2::text[])`,
|
|
1203
|
+
[tenant, [...new Set(taskIds)]]
|
|
1204
|
+
);
|
|
1205
|
+
return result.rows.map(taskRowToAttempt);
|
|
1123
1206
|
}
|
|
1124
1207
|
async claim(tenant, input) {
|
|
1125
1208
|
const claimed = await this.#pool.query(
|
|
1126
1209
|
`UPDATE task
|
|
1127
1210
|
SET owner_device_id = $3, status = 'claimed', updated_at = $4
|
|
1128
1211
|
WHERE tenant_id = $1 AND task_id = $2 AND owner_device_id IS NULL
|
|
1129
|
-
|
|
1212
|
+
AND cancel_requested_at IS NULL AND status = 'offered'
|
|
1213
|
+
RETURNING ${TASK_SELECT_COLUMNS}`,
|
|
1130
1214
|
[tenant, input.taskId, input.deviceId, this.#now()]
|
|
1131
1215
|
);
|
|
1132
1216
|
const won = claimed.rows[0];
|
|
1133
|
-
if (won !== void 0) return
|
|
1217
|
+
if (won !== void 0) return taskRowToAttempt(won);
|
|
1134
1218
|
return this.get(tenant, input.taskId);
|
|
1135
1219
|
}
|
|
1136
1220
|
async recordStatus(tenant, input) {
|
|
@@ -1138,16 +1222,336 @@ var PostgresTaskAttemptStore = class {
|
|
|
1138
1222
|
`UPDATE task
|
|
1139
1223
|
SET status = $3, updated_at = $4
|
|
1140
1224
|
WHERE tenant_id = $1 AND task_id = $2
|
|
1141
|
-
|
|
1225
|
+
AND (
|
|
1226
|
+
(cancel_requested_at IS NULL AND status NOT IN ('complete', 'failed', 'cancelled'))
|
|
1227
|
+
OR (cancel_requested_at IS NOT NULL AND $3 = 'cancelled' AND status <> 'cancelled')
|
|
1228
|
+
)
|
|
1229
|
+
RETURNING ${TASK_SELECT_COLUMNS}`,
|
|
1142
1230
|
[tenant, input.taskId, input.status, this.#now()]
|
|
1143
1231
|
);
|
|
1144
1232
|
const row = result.rows[0];
|
|
1145
|
-
return row === void 0 ?
|
|
1233
|
+
return row === void 0 ? this.get(tenant, input.taskId) : taskRowToAttempt(row);
|
|
1234
|
+
}
|
|
1235
|
+
#now() {
|
|
1236
|
+
return this.#clock.now().toISOString();
|
|
1237
|
+
}
|
|
1238
|
+
};
|
|
1239
|
+
|
|
1240
|
+
// src/stores/core/mailbox-sequence.ts
|
|
1241
|
+
async function allocateMailboxSequence(client, tenant, deviceId, now) {
|
|
1242
|
+
const allocation = await client.query(
|
|
1243
|
+
`INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
|
|
1244
|
+
VALUES ($1, $2, 2, 0, $3)
|
|
1245
|
+
ON CONFLICT (tenant_id, device_id) DO UPDATE
|
|
1246
|
+
SET next_seq = device_stream.next_seq + 1
|
|
1247
|
+
RETURNING next_seq - 1 AS seq`,
|
|
1248
|
+
[tenant, deviceId, now]
|
|
1249
|
+
);
|
|
1250
|
+
return Number(allocation.rows[0].seq);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// src/stores/core/mailbox.ts
|
|
1254
|
+
var DEFAULT_READ_LIMIT = 50;
|
|
1255
|
+
var OUTBOX_COLUMNS = "tenant_id, device_id, seq, message_id, body, body_hash, byte_size, state, appended_at";
|
|
1256
|
+
function toMailboxMessage(row) {
|
|
1257
|
+
return {
|
|
1258
|
+
tenantId: row.tenant_id,
|
|
1259
|
+
deviceId: row.device_id,
|
|
1260
|
+
// `seq` is bigint in the column and `number` on the port, because it is the
|
|
1261
|
+
// envelope `seq` on the wire. The column is wide so the counter cannot wrap
|
|
1262
|
+
// into a redelivery bug; the narrowing happens once, here.
|
|
1263
|
+
seq: Number(row.seq),
|
|
1264
|
+
messageId: row.message_id,
|
|
1265
|
+
body: row.body,
|
|
1266
|
+
bodyHash: row.body_hash,
|
|
1267
|
+
byteSize: row.byte_size,
|
|
1268
|
+
state: row.state,
|
|
1269
|
+
appendedAt: row.appended_at
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
var PostgresMailboxStore = class {
|
|
1273
|
+
#pool;
|
|
1274
|
+
#clock;
|
|
1275
|
+
constructor(pool, clock) {
|
|
1276
|
+
this.#pool = pool;
|
|
1277
|
+
this.#clock = clock;
|
|
1278
|
+
}
|
|
1279
|
+
async append(tenant, input) {
|
|
1280
|
+
this.#requireDeviceId(input.deviceId);
|
|
1281
|
+
const client = await this.#pool.connect();
|
|
1282
|
+
try {
|
|
1283
|
+
await client.query("BEGIN");
|
|
1284
|
+
const existing = await client.query(
|
|
1285
|
+
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1286
|
+
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
1287
|
+
[tenant, input.deviceId, input.messageId]
|
|
1288
|
+
);
|
|
1289
|
+
const replayed = existing.rows[0];
|
|
1290
|
+
if (replayed !== void 0) {
|
|
1291
|
+
await client.query("COMMIT");
|
|
1292
|
+
return toMailboxMessage(replayed);
|
|
1293
|
+
}
|
|
1294
|
+
const seq = await allocateMailboxSequence(client, tenant, input.deviceId, this.#now());
|
|
1295
|
+
const serializedExisting = await client.query(
|
|
1296
|
+
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1297
|
+
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
1298
|
+
[tenant, input.deviceId, input.messageId]
|
|
1299
|
+
);
|
|
1300
|
+
const winnerAfterLock = serializedExisting.rows[0];
|
|
1301
|
+
if (winnerAfterLock !== void 0) {
|
|
1302
|
+
await client.query("ROLLBACK");
|
|
1303
|
+
return toMailboxMessage(winnerAfterLock);
|
|
1304
|
+
}
|
|
1305
|
+
const materialized = await input.materialize(seq);
|
|
1306
|
+
const now = this.#now();
|
|
1307
|
+
const inserted = await client.query(
|
|
1308
|
+
`INSERT INTO outbox (${OUTBOX_COLUMNS})
|
|
1309
|
+
VALUES ($1, $2, $3::bigint, $4, $5, $6, $7::bigint, 'pending', $8)
|
|
1310
|
+
ON CONFLICT (tenant_id, device_id, message_id) DO NOTHING
|
|
1311
|
+
RETURNING ${OUTBOX_COLUMNS}`,
|
|
1312
|
+
[
|
|
1313
|
+
tenant,
|
|
1314
|
+
input.deviceId,
|
|
1315
|
+
seq,
|
|
1316
|
+
input.messageId,
|
|
1317
|
+
materialized.body,
|
|
1318
|
+
materialized.bodyHash,
|
|
1319
|
+
materialized.byteSize,
|
|
1320
|
+
now
|
|
1321
|
+
]
|
|
1322
|
+
);
|
|
1323
|
+
const row = inserted.rows[0];
|
|
1324
|
+
if (row !== void 0) {
|
|
1325
|
+
await client.query("COMMIT");
|
|
1326
|
+
return toMailboxMessage(row);
|
|
1327
|
+
}
|
|
1328
|
+
const winner = await client.query(
|
|
1329
|
+
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1330
|
+
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
1331
|
+
[tenant, input.deviceId, input.messageId]
|
|
1332
|
+
);
|
|
1333
|
+
await client.query("ROLLBACK");
|
|
1334
|
+
const won = winner.rows[0];
|
|
1335
|
+
if (won === void 0) {
|
|
1336
|
+
throw new ByokCoreError(
|
|
1337
|
+
"mailbox_message_not_found",
|
|
1338
|
+
`Message ${input.messageId} vanished during an idempotent append.`
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1341
|
+
return toMailboxMessage(won);
|
|
1342
|
+
} catch (cause) {
|
|
1343
|
+
await client.query("ROLLBACK").catch(() => {
|
|
1344
|
+
});
|
|
1345
|
+
throw cause;
|
|
1346
|
+
} finally {
|
|
1347
|
+
client.release();
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
async readAfter(tenant, query) {
|
|
1351
|
+
const limit = query.limit ?? DEFAULT_READ_LIMIT;
|
|
1352
|
+
const result = await this.#pool.query(
|
|
1353
|
+
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1354
|
+
WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending' AND seq > $3::bigint
|
|
1355
|
+
ORDER BY seq
|
|
1356
|
+
LIMIT $4`,
|
|
1357
|
+
[tenant, query.deviceId, query.afterSeq, limit + 1]
|
|
1358
|
+
);
|
|
1359
|
+
const page = result.rows.slice(0, limit).map(toMailboxMessage);
|
|
1360
|
+
return {
|
|
1361
|
+
messages: page,
|
|
1362
|
+
// Nothing above was mutated, so an identical call replays the same page.
|
|
1363
|
+
// The returned position is a READ cursor and moves no ack.
|
|
1364
|
+
nextSeq: page.at(-1)?.seq ?? query.afterSeq,
|
|
1365
|
+
hasMore: result.rows.length > page.length
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
async advanceCursor(tenant, input) {
|
|
1369
|
+
this.#requireDeviceId(input.deviceId);
|
|
1370
|
+
const now = this.#now();
|
|
1371
|
+
const moved = await this.#pool.query(
|
|
1372
|
+
`WITH moved AS (
|
|
1373
|
+
INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
|
|
1374
|
+
VALUES ($1, $2, 1, $3::bigint, $4)
|
|
1375
|
+
ON CONFLICT (tenant_id, device_id) DO UPDATE
|
|
1376
|
+
SET acked_seq = EXCLUDED.acked_seq, acked_at = EXCLUDED.acked_at
|
|
1377
|
+
WHERE device_stream.acked_seq <= EXCLUDED.acked_seq
|
|
1378
|
+
RETURNING acked_seq, acked_at
|
|
1379
|
+
), marked AS (
|
|
1380
|
+
UPDATE outbox
|
|
1381
|
+
SET state = 'acked'
|
|
1382
|
+
WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending'
|
|
1383
|
+
AND seq <= (SELECT acked_seq FROM moved)
|
|
1384
|
+
RETURNING 1
|
|
1385
|
+
)
|
|
1386
|
+
SELECT acked_seq, acked_at FROM moved`,
|
|
1387
|
+
[tenant, input.deviceId, input.ackedSeq, now]
|
|
1388
|
+
);
|
|
1389
|
+
const row = moved.rows[0];
|
|
1390
|
+
if (row !== void 0) {
|
|
1391
|
+
return {
|
|
1392
|
+
tenantId: tenant,
|
|
1393
|
+
deviceId: input.deviceId,
|
|
1394
|
+
ackedSeq: Number(row.acked_seq),
|
|
1395
|
+
updatedAt: row.acked_at ?? now
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
const current = await this.readCursor(tenant, input.deviceId);
|
|
1399
|
+
throw new CoreConflictError(
|
|
1400
|
+
"mailbox_cursor_regression",
|
|
1401
|
+
`Cursor for device ${input.deviceId} is at ${current.ackedSeq}; refusing to move it back to ${input.ackedSeq}.`,
|
|
1402
|
+
current,
|
|
1403
|
+
this.#now()
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
async readCursor(tenant, deviceId) {
|
|
1407
|
+
const result = await this.#pool.query(
|
|
1408
|
+
`SELECT acked_seq, acked_at FROM device_stream
|
|
1409
|
+
WHERE tenant_id = $1 AND device_id = $2`,
|
|
1410
|
+
[tenant, deviceId]
|
|
1411
|
+
);
|
|
1412
|
+
const row = result.rows[0];
|
|
1413
|
+
return {
|
|
1414
|
+
tenantId: tenant,
|
|
1415
|
+
deviceId,
|
|
1416
|
+
ackedSeq: row === void 0 ? 0 : Number(row.acked_seq),
|
|
1417
|
+
updatedAt: row?.acked_at ?? this.#now()
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
async collectRetired(tenant, input) {
|
|
1421
|
+
assertCanonicalTimestamp(input.ackedBefore, "ackedBefore");
|
|
1422
|
+
assertCanonicalTimestamp(input.expireUnackedBefore, "expireUnackedBefore");
|
|
1423
|
+
const swept = await this.#pool.query(
|
|
1424
|
+
`WITH deleted AS (
|
|
1425
|
+
DELETE FROM outbox
|
|
1426
|
+
WHERE tenant_id = $1
|
|
1427
|
+
AND ($2::text IS NULL OR device_id = $2::text)
|
|
1428
|
+
AND state = 'acked'
|
|
1429
|
+
AND appended_at < $3
|
|
1430
|
+
RETURNING byte_size
|
|
1431
|
+
), expired AS (
|
|
1432
|
+
UPDATE outbox
|
|
1433
|
+
SET state = 'expired'
|
|
1434
|
+
WHERE tenant_id = $1
|
|
1435
|
+
AND ($2::text IS NULL OR device_id = $2::text)
|
|
1436
|
+
AND state = 'pending'
|
|
1437
|
+
AND appended_at < $4
|
|
1438
|
+
RETURNING 1
|
|
1439
|
+
)
|
|
1440
|
+
SELECT (SELECT count(*) FROM deleted) AS deleted_count,
|
|
1441
|
+
(SELECT count(*) FROM expired) AS expired_count,
|
|
1442
|
+
(SELECT COALESCE(SUM(byte_size), 0) FROM deleted)::bigint AS released_bytes`,
|
|
1443
|
+
[tenant, input.deviceId ?? null, input.ackedBefore, input.expireUnackedBefore]
|
|
1444
|
+
);
|
|
1445
|
+
const row = swept.rows[0];
|
|
1446
|
+
return {
|
|
1447
|
+
deletedCount: Number(row.deleted_count),
|
|
1448
|
+
expiredCount: Number(row.expired_count),
|
|
1449
|
+
releasedBytes: row.released_bytes
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* The in-memory reference refuses an empty device id rather than opening a
|
|
1454
|
+
* mailbox nothing can address. Kept here so the two compositions answer the
|
|
1455
|
+
* same way; the table itself would happily store the row.
|
|
1456
|
+
*/
|
|
1457
|
+
#requireDeviceId(deviceId) {
|
|
1458
|
+
if (deviceId.length === 0) {
|
|
1459
|
+
throw new ByokCoreError("mailbox_message_not_found", "Device id must not be empty.");
|
|
1460
|
+
}
|
|
1146
1461
|
}
|
|
1147
1462
|
#now() {
|
|
1148
1463
|
return this.#clock.now().toISOString();
|
|
1149
1464
|
}
|
|
1150
1465
|
};
|
|
1466
|
+
|
|
1467
|
+
// src/stores/task-cancellations.ts
|
|
1468
|
+
var PostgresTaskCancellationStore = class {
|
|
1469
|
+
#pool;
|
|
1470
|
+
#clock;
|
|
1471
|
+
constructor(pool, clock) {
|
|
1472
|
+
this.#pool = pool;
|
|
1473
|
+
this.#clock = clock;
|
|
1474
|
+
}
|
|
1475
|
+
async request(tenant, input) {
|
|
1476
|
+
const client = await this.#pool.connect();
|
|
1477
|
+
try {
|
|
1478
|
+
await client.query("BEGIN");
|
|
1479
|
+
const selected = await client.query(
|
|
1480
|
+
`SELECT ${TASK_SELECT_COLUMNS} FROM task
|
|
1481
|
+
WHERE tenant_id = $1 AND task_id = $2
|
|
1482
|
+
FOR UPDATE`,
|
|
1483
|
+
[tenant, input.taskId]
|
|
1484
|
+
);
|
|
1485
|
+
const current = selected.rows[0];
|
|
1486
|
+
if (current === void 0) {
|
|
1487
|
+
await client.query("ROLLBACK");
|
|
1488
|
+
return void 0;
|
|
1489
|
+
}
|
|
1490
|
+
if (current.cancel_requested_at === null && (current.status === "complete" || current.status === "failed" || current.status === "cancelled")) {
|
|
1491
|
+
await client.query("COMMIT");
|
|
1492
|
+
return { attempt: taskRowToAttempt(current) };
|
|
1493
|
+
}
|
|
1494
|
+
if (current.cancel_message_id !== null) {
|
|
1495
|
+
const replayed = await client.query(
|
|
1496
|
+
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1497
|
+
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
1498
|
+
[tenant, current.device_id, current.cancel_message_id]
|
|
1499
|
+
);
|
|
1500
|
+
const message = replayed.rows[0];
|
|
1501
|
+
if (message === void 0) {
|
|
1502
|
+
if (current.status === "cancelled") {
|
|
1503
|
+
await client.query("COMMIT");
|
|
1504
|
+
return { attempt: taskRowToAttempt(current) };
|
|
1505
|
+
}
|
|
1506
|
+
throw new Error(`Cancellation delivery ${current.cancel_message_id} is missing for task ${input.taskId}`);
|
|
1507
|
+
}
|
|
1508
|
+
await client.query("COMMIT");
|
|
1509
|
+
return { attempt: taskRowToAttempt(current), message: toMailboxMessage(message) };
|
|
1510
|
+
}
|
|
1511
|
+
const now = this.#clock.now().toISOString();
|
|
1512
|
+
const messageId = input.proposedMessageId;
|
|
1513
|
+
const seq = await allocateMailboxSequence(client, tenant, current.device_id, now);
|
|
1514
|
+
const materialized = await input.materialize(seq, messageId);
|
|
1515
|
+
const insertedMessage = await client.query(
|
|
1516
|
+
`INSERT INTO outbox (${OUTBOX_COLUMNS})
|
|
1517
|
+
VALUES ($1, $2, $3::bigint, $4, $5, $6, $7::bigint, 'pending', $8)
|
|
1518
|
+
RETURNING ${OUTBOX_COLUMNS}`,
|
|
1519
|
+
[
|
|
1520
|
+
tenant,
|
|
1521
|
+
current.device_id,
|
|
1522
|
+
seq,
|
|
1523
|
+
messageId,
|
|
1524
|
+
materialized.body,
|
|
1525
|
+
materialized.bodyHash,
|
|
1526
|
+
materialized.byteSize,
|
|
1527
|
+
now
|
|
1528
|
+
]
|
|
1529
|
+
);
|
|
1530
|
+
const updated = await client.query(
|
|
1531
|
+
`UPDATE task
|
|
1532
|
+
SET status = CASE WHEN owner_device_id IS NULL THEN 'cancelled' ELSE 'cancel_requested' END,
|
|
1533
|
+
cancel_requested_at = $3,
|
|
1534
|
+
cancel_reason = $4,
|
|
1535
|
+
cancel_message_id = $5,
|
|
1536
|
+
updated_at = $3
|
|
1537
|
+
WHERE tenant_id = $1 AND task_id = $2
|
|
1538
|
+
RETURNING ${TASK_SELECT_COLUMNS}`,
|
|
1539
|
+
[tenant, input.taskId, now, input.reason ?? null, messageId]
|
|
1540
|
+
);
|
|
1541
|
+
await client.query("COMMIT");
|
|
1542
|
+
return {
|
|
1543
|
+
attempt: taskRowToAttempt(updated.rows[0]),
|
|
1544
|
+
message: toMailboxMessage(insertedMessage.rows[0])
|
|
1545
|
+
};
|
|
1546
|
+
} catch (cause) {
|
|
1547
|
+
await client.query("ROLLBACK").catch(() => {
|
|
1548
|
+
});
|
|
1549
|
+
throw cause;
|
|
1550
|
+
} finally {
|
|
1551
|
+
client.release();
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
};
|
|
1151
1555
|
function toTail(row) {
|
|
1152
1556
|
const entries = parseTimelineEvents(row.entries);
|
|
1153
1557
|
const cursor = activityCursor(entries);
|
|
@@ -1382,17 +1786,67 @@ var PostgresApprovalTimelineStore = class {
|
|
|
1382
1786
|
}
|
|
1383
1787
|
};
|
|
1384
1788
|
|
|
1789
|
+
// src/stores/device-assertion-replay.ts
|
|
1790
|
+
var PostgresDeviceAssertionReplayAuthority = class {
|
|
1791
|
+
#pool;
|
|
1792
|
+
constructor(pool) {
|
|
1793
|
+
this.#pool = pool;
|
|
1794
|
+
}
|
|
1795
|
+
async consume(input) {
|
|
1796
|
+
if (!Number.isFinite(Date.parse(input.expiresAt))) {
|
|
1797
|
+
throw new Error("device assertion replay expiry is invalid");
|
|
1798
|
+
}
|
|
1799
|
+
const result = await this.#pool.query(
|
|
1800
|
+
`INSERT INTO device_assertion_replay (
|
|
1801
|
+
tenant_id, issuer, product_id, device_id, audience, jti, expires_at
|
|
1802
|
+
)
|
|
1803
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
1804
|
+
ON CONFLICT (tenant_id, issuer, product_id, device_id, audience, jti) DO NOTHING
|
|
1805
|
+
RETURNING jti`,
|
|
1806
|
+
[
|
|
1807
|
+
input.tenantId,
|
|
1808
|
+
input.issuer,
|
|
1809
|
+
input.productId,
|
|
1810
|
+
input.deviceId,
|
|
1811
|
+
input.audience,
|
|
1812
|
+
input.jti,
|
|
1813
|
+
input.expiresAt
|
|
1814
|
+
]
|
|
1815
|
+
);
|
|
1816
|
+
return result.rowCount === 1;
|
|
1817
|
+
}
|
|
1818
|
+
/** Bounded retention cleanup; callers choose cadence and batch size. */
|
|
1819
|
+
async deleteExpired(before, limit) {
|
|
1820
|
+
if (!Number.isFinite(before.getTime()) || !Number.isSafeInteger(limit) || limit <= 0) {
|
|
1821
|
+
throw new Error("device assertion replay cleanup bounds are invalid");
|
|
1822
|
+
}
|
|
1823
|
+
const result = await this.#pool.query(
|
|
1824
|
+
`DELETE FROM device_assertion_replay
|
|
1825
|
+
WHERE ctid IN (
|
|
1826
|
+
SELECT ctid
|
|
1827
|
+
FROM device_assertion_replay
|
|
1828
|
+
WHERE expires_at <= $1
|
|
1829
|
+
ORDER BY expires_at
|
|
1830
|
+
LIMIT $2
|
|
1831
|
+
)`,
|
|
1832
|
+
[before.toISOString(), limit]
|
|
1833
|
+
);
|
|
1834
|
+
return result.rowCount ?? 0;
|
|
1835
|
+
}
|
|
1836
|
+
};
|
|
1837
|
+
|
|
1385
1838
|
// src/stores/index.ts
|
|
1386
1839
|
function createPostgresCloudStores(options) {
|
|
1387
1840
|
const { pool, clock, crypto } = options;
|
|
1388
1841
|
return {
|
|
1389
1842
|
activity: new PostgresActivityStore(pool, clock),
|
|
1390
1843
|
approvals: new PostgresApprovalTimelineStore(pool, clock),
|
|
1391
|
-
devices: new PostgresDeviceDirectory(pool),
|
|
1844
|
+
devices: new PostgresDeviceDirectory(pool, clock),
|
|
1392
1845
|
pairingCodes: new PostgresPairingCodeStore(pool, clock),
|
|
1393
1846
|
nonces: new PostgresNonceStore(pool, clock, crypto),
|
|
1394
1847
|
dedup: new PostgresInboundDedupStore(pool),
|
|
1395
1848
|
tasks: new PostgresTaskAttemptStore(pool, clock),
|
|
1849
|
+
cancellations: new PostgresTaskCancellationStore(pool, clock),
|
|
1396
1850
|
receipts: new PostgresRequestReceiptStore(pool, clock),
|
|
1397
1851
|
proofReceipts: new PostgresProofRequestReceiptStore(pool, clock),
|
|
1398
1852
|
// A second `PostgresObjectStore` instance, not a shared one: it is a
|
|
@@ -1406,7 +1860,7 @@ function createPostgresCloudStores(options) {
|
|
|
1406
1860
|
rateLimiter: new AllowAllRateLimiter()
|
|
1407
1861
|
};
|
|
1408
1862
|
}
|
|
1409
|
-
var PRESENCE_COLUMNS = "tenant_id, device_id, level, detail, configured_toolsets, observed_at, expires_at";
|
|
1863
|
+
var PRESENCE_COLUMNS = "tenant_id, device_id, level, detail, configured_toolsets, client_version, protocol_versions, runtimes, observed_at, expires_at";
|
|
1410
1864
|
function toHint(row) {
|
|
1411
1865
|
return {
|
|
1412
1866
|
tenantId: row.tenant_id,
|
|
@@ -1414,6 +1868,19 @@ function toHint(row) {
|
|
|
1414
1868
|
level: row.level,
|
|
1415
1869
|
...row.detail === null ? {} : { detail: row.detail },
|
|
1416
1870
|
...row.configured_toolsets === null ? {} : { configuredToolsets: Object.freeze([...row.configured_toolsets]) },
|
|
1871
|
+
...row.client_version === null ? {} : { clientVersion: row.client_version },
|
|
1872
|
+
...row.protocol_versions === null ? {} : { protocolVersions: Object.freeze([...row.protocol_versions]) },
|
|
1873
|
+
...row.runtimes === null ? {} : {
|
|
1874
|
+
runtimes: Object.freeze(
|
|
1875
|
+
row.runtimes.map(
|
|
1876
|
+
(runtime) => Object.freeze({
|
|
1877
|
+
id: runtime.id,
|
|
1878
|
+
...runtime.version === void 0 ? {} : { version: runtime.version },
|
|
1879
|
+
...runtime.authPresent === void 0 ? {} : { authPresent: runtime.authPresent }
|
|
1880
|
+
})
|
|
1881
|
+
)
|
|
1882
|
+
)
|
|
1883
|
+
},
|
|
1417
1884
|
observedAt: row.observed_at,
|
|
1418
1885
|
expiresAt: row.expires_at
|
|
1419
1886
|
};
|
|
@@ -1449,15 +1916,18 @@ var PostgresPresenceStore = class {
|
|
|
1449
1916
|
const allowedBefore = new Date(now.getTime() - input.minimumIntervalMs).toISOString();
|
|
1450
1917
|
const result = await this.#pool.query(
|
|
1451
1918
|
`INSERT INTO device_presence (${PRESENCE_COLUMNS})
|
|
1452
|
-
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)
|
|
1919
|
+
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8::jsonb, $9, $10)
|
|
1453
1920
|
ON CONFLICT (tenant_id, device_id) DO UPDATE
|
|
1454
1921
|
SET level = EXCLUDED.level,
|
|
1455
1922
|
detail = EXCLUDED.detail,
|
|
1456
1923
|
configured_toolsets = EXCLUDED.configured_toolsets,
|
|
1924
|
+
client_version = EXCLUDED.client_version,
|
|
1925
|
+
protocol_versions = EXCLUDED.protocol_versions,
|
|
1926
|
+
runtimes = EXCLUDED.runtimes,
|
|
1457
1927
|
observed_at = EXCLUDED.observed_at,
|
|
1458
1928
|
expires_at = EXCLUDED.expires_at
|
|
1459
1929
|
WHERE device_presence.expires_at <= EXCLUDED.observed_at
|
|
1460
|
-
OR device_presence.observed_at <= $
|
|
1930
|
+
OR device_presence.observed_at <= $11
|
|
1461
1931
|
RETURNING ${PRESENCE_COLUMNS}`,
|
|
1462
1932
|
[
|
|
1463
1933
|
tenant,
|
|
@@ -1465,6 +1935,9 @@ var PostgresPresenceStore = class {
|
|
|
1465
1935
|
input.level,
|
|
1466
1936
|
input.detail ?? null,
|
|
1467
1937
|
input.configuredToolsets === void 0 ? null : JSON.stringify(input.configuredToolsets),
|
|
1938
|
+
input.clientVersion ?? null,
|
|
1939
|
+
input.protocolVersions === void 0 ? null : JSON.stringify(input.protocolVersions),
|
|
1940
|
+
input.runtimes === void 0 ? null : JSON.stringify(input.runtimes),
|
|
1468
1941
|
observedAt,
|
|
1469
1942
|
new Date(now.getTime() + input.ttlMs).toISOString(),
|
|
1470
1943
|
allowedBefore
|
|
@@ -1732,233 +2205,6 @@ var PostgresBoardStore = class {
|
|
|
1732
2205
|
return this.#clock.now().toISOString();
|
|
1733
2206
|
}
|
|
1734
2207
|
};
|
|
1735
|
-
|
|
1736
|
-
// src/stores/core/mailbox-sequence.ts
|
|
1737
|
-
async function allocateMailboxSequence(client, tenant, deviceId, now) {
|
|
1738
|
-
const allocation = await client.query(
|
|
1739
|
-
`INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
|
|
1740
|
-
VALUES ($1, $2, 2, 0, $3)
|
|
1741
|
-
ON CONFLICT (tenant_id, device_id) DO UPDATE
|
|
1742
|
-
SET next_seq = device_stream.next_seq + 1
|
|
1743
|
-
RETURNING next_seq - 1 AS seq`,
|
|
1744
|
-
[tenant, deviceId, now]
|
|
1745
|
-
);
|
|
1746
|
-
return Number(allocation.rows[0].seq);
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
|
-
// src/stores/core/mailbox.ts
|
|
1750
|
-
var DEFAULT_READ_LIMIT = 50;
|
|
1751
|
-
var OUTBOX_COLUMNS = "tenant_id, device_id, seq, message_id, body, body_hash, byte_size, state, appended_at";
|
|
1752
|
-
function toMessage(row) {
|
|
1753
|
-
return {
|
|
1754
|
-
tenantId: row.tenant_id,
|
|
1755
|
-
deviceId: row.device_id,
|
|
1756
|
-
// `seq` is bigint in the column and `number` on the port, because it is the
|
|
1757
|
-
// envelope `seq` on the wire. The column is wide so the counter cannot wrap
|
|
1758
|
-
// into a redelivery bug; the narrowing happens once, here.
|
|
1759
|
-
seq: Number(row.seq),
|
|
1760
|
-
messageId: row.message_id,
|
|
1761
|
-
body: row.body,
|
|
1762
|
-
bodyHash: row.body_hash,
|
|
1763
|
-
byteSize: row.byte_size,
|
|
1764
|
-
state: row.state,
|
|
1765
|
-
appendedAt: row.appended_at
|
|
1766
|
-
};
|
|
1767
|
-
}
|
|
1768
|
-
var PostgresMailboxStore = class {
|
|
1769
|
-
#pool;
|
|
1770
|
-
#clock;
|
|
1771
|
-
constructor(pool, clock) {
|
|
1772
|
-
this.#pool = pool;
|
|
1773
|
-
this.#clock = clock;
|
|
1774
|
-
}
|
|
1775
|
-
async append(tenant, input) {
|
|
1776
|
-
this.#requireDeviceId(input.deviceId);
|
|
1777
|
-
const client = await this.#pool.connect();
|
|
1778
|
-
try {
|
|
1779
|
-
await client.query("BEGIN");
|
|
1780
|
-
const existing = await client.query(
|
|
1781
|
-
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1782
|
-
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
1783
|
-
[tenant, input.deviceId, input.messageId]
|
|
1784
|
-
);
|
|
1785
|
-
const replayed = existing.rows[0];
|
|
1786
|
-
if (replayed !== void 0) {
|
|
1787
|
-
await client.query("COMMIT");
|
|
1788
|
-
return toMessage(replayed);
|
|
1789
|
-
}
|
|
1790
|
-
const seq = await allocateMailboxSequence(client, tenant, input.deviceId, this.#now());
|
|
1791
|
-
const serializedExisting = await client.query(
|
|
1792
|
-
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1793
|
-
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
1794
|
-
[tenant, input.deviceId, input.messageId]
|
|
1795
|
-
);
|
|
1796
|
-
const winnerAfterLock = serializedExisting.rows[0];
|
|
1797
|
-
if (winnerAfterLock !== void 0) {
|
|
1798
|
-
await client.query("ROLLBACK");
|
|
1799
|
-
return toMessage(winnerAfterLock);
|
|
1800
|
-
}
|
|
1801
|
-
const materialized = await input.materialize(seq);
|
|
1802
|
-
const now = this.#now();
|
|
1803
|
-
const inserted = await client.query(
|
|
1804
|
-
`INSERT INTO outbox (${OUTBOX_COLUMNS})
|
|
1805
|
-
VALUES ($1, $2, $3::bigint, $4, $5, $6, $7::bigint, 'pending', $8)
|
|
1806
|
-
ON CONFLICT (tenant_id, device_id, message_id) DO NOTHING
|
|
1807
|
-
RETURNING ${OUTBOX_COLUMNS}`,
|
|
1808
|
-
[
|
|
1809
|
-
tenant,
|
|
1810
|
-
input.deviceId,
|
|
1811
|
-
seq,
|
|
1812
|
-
input.messageId,
|
|
1813
|
-
materialized.body,
|
|
1814
|
-
materialized.bodyHash,
|
|
1815
|
-
materialized.byteSize,
|
|
1816
|
-
now
|
|
1817
|
-
]
|
|
1818
|
-
);
|
|
1819
|
-
const row = inserted.rows[0];
|
|
1820
|
-
if (row !== void 0) {
|
|
1821
|
-
await client.query("COMMIT");
|
|
1822
|
-
return toMessage(row);
|
|
1823
|
-
}
|
|
1824
|
-
const winner = await client.query(
|
|
1825
|
-
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1826
|
-
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
1827
|
-
[tenant, input.deviceId, input.messageId]
|
|
1828
|
-
);
|
|
1829
|
-
await client.query("ROLLBACK");
|
|
1830
|
-
const won = winner.rows[0];
|
|
1831
|
-
if (won === void 0) {
|
|
1832
|
-
throw new ByokCoreError(
|
|
1833
|
-
"mailbox_message_not_found",
|
|
1834
|
-
`Message ${input.messageId} vanished during an idempotent append.`
|
|
1835
|
-
);
|
|
1836
|
-
}
|
|
1837
|
-
return toMessage(won);
|
|
1838
|
-
} catch (cause) {
|
|
1839
|
-
await client.query("ROLLBACK").catch(() => {
|
|
1840
|
-
});
|
|
1841
|
-
throw cause;
|
|
1842
|
-
} finally {
|
|
1843
|
-
client.release();
|
|
1844
|
-
}
|
|
1845
|
-
}
|
|
1846
|
-
async readAfter(tenant, query) {
|
|
1847
|
-
const limit = query.limit ?? DEFAULT_READ_LIMIT;
|
|
1848
|
-
const result = await this.#pool.query(
|
|
1849
|
-
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1850
|
-
WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending' AND seq > $3::bigint
|
|
1851
|
-
ORDER BY seq
|
|
1852
|
-
LIMIT $4`,
|
|
1853
|
-
[tenant, query.deviceId, query.afterSeq, limit + 1]
|
|
1854
|
-
);
|
|
1855
|
-
const page = result.rows.slice(0, limit).map(toMessage);
|
|
1856
|
-
return {
|
|
1857
|
-
messages: page,
|
|
1858
|
-
// Nothing above was mutated, so an identical call replays the same page.
|
|
1859
|
-
// The returned position is a READ cursor and moves no ack.
|
|
1860
|
-
nextSeq: page.at(-1)?.seq ?? query.afterSeq,
|
|
1861
|
-
hasMore: result.rows.length > page.length
|
|
1862
|
-
};
|
|
1863
|
-
}
|
|
1864
|
-
async advanceCursor(tenant, input) {
|
|
1865
|
-
this.#requireDeviceId(input.deviceId);
|
|
1866
|
-
const now = this.#now();
|
|
1867
|
-
const moved = await this.#pool.query(
|
|
1868
|
-
`WITH moved AS (
|
|
1869
|
-
INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
|
|
1870
|
-
VALUES ($1, $2, 1, $3::bigint, $4)
|
|
1871
|
-
ON CONFLICT (tenant_id, device_id) DO UPDATE
|
|
1872
|
-
SET acked_seq = EXCLUDED.acked_seq, acked_at = EXCLUDED.acked_at
|
|
1873
|
-
WHERE device_stream.acked_seq <= EXCLUDED.acked_seq
|
|
1874
|
-
RETURNING acked_seq, acked_at
|
|
1875
|
-
), marked AS (
|
|
1876
|
-
UPDATE outbox
|
|
1877
|
-
SET state = 'acked'
|
|
1878
|
-
WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending'
|
|
1879
|
-
AND seq <= (SELECT acked_seq FROM moved)
|
|
1880
|
-
RETURNING 1
|
|
1881
|
-
)
|
|
1882
|
-
SELECT acked_seq, acked_at FROM moved`,
|
|
1883
|
-
[tenant, input.deviceId, input.ackedSeq, now]
|
|
1884
|
-
);
|
|
1885
|
-
const row = moved.rows[0];
|
|
1886
|
-
if (row !== void 0) {
|
|
1887
|
-
return {
|
|
1888
|
-
tenantId: tenant,
|
|
1889
|
-
deviceId: input.deviceId,
|
|
1890
|
-
ackedSeq: Number(row.acked_seq),
|
|
1891
|
-
updatedAt: row.acked_at ?? now
|
|
1892
|
-
};
|
|
1893
|
-
}
|
|
1894
|
-
const current = await this.readCursor(tenant, input.deviceId);
|
|
1895
|
-
throw new CoreConflictError(
|
|
1896
|
-
"mailbox_cursor_regression",
|
|
1897
|
-
`Cursor for device ${input.deviceId} is at ${current.ackedSeq}; refusing to move it back to ${input.ackedSeq}.`,
|
|
1898
|
-
current,
|
|
1899
|
-
this.#now()
|
|
1900
|
-
);
|
|
1901
|
-
}
|
|
1902
|
-
async readCursor(tenant, deviceId) {
|
|
1903
|
-
const result = await this.#pool.query(
|
|
1904
|
-
`SELECT acked_seq, acked_at FROM device_stream
|
|
1905
|
-
WHERE tenant_id = $1 AND device_id = $2`,
|
|
1906
|
-
[tenant, deviceId]
|
|
1907
|
-
);
|
|
1908
|
-
const row = result.rows[0];
|
|
1909
|
-
return {
|
|
1910
|
-
tenantId: tenant,
|
|
1911
|
-
deviceId,
|
|
1912
|
-
ackedSeq: row === void 0 ? 0 : Number(row.acked_seq),
|
|
1913
|
-
updatedAt: row?.acked_at ?? this.#now()
|
|
1914
|
-
};
|
|
1915
|
-
}
|
|
1916
|
-
async collectRetired(tenant, input) {
|
|
1917
|
-
assertCanonicalTimestamp(input.ackedBefore, "ackedBefore");
|
|
1918
|
-
assertCanonicalTimestamp(input.expireUnackedBefore, "expireUnackedBefore");
|
|
1919
|
-
const swept = await this.#pool.query(
|
|
1920
|
-
`WITH deleted AS (
|
|
1921
|
-
DELETE FROM outbox
|
|
1922
|
-
WHERE tenant_id = $1
|
|
1923
|
-
AND ($2::text IS NULL OR device_id = $2::text)
|
|
1924
|
-
AND state = 'acked'
|
|
1925
|
-
AND appended_at < $3
|
|
1926
|
-
RETURNING byte_size
|
|
1927
|
-
), expired AS (
|
|
1928
|
-
UPDATE outbox
|
|
1929
|
-
SET state = 'expired'
|
|
1930
|
-
WHERE tenant_id = $1
|
|
1931
|
-
AND ($2::text IS NULL OR device_id = $2::text)
|
|
1932
|
-
AND state = 'pending'
|
|
1933
|
-
AND appended_at < $4
|
|
1934
|
-
RETURNING 1
|
|
1935
|
-
)
|
|
1936
|
-
SELECT (SELECT count(*) FROM deleted) AS deleted_count,
|
|
1937
|
-
(SELECT count(*) FROM expired) AS expired_count,
|
|
1938
|
-
(SELECT COALESCE(SUM(byte_size), 0) FROM deleted)::bigint AS released_bytes`,
|
|
1939
|
-
[tenant, input.deviceId ?? null, input.ackedBefore, input.expireUnackedBefore]
|
|
1940
|
-
);
|
|
1941
|
-
const row = swept.rows[0];
|
|
1942
|
-
return {
|
|
1943
|
-
deletedCount: Number(row.deleted_count),
|
|
1944
|
-
expiredCount: Number(row.expired_count),
|
|
1945
|
-
releasedBytes: row.released_bytes
|
|
1946
|
-
};
|
|
1947
|
-
}
|
|
1948
|
-
/**
|
|
1949
|
-
* The in-memory reference refuses an empty device id rather than opening a
|
|
1950
|
-
* mailbox nothing can address. Kept here so the two compositions answer the
|
|
1951
|
-
* same way; the table itself would happily store the row.
|
|
1952
|
-
*/
|
|
1953
|
-
#requireDeviceId(deviceId) {
|
|
1954
|
-
if (deviceId.length === 0) {
|
|
1955
|
-
throw new ByokCoreError("mailbox_message_not_found", "Device id must not be empty.");
|
|
1956
|
-
}
|
|
1957
|
-
}
|
|
1958
|
-
#now() {
|
|
1959
|
-
return this.#clock.now().toISOString();
|
|
1960
|
-
}
|
|
1961
|
-
};
|
|
1962
2208
|
var WARNING_NUMERATOR = 80n;
|
|
1963
2209
|
var WARNING_DENOMINATOR = 100n;
|
|
1964
2210
|
var ENTITLEMENT_COLUMNS = "tenant_id, version, hard_limit_bytes, max_object_bytes, max_inline_bytes, mailbox_limit_bytes, retention_policy_id, downgrade_grace_until";
|
|
@@ -3304,6 +3550,6 @@ var PostgresTruthCommitter = class {
|
|
|
3304
3550
|
}
|
|
3305
3551
|
};
|
|
3306
3552
|
|
|
3307
|
-
export { DEFAULT_MAX_ATTEMPTS, DEFAULT_PRESIGN_TTL_SECONDS, DEFAULT_RETRY_DELAY_MS, MAX_PRESIGN_TTL_SECONDS, MIN_PRESIGN_TTL_SECONDS, ObjectStoreRequestError, PostgresActivityStore, PostgresApprovalTimelineStore, PostgresBoardStore, PostgresDeviceDirectory, PostgresInboundDedupStore, PostgresMailboxStore, PostgresNonceStore, PostgresObjectStore, PostgresPairingCodeStore, PostgresPresenceStore, PostgresProofRequestReceiptStore, PostgresQuotaStore, PostgresRequestReceiptStore, PostgresSkillPackStore, PostgresTaskAttemptStore, PostgresTruthCommitter, PostgresTruthStore, R2BlobStoreError, R2CloudBlobStore, R2ObjectMaintenanceStore, R2_BLOB_ERROR_CODES, createByokPool, createPostgresCloudStores, createPostgresCoreStores };
|
|
3553
|
+
export { DEFAULT_MAX_ATTEMPTS, DEFAULT_PRESIGN_TTL_SECONDS, DEFAULT_RETRY_DELAY_MS, MAX_PRESIGN_TTL_SECONDS, MIN_PRESIGN_TTL_SECONDS, ObjectStoreRequestError, PostgresActivityStore, PostgresApprovalTimelineStore, PostgresBoardStore, PostgresDeviceAssertionReplayAuthority, PostgresDeviceDirectory, PostgresInboundDedupStore, PostgresMailboxStore, PostgresNonceStore, PostgresObjectStore, PostgresPairingCodeStore, PostgresPresenceStore, PostgresProofRequestReceiptStore, PostgresQuotaStore, PostgresRequestReceiptStore, PostgresSkillPackStore, PostgresTaskAttemptStore, PostgresTaskCancellationStore, PostgresTruthCommitter, PostgresTruthStore, R2BlobStoreError, R2CloudBlobStore, R2ObjectMaintenanceStore, R2_BLOB_ERROR_CODES, createByokPool, createPostgresCloudStores, createPostgresCoreStores };
|
|
3308
3554
|
//# sourceMappingURL=runtime.js.map
|
|
3309
3555
|
//# sourceMappingURL=runtime.js.map
|