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