@byok-sdk/cloud-dataplane 0.4.1 → 0.4.2
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 +52 -3
- package/dist/index.d.ts +15 -11
- package/dist/index.js +1724 -1588
- package/dist/index.js.map +1 -1
- package/dist/runtime.d.ts +37 -0
- package/dist/runtime.js +3309 -0
- package/dist/runtime.js.map +1 -0
- package/dist/sql/0007_approval_timeline.sql +13 -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/index.d.ts +7 -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,246 @@ 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
|
+
};
|
|
1273
1389
|
|
|
1274
1390
|
// src/stores/index.ts
|
|
1275
1391
|
function createPostgresCloudStores(options) {
|
|
1276
1392
|
const { pool, clock, crypto } = options;
|
|
1277
1393
|
return {
|
|
1394
|
+
activity: new PostgresActivityStore(pool, clock),
|
|
1395
|
+
approvals: new PostgresApprovalTimelineStore(pool, clock),
|
|
1278
1396
|
devices: new PostgresDeviceDirectory(pool),
|
|
1279
1397
|
pairingCodes: new PostgresPairingCodeStore(pool, clock),
|
|
1280
1398
|
nonces: new PostgresNonceStore(pool, clock, crypto),
|
|
@@ -1305,16 +1423,6 @@ function toHint(row) {
|
|
|
1305
1423
|
expiresAt: row.expires_at
|
|
1306
1424
|
};
|
|
1307
1425
|
}
|
|
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
1426
|
function assertTtl(ttlMs) {
|
|
1319
1427
|
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
|
1320
1428
|
throw new ByokCoreError(
|
|
@@ -1401,144 +1509,51 @@ var PostgresPresenceStore = class {
|
|
|
1401
1509
|
return this.#clock.now().toISOString();
|
|
1402
1510
|
}
|
|
1403
1511
|
};
|
|
1404
|
-
var
|
|
1512
|
+
var DEFAULT_LIST_LIMIT2 = 50;
|
|
1513
|
+
var BOARD_COLUMNS = "tenant_id, item_id, channel, title, status, holder_id, held_since, board_seq, created_at, updated_at";
|
|
1514
|
+
function toItem(row) {
|
|
1515
|
+
return {
|
|
1516
|
+
tenantId: row.tenant_id,
|
|
1517
|
+
itemId: row.item_id,
|
|
1518
|
+
channel: row.channel,
|
|
1519
|
+
title: row.title,
|
|
1520
|
+
status: row.status,
|
|
1521
|
+
// An unheld item has NO assignee rather than an assignee with an empty
|
|
1522
|
+
// holder, which is what lets `expect(item.assignee).toBeUndefined()` mean
|
|
1523
|
+
// "nobody holds this" in both compositions.
|
|
1524
|
+
...row.holder_id === null || row.held_since === null ? {} : { assignee: { holderId: row.holder_id, heldSince: row.held_since } },
|
|
1525
|
+
boardSeq: Number(row.board_seq),
|
|
1526
|
+
createdAt: row.created_at,
|
|
1527
|
+
updatedAt: row.updated_at
|
|
1528
|
+
};
|
|
1529
|
+
}
|
|
1530
|
+
var PostgresBoardStore = class {
|
|
1405
1531
|
#pool;
|
|
1406
1532
|
#clock;
|
|
1407
1533
|
constructor(pool, clock) {
|
|
1408
1534
|
this.#pool = pool;
|
|
1409
1535
|
this.#clock = clock;
|
|
1410
1536
|
}
|
|
1411
|
-
async
|
|
1412
|
-
|
|
1413
|
-
const
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1537
|
+
async create(tenant, input) {
|
|
1538
|
+
const now = this.#now();
|
|
1539
|
+
const boardSeq = await this.#allocateSeq(tenant);
|
|
1540
|
+
const inserted = await this.#pool.query(
|
|
1541
|
+
`INSERT INTO board_item (${BOARD_COLUMNS})
|
|
1542
|
+
VALUES ($1, $2, $3, $4, $5, NULL, NULL, $6::bigint, $7, $7)
|
|
1543
|
+
ON CONFLICT (tenant_id, item_id) DO NOTHING
|
|
1544
|
+
RETURNING ${BOARD_COLUMNS}`,
|
|
1545
|
+
[tenant, input.itemId, input.channel, input.title, input.status ?? "todo", boardSeq, now]
|
|
1546
|
+
);
|
|
1547
|
+
const row = inserted.rows[0];
|
|
1548
|
+
if (row === void 0) {
|
|
1421
1549
|
throw new ByokCoreError(
|
|
1422
|
-
"
|
|
1423
|
-
|
|
1550
|
+
"board_item_exists",
|
|
1551
|
+
`Board item ${input.itemId} already exists in this tenant.`
|
|
1424
1552
|
);
|
|
1425
1553
|
}
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
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 {
|
|
1516
|
-
#pool;
|
|
1517
|
-
#clock;
|
|
1518
|
-
constructor(pool, clock) {
|
|
1519
|
-
this.#pool = pool;
|
|
1520
|
-
this.#clock = clock;
|
|
1521
|
-
}
|
|
1522
|
-
async create(tenant, input) {
|
|
1523
|
-
const now = this.#now();
|
|
1524
|
-
const boardSeq = await this.#allocateSeq(tenant);
|
|
1525
|
-
const inserted = await this.#pool.query(
|
|
1526
|
-
`INSERT INTO board_item (${BOARD_COLUMNS})
|
|
1527
|
-
VALUES ($1, $2, $3, $4, $5, NULL, NULL, $6::bigint, $7, $7)
|
|
1528
|
-
ON CONFLICT (tenant_id, item_id) DO NOTHING
|
|
1529
|
-
RETURNING ${BOARD_COLUMNS}`,
|
|
1530
|
-
[tenant, input.itemId, input.channel, input.title, input.status ?? "todo", boardSeq, now]
|
|
1531
|
-
);
|
|
1532
|
-
const row = inserted.rows[0];
|
|
1533
|
-
if (row === void 0) {
|
|
1534
|
-
throw new ByokCoreError(
|
|
1535
|
-
"board_item_exists",
|
|
1536
|
-
`Board item ${input.itemId} already exists in this tenant.`
|
|
1537
|
-
);
|
|
1538
|
-
}
|
|
1539
|
-
return toItem(row);
|
|
1540
|
-
}
|
|
1541
|
-
async get(tenant, itemId) {
|
|
1554
|
+
return toItem(row);
|
|
1555
|
+
}
|
|
1556
|
+
async get(tenant, itemId) {
|
|
1542
1557
|
const result = await this.#pool.query(
|
|
1543
1558
|
`SELECT ${BOARD_COLUMNS} FROM board_item WHERE tenant_id = $1 AND item_id = $2`,
|
|
1544
1559
|
[tenant, itemId]
|
|
@@ -2834,7 +2849,6 @@ function createPostgresCoreStores(options) {
|
|
|
2834
2849
|
board: new PostgresBoardStore(pool, clock),
|
|
2835
2850
|
truth: new PostgresTruthStore(pool, clock),
|
|
2836
2851
|
presence: new PostgresPresenceStore(pool, clock),
|
|
2837
|
-
activity: new PostgresActivityStore(pool, clock),
|
|
2838
2852
|
objects: new PostgresObjectStore(pool, clock),
|
|
2839
2853
|
quota: new PostgresQuotaStore(pool, clock),
|
|
2840
2854
|
// No clock: a skill-pack manifest carries no timestamp, so this store reads
|
|
@@ -2842,1443 +2856,1565 @@ function createPostgresCoreStores(options) {
|
|
|
2842
2856
|
skillPacks: new PostgresSkillPackStore(pool)
|
|
2843
2857
|
};
|
|
2844
2858
|
}
|
|
2845
|
-
var
|
|
2846
|
-
var
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
}
|
|
2863
|
-
}
|
|
2864
|
-
|
|
2859
|
+
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";
|
|
2860
|
+
var RECEIPT_COLUMNS = "tenant_id, device_id, request_id, operation, resource, body_sha256, body_size, response_status, response_body, recorded_at";
|
|
2861
|
+
function toBody2(row) {
|
|
2862
|
+
return row.body_kind === "inline" ? { kind: "inline", body: row.body_inline ?? "" } : { kind: "object", hash: row.body_object_hash ?? "" };
|
|
2863
|
+
}
|
|
2864
|
+
function toRecord3(tenant, row) {
|
|
2865
|
+
return {
|
|
2866
|
+
tenantId: tenant,
|
|
2867
|
+
kind: row.kind,
|
|
2868
|
+
recordKey: row.subject_id,
|
|
2869
|
+
rev: row.rev,
|
|
2870
|
+
contentHash: row.content_hash,
|
|
2871
|
+
byteSize: row.byte_size,
|
|
2872
|
+
body: toBody2(row),
|
|
2873
|
+
...row.label === null ? {} : { label: row.label },
|
|
2874
|
+
...row.request_id === null ? {} : { requestId: row.request_id },
|
|
2875
|
+
writtenAt: row.written_at
|
|
2876
|
+
};
|
|
2877
|
+
}
|
|
2878
|
+
function toReceipt3(tenant, row) {
|
|
2879
|
+
return {
|
|
2880
|
+
tenantId: tenant,
|
|
2881
|
+
deviceId: row.device_id,
|
|
2882
|
+
requestId: row.request_id,
|
|
2883
|
+
operation: row.operation,
|
|
2884
|
+
resource: row.resource,
|
|
2885
|
+
bodySha256: row.body_sha256,
|
|
2886
|
+
bodySize: row.body_size,
|
|
2887
|
+
responseStatus: row.response_status,
|
|
2888
|
+
responseBody: row.response_body,
|
|
2889
|
+
recordedAt: row.recorded_at.toISOString()
|
|
2890
|
+
};
|
|
2891
|
+
}
|
|
2892
|
+
function sameBinding(receipt, input) {
|
|
2893
|
+
return receipt.operation === input.operation && receipt.resource === input.resource && receipt.bodySha256 === input.proofBodySha256 && receipt.bodySize === input.proofBodySize;
|
|
2894
|
+
}
|
|
2895
|
+
function bodyColumns2(body) {
|
|
2896
|
+
return body.kind === "inline" ? ["inline", body.body, null] : ["object", null, body.hash];
|
|
2897
|
+
}
|
|
2898
|
+
function writeKey(write) {
|
|
2899
|
+
return `${write.kind}\0${write.recordKey}`;
|
|
2900
|
+
}
|
|
2901
|
+
function referenceId(write) {
|
|
2902
|
+
return `${write.kind}:${write.recordKey}`;
|
|
2903
|
+
}
|
|
2904
|
+
var PostgresTruthCommitter = class {
|
|
2865
2905
|
#pool;
|
|
2866
2906
|
#clock;
|
|
2867
|
-
#
|
|
2868
|
-
#
|
|
2907
|
+
#crypto;
|
|
2908
|
+
#truth;
|
|
2869
2909
|
constructor(options) {
|
|
2870
2910
|
this.#pool = options.pool;
|
|
2871
2911
|
this.#clock = options.clock;
|
|
2872
|
-
this.#
|
|
2873
|
-
this.#
|
|
2912
|
+
this.#crypto = options.crypto;
|
|
2913
|
+
this.#truth = new PostgresTruthStore(options.pool, options.clock);
|
|
2874
2914
|
}
|
|
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]);
|
|
2915
|
+
getRecord(tenant, selector) {
|
|
2916
|
+
return this.#truth.getRecord(tenant, selector);
|
|
2903
2917
|
}
|
|
2904
|
-
|
|
2905
|
-
return this.#
|
|
2918
|
+
listManifest(tenant, query) {
|
|
2919
|
+
return this.#truth.listManifest(tenant, query);
|
|
2906
2920
|
}
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
assertIdentifier(jobId, "jobId");
|
|
2921
|
+
async commit(tenant, input) {
|
|
2922
|
+
await this.#validateInput(input);
|
|
2910
2923
|
const client = await this.#pool.connect();
|
|
2911
|
-
let jobStarted = false;
|
|
2912
2924
|
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;
|
|
2925
|
+
await client.query("BEGIN");
|
|
2926
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
|
|
2927
|
+
JSON.stringify(["truth-receipt", tenant, input.deviceId, input.requestId])
|
|
2928
|
+
]);
|
|
2929
|
+
const replay = await this.#readReceipt(client, tenant, input.deviceId, input.requestId);
|
|
2930
|
+
if (replay !== void 0) {
|
|
2931
|
+
if (!sameBinding(replay, input)) {
|
|
2932
|
+
throw new TruthCommitError(
|
|
2933
|
+
"proof_request_conflict",
|
|
2934
|
+
`Request ${input.requestId} was already used with a different binding.`
|
|
2935
|
+
);
|
|
2960
2936
|
}
|
|
2937
|
+
const response2 = TruthCommitResponseSchema.parse(JSON.parse(replay.responseBody));
|
|
2938
|
+
await client.query("COMMIT");
|
|
2939
|
+
return { response: response2, replayed: true };
|
|
2961
2940
|
}
|
|
2962
|
-
await this.#
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2941
|
+
const before = await this.#lockCurrentRecords(client, tenant, input.writes);
|
|
2942
|
+
this.#assertWritePreconditions(input.writes, before);
|
|
2943
|
+
await this.#lockAndVerifyObjects(client, tenant, input.writes, before);
|
|
2944
|
+
const inlineAffected = input.writes.some((write) => {
|
|
2945
|
+
const current = before.get(writeKey(write));
|
|
2946
|
+
if (write.kind === "task.terminal" && current !== void 0) return false;
|
|
2947
|
+
return current?.body.kind === "inline" || write.body.kind === "inline";
|
|
2948
|
+
});
|
|
2949
|
+
const inlineDelta = inlineAffected ? await this.#prepareInlineAccounting(client, tenant, input.writes, before) : 0n;
|
|
2950
|
+
const applied = await this.#applyWrites(client, tenant, input, before);
|
|
2951
|
+
await this.#replaceObjectReferences(client, tenant, applied);
|
|
2952
|
+
await this.#settleInlineAccounting(client, tenant, inlineDelta);
|
|
2953
|
+
const response = {
|
|
2954
|
+
primary: truthRecordMetadata(applied[0].record),
|
|
2955
|
+
snapshots: applied.slice(1).map((entry) => truthRecordMetadata(entry.record))
|
|
2956
|
+
};
|
|
2957
|
+
await client.query(
|
|
2958
|
+
`INSERT INTO proof_request_receipt (${RECEIPT_COLUMNS})
|
|
2959
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7::bigint, 200, $8, $9)`,
|
|
2960
|
+
[
|
|
2961
|
+
tenant,
|
|
2962
|
+
input.deviceId,
|
|
2963
|
+
input.requestId,
|
|
2964
|
+
input.operation,
|
|
2965
|
+
input.resource,
|
|
2966
|
+
input.proofBodySha256,
|
|
2967
|
+
input.proofBodySize,
|
|
2968
|
+
JSON.stringify(response),
|
|
2969
|
+
this.#now()
|
|
2970
|
+
]
|
|
2968
2971
|
);
|
|
2969
|
-
await
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
if (jobStarted) {
|
|
2975
|
-
await this.#failJob(client, tenant, jobId, cause).catch(() => {
|
|
2976
|
-
});
|
|
2977
|
-
}
|
|
2978
|
-
throw cause;
|
|
2972
|
+
await client.query("COMMIT");
|
|
2973
|
+
return { response, replayed: false };
|
|
2974
|
+
} catch (error) {
|
|
2975
|
+
await client.query("ROLLBACK").catch(() => void 0);
|
|
2976
|
+
throw error;
|
|
2979
2977
|
} finally {
|
|
2980
|
-
await client.query("SELECT pg_advisory_unlock(hashtextextended($1, $2))", [
|
|
2981
|
-
tenant,
|
|
2982
|
-
ADVISORY_LOCK_NAMESPACE
|
|
2983
|
-
]).catch(() => {
|
|
2984
|
-
});
|
|
2985
2978
|
client.release();
|
|
2986
2979
|
}
|
|
2987
2980
|
}
|
|
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
|
-
);
|
|
2981
|
+
async #validateInput(input) {
|
|
2982
|
+
if (input.requestId.length === 0 || input.requestId.length > TRUTH_REQUEST_ID_MAX_LENGTH) {
|
|
2983
|
+
throw new TruthCommitError("proof_request_conflict", "Request id is outside the record contract.");
|
|
2997
2984
|
}
|
|
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);
|
|
2985
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2986
|
+
const objectSizes = /* @__PURE__ */ new Map();
|
|
2987
|
+
for (const write of input.writes) {
|
|
2988
|
+
const key = writeKey(write);
|
|
2989
|
+
if (seen.has(key)) {
|
|
2990
|
+
throw new TruthCommitError("proof_request_conflict", `Duplicate truth write ${key}.`);
|
|
2991
|
+
}
|
|
2992
|
+
seen.add(key);
|
|
2993
|
+
if (write.body.kind === "inline") {
|
|
2994
|
+
const bytes = new TextEncoder().encode(write.body.body);
|
|
2995
|
+
if (BigInt(bytes.byteLength) !== write.byteSize) {
|
|
2996
|
+
throw new ByokCoreError("storage_integrity_mismatch", "Inline byte size disagrees with its content.");
|
|
2997
|
+
}
|
|
2998
|
+
if (await this.#crypto.sha256(bytes) !== write.contentHash) {
|
|
2999
|
+
throw new ByokCoreError("storage_integrity_mismatch", "Inline hash disagrees with its content.");
|
|
3000
|
+
}
|
|
3001
|
+
} else if (write.body.hash !== write.contentHash) {
|
|
3002
|
+
throw new ByokCoreError("storage_integrity_mismatch", "Object body hash disagrees with record hash.");
|
|
3039
3003
|
} 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]
|
|
3004
|
+
const priorSize = objectSizes.get(write.body.hash);
|
|
3005
|
+
if (priorSize !== void 0 && priorSize !== write.byteSize) {
|
|
3006
|
+
throw new ByokCoreError(
|
|
3007
|
+
"storage_integrity_mismatch",
|
|
3008
|
+
`Object ${write.body.hash} was declared with inconsistent byte sizes.`
|
|
3069
3009
|
);
|
|
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
3010
|
}
|
|
3011
|
+
objectSizes.set(write.body.hash, write.byteSize);
|
|
3143
3012
|
}
|
|
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
3013
|
}
|
|
3153
|
-
if (rejection !== void 0) throw rejection;
|
|
3154
|
-
return result;
|
|
3155
3014
|
}
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3015
|
+
async #readReceipt(client, tenant, deviceId, requestId) {
|
|
3016
|
+
const result = await client.query(
|
|
3017
|
+
`SELECT ${RECEIPT_COLUMNS} FROM proof_request_receipt
|
|
3018
|
+
WHERE tenant_id = $1 AND device_id = $2 AND request_id = $3`,
|
|
3019
|
+
[tenant, deviceId, requestId]
|
|
3020
|
+
);
|
|
3021
|
+
const row = result.rows[0];
|
|
3022
|
+
return row === void 0 ? void 0 : toReceipt3(tenant, row);
|
|
3023
|
+
}
|
|
3024
|
+
async #lockCurrentRecords(client, tenant, writes) {
|
|
3025
|
+
const current = /* @__PURE__ */ new Map();
|
|
3026
|
+
const ordered = [...writes].sort((a, b) => writeKey(a).localeCompare(writeKey(b)));
|
|
3027
|
+
for (const write of ordered) {
|
|
3028
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
|
|
3029
|
+
JSON.stringify(["truth-record", tenant, write.kind, write.recordKey])
|
|
3030
|
+
]);
|
|
3031
|
+
}
|
|
3032
|
+
for (const write of ordered) {
|
|
3033
|
+
const result = await client.query(
|
|
3034
|
+
`SELECT ${RECORD_COLUMNS2} FROM attested_record
|
|
3035
|
+
WHERE tenant_id = $1 AND kind = $2 AND subject_id = $3
|
|
3036
|
+
FOR UPDATE`,
|
|
3037
|
+
[tenant, write.kind, write.recordKey]
|
|
3175
3038
|
);
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3039
|
+
current.set(
|
|
3040
|
+
writeKey(write),
|
|
3041
|
+
result.rows[0] === void 0 ? void 0 : toRecord3(tenant, result.rows[0])
|
|
3042
|
+
);
|
|
3043
|
+
}
|
|
3044
|
+
return current;
|
|
3045
|
+
}
|
|
3046
|
+
#assertWritePreconditions(writes, current) {
|
|
3047
|
+
for (const write of writes) {
|
|
3048
|
+
const before = current.get(writeKey(write));
|
|
3049
|
+
if (write.kind === "task.terminal") {
|
|
3050
|
+
if (before !== void 0 && before.contentHash !== write.contentHash) {
|
|
3051
|
+
throw new CoreConflictError(
|
|
3052
|
+
"terminal_conflict",
|
|
3053
|
+
`Task ${write.recordKey} already has a different immutable terminal.`,
|
|
3054
|
+
before,
|
|
3055
|
+
this.#now()
|
|
3056
|
+
);
|
|
3057
|
+
}
|
|
3058
|
+
} else if ((before?.rev ?? 0) !== write.expectedRev) {
|
|
3059
|
+
throw new CoreConflictError(
|
|
3060
|
+
"truth_revision_conflict",
|
|
3061
|
+
`${write.kind}/${write.recordKey} is at rev ${before?.rev ?? 0}, not ${write.expectedRev}.`,
|
|
3062
|
+
before,
|
|
3063
|
+
this.#now()
|
|
3196
3064
|
);
|
|
3197
|
-
row = removed.rows[0];
|
|
3198
3065
|
}
|
|
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
3066
|
}
|
|
3208
|
-
if (rejection !== void 0) throw rejection;
|
|
3209
|
-
return toMailboxMessage(row);
|
|
3210
3067
|
}
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3068
|
+
async #lockAndVerifyObjects(client, tenant, writes, current) {
|
|
3069
|
+
const requested = /* @__PURE__ */ new Map();
|
|
3070
|
+
const affected = /* @__PURE__ */ new Set();
|
|
3071
|
+
for (const write of writes) {
|
|
3072
|
+
const before = current.get(writeKey(write));
|
|
3073
|
+
if (write.kind === "task.terminal" && before !== void 0) continue;
|
|
3074
|
+
if (before?.body.kind === "object") affected.add(before.body.hash);
|
|
3075
|
+
if (write.body.kind === "object") {
|
|
3076
|
+
const existing = requested.get(write.body.hash);
|
|
3077
|
+
if (existing !== void 0 && existing !== write.byteSize) {
|
|
3078
|
+
throw new ByokCoreError(
|
|
3079
|
+
"storage_integrity_mismatch",
|
|
3080
|
+
`Object ${write.body.hash} was declared with inconsistent byte sizes.`
|
|
3081
|
+
);
|
|
3082
|
+
}
|
|
3083
|
+
requested.set(write.body.hash, write.byteSize);
|
|
3084
|
+
affected.add(write.body.hash);
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
for (const hash of [...affected].sort()) {
|
|
3088
|
+
const result = await client.query(
|
|
3089
|
+
`SELECT hash, byte_size, state FROM object_manifest
|
|
3090
|
+
WHERE tenant_id = $1 AND hash = $2 FOR UPDATE`,
|
|
3091
|
+
[tenant, hash]
|
|
3223
3092
|
);
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3093
|
+
const manifest = result.rows[0];
|
|
3094
|
+
const byteSize = requested.get(hash);
|
|
3095
|
+
if (manifest === void 0 || byteSize !== void 0 && (manifest.state !== "committed" || manifest.byte_size !== byteSize)) {
|
|
3096
|
+
throw new TruthCommitError(
|
|
3097
|
+
"truth_object_not_committed",
|
|
3098
|
+
`Object ${hash} is not a committed matching manifest.`
|
|
3228
3099
|
);
|
|
3229
3100
|
}
|
|
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
3101
|
}
|
|
3260
3102
|
}
|
|
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`,
|
|
3103
|
+
async #prepareInlineAccounting(client, tenant, writes, current) {
|
|
3104
|
+
const entitlementResult = await client.query(
|
|
3105
|
+
`SELECT hard_limit_bytes, max_inline_bytes, downgrade_grace_until
|
|
3106
|
+
FROM storage_entitlement WHERE tenant_id = $1 FOR UPDATE`,
|
|
3270
3107
|
[tenant]
|
|
3271
3108
|
);
|
|
3272
|
-
const
|
|
3273
|
-
if (
|
|
3274
|
-
throw new
|
|
3275
|
-
"cleanup_policy_missing",
|
|
3276
|
-
`Tenant ${tenant} has no retention policy matching its entitlement.`
|
|
3277
|
-
);
|
|
3109
|
+
const entitlement = entitlementResult.rows[0];
|
|
3110
|
+
if (entitlement === void 0) {
|
|
3111
|
+
throw new ByokCoreError("storage_entitlement_missing", "Tenant has no storage entitlement.");
|
|
3278
3112
|
}
|
|
3279
|
-
const policy = toPolicy(row);
|
|
3280
|
-
assertPolicy(policy);
|
|
3281
|
-
return policy;
|
|
3282
|
-
}
|
|
3283
|
-
async #startJob(client, tenant, jobId) {
|
|
3284
3113
|
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]
|
|
3114
|
+
await client.query(
|
|
3115
|
+
`UPDATE storage_reservation SET state = 'expired', settled_at = $2
|
|
3116
|
+
WHERE tenant_id = $1 AND state = 'reserved' AND expires_at <= $2`,
|
|
3117
|
+
[tenant, now]
|
|
3294
3118
|
);
|
|
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}.`
|
|
3119
|
+
const usageResult = await client.query(
|
|
3120
|
+
`SELECT u.committed_object_bytes, u.committed_inline_bytes,
|
|
3121
|
+
COALESCE((SELECT SUM(expected_bytes) FROM storage_reservation r
|
|
3122
|
+
WHERE r.tenant_id = $1 AND r.state = 'reserved'), 0)::bigint AS reserved_bytes
|
|
3123
|
+
FROM storage_usage u WHERE u.tenant_id = $1 FOR UPDATE`,
|
|
3124
|
+
[tenant]
|
|
3125
|
+
);
|
|
3126
|
+
const usage = usageResult.rows[0];
|
|
3127
|
+
if (usage === void 0) throw new Error(`storage usage for ${tenant} is missing`);
|
|
3128
|
+
const affectedHashes = /* @__PURE__ */ new Set();
|
|
3129
|
+
const sizes = /* @__PURE__ */ new Map();
|
|
3130
|
+
for (const write of writes) {
|
|
3131
|
+
const before = current.get(writeKey(write));
|
|
3132
|
+
if (write.kind === "task.terminal" && before !== void 0) continue;
|
|
3133
|
+
if (before?.body.kind === "inline") {
|
|
3134
|
+
affectedHashes.add(before.contentHash);
|
|
3135
|
+
sizes.set(before.contentHash, before.byteSize);
|
|
3136
|
+
}
|
|
3137
|
+
if (write.body.kind !== "inline") continue;
|
|
3138
|
+
if (write.byteSize > entitlement.max_inline_bytes) {
|
|
3139
|
+
throw new ByokCoreError(
|
|
3140
|
+
"storage_object_too_large",
|
|
3141
|
+
`Inline truth ${write.kind}/${write.recordKey} exceeds maxInlineBytes.`
|
|
3366
3142
|
);
|
|
3367
3143
|
}
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3144
|
+
const knownSize = sizes.get(write.contentHash);
|
|
3145
|
+
if (knownSize !== void 0 && knownSize !== write.byteSize) {
|
|
3146
|
+
throw new ByokCoreError(
|
|
3147
|
+
"storage_integrity_mismatch",
|
|
3148
|
+
`Inline hash ${write.contentHash} was declared with inconsistent byte sizes.`
|
|
3149
|
+
);
|
|
3150
|
+
}
|
|
3151
|
+
affectedHashes.add(write.contentHash);
|
|
3152
|
+
sizes.set(write.contentHash, write.byteSize);
|
|
3153
|
+
}
|
|
3154
|
+
const hashes = [...affectedHashes].sort();
|
|
3155
|
+
const baseline = new Map(hashes.map((hash) => [hash, 0n]));
|
|
3156
|
+
const existing = await client.query(
|
|
3157
|
+
`SELECT content_hash, byte_size, count(*)::bigint AS ref_count
|
|
3158
|
+
FROM attested_record
|
|
3159
|
+
WHERE tenant_id = $1 AND body_kind = 'inline' AND content_hash = ANY($2::text[])
|
|
3160
|
+
GROUP BY content_hash, byte_size`,
|
|
3161
|
+
[tenant, hashes]
|
|
3162
|
+
);
|
|
3163
|
+
for (const row of existing.rows) {
|
|
3164
|
+
const knownSize = sizes.get(row.content_hash);
|
|
3165
|
+
if (knownSize !== void 0 && knownSize !== row.byte_size) {
|
|
3166
|
+
throw new ByokCoreError(
|
|
3167
|
+
"storage_integrity_mismatch",
|
|
3168
|
+
`Stored inline hash ${row.content_hash} disagrees on byte size.`
|
|
3169
|
+
);
|
|
3170
|
+
}
|
|
3171
|
+
if ((baseline.get(row.content_hash) ?? 0n) !== 0n) {
|
|
3172
|
+
throw new ByokCoreError(
|
|
3173
|
+
"storage_integrity_mismatch",
|
|
3174
|
+
`Stored inline hash ${row.content_hash} has multiple byte sizes.`
|
|
3175
|
+
);
|
|
3176
|
+
}
|
|
3177
|
+
baseline.set(row.content_hash, row.ref_count);
|
|
3178
|
+
sizes.set(row.content_hash, row.byte_size);
|
|
3179
|
+
}
|
|
3180
|
+
const projected = new Map(baseline);
|
|
3181
|
+
for (const write of writes) {
|
|
3182
|
+
const before = current.get(writeKey(write));
|
|
3183
|
+
if (write.kind === "task.terminal" && before !== void 0) continue;
|
|
3184
|
+
if (before?.body.kind === "inline") {
|
|
3185
|
+
projected.set(before.contentHash, (projected.get(before.contentHash) ?? 0n) - 1n);
|
|
3186
|
+
}
|
|
3187
|
+
if (write.body.kind === "inline") {
|
|
3188
|
+
projected.set(write.contentHash, (projected.get(write.contentHash) ?? 0n) + 1n);
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
let delta = 0n;
|
|
3192
|
+
let newlyCommitted = 0n;
|
|
3193
|
+
for (const hash of hashes) {
|
|
3194
|
+
const before = baseline.get(hash) ?? 0n;
|
|
3195
|
+
const after = projected.get(hash) ?? 0n;
|
|
3196
|
+
if (after < 0n) throw new Error(`inline reference count for ${hash} would become negative`);
|
|
3197
|
+
const byteSize = sizes.get(hash);
|
|
3198
|
+
if (byteSize === void 0) throw new Error(`inline byte size for ${hash} is missing`);
|
|
3199
|
+
if (before === 0n && after > 0n) {
|
|
3200
|
+
delta += byteSize;
|
|
3201
|
+
newlyCommitted += byteSize;
|
|
3202
|
+
} else if (before > 0n && after === 0n) {
|
|
3203
|
+
delta -= byteSize;
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
const used = usage.committed_object_bytes + usage.committed_inline_bytes + usage.reserved_bytes;
|
|
3207
|
+
if (newlyCommitted > 0n && used >= entitlement.hard_limit_bytes && entitlement.downgrade_grace_until !== null && entitlement.downgrade_grace_until <= now) {
|
|
3208
|
+
throw new ByokCoreError("storage_write_suspended", "Durable writes are suspended.");
|
|
3209
|
+
}
|
|
3210
|
+
if (used + delta > entitlement.hard_limit_bytes) {
|
|
3211
|
+
throw new ByokCoreError("storage_quota_exceeded", "Final inline truth usage exceeds quota.");
|
|
3212
|
+
}
|
|
3213
|
+
return delta;
|
|
3214
|
+
}
|
|
3215
|
+
async #applyWrites(client, tenant, input, current) {
|
|
3216
|
+
const applied = [];
|
|
3217
|
+
for (const write of input.writes) {
|
|
3218
|
+
const before = current.get(writeKey(write));
|
|
3219
|
+
if (write.kind === "task.terminal" && before !== void 0) {
|
|
3220
|
+
applied.push({ input: write, before, record: before, mutated: false });
|
|
3221
|
+
continue;
|
|
3222
|
+
}
|
|
3223
|
+
const [bodyKind, bodyInline, bodyObjectHash] = bodyColumns2(write.body);
|
|
3224
|
+
const values = [
|
|
3225
|
+
tenant,
|
|
3226
|
+
write.kind,
|
|
3227
|
+
write.recordKey,
|
|
3228
|
+
write.contentHash,
|
|
3229
|
+
write.byteSize,
|
|
3230
|
+
bodyKind,
|
|
3231
|
+
bodyInline,
|
|
3232
|
+
bodyObjectHash,
|
|
3233
|
+
write.label ?? null,
|
|
3234
|
+
input.requestId,
|
|
3235
|
+
this.#now()
|
|
3236
|
+
];
|
|
3237
|
+
const result = before === void 0 ? await client.query(
|
|
3238
|
+
`INSERT INTO attested_record (${RECORD_COLUMNS2})
|
|
3239
|
+
VALUES ($1, $2, $3, 1, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
3240
|
+
RETURNING ${RECORD_COLUMNS2}`,
|
|
3241
|
+
values
|
|
3242
|
+
) : await client.query(
|
|
3243
|
+
`UPDATE attested_record
|
|
3244
|
+
SET rev = rev + 1, content_hash = $4, byte_size = $5,
|
|
3245
|
+
body_kind = $6, body_inline = $7, body_object_hash = $8,
|
|
3246
|
+
label = $9, request_id = $10, written_at = $11
|
|
3247
|
+
WHERE tenant_id = $1 AND kind = $2 AND subject_id = $3
|
|
3248
|
+
RETURNING ${RECORD_COLUMNS2}`,
|
|
3249
|
+
values
|
|
3250
|
+
);
|
|
3251
|
+
applied.push({
|
|
3252
|
+
input: write,
|
|
3253
|
+
before,
|
|
3254
|
+
record: toRecord3(tenant, result.rows[0]),
|
|
3255
|
+
mutated: true
|
|
3372
3256
|
});
|
|
3373
|
-
throw cause;
|
|
3374
3257
|
}
|
|
3258
|
+
return applied;
|
|
3375
3259
|
}
|
|
3376
|
-
async #
|
|
3377
|
-
|
|
3378
|
-
|
|
3260
|
+
async #replaceObjectReferences(client, tenant, applied) {
|
|
3261
|
+
const affected = /* @__PURE__ */ new Set();
|
|
3262
|
+
for (const entry of applied) {
|
|
3263
|
+
if (!entry.mutated) continue;
|
|
3264
|
+
const refId = referenceId(entry.input);
|
|
3265
|
+
if (entry.input.body.kind === "object") {
|
|
3266
|
+
affected.add(entry.input.body.hash);
|
|
3267
|
+
await client.query(
|
|
3268
|
+
`INSERT INTO object_reference (tenant_id, hash, ref_kind, ref_id, created_at)
|
|
3269
|
+
VALUES ($1, $2, 'truth', $3, $4)
|
|
3270
|
+
ON CONFLICT (tenant_id, hash, ref_kind, ref_id) DO NOTHING`,
|
|
3271
|
+
[tenant, entry.input.body.hash, refId, this.#now()]
|
|
3272
|
+
);
|
|
3273
|
+
}
|
|
3274
|
+
if (entry.before?.body.kind === "object" && (entry.input.body.kind !== "object" || entry.input.body.hash !== entry.before.body.hash)) {
|
|
3275
|
+
affected.add(entry.before.body.hash);
|
|
3276
|
+
await client.query(
|
|
3277
|
+
`DELETE FROM object_reference
|
|
3278
|
+
WHERE tenant_id = $1 AND hash = $2 AND ref_kind = 'truth' AND ref_id = $3`,
|
|
3279
|
+
[tenant, entry.before.body.hash, refId]
|
|
3280
|
+
);
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
for (const hash of [...affected].sort()) {
|
|
3379
3284
|
await client.query(
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
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()]
|
|
3285
|
+
`UPDATE object_manifest
|
|
3286
|
+
SET ref_count = (SELECT count(*) FROM object_reference r
|
|
3287
|
+
WHERE r.tenant_id = $1 AND r.hash = $2),
|
|
3288
|
+
updated_at = $3
|
|
3289
|
+
WHERE tenant_id = $1 AND hash = $2`,
|
|
3290
|
+
[tenant, hash, this.#now()]
|
|
3412
3291
|
);
|
|
3413
|
-
await client.query("COMMIT");
|
|
3414
|
-
return BigInt(marked.rowCount ?? 0);
|
|
3415
|
-
} catch (cause) {
|
|
3416
|
-
await client.query("ROLLBACK").catch(() => {
|
|
3417
|
-
});
|
|
3418
|
-
throw cause;
|
|
3419
3292
|
}
|
|
3420
3293
|
}
|
|
3421
|
-
async #
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
JOIN storage_usage u ON u.tenant_id = m.tenant_id
|
|
3427
|
-
WHERE m.tenant_id = $1 AND m.hash = $2
|
|
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,
|
|
3294
|
+
async #settleInlineAccounting(client, tenant, delta) {
|
|
3295
|
+
if (delta !== 0n) {
|
|
3296
|
+
const updated = await client.query(
|
|
3297
|
+
`UPDATE storage_usage
|
|
3298
|
+
SET committed_inline_bytes = committed_inline_bytes + $2::bigint,
|
|
3449
3299
|
updated_at = $3
|
|
3450
|
-
|
|
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]
|
|
3481
|
-
);
|
|
3482
|
-
for (const manifest of page.rows) {
|
|
3483
|
-
const observed = await this.#objectStorage.inspectObject(
|
|
3484
|
-
tenant,
|
|
3485
|
-
manifest.hash
|
|
3486
|
-
);
|
|
3487
|
-
if (manifest.state === "delete_pending") {
|
|
3488
|
-
if (observed === void 0) {
|
|
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;
|
|
3504
|
-
}
|
|
3505
|
-
if (observed === void 0) {
|
|
3506
|
-
counts.missingObjects += 1n;
|
|
3507
|
-
} else if (observed.observedByteSize !== manifest.byte_size || observed.observedContentType !== manifest.content_type) {
|
|
3508
|
-
counts.shapeDrift += 1n;
|
|
3509
|
-
}
|
|
3510
|
-
}
|
|
3511
|
-
await this.#advanceLexicalCursor(
|
|
3512
|
-
client,
|
|
3513
|
-
tenant,
|
|
3514
|
-
"manifest",
|
|
3515
|
-
page.rows.at(-1)?.hash,
|
|
3516
|
-
page.rows.length
|
|
3517
|
-
);
|
|
3518
|
-
}
|
|
3519
|
-
async #reconcileR2(client, tenant, counts) {
|
|
3520
|
-
const cursor = await this.#readCursor(client, tenant, "r2");
|
|
3521
|
-
const page = await this.#objectStorage.listTenantObjects(
|
|
3522
|
-
tenant,
|
|
3523
|
-
cursor ?? void 0,
|
|
3524
|
-
this.#batchSize
|
|
3525
|
-
);
|
|
3526
|
-
for (const object of page.objects) {
|
|
3527
|
-
if (object.hash === void 0) {
|
|
3528
|
-
counts.invalidObjectKeys += 1n;
|
|
3529
|
-
continue;
|
|
3530
|
-
}
|
|
3531
|
-
const manifest = await client.query(
|
|
3532
|
-
"SELECT state FROM object_manifest WHERE tenant_id = $1 AND hash = $2",
|
|
3533
|
-
[tenant, object.hash]
|
|
3534
|
-
);
|
|
3535
|
-
const state = manifest.rows[0]?.state;
|
|
3536
|
-
if (state !== void 0 && state !== "deleted") continue;
|
|
3537
|
-
const observed = await this.#objectStorage.inspectObject(tenant, object.hash);
|
|
3538
|
-
if (observed === void 0) continue;
|
|
3539
|
-
const witnessed = await client.query(
|
|
3540
|
-
`INSERT INTO object_manifest (
|
|
3541
|
-
tenant_id, hash, byte_size, content_type, state, ref_count,
|
|
3542
|
-
created_at, updated_at, delete_pending_at,
|
|
3543
|
-
gc_accounted_bytes, gc_accounted_object
|
|
3544
|
-
) VALUES ($1, $2, $3, $4, 'pending', 0, $5, $5, NULL, NULL, NULL)
|
|
3545
|
-
ON CONFLICT (tenant_id, hash) DO UPDATE
|
|
3546
|
-
SET byte_size = EXCLUDED.byte_size,
|
|
3547
|
-
content_type = EXCLUDED.content_type,
|
|
3548
|
-
state = 'pending', ref_count = 0,
|
|
3549
|
-
created_at = EXCLUDED.created_at,
|
|
3550
|
-
updated_at = EXCLUDED.updated_at,
|
|
3551
|
-
delete_pending_at = NULL,
|
|
3552
|
-
gc_accounted_bytes = NULL,
|
|
3553
|
-
gc_accounted_object = NULL
|
|
3554
|
-
WHERE object_manifest.state = 'deleted'
|
|
3300
|
+
WHERE tenant_id = $1 AND committed_inline_bytes + $2::bigint >= 0
|
|
3555
3301
|
RETURNING 1`,
|
|
3556
|
-
[
|
|
3557
|
-
tenant,
|
|
3558
|
-
object.hash,
|
|
3559
|
-
observed.observedByteSize,
|
|
3560
|
-
observed.observedContentType,
|
|
3561
|
-
this.#now()
|
|
3562
|
-
]
|
|
3302
|
+
[tenant, delta, this.#now()]
|
|
3563
3303
|
);
|
|
3564
|
-
|
|
3565
|
-
}
|
|
3566
|
-
if (page.nextContinuationToken === void 0) {
|
|
3567
|
-
await this.#clearCursor(client, tenant, "r2");
|
|
3568
|
-
} else {
|
|
3569
|
-
await this.#writeCursor(client, tenant, "r2", page.nextContinuationToken);
|
|
3570
|
-
}
|
|
3571
|
-
}
|
|
3572
|
-
async #advanceLexicalCursor(client, tenant, kind, lastValue, rowCount) {
|
|
3573
|
-
if (lastValue === void 0 || rowCount < this.#batchSize) {
|
|
3574
|
-
await this.#clearCursor(client, tenant, kind);
|
|
3575
|
-
} else {
|
|
3576
|
-
await this.#writeCursor(client, tenant, kind, lastValue);
|
|
3304
|
+
if (updated.rowCount !== 1) throw new Error("inline accounting would become negative");
|
|
3577
3305
|
}
|
|
3578
3306
|
}
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
"SELECT cursor_value FROM gc_cursor WHERE tenant_id = $1 AND cursor_kind = $2",
|
|
3582
|
-
[tenant, kind]
|
|
3583
|
-
);
|
|
3584
|
-
return result.rows[0]?.cursor_value ?? null;
|
|
3585
|
-
}
|
|
3586
|
-
async #writeCursor(client, tenant, kind, value) {
|
|
3587
|
-
await client.query(
|
|
3588
|
-
`INSERT INTO gc_cursor (tenant_id, cursor_kind, cursor_value, updated_at)
|
|
3589
|
-
VALUES ($1, $2, $3, $4)
|
|
3590
|
-
ON CONFLICT (tenant_id, cursor_kind) DO UPDATE
|
|
3591
|
-
SET cursor_value = EXCLUDED.cursor_value, updated_at = EXCLUDED.updated_at`,
|
|
3592
|
-
[tenant, kind, value, this.#now()]
|
|
3593
|
-
);
|
|
3594
|
-
}
|
|
3595
|
-
async #clearCursor(client, tenant, kind) {
|
|
3596
|
-
await client.query(
|
|
3597
|
-
"DELETE FROM gc_cursor WHERE tenant_id = $1 AND cursor_kind = $2",
|
|
3598
|
-
[tenant, kind]
|
|
3599
|
-
);
|
|
3600
|
-
}
|
|
3601
|
-
async #finishJob(client, tenant, jobId, state, counts) {
|
|
3602
|
-
const finished = await client.query(
|
|
3603
|
-
`UPDATE cleanup_job SET
|
|
3604
|
-
state = $3, finished_at = $4,
|
|
3605
|
-
mailbox_deleted_count = $5, mailbox_expired_count = $6,
|
|
3606
|
-
mailbox_released_bytes = $7, reservations_expired = $8,
|
|
3607
|
-
ttl_rows_deleted = $9,
|
|
3608
|
-
objects_tombstoned = $10, objects_deleted = $11,
|
|
3609
|
-
object_released_bytes = $12, orphan_witnesses_created = $13,
|
|
3610
|
-
missing_objects = $14, shape_drift = $15,
|
|
3611
|
-
invalid_object_keys = $16, operation_errors = $17,
|
|
3612
|
-
error_message = NULL
|
|
3613
|
-
WHERE tenant_id = $1 AND job_id = $2
|
|
3614
|
-
RETURNING ${JOB_COLUMNS}`,
|
|
3615
|
-
[
|
|
3616
|
-
tenant,
|
|
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
|
-
]
|
|
3634
|
-
);
|
|
3635
|
-
return toCleanupResult(finished.rows[0]);
|
|
3636
|
-
}
|
|
3637
|
-
async #failJob(client, tenant, jobId, cause) {
|
|
3638
|
-
const message = (cause instanceof Error ? cause.message : String(cause)).slice(0, 2e3);
|
|
3639
|
-
await client.query(
|
|
3640
|
-
`UPDATE cleanup_job
|
|
3641
|
-
SET state = 'failed', finished_at = $3, error_message = $4
|
|
3642
|
-
WHERE tenant_id = $1 AND job_id = $2`,
|
|
3643
|
-
[tenant, jobId, this.#now(), message]
|
|
3644
|
-
);
|
|
3307
|
+
#now() {
|
|
3308
|
+
return this.#clock.now().toISOString();
|
|
3645
3309
|
}
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3310
|
+
};
|
|
3311
|
+
var MIGRATION_ADVISORY_LOCK_KEY = "4021960801";
|
|
3312
|
+
var MIGRATION_FILENAME_PATTERN = /^(\d{4})[_-].+\.sql$/;
|
|
3313
|
+
var LEDGER_DDL = `
|
|
3314
|
+
CREATE TABLE IF NOT EXISTS byok_schema_migration (
|
|
3315
|
+
version text PRIMARY KEY,
|
|
3316
|
+
checksum text NOT NULL,
|
|
3317
|
+
applied_at timestamptz NOT NULL
|
|
3318
|
+
)`;
|
|
3319
|
+
var MigrationChecksumMismatchError = class extends Error {
|
|
3320
|
+
version;
|
|
3321
|
+
expectedChecksum;
|
|
3322
|
+
actualChecksum;
|
|
3323
|
+
constructor(version, expectedChecksum, actualChecksum) {
|
|
3324
|
+
super(
|
|
3325
|
+
`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.`
|
|
3650
3326
|
);
|
|
3651
|
-
|
|
3327
|
+
this.name = "MigrationChecksumMismatchError";
|
|
3328
|
+
this.version = version;
|
|
3329
|
+
this.expectedChecksum = expectedChecksum;
|
|
3330
|
+
this.actualChecksum = actualChecksum;
|
|
3652
3331
|
}
|
|
3653
|
-
|
|
3654
|
-
|
|
3332
|
+
};
|
|
3333
|
+
var MigrationFilenameError = class extends Error {
|
|
3334
|
+
filename;
|
|
3335
|
+
constructor(filename, reason) {
|
|
3336
|
+
super(`Migration file ${filename} is not usable: ${reason}`);
|
|
3337
|
+
this.name = "MigrationFilenameError";
|
|
3338
|
+
this.filename = filename;
|
|
3655
3339
|
}
|
|
3656
3340
|
};
|
|
3657
|
-
function
|
|
3658
|
-
|
|
3659
|
-
return new PostgresCloudCleanup({
|
|
3660
|
-
pool: options.pool,
|
|
3661
|
-
clock: options.clock,
|
|
3662
|
-
objectStorage,
|
|
3663
|
-
...options.batchSize === void 0 ? {} : { batchSize: options.batchSize }
|
|
3664
|
-
});
|
|
3341
|
+
function sha256(text) {
|
|
3342
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
3665
3343
|
}
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
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
|
-
};
|
|
3344
|
+
async function readMigrationFiles(directory) {
|
|
3345
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
3346
|
+
const files = [];
|
|
3347
|
+
for (const entry of entries) {
|
|
3348
|
+
if (!entry.isFile() || !entry.name.endsWith(".sql")) continue;
|
|
3349
|
+
const match = MIGRATION_FILENAME_PATTERN.exec(entry.name);
|
|
3350
|
+
if (match === null) {
|
|
3351
|
+
throw new MigrationFilenameError(
|
|
3352
|
+
entry.name,
|
|
3353
|
+
"expected a four-digit prefix, e.g. 0001_cloud_local.sql"
|
|
3354
|
+
);
|
|
3355
|
+
}
|
|
3356
|
+
const sql = await readFile(join(directory, entry.name), "utf8");
|
|
3357
|
+
files.push({
|
|
3358
|
+
version: entry.name,
|
|
3359
|
+
ordinal: Number.parseInt(match[1], 10),
|
|
3360
|
+
checksum: sha256(sql),
|
|
3361
|
+
sql
|
|
3362
|
+
});
|
|
3363
|
+
}
|
|
3364
|
+
files.sort((left, right) => left.ordinal - right.ordinal);
|
|
3365
|
+
for (let index = 1; index < files.length; index += 1) {
|
|
3366
|
+
const previous = files[index - 1];
|
|
3367
|
+
const current = files[index];
|
|
3368
|
+
if (previous.ordinal === current.ordinal) {
|
|
3369
|
+
throw new MigrationFilenameError(
|
|
3370
|
+
current.version,
|
|
3371
|
+
`duplicate prefix ${String(current.ordinal).padStart(4, "0")}, already used by ${previous.version}`
|
|
3372
|
+
);
|
|
3373
|
+
}
|
|
3374
|
+
}
|
|
3375
|
+
return files;
|
|
3737
3376
|
}
|
|
3738
|
-
function
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
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
|
-
};
|
|
3377
|
+
async function readLedger(client) {
|
|
3378
|
+
const result = await client.query(
|
|
3379
|
+
"SELECT version, checksum FROM byok_schema_migration"
|
|
3380
|
+
);
|
|
3381
|
+
return new Map(result.rows.map((row) => [row.version, row.checksum]));
|
|
3750
3382
|
}
|
|
3751
|
-
function
|
|
3383
|
+
async function migrate(pool, directory) {
|
|
3384
|
+
const files = await readMigrationFiles(directory);
|
|
3385
|
+
const client = await pool.connect();
|
|
3752
3386
|
try {
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3387
|
+
await client.query("SELECT pg_advisory_lock($1)", [MIGRATION_ADVISORY_LOCK_KEY]);
|
|
3388
|
+
try {
|
|
3389
|
+
await client.query(LEDGER_DDL);
|
|
3390
|
+
const ledger = await readLedger(client);
|
|
3391
|
+
const applied = [];
|
|
3392
|
+
const alreadyApplied = [];
|
|
3393
|
+
for (const file of files) {
|
|
3394
|
+
const recordedChecksum = ledger.get(file.version);
|
|
3395
|
+
if (recordedChecksum !== void 0) {
|
|
3396
|
+
if (recordedChecksum !== file.checksum) {
|
|
3397
|
+
throw new MigrationChecksumMismatchError(file.version, recordedChecksum, file.checksum);
|
|
3398
|
+
}
|
|
3399
|
+
alreadyApplied.push(file.version);
|
|
3400
|
+
continue;
|
|
3401
|
+
}
|
|
3402
|
+
await client.query("BEGIN");
|
|
3403
|
+
try {
|
|
3404
|
+
await client.query(file.sql);
|
|
3405
|
+
await client.query(
|
|
3406
|
+
"INSERT INTO byok_schema_migration (version, checksum, applied_at) VALUES ($1, $2, now())",
|
|
3407
|
+
[file.version, file.checksum]
|
|
3408
|
+
);
|
|
3409
|
+
await client.query("COMMIT");
|
|
3410
|
+
} catch (error) {
|
|
3411
|
+
await client.query("ROLLBACK").catch(() => {
|
|
3412
|
+
});
|
|
3413
|
+
throw error;
|
|
3414
|
+
}
|
|
3415
|
+
applied.push(file.version);
|
|
3416
|
+
}
|
|
3417
|
+
return { applied, alreadyApplied };
|
|
3418
|
+
} finally {
|
|
3419
|
+
await client.query("SELECT pg_advisory_unlock($1)", [MIGRATION_ADVISORY_LOCK_KEY]);
|
|
3756
3420
|
}
|
|
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
|
-
);
|
|
3421
|
+
} finally {
|
|
3422
|
+
client.release();
|
|
3771
3423
|
}
|
|
3772
3424
|
}
|
|
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;
|
|
3425
|
+
function migrationsDir() {
|
|
3426
|
+
return fileURLToPath(new URL("./sql", import.meta.url));
|
|
3777
3427
|
}
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3428
|
+
var DEFAULT_BATCH_SIZE = 100;
|
|
3429
|
+
var MAX_BATCH_SIZE = 1e3;
|
|
3430
|
+
var ADVISORY_LOCK_NAMESPACE = 1106736963;
|
|
3431
|
+
var OUTBOX_COLUMNS2 = "tenant_id, device_id, seq, message_id, body, body_hash, byte_size, state, appended_at, replay_source_seq";
|
|
3432
|
+
var CLOUD_CLEANUP_ERROR_CODES = {
|
|
3433
|
+
cleanup_invalid_input: "cleanup_invalid_input",
|
|
3434
|
+
cleanup_policy_missing: "cleanup_policy_missing",
|
|
3435
|
+
cleanup_job_running: "cleanup_job_running",
|
|
3436
|
+
cleanup_dead_letter_not_found: "cleanup_dead_letter_not_found",
|
|
3437
|
+
cleanup_accounting_drift: "cleanup_accounting_drift"
|
|
3438
|
+
};
|
|
3439
|
+
var CloudCleanupError = class extends Error {
|
|
3440
|
+
code;
|
|
3441
|
+
constructor(code, message, options) {
|
|
3442
|
+
super(message, options);
|
|
3443
|
+
this.name = "CloudCleanupError";
|
|
3444
|
+
this.code = code;
|
|
3792
3445
|
}
|
|
3793
|
-
}
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3446
|
+
};
|
|
3447
|
+
var PostgresCloudCleanup = class {
|
|
3448
|
+
#pool;
|
|
3449
|
+
#clock;
|
|
3450
|
+
#objectStorage;
|
|
3451
|
+
#batchSize;
|
|
3452
|
+
constructor(options) {
|
|
3453
|
+
this.#pool = options.pool;
|
|
3454
|
+
this.#clock = options.clock;
|
|
3455
|
+
this.#objectStorage = options.objectStorage;
|
|
3456
|
+
this.#batchSize = assertBatchSize(options.batchSize ?? DEFAULT_BATCH_SIZE);
|
|
3800
3457
|
}
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3458
|
+
async writeRetentionPolicy(tenant, input) {
|
|
3459
|
+
assertPolicy(input);
|
|
3460
|
+
const written = await this.#pool.query(
|
|
3461
|
+
`INSERT INTO tenant_retention_policy (
|
|
3462
|
+
tenant_id, policy_id, mailbox_acked_retention_ms,
|
|
3463
|
+
mailbox_unacked_retention_ms, request_receipt_retention_ms,
|
|
3464
|
+
object_orphan_grace_ms, updated_at
|
|
3465
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
3466
|
+
ON CONFLICT (tenant_id, policy_id) DO UPDATE
|
|
3467
|
+
SET mailbox_acked_retention_ms = EXCLUDED.mailbox_acked_retention_ms,
|
|
3468
|
+
mailbox_unacked_retention_ms = EXCLUDED.mailbox_unacked_retention_ms,
|
|
3469
|
+
request_receipt_retention_ms = EXCLUDED.request_receipt_retention_ms,
|
|
3470
|
+
object_orphan_grace_ms = EXCLUDED.object_orphan_grace_ms,
|
|
3471
|
+
updated_at = EXCLUDED.updated_at
|
|
3472
|
+
RETURNING tenant_id, policy_id, mailbox_acked_retention_ms,
|
|
3473
|
+
mailbox_unacked_retention_ms, request_receipt_retention_ms,
|
|
3474
|
+
object_orphan_grace_ms, updated_at`,
|
|
3475
|
+
[
|
|
3476
|
+
tenant,
|
|
3477
|
+
input.policyId,
|
|
3478
|
+
input.mailboxAckedRetentionMs,
|
|
3479
|
+
input.mailboxUnackedRetentionMs,
|
|
3480
|
+
input.requestReceiptRetentionMs,
|
|
3481
|
+
input.objectOrphanGraceMs,
|
|
3482
|
+
this.#now()
|
|
3483
|
+
]
|
|
3808
3484
|
);
|
|
3485
|
+
return toPolicy(written.rows[0]);
|
|
3809
3486
|
}
|
|
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
|
-
);
|
|
3818
|
-
}
|
|
3819
|
-
}
|
|
3820
|
-
function cutoff(now, durationMs) {
|
|
3821
|
-
return new Date(now.getTime() - Number(durationMs)).toISOString();
|
|
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);
|
|
3487
|
+
async readRetentionPolicy(tenant) {
|
|
3488
|
+
return this.#readRetentionPolicy(this.#pool, tenant);
|
|
3890
3489
|
}
|
|
3891
|
-
|
|
3892
|
-
|
|
3490
|
+
/** Run one bounded tenant maintenance cycle. Completed job ids are replay-safe. */
|
|
3491
|
+
async runTenant(tenant, jobId) {
|
|
3492
|
+
assertIdentifier(jobId, "jobId");
|
|
3893
3493
|
const client = await this.#pool.connect();
|
|
3494
|
+
let jobStarted = false;
|
|
3894
3495
|
try {
|
|
3895
|
-
await client.query(
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3496
|
+
const lock = await client.query(
|
|
3497
|
+
"SELECT pg_try_advisory_lock(hashtextextended($1, $2)) AS locked",
|
|
3498
|
+
[tenant, ADVISORY_LOCK_NAMESPACE]
|
|
3499
|
+
);
|
|
3500
|
+
if (lock.rows[0]?.locked !== true) {
|
|
3501
|
+
throw new CloudCleanupError(
|
|
3502
|
+
"cleanup_job_running",
|
|
3503
|
+
`A cleanup job is already running for tenant ${tenant}.`
|
|
3504
|
+
);
|
|
3505
|
+
}
|
|
3506
|
+
const replay = await this.#startJob(client, tenant, jobId);
|
|
3507
|
+
if (replay !== void 0) return replay;
|
|
3508
|
+
jobStarted = true;
|
|
3509
|
+
const policy = await this.#readRetentionPolicy(client, tenant);
|
|
3510
|
+
const counts = emptyCounts();
|
|
3511
|
+
const retention = await this.#runRetention(client, tenant, policy);
|
|
3512
|
+
counts.mailboxDeletedCount = retention.mailbox_deleted_count;
|
|
3513
|
+
counts.mailboxExpiredCount = retention.mailbox_expired_count;
|
|
3514
|
+
counts.mailboxReleasedBytes = retention.mailbox_released_bytes;
|
|
3515
|
+
counts.reservationsExpired = retention.reservations_expired;
|
|
3516
|
+
counts.ttlRowsDeleted = retention.ttl_rows_deleted;
|
|
3517
|
+
const orphanCutoff = cutoff(this.#clock.now(), policy.objectOrphanGraceMs);
|
|
3518
|
+
counts.objectsTombstoned = await this.#markTombstones(client, tenant, orphanCutoff);
|
|
3519
|
+
const deleteCursor = await this.#readCursor(client, tenant, "delete");
|
|
3520
|
+
const pending = await client.query(
|
|
3521
|
+
`SELECT hash, byte_size, content_type, state,
|
|
3522
|
+
gc_accounted_bytes, gc_accounted_object
|
|
3523
|
+
FROM object_manifest
|
|
3524
|
+
WHERE tenant_id = $1
|
|
3525
|
+
AND state = 'delete_pending'
|
|
3526
|
+
AND gc_accounted_bytes IS NOT NULL
|
|
3527
|
+
AND gc_accounted_object IS NOT NULL
|
|
3528
|
+
AND hash > $2
|
|
3529
|
+
ORDER BY hash
|
|
3530
|
+
LIMIT $3`,
|
|
3531
|
+
[tenant, deleteCursor ?? "", this.#batchSize]
|
|
3532
|
+
);
|
|
3533
|
+
for (const manifest of pending.rows) {
|
|
3534
|
+
try {
|
|
3535
|
+
await this.#objectStorage.deleteObject(tenant, manifest.hash);
|
|
3536
|
+
const released = await this.#settleDeleted(client, tenant, manifest.hash);
|
|
3537
|
+
if (released !== void 0) {
|
|
3538
|
+
counts.objectsDeleted += 1n;
|
|
3539
|
+
counts.objectReleasedBytes += released;
|
|
3540
|
+
}
|
|
3541
|
+
} catch {
|
|
3542
|
+
counts.operationErrors += 1n;
|
|
3906
3543
|
}
|
|
3907
|
-
const response2 = TruthCommitResponseSchema.parse(JSON.parse(replay.responseBody));
|
|
3908
|
-
await client.query("COMMIT");
|
|
3909
|
-
return { response: response2, replayed: true };
|
|
3910
3544
|
}
|
|
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
|
-
]
|
|
3545
|
+
await this.#advanceLexicalCursor(
|
|
3546
|
+
client,
|
|
3547
|
+
tenant,
|
|
3548
|
+
"delete",
|
|
3549
|
+
pending.rows.at(-1)?.hash,
|
|
3550
|
+
pending.rows.length
|
|
3941
3551
|
);
|
|
3942
|
-
await client
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3552
|
+
await this.#reconcileManifests(client, tenant, counts);
|
|
3553
|
+
await this.#reconcileR2(client, tenant, counts);
|
|
3554
|
+
const state = counts.operationErrors === 0n ? "completed" : "completed_with_errors";
|
|
3555
|
+
return this.#finishJob(client, tenant, jobId, state, counts);
|
|
3556
|
+
} catch (cause) {
|
|
3557
|
+
if (jobStarted) {
|
|
3558
|
+
await this.#failJob(client, tenant, jobId, cause).catch(() => {
|
|
3559
|
+
});
|
|
3560
|
+
}
|
|
3561
|
+
throw cause;
|
|
3947
3562
|
} finally {
|
|
3563
|
+
await client.query("SELECT pg_advisory_unlock(hashtextextended($1, $2))", [
|
|
3564
|
+
tenant,
|
|
3565
|
+
ADVISORY_LOCK_NAMESPACE
|
|
3566
|
+
]).catch(() => {
|
|
3567
|
+
});
|
|
3948
3568
|
client.release();
|
|
3949
3569
|
}
|
|
3950
3570
|
}
|
|
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
|
-
}
|
|
3983
|
-
}
|
|
3984
|
-
}
|
|
3985
|
-
async #readReceipt(client, tenant, deviceId, requestId) {
|
|
3986
|
-
const result = await client.query(
|
|
3987
|
-
`SELECT ${RECEIPT_COLUMNS} FROM proof_request_receipt
|
|
3988
|
-
WHERE tenant_id = $1 AND device_id = $2 AND request_id = $3`,
|
|
3989
|
-
[tenant, deviceId, requestId]
|
|
3990
|
-
);
|
|
3991
|
-
const row = result.rows[0];
|
|
3992
|
-
return row === void 0 ? void 0 : toReceipt3(tenant, row);
|
|
3993
|
-
}
|
|
3994
|
-
async #lockCurrentRecords(client, tenant, writes) {
|
|
3995
|
-
const current = /* @__PURE__ */ new Map();
|
|
3996
|
-
const ordered = [...writes].sort((a, b) => writeKey(a).localeCompare(writeKey(b)));
|
|
3997
|
-
for (const write of ordered) {
|
|
3998
|
-
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
|
|
3999
|
-
JSON.stringify(["truth-record", tenant, write.kind, write.recordKey])
|
|
4000
|
-
]);
|
|
3571
|
+
async listDeadLetters(tenant, query = {}) {
|
|
3572
|
+
const limit = assertBatchSize(query.limit ?? DEFAULT_BATCH_SIZE);
|
|
3573
|
+
if (query.deviceId !== void 0) assertIdentifier(query.deviceId, "deviceId");
|
|
3574
|
+
if (query.after !== void 0) assertDeadLetterRef(query.after);
|
|
3575
|
+
if (query.deviceId !== void 0 && query.after !== void 0 && query.deviceId !== query.after.deviceId) {
|
|
3576
|
+
throw new CloudCleanupError(
|
|
3577
|
+
"cleanup_invalid_input",
|
|
3578
|
+
"A device-scoped dead-letter cursor must belong to the same device."
|
|
3579
|
+
);
|
|
4001
3580
|
}
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
3581
|
+
const listed = await this.#pool.query(
|
|
3582
|
+
`SELECT ${OUTBOX_COLUMNS2} FROM outbox
|
|
3583
|
+
WHERE tenant_id = $1
|
|
3584
|
+
AND state = 'expired'
|
|
3585
|
+
AND ($2::text IS NULL OR device_id = $2::text)
|
|
3586
|
+
AND (device_id > $3 OR (device_id = $3 AND seq > $4::bigint))
|
|
3587
|
+
ORDER BY device_id, seq
|
|
3588
|
+
LIMIT $5`,
|
|
3589
|
+
[
|
|
3590
|
+
tenant,
|
|
3591
|
+
query.deviceId ?? null,
|
|
3592
|
+
query.after?.deviceId ?? "",
|
|
3593
|
+
query.after?.seq ?? 0,
|
|
3594
|
+
limit + 1
|
|
3595
|
+
]
|
|
3596
|
+
);
|
|
3597
|
+
return {
|
|
3598
|
+
messages: listed.rows.slice(0, limit).map(toMailboxMessage),
|
|
3599
|
+
hasMore: listed.rows.length > limit
|
|
3600
|
+
};
|
|
3601
|
+
}
|
|
3602
|
+
/** Clone an expired row to a new monotonic seq. The original remains evidence. */
|
|
3603
|
+
async replayDeadLetter(tenant, input) {
|
|
3604
|
+
assertDeadLetterRef(input);
|
|
3605
|
+
assertIdentifier(input.replayMessageId, "replayMessageId");
|
|
3606
|
+
const client = await this.#pool.connect();
|
|
3607
|
+
let result;
|
|
3608
|
+
let rejection;
|
|
3609
|
+
let rollbackAllocation = false;
|
|
3610
|
+
try {
|
|
3611
|
+
await client.query("BEGIN");
|
|
3612
|
+
const originalResult = await client.query(
|
|
3613
|
+
`SELECT ${OUTBOX_COLUMNS2} FROM outbox
|
|
3614
|
+
WHERE tenant_id = $1 AND device_id = $2 AND seq = $3::bigint
|
|
3615
|
+
AND state = 'expired'
|
|
4006
3616
|
FOR UPDATE`,
|
|
4007
|
-
[tenant,
|
|
4008
|
-
);
|
|
4009
|
-
current.set(
|
|
4010
|
-
writeKey(write),
|
|
4011
|
-
result.rows[0] === void 0 ? void 0 : toRecord3(tenant, result.rows[0])
|
|
3617
|
+
[tenant, input.deviceId, input.seq]
|
|
4012
3618
|
);
|
|
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()
|
|
3619
|
+
const original = originalResult.rows[0];
|
|
3620
|
+
if (original === void 0) {
|
|
3621
|
+
rejection = deadLetterMissing(input);
|
|
3622
|
+
} else {
|
|
3623
|
+
const existingResult = await client.query(
|
|
3624
|
+
`SELECT ${OUTBOX_COLUMNS2} FROM outbox
|
|
3625
|
+
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
3626
|
+
[tenant, input.deviceId, input.replayMessageId]
|
|
4034
3627
|
);
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
3628
|
+
const existing = existingResult.rows[0];
|
|
3629
|
+
if (existing !== void 0) {
|
|
3630
|
+
if (!replayMatches(existing, original)) {
|
|
3631
|
+
rejection = new CloudCleanupError(
|
|
3632
|
+
"cleanup_invalid_input",
|
|
3633
|
+
`Replay id ${input.replayMessageId} already binds a different replay delivery.`
|
|
3634
|
+
);
|
|
3635
|
+
} else {
|
|
3636
|
+
result = toMailboxMessage(existing);
|
|
3637
|
+
}
|
|
3638
|
+
} else {
|
|
3639
|
+
const entitlement = await client.query(
|
|
3640
|
+
`SELECT e.mailbox_limit_bytes, u.mailbox_bytes
|
|
3641
|
+
FROM storage_entitlement e
|
|
3642
|
+
JOIN storage_usage u ON u.tenant_id = e.tenant_id
|
|
3643
|
+
WHERE e.tenant_id = $1
|
|
3644
|
+
FOR UPDATE OF e, u`,
|
|
3645
|
+
[tenant]
|
|
3646
|
+
);
|
|
3647
|
+
const capacity = entitlement.rows[0];
|
|
3648
|
+
const serializedExisting = await client.query(
|
|
3649
|
+
`SELECT ${OUTBOX_COLUMNS2} FROM outbox
|
|
3650
|
+
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
3651
|
+
[tenant, input.deviceId, input.replayMessageId]
|
|
4051
3652
|
);
|
|
3653
|
+
const winner = serializedExisting.rows[0];
|
|
3654
|
+
if (winner !== void 0) {
|
|
3655
|
+
if (!replayMatches(winner, original)) {
|
|
3656
|
+
rejection = new CloudCleanupError(
|
|
3657
|
+
"cleanup_invalid_input",
|
|
3658
|
+
`Replay id ${input.replayMessageId} already binds a different replay delivery.`
|
|
3659
|
+
);
|
|
3660
|
+
} else {
|
|
3661
|
+
result = toMailboxMessage(winner);
|
|
3662
|
+
}
|
|
3663
|
+
} else if (capacity === void 0) {
|
|
3664
|
+
rejection = new CloudCleanupError(
|
|
3665
|
+
"cleanup_policy_missing",
|
|
3666
|
+
`Tenant ${tenant} has no storage entitlement/usage row.`
|
|
3667
|
+
);
|
|
3668
|
+
} else {
|
|
3669
|
+
const seq = await allocateMailboxSequence(
|
|
3670
|
+
client,
|
|
3671
|
+
tenant,
|
|
3672
|
+
input.deviceId,
|
|
3673
|
+
this.#now()
|
|
3674
|
+
);
|
|
3675
|
+
const afterAllocation = await client.query(
|
|
3676
|
+
`SELECT ${OUTBOX_COLUMNS2} FROM outbox
|
|
3677
|
+
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
3678
|
+
[tenant, input.deviceId, input.replayMessageId]
|
|
3679
|
+
);
|
|
3680
|
+
const appendWinner = afterAllocation.rows[0];
|
|
3681
|
+
if (appendWinner !== void 0) {
|
|
3682
|
+
rollbackAllocation = true;
|
|
3683
|
+
if (!replayMatches(appendWinner, original)) {
|
|
3684
|
+
rejection = new CloudCleanupError(
|
|
3685
|
+
"cleanup_invalid_input",
|
|
3686
|
+
`Replay id ${input.replayMessageId} already binds a different replay delivery.`
|
|
3687
|
+
);
|
|
3688
|
+
} else {
|
|
3689
|
+
result = toMailboxMessage(appendWinner);
|
|
3690
|
+
}
|
|
3691
|
+
} else {
|
|
3692
|
+
const rebound = materializeReplayBody(original, seq);
|
|
3693
|
+
if (capacity.mailbox_bytes + rebound.byteSize > capacity.mailbox_limit_bytes) {
|
|
3694
|
+
rejection = new ByokCoreError(
|
|
3695
|
+
"storage_quota_exceeded",
|
|
3696
|
+
`Replaying the dead letter would exceed tenant ${tenant}'s mailbox limit.`
|
|
3697
|
+
);
|
|
3698
|
+
} else {
|
|
3699
|
+
const inserted = await client.query(
|
|
3700
|
+
`INSERT INTO outbox (${OUTBOX_COLUMNS2})
|
|
3701
|
+
VALUES ($1, $2, $3::bigint, $4, $5, $6, $7::bigint, 'pending', $8, $9)
|
|
3702
|
+
RETURNING ${OUTBOX_COLUMNS2}`,
|
|
3703
|
+
[
|
|
3704
|
+
tenant,
|
|
3705
|
+
input.deviceId,
|
|
3706
|
+
seq,
|
|
3707
|
+
input.replayMessageId,
|
|
3708
|
+
rebound.body,
|
|
3709
|
+
rebound.bodyHash,
|
|
3710
|
+
rebound.byteSize,
|
|
3711
|
+
this.#now(),
|
|
3712
|
+
original.seq
|
|
3713
|
+
]
|
|
3714
|
+
);
|
|
3715
|
+
await client.query(
|
|
3716
|
+
`UPDATE storage_usage
|
|
3717
|
+
SET mailbox_bytes = mailbox_bytes + $2::bigint, updated_at = $3
|
|
3718
|
+
WHERE tenant_id = $1`,
|
|
3719
|
+
[tenant, rebound.byteSize, this.#now()]
|
|
3720
|
+
);
|
|
3721
|
+
result = toMailboxMessage(inserted.rows[0]);
|
|
3722
|
+
}
|
|
3723
|
+
}
|
|
3724
|
+
}
|
|
4052
3725
|
}
|
|
4053
|
-
requested.set(write.body.hash, write.byteSize);
|
|
4054
|
-
affected.add(write.body.hash);
|
|
4055
3726
|
}
|
|
3727
|
+
if (rejection === void 0 && !rollbackAllocation) await client.query("COMMIT");
|
|
3728
|
+
else await client.query("ROLLBACK");
|
|
3729
|
+
} catch (cause) {
|
|
3730
|
+
await client.query("ROLLBACK").catch(() => {
|
|
3731
|
+
});
|
|
3732
|
+
throw cause;
|
|
3733
|
+
} finally {
|
|
3734
|
+
client.release();
|
|
4056
3735
|
}
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
3736
|
+
if (rejection !== void 0) throw rejection;
|
|
3737
|
+
return result;
|
|
3738
|
+
}
|
|
3739
|
+
/** Explicit operator discard. Automatic retention never deletes dead letters. */
|
|
3740
|
+
async discardDeadLetter(tenant, ref) {
|
|
3741
|
+
assertDeadLetterRef(ref);
|
|
3742
|
+
const client = await this.#pool.connect();
|
|
3743
|
+
let row;
|
|
3744
|
+
let rejection;
|
|
3745
|
+
try {
|
|
3746
|
+
await client.query("BEGIN");
|
|
3747
|
+
const existing = await client.query(
|
|
3748
|
+
`SELECT ${OUTBOX_COLUMNS2} FROM outbox
|
|
3749
|
+
WHERE tenant_id = $1 AND device_id = $2 AND seq = $3::bigint
|
|
3750
|
+
AND state = 'expired'
|
|
3751
|
+
FOR UPDATE`,
|
|
3752
|
+
[tenant, ref.deviceId, ref.seq]
|
|
4062
3753
|
);
|
|
4063
|
-
const
|
|
4064
|
-
const
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
3754
|
+
const deadLetter = existing.rows[0];
|
|
3755
|
+
const usage = await client.query(
|
|
3756
|
+
"SELECT mailbox_bytes FROM storage_usage WHERE tenant_id = $1 FOR UPDATE",
|
|
3757
|
+
[tenant]
|
|
3758
|
+
);
|
|
3759
|
+
if (deadLetter === void 0) {
|
|
3760
|
+
rejection = deadLetterMissing(ref);
|
|
3761
|
+
} else if (usage.rows[0] === void 0 || usage.rows[0].mailbox_bytes < deadLetter.byte_size) {
|
|
3762
|
+
rejection = new CloudCleanupError(
|
|
3763
|
+
"cleanup_accounting_drift",
|
|
3764
|
+
`Mailbox accounting cannot release dead letter ${ref.deviceId}/${String(ref.seq)}.`
|
|
3765
|
+
);
|
|
3766
|
+
} else {
|
|
3767
|
+
const removed = await client.query(
|
|
3768
|
+
`DELETE FROM outbox
|
|
3769
|
+
WHERE tenant_id = $1 AND device_id = $2 AND seq = $3::bigint
|
|
3770
|
+
AND state = 'expired'
|
|
3771
|
+
RETURNING ${OUTBOX_COLUMNS2}`,
|
|
3772
|
+
[tenant, ref.deviceId, ref.seq]
|
|
3773
|
+
);
|
|
3774
|
+
await client.query(
|
|
3775
|
+
`UPDATE storage_usage
|
|
3776
|
+
SET mailbox_bytes = mailbox_bytes - $2::bigint, updated_at = $3
|
|
3777
|
+
WHERE tenant_id = $1`,
|
|
3778
|
+
[tenant, deadLetter.byte_size, this.#now()]
|
|
4069
3779
|
);
|
|
3780
|
+
row = removed.rows[0];
|
|
4070
3781
|
}
|
|
3782
|
+
if (rejection === void 0) await client.query("COMMIT");
|
|
3783
|
+
else await client.query("ROLLBACK");
|
|
3784
|
+
} catch (cause) {
|
|
3785
|
+
await client.query("ROLLBACK").catch(() => {
|
|
3786
|
+
});
|
|
3787
|
+
throw cause;
|
|
3788
|
+
} finally {
|
|
3789
|
+
client.release();
|
|
4071
3790
|
}
|
|
3791
|
+
if (rejection !== void 0) throw rejection;
|
|
3792
|
+
return toMailboxMessage(row);
|
|
4072
3793
|
}
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
const
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
COALESCE((SELECT SUM(expected_bytes) FROM storage_reservation r
|
|
4092
|
-
WHERE r.tenant_id = $1 AND r.state = 'reserved'), 0)::bigint AS reserved_bytes
|
|
4093
|
-
FROM storage_usage u WHERE u.tenant_id = $1 FOR UPDATE`,
|
|
4094
|
-
[tenant]
|
|
4095
|
-
);
|
|
4096
|
-
const usage = usageResult.rows[0];
|
|
4097
|
-
if (usage === void 0) throw new Error(`storage usage for ${tenant} is missing`);
|
|
4098
|
-
const affectedHashes = /* @__PURE__ */ new Set();
|
|
4099
|
-
const sizes = /* @__PURE__ */ new Map();
|
|
4100
|
-
for (const write of writes) {
|
|
4101
|
-
const before = current.get(writeKey(write));
|
|
4102
|
-
if (write.kind === "task.terminal" && before !== void 0) continue;
|
|
4103
|
-
if (before?.body.kind === "inline") {
|
|
4104
|
-
affectedHashes.add(before.contentHash);
|
|
4105
|
-
sizes.set(before.contentHash, before.byteSize);
|
|
4106
|
-
}
|
|
4107
|
-
if (write.body.kind !== "inline") continue;
|
|
4108
|
-
if (write.byteSize > entitlement.max_inline_bytes) {
|
|
4109
|
-
throw new ByokCoreError(
|
|
4110
|
-
"storage_object_too_large",
|
|
4111
|
-
`Inline truth ${write.kind}/${write.recordKey} exceeds maxInlineBytes.`
|
|
4112
|
-
);
|
|
4113
|
-
}
|
|
4114
|
-
const knownSize = sizes.get(write.contentHash);
|
|
4115
|
-
if (knownSize !== void 0 && knownSize !== write.byteSize) {
|
|
4116
|
-
throw new ByokCoreError(
|
|
4117
|
-
"storage_integrity_mismatch",
|
|
4118
|
-
`Inline hash ${write.contentHash} was declared with inconsistent byte sizes.`
|
|
4119
|
-
);
|
|
4120
|
-
}
|
|
4121
|
-
affectedHashes.add(write.contentHash);
|
|
4122
|
-
sizes.set(write.contentHash, write.byteSize);
|
|
4123
|
-
}
|
|
4124
|
-
const hashes = [...affectedHashes].sort();
|
|
4125
|
-
const baseline = new Map(hashes.map((hash) => [hash, 0n]));
|
|
4126
|
-
const existing = await client.query(
|
|
4127
|
-
`SELECT content_hash, byte_size, count(*)::bigint AS ref_count
|
|
4128
|
-
FROM attested_record
|
|
4129
|
-
WHERE tenant_id = $1 AND body_kind = 'inline' AND content_hash = ANY($2::text[])
|
|
4130
|
-
GROUP BY content_hash, byte_size`,
|
|
4131
|
-
[tenant, hashes]
|
|
4132
|
-
);
|
|
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
|
-
}
|
|
4185
|
-
async #applyWrites(client, tenant, input, current) {
|
|
4186
|
-
const applied = [];
|
|
4187
|
-
for (const write of input.writes) {
|
|
4188
|
-
const before = current.get(writeKey(write));
|
|
4189
|
-
if (write.kind === "task.terminal" && before !== void 0) {
|
|
4190
|
-
applied.push({ input: write, before, record: before, mutated: false });
|
|
4191
|
-
continue;
|
|
3794
|
+
/**
|
|
3795
|
+
* Explicit recovery operation: rebuild object accounting from committed
|
|
3796
|
+
* Postgres manifests. Reconciliation must run first; R2 LIST is never used as
|
|
3797
|
+
* billing authority and inline/mailbox usage is left untouched.
|
|
3798
|
+
*/
|
|
3799
|
+
async rebuildObjectUsage(tenant) {
|
|
3800
|
+
const client = await this.#pool.connect();
|
|
3801
|
+
try {
|
|
3802
|
+
await client.query("BEGIN");
|
|
3803
|
+
const locked = await client.query(
|
|
3804
|
+
"SELECT 1 FROM storage_usage WHERE tenant_id = $1 FOR UPDATE",
|
|
3805
|
+
[tenant]
|
|
3806
|
+
);
|
|
3807
|
+
if (locked.rowCount === 0) {
|
|
3808
|
+
throw new CloudCleanupError(
|
|
3809
|
+
"cleanup_policy_missing",
|
|
3810
|
+
`Tenant ${tenant} has no storage usage row to rebuild.`
|
|
3811
|
+
);
|
|
4192
3812
|
}
|
|
4193
|
-
const
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
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
|
|
3813
|
+
const rebuilt = await client.query(
|
|
3814
|
+
`WITH authority AS MATERIALIZED (
|
|
3815
|
+
SELECT COALESCE(SUM(byte_size), 0)::bigint AS committed_object_bytes,
|
|
3816
|
+
count(*)::bigint AS object_count
|
|
3817
|
+
FROM object_manifest
|
|
3818
|
+
WHERE tenant_id = $1 AND state = 'committed'
|
|
3819
|
+
)
|
|
3820
|
+
UPDATE storage_usage u
|
|
3821
|
+
SET committed_object_bytes = authority.committed_object_bytes,
|
|
3822
|
+
object_count = authority.object_count,
|
|
3823
|
+
updated_at = $2
|
|
3824
|
+
FROM authority
|
|
3825
|
+
WHERE u.tenant_id = $1
|
|
3826
|
+
RETURNING u.committed_object_bytes, u.object_count, u.updated_at`,
|
|
3827
|
+
[tenant, this.#now()]
|
|
4220
3828
|
);
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
3829
|
+
await client.query("COMMIT");
|
|
3830
|
+
const row = rebuilt.rows[0];
|
|
3831
|
+
return {
|
|
3832
|
+
committedObjectBytes: row.committed_object_bytes,
|
|
3833
|
+
objectCount: row.object_count,
|
|
3834
|
+
updatedAt: row.updated_at
|
|
3835
|
+
};
|
|
3836
|
+
} catch (cause) {
|
|
3837
|
+
await client.query("ROLLBACK").catch(() => {
|
|
4226
3838
|
});
|
|
3839
|
+
throw cause;
|
|
3840
|
+
} finally {
|
|
3841
|
+
client.release();
|
|
4227
3842
|
}
|
|
4228
|
-
return applied;
|
|
4229
3843
|
}
|
|
4230
|
-
async #
|
|
4231
|
-
const
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
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()]
|
|
3844
|
+
async #readRetentionPolicy(queryable, tenant) {
|
|
3845
|
+
const result = await queryable.query(
|
|
3846
|
+
`SELECT p.tenant_id, p.policy_id, p.mailbox_acked_retention_ms,
|
|
3847
|
+
p.mailbox_unacked_retention_ms, p.request_receipt_retention_ms,
|
|
3848
|
+
p.object_orphan_grace_ms, p.updated_at
|
|
3849
|
+
FROM storage_entitlement e
|
|
3850
|
+
JOIN tenant_retention_policy p
|
|
3851
|
+
ON p.tenant_id = e.tenant_id AND p.policy_id = e.retention_policy_id
|
|
3852
|
+
WHERE e.tenant_id = $1`,
|
|
3853
|
+
[tenant]
|
|
3854
|
+
);
|
|
3855
|
+
const row = result.rows[0];
|
|
3856
|
+
if (row === void 0) {
|
|
3857
|
+
throw new CloudCleanupError(
|
|
3858
|
+
"cleanup_policy_missing",
|
|
3859
|
+
`Tenant ${tenant} has no retention policy matching its entitlement.`
|
|
4261
3860
|
);
|
|
4262
3861
|
}
|
|
3862
|
+
const policy = toPolicy(row);
|
|
3863
|
+
assertPolicy(policy);
|
|
3864
|
+
return policy;
|
|
4263
3865
|
}
|
|
4264
|
-
async #
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
3866
|
+
async #startJob(client, tenant, jobId) {
|
|
3867
|
+
const now = this.#now();
|
|
3868
|
+
const started = await client.query(
|
|
3869
|
+
`INSERT INTO cleanup_job (tenant_id, job_id, kind, state, started_at)
|
|
3870
|
+
VALUES ($1, $2, 'tenant_cleanup', 'running', $3)
|
|
3871
|
+
ON CONFLICT (tenant_id, job_id) DO UPDATE
|
|
3872
|
+
SET state = 'running', started_at = EXCLUDED.started_at,
|
|
3873
|
+
finished_at = NULL, error_message = NULL
|
|
3874
|
+
WHERE cleanup_job.state IN ('running', 'failed')
|
|
3875
|
+
RETURNING ${JOB_COLUMNS}`,
|
|
3876
|
+
[tenant, jobId, now]
|
|
3877
|
+
);
|
|
3878
|
+
if (started.rows[0] !== void 0) return void 0;
|
|
3879
|
+
const existing = await this.#readJob(client, tenant, jobId);
|
|
3880
|
+
return toCleanupResult(existing);
|
|
3881
|
+
}
|
|
3882
|
+
async #runRetention(client, tenant, policy) {
|
|
3883
|
+
const ackedBefore = cutoff(this.#clock.now(), policy.mailboxAckedRetentionMs);
|
|
3884
|
+
const expireBefore = cutoff(this.#clock.now(), policy.mailboxUnackedRetentionMs);
|
|
3885
|
+
const receiptBefore = cutoff(this.#clock.now(), policy.requestReceiptRetentionMs);
|
|
3886
|
+
const now = this.#now();
|
|
3887
|
+
try {
|
|
3888
|
+
await client.query("BEGIN");
|
|
3889
|
+
const swept = await client.query(
|
|
3890
|
+
`WITH deleted AS (
|
|
3891
|
+
DELETE FROM outbox
|
|
3892
|
+
WHERE tenant_id = $1 AND state = 'acked' AND appended_at < $2
|
|
3893
|
+
RETURNING byte_size
|
|
3894
|
+
), released AS MATERIALIZED (
|
|
3895
|
+
SELECT COALESCE(SUM(byte_size), 0)::bigint AS bytes FROM deleted
|
|
3896
|
+
), expired AS (
|
|
3897
|
+
UPDATE outbox SET state = 'expired'
|
|
3898
|
+
WHERE tenant_id = $1 AND state = 'pending' AND appended_at < $3
|
|
3899
|
+
RETURNING 1
|
|
3900
|
+
), reservations AS (
|
|
3901
|
+
UPDATE storage_reservation SET state = 'expired', settled_at = $4
|
|
3902
|
+
WHERE tenant_id = $1 AND state = 'reserved' AND expires_at <= $4
|
|
3903
|
+
RETURNING 1
|
|
3904
|
+
), nonces AS (
|
|
3905
|
+
DELETE FROM auth_nonce
|
|
3906
|
+
WHERE tenant_id = $1 AND (used OR expires_at <= $4::timestamptz)
|
|
3907
|
+
RETURNING 1
|
|
3908
|
+
), pairing_codes AS (
|
|
3909
|
+
DELETE FROM pairing_code
|
|
3910
|
+
WHERE tenant_id = $1
|
|
3911
|
+
AND (redeemed_at IS NOT NULL OR expires_at <= $4::timestamptz)
|
|
3912
|
+
RETURNING 1
|
|
3913
|
+
), receipts AS (
|
|
3914
|
+
DELETE FROM device_request_receipts
|
|
3915
|
+
WHERE tenant_id = $1 AND recorded_at < $5::timestamptz
|
|
3916
|
+
RETURNING 1
|
|
3917
|
+
), presence AS (
|
|
3918
|
+
DELETE FROM device_presence
|
|
3919
|
+
WHERE tenant_id = $1 AND expires_at <= $4
|
|
3920
|
+
RETURNING 1
|
|
3921
|
+
), activity AS (
|
|
3922
|
+
DELETE FROM activity_tail
|
|
3923
|
+
WHERE tenant_id = $1 AND expires_at <= $4
|
|
3924
|
+
RETURNING 1
|
|
3925
|
+
), approvals AS (
|
|
3926
|
+
DELETE FROM approval_timeline_tail
|
|
3927
|
+
WHERE tenant_id = $1 AND expires_at <= $4
|
|
3928
|
+
RETURNING 1
|
|
3929
|
+
), accounted AS (
|
|
3930
|
+
UPDATE storage_usage u
|
|
3931
|
+
SET mailbox_bytes = u.mailbox_bytes - released.bytes, updated_at = $4
|
|
3932
|
+
FROM released
|
|
3933
|
+
WHERE u.tenant_id = $1 AND u.mailbox_bytes >= released.bytes
|
|
3934
|
+
RETURNING released.bytes
|
|
3935
|
+
)
|
|
3936
|
+
SELECT (SELECT count(*) FROM deleted)::bigint AS mailbox_deleted_count,
|
|
3937
|
+
(SELECT count(*) FROM expired)::bigint AS mailbox_expired_count,
|
|
3938
|
+
(SELECT bytes FROM released)::bigint AS mailbox_released_bytes,
|
|
3939
|
+
(SELECT count(*) FROM accounted)::bigint AS usage_accounted,
|
|
3940
|
+
(SELECT count(*) FROM reservations)::bigint AS reservations_expired,
|
|
3941
|
+
((SELECT count(*) FROM nonces)
|
|
3942
|
+
+ (SELECT count(*) FROM pairing_codes)
|
|
3943
|
+
+ (SELECT count(*) FROM receipts)
|
|
3944
|
+
+ (SELECT count(*) FROM presence)
|
|
3945
|
+
+ (SELECT count(*) FROM activity)
|
|
3946
|
+
+ (SELECT count(*) FROM approvals))::bigint AS ttl_rows_deleted`,
|
|
3947
|
+
[tenant, ackedBefore, expireBefore, now, receiptBefore]
|
|
3948
|
+
);
|
|
3949
|
+
const result = swept.rows[0];
|
|
3950
|
+
if (result.usage_accounted !== 1n) {
|
|
3951
|
+
throw new CloudCleanupError(
|
|
3952
|
+
"cleanup_accounting_drift",
|
|
3953
|
+
`Mailbox accounting cannot release ${String(result.mailbox_released_bytes)} deleted bytes for tenant ${tenant}.`
|
|
3954
|
+
);
|
|
3955
|
+
}
|
|
3956
|
+
await client.query("COMMIT");
|
|
3957
|
+
return result;
|
|
3958
|
+
} catch (cause) {
|
|
3959
|
+
await client.query("ROLLBACK").catch(() => {
|
|
3960
|
+
});
|
|
3961
|
+
throw cause;
|
|
3962
|
+
}
|
|
3963
|
+
}
|
|
3964
|
+
async #markTombstones(client, tenant, orphanCutoff) {
|
|
3965
|
+
try {
|
|
3966
|
+
await client.query("BEGIN");
|
|
3967
|
+
await client.query(
|
|
3968
|
+
"SELECT 1 FROM storage_entitlement WHERE tenant_id = $1 FOR UPDATE",
|
|
3969
|
+
[tenant]
|
|
3970
|
+
);
|
|
3971
|
+
const marked = await client.query(
|
|
3972
|
+
`WITH candidates AS MATERIALIZED (
|
|
3973
|
+
SELECT m.tenant_id, m.hash
|
|
3974
|
+
FROM object_manifest m
|
|
3975
|
+
WHERE m.tenant_id = $1
|
|
3976
|
+
AND m.state IN ('pending', 'committed')
|
|
3977
|
+
AND m.ref_count = 0
|
|
3978
|
+
AND m.updated_at < $2
|
|
3979
|
+
AND NOT EXISTS (
|
|
3980
|
+
SELECT 1 FROM object_reference r
|
|
3981
|
+
WHERE r.tenant_id = m.tenant_id AND r.hash = m.hash
|
|
3982
|
+
)
|
|
3983
|
+
AND NOT EXISTS (
|
|
3984
|
+
SELECT 1 FROM storage_reservation s
|
|
3985
|
+
WHERE s.tenant_id = m.tenant_id AND s.content_hash = m.hash
|
|
3986
|
+
AND s.state = 'reserved'
|
|
3987
|
+
)
|
|
3988
|
+
ORDER BY m.updated_at, m.hash
|
|
3989
|
+
LIMIT $3
|
|
3990
|
+
FOR UPDATE OF m SKIP LOCKED
|
|
3991
|
+
)
|
|
3992
|
+
UPDATE object_manifest m
|
|
3993
|
+
SET gc_accounted_bytes = CASE WHEN m.state = 'committed' THEN m.byte_size ELSE 0 END,
|
|
3994
|
+
gc_accounted_object = (m.state = 'committed'),
|
|
3995
|
+
state = 'delete_pending', delete_pending_at = $4, updated_at = $4
|
|
3996
|
+
FROM candidates c
|
|
3997
|
+
WHERE m.tenant_id = c.tenant_id AND m.hash = c.hash
|
|
3998
|
+
RETURNING m.hash`,
|
|
3999
|
+
[tenant, orphanCutoff, this.#batchSize, this.#now()]
|
|
4000
|
+
);
|
|
4001
|
+
await client.query("COMMIT");
|
|
4002
|
+
return BigInt(marked.rowCount ?? 0);
|
|
4003
|
+
} catch (cause) {
|
|
4004
|
+
await client.query("ROLLBACK").catch(() => {
|
|
4005
|
+
});
|
|
4006
|
+
throw cause;
|
|
4007
|
+
}
|
|
4008
|
+
}
|
|
4009
|
+
async #settleDeleted(client, tenant, hash) {
|
|
4010
|
+
const settled = await client.query(
|
|
4011
|
+
`WITH candidate AS MATERIALIZED (
|
|
4012
|
+
SELECT m.gc_accounted_bytes, m.gc_accounted_object
|
|
4013
|
+
FROM object_manifest m
|
|
4014
|
+
JOIN storage_usage u ON u.tenant_id = m.tenant_id
|
|
4015
|
+
WHERE m.tenant_id = $1 AND m.hash = $2
|
|
4016
|
+
AND m.state = 'delete_pending'
|
|
4017
|
+
AND m.ref_count = 0
|
|
4018
|
+
AND m.gc_accounted_bytes IS NOT NULL
|
|
4019
|
+
AND m.gc_accounted_object IS NOT NULL
|
|
4020
|
+
AND NOT EXISTS (
|
|
4021
|
+
SELECT 1 FROM object_reference r
|
|
4022
|
+
WHERE r.tenant_id = m.tenant_id AND r.hash = m.hash
|
|
4023
|
+
)
|
|
4024
|
+
AND u.committed_object_bytes >= m.gc_accounted_bytes
|
|
4025
|
+
AND u.object_count >= CASE WHEN m.gc_accounted_object THEN 1 ELSE 0 END
|
|
4026
|
+
FOR UPDATE OF m, u
|
|
4027
|
+
), moved AS (
|
|
4028
|
+
UPDATE object_manifest m
|
|
4029
|
+
SET state = 'deleted', updated_at = $3
|
|
4030
|
+
FROM candidate c
|
|
4031
|
+
WHERE m.tenant_id = $1 AND m.hash = $2 AND m.state = 'delete_pending'
|
|
4032
|
+
RETURNING c.gc_accounted_bytes, c.gc_accounted_object
|
|
4033
|
+
), accounted AS (
|
|
4034
|
+
UPDATE storage_usage u
|
|
4035
|
+
SET committed_object_bytes = u.committed_object_bytes - moved.gc_accounted_bytes,
|
|
4036
|
+
object_count = u.object_count - CASE WHEN moved.gc_accounted_object THEN 1 ELSE 0 END,
|
|
4269
4037
|
updated_at = $3
|
|
4270
|
-
|
|
4038
|
+
FROM moved
|
|
4039
|
+
WHERE u.tenant_id = $1
|
|
4040
|
+
RETURNING moved.gc_accounted_bytes
|
|
4041
|
+
)
|
|
4042
|
+
SELECT gc_accounted_bytes FROM accounted`,
|
|
4043
|
+
[tenant, hash, this.#now()]
|
|
4044
|
+
);
|
|
4045
|
+
const row = settled.rows[0];
|
|
4046
|
+
if (row !== void 0) return row.gc_accounted_bytes;
|
|
4047
|
+
const current = await client.query(
|
|
4048
|
+
"SELECT state FROM object_manifest WHERE tenant_id = $1 AND hash = $2",
|
|
4049
|
+
[tenant, hash]
|
|
4050
|
+
);
|
|
4051
|
+
if (current.rows[0]?.state === "deleted") return void 0;
|
|
4052
|
+
throw new CloudCleanupError(
|
|
4053
|
+
"cleanup_accounting_drift",
|
|
4054
|
+
`Object ${hash} could not settle its delete tombstone against storage usage.`
|
|
4055
|
+
);
|
|
4056
|
+
}
|
|
4057
|
+
async #reconcileManifests(client, tenant, counts) {
|
|
4058
|
+
const cursor = await this.#readCursor(client, tenant, "manifest");
|
|
4059
|
+
const page = await client.query(
|
|
4060
|
+
`SELECT hash, byte_size, content_type, state,
|
|
4061
|
+
gc_accounted_bytes, gc_accounted_object
|
|
4062
|
+
FROM object_manifest
|
|
4063
|
+
WHERE tenant_id = $1
|
|
4064
|
+
AND state IN ('committed', 'delete_pending')
|
|
4065
|
+
AND hash > $2
|
|
4066
|
+
ORDER BY hash
|
|
4067
|
+
LIMIT $3`,
|
|
4068
|
+
[tenant, cursor ?? "", this.#batchSize]
|
|
4069
|
+
);
|
|
4070
|
+
for (const manifest of page.rows) {
|
|
4071
|
+
const observed = await this.#objectStorage.inspectObject(
|
|
4072
|
+
tenant,
|
|
4073
|
+
manifest.hash
|
|
4074
|
+
);
|
|
4075
|
+
if (manifest.state === "delete_pending") {
|
|
4076
|
+
if (observed === void 0) {
|
|
4077
|
+
try {
|
|
4078
|
+
const released = await this.#settleDeleted(
|
|
4079
|
+
client,
|
|
4080
|
+
tenant,
|
|
4081
|
+
manifest.hash
|
|
4082
|
+
);
|
|
4083
|
+
if (released !== void 0) {
|
|
4084
|
+
counts.objectsDeleted += 1n;
|
|
4085
|
+
counts.objectReleasedBytes += released;
|
|
4086
|
+
}
|
|
4087
|
+
} catch {
|
|
4088
|
+
counts.operationErrors += 1n;
|
|
4089
|
+
}
|
|
4090
|
+
}
|
|
4091
|
+
continue;
|
|
4092
|
+
}
|
|
4093
|
+
if (observed === void 0) {
|
|
4094
|
+
counts.missingObjects += 1n;
|
|
4095
|
+
} else if (observed.observedByteSize !== manifest.byte_size || observed.observedContentType !== manifest.content_type) {
|
|
4096
|
+
counts.shapeDrift += 1n;
|
|
4097
|
+
}
|
|
4098
|
+
}
|
|
4099
|
+
await this.#advanceLexicalCursor(
|
|
4100
|
+
client,
|
|
4101
|
+
tenant,
|
|
4102
|
+
"manifest",
|
|
4103
|
+
page.rows.at(-1)?.hash,
|
|
4104
|
+
page.rows.length
|
|
4105
|
+
);
|
|
4106
|
+
}
|
|
4107
|
+
async #reconcileR2(client, tenant, counts) {
|
|
4108
|
+
const cursor = await this.#readCursor(client, tenant, "r2");
|
|
4109
|
+
const page = await this.#objectStorage.listTenantObjects(
|
|
4110
|
+
tenant,
|
|
4111
|
+
cursor ?? void 0,
|
|
4112
|
+
this.#batchSize
|
|
4113
|
+
);
|
|
4114
|
+
for (const object of page.objects) {
|
|
4115
|
+
if (object.hash === void 0) {
|
|
4116
|
+
counts.invalidObjectKeys += 1n;
|
|
4117
|
+
continue;
|
|
4118
|
+
}
|
|
4119
|
+
const manifest = await client.query(
|
|
4120
|
+
"SELECT state FROM object_manifest WHERE tenant_id = $1 AND hash = $2",
|
|
4121
|
+
[tenant, object.hash]
|
|
4122
|
+
);
|
|
4123
|
+
const state = manifest.rows[0]?.state;
|
|
4124
|
+
if (state !== void 0 && state !== "deleted") continue;
|
|
4125
|
+
const observed = await this.#objectStorage.inspectObject(tenant, object.hash);
|
|
4126
|
+
if (observed === void 0) continue;
|
|
4127
|
+
const witnessed = await client.query(
|
|
4128
|
+
`INSERT INTO object_manifest (
|
|
4129
|
+
tenant_id, hash, byte_size, content_type, state, ref_count,
|
|
4130
|
+
created_at, updated_at, delete_pending_at,
|
|
4131
|
+
gc_accounted_bytes, gc_accounted_object
|
|
4132
|
+
) VALUES ($1, $2, $3, $4, 'pending', 0, $5, $5, NULL, NULL, NULL)
|
|
4133
|
+
ON CONFLICT (tenant_id, hash) DO UPDATE
|
|
4134
|
+
SET byte_size = EXCLUDED.byte_size,
|
|
4135
|
+
content_type = EXCLUDED.content_type,
|
|
4136
|
+
state = 'pending', ref_count = 0,
|
|
4137
|
+
created_at = EXCLUDED.created_at,
|
|
4138
|
+
updated_at = EXCLUDED.updated_at,
|
|
4139
|
+
delete_pending_at = NULL,
|
|
4140
|
+
gc_accounted_bytes = NULL,
|
|
4141
|
+
gc_accounted_object = NULL
|
|
4142
|
+
WHERE object_manifest.state = 'deleted'
|
|
4271
4143
|
RETURNING 1`,
|
|
4272
|
-
[
|
|
4144
|
+
[
|
|
4145
|
+
tenant,
|
|
4146
|
+
object.hash,
|
|
4147
|
+
observed.observedByteSize,
|
|
4148
|
+
observed.observedContentType,
|
|
4149
|
+
this.#now()
|
|
4150
|
+
]
|
|
4273
4151
|
);
|
|
4274
|
-
|
|
4152
|
+
counts.orphanWitnessesCreated += BigInt(witnessed.rowCount ?? 0);
|
|
4153
|
+
}
|
|
4154
|
+
if (page.nextContinuationToken === void 0) {
|
|
4155
|
+
await this.#clearCursor(client, tenant, "r2");
|
|
4156
|
+
} else {
|
|
4157
|
+
await this.#writeCursor(client, tenant, "r2", page.nextContinuationToken);
|
|
4158
|
+
}
|
|
4159
|
+
}
|
|
4160
|
+
async #advanceLexicalCursor(client, tenant, kind, lastValue, rowCount) {
|
|
4161
|
+
if (lastValue === void 0 || rowCount < this.#batchSize) {
|
|
4162
|
+
await this.#clearCursor(client, tenant, kind);
|
|
4163
|
+
} else {
|
|
4164
|
+
await this.#writeCursor(client, tenant, kind, lastValue);
|
|
4275
4165
|
}
|
|
4276
4166
|
}
|
|
4167
|
+
async #readCursor(client, tenant, kind) {
|
|
4168
|
+
const result = await client.query(
|
|
4169
|
+
"SELECT cursor_value FROM gc_cursor WHERE tenant_id = $1 AND cursor_kind = $2",
|
|
4170
|
+
[tenant, kind]
|
|
4171
|
+
);
|
|
4172
|
+
return result.rows[0]?.cursor_value ?? null;
|
|
4173
|
+
}
|
|
4174
|
+
async #writeCursor(client, tenant, kind, value) {
|
|
4175
|
+
await client.query(
|
|
4176
|
+
`INSERT INTO gc_cursor (tenant_id, cursor_kind, cursor_value, updated_at)
|
|
4177
|
+
VALUES ($1, $2, $3, $4)
|
|
4178
|
+
ON CONFLICT (tenant_id, cursor_kind) DO UPDATE
|
|
4179
|
+
SET cursor_value = EXCLUDED.cursor_value, updated_at = EXCLUDED.updated_at`,
|
|
4180
|
+
[tenant, kind, value, this.#now()]
|
|
4181
|
+
);
|
|
4182
|
+
}
|
|
4183
|
+
async #clearCursor(client, tenant, kind) {
|
|
4184
|
+
await client.query(
|
|
4185
|
+
"DELETE FROM gc_cursor WHERE tenant_id = $1 AND cursor_kind = $2",
|
|
4186
|
+
[tenant, kind]
|
|
4187
|
+
);
|
|
4188
|
+
}
|
|
4189
|
+
async #finishJob(client, tenant, jobId, state, counts) {
|
|
4190
|
+
const finished = await client.query(
|
|
4191
|
+
`UPDATE cleanup_job SET
|
|
4192
|
+
state = $3, finished_at = $4,
|
|
4193
|
+
mailbox_deleted_count = $5, mailbox_expired_count = $6,
|
|
4194
|
+
mailbox_released_bytes = $7, reservations_expired = $8,
|
|
4195
|
+
ttl_rows_deleted = $9,
|
|
4196
|
+
objects_tombstoned = $10, objects_deleted = $11,
|
|
4197
|
+
object_released_bytes = $12, orphan_witnesses_created = $13,
|
|
4198
|
+
missing_objects = $14, shape_drift = $15,
|
|
4199
|
+
invalid_object_keys = $16, operation_errors = $17,
|
|
4200
|
+
error_message = NULL
|
|
4201
|
+
WHERE tenant_id = $1 AND job_id = $2
|
|
4202
|
+
RETURNING ${JOB_COLUMNS}`,
|
|
4203
|
+
[
|
|
4204
|
+
tenant,
|
|
4205
|
+
jobId,
|
|
4206
|
+
state,
|
|
4207
|
+
this.#now(),
|
|
4208
|
+
counts.mailboxDeletedCount,
|
|
4209
|
+
counts.mailboxExpiredCount,
|
|
4210
|
+
counts.mailboxReleasedBytes,
|
|
4211
|
+
counts.reservationsExpired,
|
|
4212
|
+
counts.ttlRowsDeleted,
|
|
4213
|
+
counts.objectsTombstoned,
|
|
4214
|
+
counts.objectsDeleted,
|
|
4215
|
+
counts.objectReleasedBytes,
|
|
4216
|
+
counts.orphanWitnessesCreated,
|
|
4217
|
+
counts.missingObjects,
|
|
4218
|
+
counts.shapeDrift,
|
|
4219
|
+
counts.invalidObjectKeys,
|
|
4220
|
+
counts.operationErrors
|
|
4221
|
+
]
|
|
4222
|
+
);
|
|
4223
|
+
return toCleanupResult(finished.rows[0]);
|
|
4224
|
+
}
|
|
4225
|
+
async #failJob(client, tenant, jobId, cause) {
|
|
4226
|
+
const message = (cause instanceof Error ? cause.message : String(cause)).slice(0, 2e3);
|
|
4227
|
+
await client.query(
|
|
4228
|
+
`UPDATE cleanup_job
|
|
4229
|
+
SET state = 'failed', finished_at = $3, error_message = $4
|
|
4230
|
+
WHERE tenant_id = $1 AND job_id = $2`,
|
|
4231
|
+
[tenant, jobId, this.#now(), message]
|
|
4232
|
+
);
|
|
4233
|
+
}
|
|
4234
|
+
async #readJob(client, tenant, jobId) {
|
|
4235
|
+
const result = await client.query(
|
|
4236
|
+
`SELECT ${JOB_COLUMNS} FROM cleanup_job WHERE tenant_id = $1 AND job_id = $2`,
|
|
4237
|
+
[tenant, jobId]
|
|
4238
|
+
);
|
|
4239
|
+
return result.rows[0];
|
|
4240
|
+
}
|
|
4277
4241
|
#now() {
|
|
4278
4242
|
return this.#clock.now().toISOString();
|
|
4279
4243
|
}
|
|
4280
4244
|
};
|
|
4245
|
+
function createPostgresCloudMaintenance(options) {
|
|
4246
|
+
const objectStorage = new R2ObjectMaintenanceStore(options.objectStorage);
|
|
4247
|
+
return new PostgresCloudCleanup({
|
|
4248
|
+
pool: options.pool,
|
|
4249
|
+
clock: options.clock,
|
|
4250
|
+
objectStorage,
|
|
4251
|
+
...options.batchSize === void 0 ? {} : { batchSize: options.batchSize }
|
|
4252
|
+
});
|
|
4253
|
+
}
|
|
4254
|
+
var JOB_COLUMNS = [
|
|
4255
|
+
"tenant_id",
|
|
4256
|
+
"job_id",
|
|
4257
|
+
"state",
|
|
4258
|
+
"started_at",
|
|
4259
|
+
"finished_at",
|
|
4260
|
+
"mailbox_deleted_count",
|
|
4261
|
+
"mailbox_expired_count",
|
|
4262
|
+
"mailbox_released_bytes",
|
|
4263
|
+
"reservations_expired",
|
|
4264
|
+
"ttl_rows_deleted",
|
|
4265
|
+
"objects_tombstoned",
|
|
4266
|
+
"objects_deleted",
|
|
4267
|
+
"object_released_bytes",
|
|
4268
|
+
"orphan_witnesses_created",
|
|
4269
|
+
"missing_objects",
|
|
4270
|
+
"shape_drift",
|
|
4271
|
+
"invalid_object_keys",
|
|
4272
|
+
"operation_errors",
|
|
4273
|
+
"error_message"
|
|
4274
|
+
].join(", ");
|
|
4275
|
+
function emptyCounts() {
|
|
4276
|
+
return {
|
|
4277
|
+
mailboxDeletedCount: 0n,
|
|
4278
|
+
mailboxExpiredCount: 0n,
|
|
4279
|
+
mailboxReleasedBytes: 0n,
|
|
4280
|
+
reservationsExpired: 0n,
|
|
4281
|
+
ttlRowsDeleted: 0n,
|
|
4282
|
+
objectsTombstoned: 0n,
|
|
4283
|
+
objectsDeleted: 0n,
|
|
4284
|
+
objectReleasedBytes: 0n,
|
|
4285
|
+
orphanWitnessesCreated: 0n,
|
|
4286
|
+
missingObjects: 0n,
|
|
4287
|
+
shapeDrift: 0n,
|
|
4288
|
+
invalidObjectKeys: 0n,
|
|
4289
|
+
operationErrors: 0n
|
|
4290
|
+
};
|
|
4291
|
+
}
|
|
4292
|
+
function toPolicy(row) {
|
|
4293
|
+
return {
|
|
4294
|
+
tenantId: tenantId(row.tenant_id),
|
|
4295
|
+
policyId: row.policy_id,
|
|
4296
|
+
mailboxAckedRetentionMs: row.mailbox_acked_retention_ms,
|
|
4297
|
+
mailboxUnackedRetentionMs: row.mailbox_unacked_retention_ms,
|
|
4298
|
+
requestReceiptRetentionMs: row.request_receipt_retention_ms,
|
|
4299
|
+
objectOrphanGraceMs: row.object_orphan_grace_ms,
|
|
4300
|
+
updatedAt: row.updated_at
|
|
4301
|
+
};
|
|
4302
|
+
}
|
|
4303
|
+
function toCleanupResult(row) {
|
|
4304
|
+
return {
|
|
4305
|
+
tenantId: tenantId(row.tenant_id),
|
|
4306
|
+
jobId: row.job_id,
|
|
4307
|
+
state: row.state,
|
|
4308
|
+
startedAt: row.started_at,
|
|
4309
|
+
...row.finished_at === null ? {} : { finishedAt: row.finished_at },
|
|
4310
|
+
mailboxDeletedCount: row.mailbox_deleted_count,
|
|
4311
|
+
mailboxExpiredCount: row.mailbox_expired_count,
|
|
4312
|
+
mailboxReleasedBytes: row.mailbox_released_bytes,
|
|
4313
|
+
reservationsExpired: row.reservations_expired,
|
|
4314
|
+
ttlRowsDeleted: row.ttl_rows_deleted,
|
|
4315
|
+
objectsTombstoned: row.objects_tombstoned,
|
|
4316
|
+
objectsDeleted: row.objects_deleted,
|
|
4317
|
+
objectReleasedBytes: row.object_released_bytes,
|
|
4318
|
+
orphanWitnessesCreated: row.orphan_witnesses_created,
|
|
4319
|
+
missingObjects: row.missing_objects,
|
|
4320
|
+
shapeDrift: row.shape_drift,
|
|
4321
|
+
invalidObjectKeys: row.invalid_object_keys,
|
|
4322
|
+
operationErrors: row.operation_errors,
|
|
4323
|
+
...row.error_message === null ? {} : { errorMessage: row.error_message }
|
|
4324
|
+
};
|
|
4325
|
+
}
|
|
4326
|
+
function toMailboxMessage(row) {
|
|
4327
|
+
return {
|
|
4328
|
+
tenantId: tenantId(row.tenant_id),
|
|
4329
|
+
deviceId: row.device_id,
|
|
4330
|
+
seq: Number(row.seq),
|
|
4331
|
+
messageId: row.message_id,
|
|
4332
|
+
body: row.body,
|
|
4333
|
+
bodyHash: row.body_hash,
|
|
4334
|
+
byteSize: row.byte_size,
|
|
4335
|
+
state: row.state,
|
|
4336
|
+
appendedAt: row.appended_at
|
|
4337
|
+
};
|
|
4338
|
+
}
|
|
4339
|
+
function materializeReplayBody(original, seq) {
|
|
4340
|
+
try {
|
|
4341
|
+
const envelope = decodeEnvelope(original.body);
|
|
4342
|
+
if (!isServerToDaemonType(envelope.type)) {
|
|
4343
|
+
throw new Error(`Envelope type ${envelope.type} is not server-to-daemon.`);
|
|
4344
|
+
}
|
|
4345
|
+
const rebound = EnvelopeSchema.parse({ ...envelope, seq });
|
|
4346
|
+
const body = encodeEnvelope(rebound);
|
|
4347
|
+
const bytes = new TextEncoder().encode(body);
|
|
4348
|
+
return {
|
|
4349
|
+
body,
|
|
4350
|
+
bodyHash: contentHash(`sha256:${createHash("sha256").update(bytes).digest("hex")}`),
|
|
4351
|
+
byteSize: BigInt(bytes.length)
|
|
4352
|
+
};
|
|
4353
|
+
} catch (cause) {
|
|
4354
|
+
throw new CloudCleanupError(
|
|
4355
|
+
"cleanup_invalid_input",
|
|
4356
|
+
`Dead letter ${original.device_id}/${String(original.seq)} is not a replayable server-to-daemon envelope.`,
|
|
4357
|
+
{ cause }
|
|
4358
|
+
);
|
|
4359
|
+
}
|
|
4360
|
+
}
|
|
4361
|
+
function replayMatches(row, original) {
|
|
4362
|
+
if (row.replay_source_seq !== original.seq) return false;
|
|
4363
|
+
const expected = materializeReplayBody(original, Number(row.seq));
|
|
4364
|
+
return row.body === expected.body && row.body_hash === expected.bodyHash && row.byte_size === expected.byteSize;
|
|
4365
|
+
}
|
|
4366
|
+
function assertPolicy(input) {
|
|
4367
|
+
assertIdentifier(input.policyId, "policyId");
|
|
4368
|
+
for (const [field, value] of [
|
|
4369
|
+
["mailboxAckedRetentionMs", input.mailboxAckedRetentionMs],
|
|
4370
|
+
["mailboxUnackedRetentionMs", input.mailboxUnackedRetentionMs],
|
|
4371
|
+
["requestReceiptRetentionMs", input.requestReceiptRetentionMs],
|
|
4372
|
+
["objectOrphanGraceMs", input.objectOrphanGraceMs]
|
|
4373
|
+
]) {
|
|
4374
|
+
if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
4375
|
+
throw new CloudCleanupError(
|
|
4376
|
+
"cleanup_invalid_input",
|
|
4377
|
+
`${field} must be a non-negative duration no larger than Number.MAX_SAFE_INTEGER milliseconds.`
|
|
4378
|
+
);
|
|
4379
|
+
}
|
|
4380
|
+
}
|
|
4381
|
+
}
|
|
4382
|
+
function assertBatchSize(value) {
|
|
4383
|
+
if (!Number.isInteger(value) || value < 1 || value > MAX_BATCH_SIZE) {
|
|
4384
|
+
throw new CloudCleanupError(
|
|
4385
|
+
"cleanup_invalid_input",
|
|
4386
|
+
`batchSize/limit must be a whole number in [1, ${String(MAX_BATCH_SIZE)}].`
|
|
4387
|
+
);
|
|
4388
|
+
}
|
|
4389
|
+
return value;
|
|
4390
|
+
}
|
|
4391
|
+
function assertIdentifier(value, field) {
|
|
4392
|
+
if (value.length === 0 || value.length > 256 || value.trim() !== value) {
|
|
4393
|
+
throw new CloudCleanupError(
|
|
4394
|
+
"cleanup_invalid_input",
|
|
4395
|
+
`${field} must be a non-empty, unpadded string no longer than 256 characters.`
|
|
4396
|
+
);
|
|
4397
|
+
}
|
|
4398
|
+
}
|
|
4399
|
+
function assertDeadLetterRef(ref) {
|
|
4400
|
+
assertIdentifier(ref.deviceId, "deviceId");
|
|
4401
|
+
if (!Number.isSafeInteger(ref.seq) || ref.seq < 1) {
|
|
4402
|
+
throw new CloudCleanupError(
|
|
4403
|
+
"cleanup_invalid_input",
|
|
4404
|
+
"A dead-letter seq must be a positive safe integer."
|
|
4405
|
+
);
|
|
4406
|
+
}
|
|
4407
|
+
}
|
|
4408
|
+
function cutoff(now, durationMs) {
|
|
4409
|
+
return new Date(now.getTime() - Number(durationMs)).toISOString();
|
|
4410
|
+
}
|
|
4411
|
+
function deadLetterMissing(ref) {
|
|
4412
|
+
return new CloudCleanupError(
|
|
4413
|
+
"cleanup_dead_letter_not_found",
|
|
4414
|
+
`Expired mailbox row ${ref.deviceId}/${String(ref.seq)} was not found.`
|
|
4415
|
+
);
|
|
4416
|
+
}
|
|
4281
4417
|
|
|
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 };
|
|
4418
|
+
export { CLOUD_CLEANUP_ERROR_CODES, CloudCleanupError, DEFAULT_MAX_ATTEMPTS, DEFAULT_PRESIGN_TTL_SECONDS, DEFAULT_RETRY_DELAY_MS, MAX_PRESIGN_TTL_SECONDS, MIN_PRESIGN_TTL_SECONDS, MigrationChecksumMismatchError, MigrationFilenameError, ObjectStoreRequestError, PostgresActivityStore, PostgresApprovalTimelineStore, PostgresBoardStore, PostgresCloudCleanup, PostgresDeviceDirectory, PostgresInboundDedupStore, PostgresMailboxStore, PostgresNonceStore, PostgresObjectStore, PostgresPairingCodeStore, PostgresPresenceStore, PostgresProofRequestReceiptStore, PostgresQuotaStore, PostgresRequestReceiptStore, PostgresSkillPackStore, PostgresTaskAttemptStore, PostgresTruthCommitter, PostgresTruthStore, R2BlobStoreError, R2CloudBlobStore, R2ObjectMaintenanceStore, R2_BLOB_ERROR_CODES, createByokPool, createPostgresCloudMaintenance, createPostgresCloudStores, createPostgresCoreStores, migrate, migrationsDir, readMigrationFiles };
|
|
4283
4419
|
//# sourceMappingURL=index.js.map
|
|
4284
4420
|
//# sourceMappingURL=index.js.map
|