@byok-sdk/cloud-dataplane 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +224 -0
- package/dist/cleanup.d.ts +106 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +4284 -0
- package/dist/index.js.map +1 -0
- package/dist/migrate.d.ts +54 -0
- package/dist/migrations-dir.d.ts +7 -0
- package/dist/pool.d.ts +39 -0
- package/dist/sql/0001_cloud_local.sql +184 -0
- package/dist/sql/0002_core_domain.sql +416 -0
- package/dist/sql/0003_cloud_cleanup.sql +97 -0
- package/dist/sql/0004_device_proof_truth.sql +39 -0
- package/dist/sql/0005_skill_packs.sql +78 -0
- package/dist/sql/0006_device_presence_toolsets.sql +18 -0
- package/dist/stores/core/board.d.ts +38 -0
- package/dist/stores/core/index.d.ts +37 -0
- package/dist/stores/core/mailbox-sequence.d.ts +10 -0
- package/dist/stores/core/mailbox.d.ts +32 -0
- package/dist/stores/core/objects.d.ts +49 -0
- package/dist/stores/core/presence.d.ts +34 -0
- package/dist/stores/core/quota.d.ts +56 -0
- package/dist/stores/core/skill-pack.d.ts +41 -0
- package/dist/stores/core/truth.d.ts +32 -0
- package/dist/stores/dedup.d.ts +22 -0
- package/dist/stores/devices.d.ts +23 -0
- package/dist/stores/index.d.ts +55 -0
- package/dist/stores/nonces.d.ts +26 -0
- package/dist/stores/pairing-codes.d.ts +26 -0
- package/dist/stores/proof-receipts.d.ts +12 -0
- package/dist/stores/r2-blobs.d.ts +249 -0
- package/dist/stores/receipts.d.ts +25 -0
- package/dist/stores/task-attempts.d.ts +36 -0
- package/dist/truth-committer.d.ts +15 -0
- package/package.json +59 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4284 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import { readdir, readFile } from 'fs/promises';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
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
|
+
import { decodeEnvelope, isServerToDaemonType, EnvelopeSchema, encodeEnvelope } from '@byok-sdk/protocol';
|
|
11
|
+
|
|
12
|
+
// src/pool.ts
|
|
13
|
+
var defaultTypeParser = pg.types.getTypeParser;
|
|
14
|
+
var int8Parsers = {
|
|
15
|
+
getTypeParser(id, format) {
|
|
16
|
+
if (id === pg.types.builtins.INT8) return (value) => BigInt(value);
|
|
17
|
+
return defaultTypeParser(id, format);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
function createByokPool(options) {
|
|
21
|
+
const { onPoolError, ...poolConfig } = options;
|
|
22
|
+
const pool = new pg.Pool({ ...poolConfig, types: int8Parsers });
|
|
23
|
+
pool.on("error", (err, client) => {
|
|
24
|
+
if (onPoolError) {
|
|
25
|
+
onPoolError(err, client);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
console.error("[byok-sdk] idle pg pool client error (handled, not fatal)", err);
|
|
29
|
+
});
|
|
30
|
+
return pool;
|
|
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
|
+
var DEFAULT_LIST_LIMIT = 100;
|
|
150
|
+
var MANIFEST_COLUMNS = "tenant_id, hash, byte_size, content_type, state, ref_count, created_at, updated_at, delete_pending_at";
|
|
151
|
+
function toEntry(row) {
|
|
152
|
+
return {
|
|
153
|
+
tenantId: row.tenant_id,
|
|
154
|
+
hash: row.hash,
|
|
155
|
+
byteSize: row.byte_size,
|
|
156
|
+
contentType: row.content_type,
|
|
157
|
+
state: row.state,
|
|
158
|
+
refCount: row.ref_count,
|
|
159
|
+
createdAt: row.created_at,
|
|
160
|
+
updatedAt: row.updated_at,
|
|
161
|
+
...row.delete_pending_at === null ? {} : { deletePendingAt: row.delete_pending_at }
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
var PostgresObjectStore = class {
|
|
165
|
+
#pool;
|
|
166
|
+
#clock;
|
|
167
|
+
constructor(pool, clock) {
|
|
168
|
+
this.#pool = pool;
|
|
169
|
+
this.#clock = clock;
|
|
170
|
+
}
|
|
171
|
+
async putManifest(tenant, input) {
|
|
172
|
+
const now = this.#now();
|
|
173
|
+
const upserted = await this.#pool.query(
|
|
174
|
+
`INSERT INTO object_manifest (${MANIFEST_COLUMNS})
|
|
175
|
+
VALUES ($1, $2, $3::bigint, $4, 'pending', 0, $5, $5, NULL)
|
|
176
|
+
ON CONFLICT (tenant_id, hash) DO UPDATE
|
|
177
|
+
SET byte_size = EXCLUDED.byte_size,
|
|
178
|
+
content_type = EXCLUDED.content_type,
|
|
179
|
+
state = 'pending',
|
|
180
|
+
ref_count = 0,
|
|
181
|
+
created_at = EXCLUDED.created_at,
|
|
182
|
+
updated_at = EXCLUDED.updated_at,
|
|
183
|
+
delete_pending_at = NULL
|
|
184
|
+
WHERE object_manifest.state = 'deleted'
|
|
185
|
+
RETURNING ${MANIFEST_COLUMNS}`,
|
|
186
|
+
[tenant, input.hash, input.byteSize, input.contentType, now]
|
|
187
|
+
);
|
|
188
|
+
const row = upserted.rows[0];
|
|
189
|
+
if (row !== void 0) return toEntry(row);
|
|
190
|
+
return this.#require(tenant, input.hash);
|
|
191
|
+
}
|
|
192
|
+
async commit(tenant, input) {
|
|
193
|
+
const committed = await this.#pool.query(
|
|
194
|
+
`UPDATE object_manifest
|
|
195
|
+
SET state = 'committed', updated_at = $3
|
|
196
|
+
WHERE tenant_id = $1 AND hash = $2
|
|
197
|
+
AND state = 'pending'
|
|
198
|
+
AND byte_size = $4::bigint
|
|
199
|
+
AND content_type = $5
|
|
200
|
+
RETURNING ${MANIFEST_COLUMNS}`,
|
|
201
|
+
[tenant, input.hash, this.#now(), input.observedByteSize, input.observedContentType]
|
|
202
|
+
);
|
|
203
|
+
const row = committed.rows[0];
|
|
204
|
+
if (row !== void 0) return toEntry(row);
|
|
205
|
+
const current = await this.#require(tenant, input.hash);
|
|
206
|
+
if (current.byteSize !== input.observedByteSize || current.contentType !== input.observedContentType) {
|
|
207
|
+
throw new ByokCoreError(
|
|
208
|
+
"storage_integrity_mismatch",
|
|
209
|
+
`Observed object ${input.hash} (${String(input.observedByteSize)} bytes, ${input.observedContentType}) does not match the declared manifest.`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (current.state === "committed") return current;
|
|
213
|
+
throw this.#stateInvalid(current.state, "committed");
|
|
214
|
+
}
|
|
215
|
+
async get(tenant, hash) {
|
|
216
|
+
const result = await this.#pool.query(
|
|
217
|
+
`SELECT ${MANIFEST_COLUMNS} FROM object_manifest WHERE tenant_id = $1 AND hash = $2`,
|
|
218
|
+
[tenant, hash]
|
|
219
|
+
);
|
|
220
|
+
const row = result.rows[0];
|
|
221
|
+
return row === void 0 ? void 0 : toEntry(row);
|
|
222
|
+
}
|
|
223
|
+
async list(tenant, query) {
|
|
224
|
+
if (query.deletePendingBefore !== void 0) {
|
|
225
|
+
assertCanonicalTimestamp(query.deletePendingBefore, "deletePendingBefore");
|
|
226
|
+
}
|
|
227
|
+
const result = await this.#pool.query(
|
|
228
|
+
`SELECT ${MANIFEST_COLUMNS} FROM object_manifest
|
|
229
|
+
WHERE tenant_id = $1
|
|
230
|
+
AND ($2::text IS NULL OR state = $2::text)
|
|
231
|
+
AND ($3::text IS NULL
|
|
232
|
+
OR (delete_pending_at IS NOT NULL AND delete_pending_at < $3::text))
|
|
233
|
+
ORDER BY hash COLLATE "C"
|
|
234
|
+
LIMIT $4`,
|
|
235
|
+
[
|
|
236
|
+
tenant,
|
|
237
|
+
query.state ?? null,
|
|
238
|
+
query.deletePendingBefore ?? null,
|
|
239
|
+
query.limit ?? DEFAULT_LIST_LIMIT
|
|
240
|
+
]
|
|
241
|
+
);
|
|
242
|
+
return result.rows.map(toEntry);
|
|
243
|
+
}
|
|
244
|
+
async addReference(tenant, input) {
|
|
245
|
+
return this.#withManifestLocked(tenant, input.hash, async (client, current) => {
|
|
246
|
+
if (current.state !== "committed") {
|
|
247
|
+
return new ByokCoreError(
|
|
248
|
+
"object_state_invalid",
|
|
249
|
+
`Only committed objects can be referenced; ${input.hash} is ${current.state}.`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
await client.query(
|
|
253
|
+
`INSERT INTO object_reference (tenant_id, hash, ref_kind, ref_id, created_at)
|
|
254
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
255
|
+
ON CONFLICT (tenant_id, hash, ref_kind, ref_id) DO NOTHING`,
|
|
256
|
+
[tenant, input.hash, input.refKind, input.refId, this.#now()]
|
|
257
|
+
);
|
|
258
|
+
return this.#recount(client, tenant, input.hash);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
async removeReference(tenant, input) {
|
|
262
|
+
return this.#withManifestLocked(tenant, input.hash, async (client) => {
|
|
263
|
+
await client.query(
|
|
264
|
+
`DELETE FROM object_reference
|
|
265
|
+
WHERE tenant_id = $1 AND hash = $2 AND ref_kind = $3 AND ref_id = $4`,
|
|
266
|
+
[tenant, input.hash, input.refKind, input.refId]
|
|
267
|
+
);
|
|
268
|
+
return this.#recount(client, tenant, input.hash);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
async markDeletePending(tenant, hash) {
|
|
272
|
+
const marked = await this.#pool.query(
|
|
273
|
+
`UPDATE object_manifest
|
|
274
|
+
SET gc_accounted_bytes = CASE WHEN state = 'committed' THEN byte_size ELSE 0 END,
|
|
275
|
+
gc_accounted_object = (state = 'committed'),
|
|
276
|
+
state = 'delete_pending', delete_pending_at = $3, updated_at = $3
|
|
277
|
+
WHERE tenant_id = $1 AND hash = $2
|
|
278
|
+
AND ref_count = 0
|
|
279
|
+
AND state IN ('pending', 'committed')
|
|
280
|
+
RETURNING ${MANIFEST_COLUMNS}`,
|
|
281
|
+
[tenant, hash, this.#now()]
|
|
282
|
+
);
|
|
283
|
+
const row = marked.rows[0];
|
|
284
|
+
if (row !== void 0) return toEntry(row);
|
|
285
|
+
const current = await this.#require(tenant, hash);
|
|
286
|
+
if (current.refCount !== 0) {
|
|
287
|
+
throw new ByokCoreError(
|
|
288
|
+
"object_state_invalid",
|
|
289
|
+
`Object ${hash} still has ${current.refCount} reference(s).`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
throw this.#stateInvalid(current.state, "delete_pending");
|
|
293
|
+
}
|
|
294
|
+
async markDeleted(tenant, hash) {
|
|
295
|
+
const deleted = await this.#pool.query(
|
|
296
|
+
`UPDATE object_manifest
|
|
297
|
+
SET state = 'deleted', updated_at = $3
|
|
298
|
+
WHERE tenant_id = $1 AND hash = $2 AND state = 'delete_pending'
|
|
299
|
+
RETURNING ${MANIFEST_COLUMNS}`,
|
|
300
|
+
[tenant, hash, this.#now()]
|
|
301
|
+
);
|
|
302
|
+
const row = deleted.rows[0];
|
|
303
|
+
if (row !== void 0) return toEntry(row);
|
|
304
|
+
const current = await this.#require(tenant, hash);
|
|
305
|
+
throw this.#stateInvalid(current.state, "deleted");
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Runs a reference mutation with the manifest row held under `FOR UPDATE`.
|
|
309
|
+
*
|
|
310
|
+
* The lock is taken before anything is read, so the state the callback judges
|
|
311
|
+
* is the state no concurrent `markDeletePending` can move underneath it. A
|
|
312
|
+
* typed refusal is RETURNED rather than thrown, the way
|
|
313
|
+
* `PostgresQuotaStore.reserve` defers its rejection past `COMMIT`: it keeps
|
|
314
|
+
* the `catch` reserved for genuine faults, so a rollback that itself fails
|
|
315
|
+
* cannot replace this store's own answer.
|
|
316
|
+
*/
|
|
317
|
+
async #withManifestLocked(tenant, hash, mutate) {
|
|
318
|
+
const client = await this.#pool.connect();
|
|
319
|
+
let settled;
|
|
320
|
+
try {
|
|
321
|
+
await client.query("BEGIN");
|
|
322
|
+
const locked = await client.query(
|
|
323
|
+
`SELECT ${MANIFEST_COLUMNS} FROM object_manifest
|
|
324
|
+
WHERE tenant_id = $1 AND hash = $2
|
|
325
|
+
FOR UPDATE`,
|
|
326
|
+
[tenant, hash]
|
|
327
|
+
);
|
|
328
|
+
const row = locked.rows[0];
|
|
329
|
+
settled = row === void 0 ? this.#notFound(hash) : await mutate(client, toEntry(row));
|
|
330
|
+
await client.query("COMMIT");
|
|
331
|
+
} catch (error) {
|
|
332
|
+
await client.query("ROLLBACK").catch(() => {
|
|
333
|
+
});
|
|
334
|
+
throw error;
|
|
335
|
+
} finally {
|
|
336
|
+
client.release();
|
|
337
|
+
}
|
|
338
|
+
if (settled instanceof ByokCoreError) throw settled;
|
|
339
|
+
return settled;
|
|
340
|
+
}
|
|
341
|
+
/** Sets `ref_count` to what the reference rows actually say. */
|
|
342
|
+
async #recount(client, tenant, hash) {
|
|
343
|
+
const result = await client.query(
|
|
344
|
+
`UPDATE object_manifest
|
|
345
|
+
SET ref_count = (SELECT count(*) FROM object_reference r
|
|
346
|
+
WHERE r.tenant_id = $1 AND r.hash = $2),
|
|
347
|
+
updated_at = $3
|
|
348
|
+
WHERE tenant_id = $1 AND hash = $2
|
|
349
|
+
RETURNING ${MANIFEST_COLUMNS}`,
|
|
350
|
+
[tenant, hash, this.#now()]
|
|
351
|
+
);
|
|
352
|
+
const row = result.rows[0];
|
|
353
|
+
if (row === void 0) throw this.#notFound(hash);
|
|
354
|
+
return toEntry(row);
|
|
355
|
+
}
|
|
356
|
+
async #require(tenant, hash) {
|
|
357
|
+
const entry = await this.get(tenant, hash);
|
|
358
|
+
if (entry === void 0) throw this.#notFound(hash);
|
|
359
|
+
return entry;
|
|
360
|
+
}
|
|
361
|
+
#notFound(hash) {
|
|
362
|
+
return new ByokCoreError(
|
|
363
|
+
"object_not_found",
|
|
364
|
+
`Object ${hash} has no manifest row in this tenant.`
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
#stateInvalid(from, to) {
|
|
368
|
+
return new ByokCoreError(
|
|
369
|
+
"object_state_invalid",
|
|
370
|
+
`${from} to ${to} is not a legal object manifest transition.`
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
#now() {
|
|
374
|
+
return this.#clock.now().toISOString();
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
var DEFAULT_PRESIGN_TTL_SECONDS = 15 * 60;
|
|
378
|
+
var MIN_PRESIGN_TTL_SECONDS = 1;
|
|
379
|
+
var MAX_PRESIGN_TTL_SECONDS = 604800;
|
|
380
|
+
var DEFAULT_MAX_ATTEMPTS = 3;
|
|
381
|
+
var DEFAULT_RETRY_DELAY_MS = 100;
|
|
382
|
+
var ObjectStoreRequestError = class extends Error {
|
|
383
|
+
status;
|
|
384
|
+
attempts;
|
|
385
|
+
constructor(message, attempts, status, options) {
|
|
386
|
+
super(message, options);
|
|
387
|
+
this.name = "ObjectStoreRequestError";
|
|
388
|
+
this.attempts = attempts;
|
|
389
|
+
this.status = status;
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
var R2_BLOB_ERROR_CODES = {
|
|
393
|
+
/**
|
|
394
|
+
* A tenant id that cannot be one safe path segment. Wire-relevant: it is the
|
|
395
|
+
* only signal a control plane gets that the id it issued cannot address
|
|
396
|
+
* object storage, and it is raised BEFORE any key is built.
|
|
397
|
+
*/
|
|
398
|
+
storage_tenant_key_unsafe: "storage_tenant_key_unsafe",
|
|
399
|
+
/** A presign lifetime outside `[MIN_PRESIGN_TTL_SECONDS, MAX_PRESIGN_TTL_SECONDS]`. Construction-time only. */
|
|
400
|
+
storage_presign_ttl_invalid: "storage_presign_ttl_invalid",
|
|
401
|
+
/** ListObjectsV2 accepts 1..1000 keys per page. Maintenance input only. */
|
|
402
|
+
storage_list_limit_invalid: "storage_list_limit_invalid",
|
|
403
|
+
/** Continuation tokens are opaque but non-empty. Maintenance input only. */
|
|
404
|
+
storage_list_cursor_invalid: "storage_list_cursor_invalid"
|
|
405
|
+
};
|
|
406
|
+
var R2BlobStoreError = class extends Error {
|
|
407
|
+
code;
|
|
408
|
+
constructor(code, message, options) {
|
|
409
|
+
super(message, options);
|
|
410
|
+
this.name = "R2BlobStoreError";
|
|
411
|
+
this.code = code;
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
var R2CloudBlobStore = class {
|
|
415
|
+
#objects;
|
|
416
|
+
#signingClock;
|
|
417
|
+
#client;
|
|
418
|
+
#origin;
|
|
419
|
+
#bucket;
|
|
420
|
+
#keyPrefix;
|
|
421
|
+
#presignTtlSeconds;
|
|
422
|
+
#fetch;
|
|
423
|
+
#maxAttempts;
|
|
424
|
+
#retryDelayMs;
|
|
425
|
+
constructor(options) {
|
|
426
|
+
this.#objects = options.objects;
|
|
427
|
+
this.#signingClock = options.signingClock;
|
|
428
|
+
this.#client = new AwsClient({
|
|
429
|
+
accessKeyId: options.accessKeyId,
|
|
430
|
+
secretAccessKey: options.secretAccessKey,
|
|
431
|
+
service: "s3",
|
|
432
|
+
region: options.region,
|
|
433
|
+
// The client's own retry loop is bypassed: this store only ever calls
|
|
434
|
+
// `sign`, and drives its own bounded, jitter-free retries below so the
|
|
435
|
+
// transient-error dimension can assert an exact attempt sequence.
|
|
436
|
+
retries: 0
|
|
437
|
+
});
|
|
438
|
+
this.#origin = options.endpoint.replace(/\/+$/, "");
|
|
439
|
+
this.#bucket = options.bucket;
|
|
440
|
+
this.#keyPrefix = resolveKeyPrefix(options.keyPrefix);
|
|
441
|
+
this.#presignTtlSeconds = assertPresignTtl(options.presignTtlSeconds ?? DEFAULT_PRESIGN_TTL_SECONDS);
|
|
442
|
+
this.#fetch = options.fetch ?? ((request) => globalThis.fetch(request));
|
|
443
|
+
this.#maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
444
|
+
this.#retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Reserve the manifest row, then hand back a PUT bound to this tenant, this
|
|
448
|
+
* key, this length, this type, and this expiry.
|
|
449
|
+
*
|
|
450
|
+
* `putManifest` is idempotent per (tenant, hash), so a device that declares
|
|
451
|
+
* the same content twice while it is still `pending` gets the same row and
|
|
452
|
+
* the same key — an interrupted upload is retried, not duplicated. It is
|
|
453
|
+
* idempotent per TENANT, which is the same reason the key embeds the tenant:
|
|
454
|
+
* two tenants holding identical bytes hold two independent objects, and
|
|
455
|
+
* neither can learn of the other's.
|
|
456
|
+
*
|
|
457
|
+
* Idempotence stops at `committed`, and that boundary is the point: a
|
|
458
|
+
* committed object is what a truth record is allowed to reference, so it has
|
|
459
|
+
* to be immutable, and re-issuing a write grant for one is the only way this
|
|
460
|
+
* adapter could make it otherwise.
|
|
461
|
+
*/
|
|
462
|
+
async createUpload(tenant, reservation) {
|
|
463
|
+
this.#assertReservedObject(tenant, reservation);
|
|
464
|
+
const hash = contentHash(reservation.contentHash);
|
|
465
|
+
const byteSize = reservation.expectedBytes;
|
|
466
|
+
assertKeySegmentTenant(tenant);
|
|
467
|
+
const entry = await this.#objects.putManifest(tenant, {
|
|
468
|
+
hash,
|
|
469
|
+
byteSize,
|
|
470
|
+
contentType: reservation.contentType
|
|
471
|
+
});
|
|
472
|
+
if (entry.byteSize !== byteSize || entry.contentType !== reservation.contentType) {
|
|
473
|
+
throw new ByokCoreError(
|
|
474
|
+
"storage_integrity_mismatch",
|
|
475
|
+
`Object ${hash} is already declared as ${String(entry.byteSize)} bytes of ${entry.contentType}; this upload declares ${String(byteSize)} bytes of ${reservation.contentType}.`
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
if (entry.state !== "pending") {
|
|
479
|
+
throw new ByokCoreError(
|
|
480
|
+
"object_state_invalid",
|
|
481
|
+
`Object ${hash} is ${entry.state}; only a pending object can receive an upload grant.`
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
const url = this.#objectUrl(tenant, hash);
|
|
485
|
+
url.searchParams.set("X-Amz-Expires", String(this.#presignTtlSeconds));
|
|
486
|
+
const signed = await this.#client.sign(
|
|
487
|
+
new Request(url, {
|
|
488
|
+
method: "PUT",
|
|
489
|
+
headers: {
|
|
490
|
+
"content-length": String(byteSize),
|
|
491
|
+
"content-type": reservation.contentType
|
|
492
|
+
}
|
|
493
|
+
}),
|
|
494
|
+
{
|
|
495
|
+
// `allHeaders` is load-bearing: aws4fetch treats `content-length` and
|
|
496
|
+
// `content-type` as unsignable by default (they are per-hop headers for
|
|
497
|
+
// most services), and without this the grant would bind the key and the
|
|
498
|
+
// expiry but not the SHAPE of what may be written to it.
|
|
499
|
+
aws: { signQuery: true, allHeaders: true, datetime: this.#datetime() }
|
|
500
|
+
}
|
|
501
|
+
);
|
|
502
|
+
return { blobId: hash, uploadUrl: signed.url };
|
|
503
|
+
}
|
|
504
|
+
async observeUpload(tenant, blobId, reservation) {
|
|
505
|
+
if (!isContentHash(blobId) || reservation.tenantId !== tenant || reservation.kind !== "object" || reservation.contentHash !== blobId) {
|
|
506
|
+
return void 0;
|
|
507
|
+
}
|
|
508
|
+
const entry = await this.#objects.get(tenant, reservation.contentHash);
|
|
509
|
+
if (entry === void 0 || entry.state !== "pending" && entry.state !== "committed") {
|
|
510
|
+
return void 0;
|
|
511
|
+
}
|
|
512
|
+
const observed = await this.#head(tenant, reservation.contentHash);
|
|
513
|
+
if (!observed.present) return void 0;
|
|
514
|
+
return {
|
|
515
|
+
observedByteSize: observed.byteSize,
|
|
516
|
+
observedContentType: observed.contentType
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* A GET for a committed object this tenant owns; `undefined` otherwise.
|
|
521
|
+
*
|
|
522
|
+
* Every miss answers identically — unknown hash, another tenant's object, a
|
|
523
|
+
* malformed id, bytes that never landed, a tombstoned row. A caller cannot
|
|
524
|
+
* tell them apart, which is what keeps `getDownloadUrl` from being an
|
|
525
|
+
* existence oracle across tenants.
|
|
526
|
+
*
|
|
527
|
+
* This is a pure committed-manifest gate. Observation and commit belong to
|
|
528
|
+
* the explicit finalize route; a download must never decide accounting.
|
|
529
|
+
*/
|
|
530
|
+
async getDownloadUrl(tenant, blobId) {
|
|
531
|
+
if (!isContentHash(blobId)) return void 0;
|
|
532
|
+
const hash = blobId;
|
|
533
|
+
const entry = await this.#objects.get(tenant, hash);
|
|
534
|
+
if (entry === void 0) return void 0;
|
|
535
|
+
if (entry.state !== "committed") {
|
|
536
|
+
return void 0;
|
|
537
|
+
}
|
|
538
|
+
const url = this.#objectUrl(tenant, hash);
|
|
539
|
+
url.searchParams.set("X-Amz-Expires", String(this.#presignTtlSeconds));
|
|
540
|
+
const signed = await this.#client.sign(new Request(url, { method: "GET" }), {
|
|
541
|
+
aws: { signQuery: true, datetime: this.#datetime() }
|
|
542
|
+
});
|
|
543
|
+
return signed.url;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* The ONLY place an object key is built, and therefore the only place the
|
|
547
|
+
* key's two segments have to be safe.
|
|
548
|
+
*
|
|
549
|
+
* The hash half is closed by construction: `tenantObjectKey` is core's and it
|
|
550
|
+
* takes a `ContentHash` — a branded type with exactly one mint point that
|
|
551
|
+
* rejects anything but 64 lowercase hex — so a traversal segment, an absolute
|
|
552
|
+
* path, or an uppercase digest cannot be smuggled through a parameter that
|
|
553
|
+
* will not accept them.
|
|
554
|
+
*
|
|
555
|
+
* The tenant half is NOT, and that asymmetry is why the guard below exists.
|
|
556
|
+
* `tenantId()` fails closed on empty, padded, over-long, and `NUL`-bearing
|
|
557
|
+
* values, and deliberately normalizes nothing else — normalizing would make
|
|
558
|
+
* the SDK disagree with the control plane that issued the id about its
|
|
559
|
+
* canonical form (`@byok-sdk/core`'s `tenant.ts`). So `a/../b` is a legitimate
|
|
560
|
+
* tenant id, and the line below would otherwise hand it to `new URL()`, which
|
|
561
|
+
* resolves the traversal and returns tenant `b`'s key. That is a cross-tenant
|
|
562
|
+
* alias: `a/../b` could probe, overwrite, and read what `b` owns.
|
|
563
|
+
*
|
|
564
|
+
* Refused rather than encoded. Percent-encoding the segment would keep the
|
|
565
|
+
* key distinct, and would also give every tenant id two spellings — the one
|
|
566
|
+
* the control plane issued and the one at rest in the object store — which is
|
|
567
|
+
* the second source of truth core refused to create in the first place.
|
|
568
|
+
*/
|
|
569
|
+
#objectUrl(tenant, hash) {
|
|
570
|
+
assertKeySegmentTenant(tenant);
|
|
571
|
+
return new URL(
|
|
572
|
+
`${this.#origin}/${this.#bucket}/${tenantObjectKey(tenant, hash, this.#keyPrefix)}`
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* `HEAD` the key, with bounded retries for transient faults.
|
|
577
|
+
*
|
|
578
|
+
* `HEAD` is idempotent by construction, so a retry can never double an
|
|
579
|
+
* effect — which is the whole reason the retry loop is allowed to exist here
|
|
580
|
+
* and not around anything that writes.
|
|
581
|
+
*/
|
|
582
|
+
async #head(tenant, hash) {
|
|
583
|
+
const url = this.#objectUrl(tenant, hash);
|
|
584
|
+
const { response, attempts } = await this.#send(new Request(url, { method: "HEAD" }));
|
|
585
|
+
if (response.status === 404) {
|
|
586
|
+
return { present: false, byteSize: 0n, contentType: "" };
|
|
587
|
+
}
|
|
588
|
+
if (!response.ok) {
|
|
589
|
+
throw new ObjectStoreRequestError(
|
|
590
|
+
`HEAD on the object store answered ${response.status}.`,
|
|
591
|
+
attempts,
|
|
592
|
+
response.status
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
const length = response.headers.get("content-length");
|
|
596
|
+
const contentType = response.headers.get("content-type");
|
|
597
|
+
if (length === null || contentType === null) {
|
|
598
|
+
throw new ObjectStoreRequestError(
|
|
599
|
+
`HEAD on the object store omitted content-length or content-type, so ${hash} cannot be verified.`,
|
|
600
|
+
attempts,
|
|
601
|
+
response.status
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
return { present: true, byteSize: BigInt(length), contentType };
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Signs, sends, and retries 5xx/429/network faults with a doubling delay.
|
|
608
|
+
*
|
|
609
|
+
* Returns the attempt count alongside the response because the caller is what
|
|
610
|
+
* decides the answer was a failure. A 4xx costs however many attempts the
|
|
611
|
+
* transient faults before it did, and `attempts` is the only field
|
|
612
|
+
* {@link ObjectStoreRequestError} carries that an operator can use to tell a
|
|
613
|
+
* flapping remote from a hard refusal — so it has to be counted here, where
|
|
614
|
+
* the loop is, rather than assumed to be 1 at the throw site.
|
|
615
|
+
*/
|
|
616
|
+
async #send(request) {
|
|
617
|
+
const failures = [];
|
|
618
|
+
for (let attempt = 1; attempt <= this.#maxAttempts; attempt += 1) {
|
|
619
|
+
const signed = await this.#client.sign(request.clone(), {
|
|
620
|
+
aws: { datetime: this.#datetime() }
|
|
621
|
+
});
|
|
622
|
+
const outcome = await this.#attempt(signed);
|
|
623
|
+
if (outcome.response !== void 0) return { response: outcome.response, attempts: attempt };
|
|
624
|
+
failures.push(outcome.failure);
|
|
625
|
+
if (attempt < this.#maxAttempts) {
|
|
626
|
+
await this.#sleep(this.#retryDelayMs * 2 ** (attempt - 1));
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
throw new ObjectStoreRequestError(
|
|
630
|
+
`The object store failed ${this.#maxAttempts} attempt(s): ${failures.join(", ")}.`,
|
|
631
|
+
this.#maxAttempts
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
async #attempt(signed) {
|
|
635
|
+
try {
|
|
636
|
+
const response = await this.#fetch(signed);
|
|
637
|
+
if (response.status !== 429 && response.status < 500) return { response, failure: "" };
|
|
638
|
+
return { failure: `HTTP ${response.status}` };
|
|
639
|
+
} catch (cause) {
|
|
640
|
+
return { failure: cause instanceof Error ? cause.message : String(cause) };
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
#sleep(ms) {
|
|
644
|
+
return new Promise((resolve) => {
|
|
645
|
+
setTimeout(resolve, ms);
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
/** SigV4's `YYYYMMDDTHHmmssZ`, off the signing clock — see its doc for why that is a separate one. */
|
|
649
|
+
#datetime() {
|
|
650
|
+
return this.#signingClock.now().toISOString().replaceAll(/[:-]|\.\d{3}/g, "");
|
|
651
|
+
}
|
|
652
|
+
#assertReservedObject(tenant, reservation) {
|
|
653
|
+
if (reservation.tenantId === tenant && reservation.kind === "object" && reservation.state === "reserved" && reservation.expectedBytes >= 0n) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
throw new ByokCoreError(
|
|
657
|
+
"storage_integrity_mismatch",
|
|
658
|
+
"An upload grant requires a reserved object reservation owned by this tenant."
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
var R2ObjectMaintenanceStore = class {
|
|
663
|
+
#signingClock;
|
|
664
|
+
#client;
|
|
665
|
+
#origin;
|
|
666
|
+
#bucket;
|
|
667
|
+
#keyPrefix;
|
|
668
|
+
#fetch;
|
|
669
|
+
#maxAttempts;
|
|
670
|
+
#retryDelayMs;
|
|
671
|
+
constructor(options) {
|
|
672
|
+
this.#signingClock = options.signingClock;
|
|
673
|
+
this.#client = new AwsClient({
|
|
674
|
+
accessKeyId: options.accessKeyId,
|
|
675
|
+
secretAccessKey: options.secretAccessKey,
|
|
676
|
+
service: "s3",
|
|
677
|
+
region: options.region,
|
|
678
|
+
retries: 0
|
|
679
|
+
});
|
|
680
|
+
this.#origin = options.endpoint.replace(/\/+$/, "");
|
|
681
|
+
this.#bucket = options.bucket;
|
|
682
|
+
this.#keyPrefix = resolveKeyPrefix(options.keyPrefix);
|
|
683
|
+
this.#fetch = options.fetch ?? ((request) => globalThis.fetch(request));
|
|
684
|
+
this.#maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
685
|
+
this.#retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
|
686
|
+
}
|
|
687
|
+
async inspectObject(tenant, hash) {
|
|
688
|
+
const observed = await this.#head(tenant, hash);
|
|
689
|
+
if (!observed.present) return void 0;
|
|
690
|
+
return {
|
|
691
|
+
observedByteSize: observed.byteSize,
|
|
692
|
+
observedContentType: observed.contentType
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
async deleteObject(tenant, hash) {
|
|
696
|
+
const url = this.#objectUrl(tenant, hash);
|
|
697
|
+
const { response, attempts } = await this.#send(new Request(url, { method: "DELETE" }));
|
|
698
|
+
if (response.status === 404) return "absent";
|
|
699
|
+
if (!response.ok) {
|
|
700
|
+
throw new ObjectStoreRequestError(
|
|
701
|
+
`DELETE on the object store answered ${response.status}.`,
|
|
702
|
+
attempts,
|
|
703
|
+
response.status
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
return "deleted";
|
|
707
|
+
}
|
|
708
|
+
async listTenantObjects(tenant, continuationToken, limit = 100) {
|
|
709
|
+
assertKeySegmentTenant(tenant);
|
|
710
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1e3) {
|
|
711
|
+
throw new R2BlobStoreError(
|
|
712
|
+
"storage_list_limit_invalid",
|
|
713
|
+
`A ListObjectsV2 page limit of ${String(limit)} is not a whole number in [1, 1000].`
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
const prefix = `${tenant}/sha256/`;
|
|
717
|
+
const url = new URL(`${this.#origin}/${this.#bucket}`);
|
|
718
|
+
url.searchParams.set("list-type", "2");
|
|
719
|
+
url.searchParams.set("prefix", prefix);
|
|
720
|
+
url.searchParams.set("max-keys", String(limit));
|
|
721
|
+
if (continuationToken !== void 0) {
|
|
722
|
+
if (continuationToken.length === 0) {
|
|
723
|
+
throw new R2BlobStoreError(
|
|
724
|
+
"storage_list_cursor_invalid",
|
|
725
|
+
"A ListObjectsV2 continuation token must not be empty."
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
url.searchParams.set("continuation-token", continuationToken);
|
|
729
|
+
}
|
|
730
|
+
const { response, attempts } = await this.#send(new Request(url, { method: "GET" }));
|
|
731
|
+
if (!response.ok) {
|
|
732
|
+
throw new ObjectStoreRequestError(
|
|
733
|
+
`ListObjectsV2 on the object store answered ${response.status}.`,
|
|
734
|
+
attempts,
|
|
735
|
+
response.status
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
return parseListObjectsV2(await response.text(), prefix, attempts);
|
|
739
|
+
}
|
|
740
|
+
async #head(tenant, hash) {
|
|
741
|
+
const { response, attempts } = await this.#send(
|
|
742
|
+
new Request(this.#objectUrl(tenant, hash), { method: "HEAD" })
|
|
743
|
+
);
|
|
744
|
+
if (response.status === 404) {
|
|
745
|
+
return { present: false, byteSize: 0n, contentType: "" };
|
|
746
|
+
}
|
|
747
|
+
if (!response.ok) {
|
|
748
|
+
throw new ObjectStoreRequestError(
|
|
749
|
+
`HEAD on the object store answered ${response.status}.`,
|
|
750
|
+
attempts,
|
|
751
|
+
response.status
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
const length = response.headers.get("content-length");
|
|
755
|
+
const contentType = response.headers.get("content-type");
|
|
756
|
+
if (length === null || contentType === null) {
|
|
757
|
+
throw new ObjectStoreRequestError(
|
|
758
|
+
`HEAD on the object store omitted content-length or content-type, so ${hash} cannot be observed.`,
|
|
759
|
+
attempts,
|
|
760
|
+
response.status
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
return { present: true, byteSize: BigInt(length), contentType };
|
|
764
|
+
}
|
|
765
|
+
#objectUrl(tenant, hash) {
|
|
766
|
+
assertKeySegmentTenant(tenant);
|
|
767
|
+
return new URL(
|
|
768
|
+
`${this.#origin}/${this.#bucket}/${tenantObjectKey(tenant, hash, this.#keyPrefix)}`
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
async #send(request) {
|
|
772
|
+
const failures = [];
|
|
773
|
+
for (let attempt = 1; attempt <= this.#maxAttempts; attempt += 1) {
|
|
774
|
+
const signed = await this.#client.sign(request.clone(), {
|
|
775
|
+
aws: { datetime: this.#datetime() }
|
|
776
|
+
});
|
|
777
|
+
try {
|
|
778
|
+
const response = await this.#fetch(signed);
|
|
779
|
+
if (response.status !== 429 && response.status < 500) {
|
|
780
|
+
return { response, attempts: attempt };
|
|
781
|
+
}
|
|
782
|
+
failures.push(`HTTP ${response.status}`);
|
|
783
|
+
} catch (cause) {
|
|
784
|
+
failures.push(cause instanceof Error ? cause.message : String(cause));
|
|
785
|
+
}
|
|
786
|
+
if (attempt < this.#maxAttempts) {
|
|
787
|
+
await new Promise((resolve) => {
|
|
788
|
+
setTimeout(resolve, this.#retryDelayMs * 2 ** (attempt - 1));
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
throw new ObjectStoreRequestError(
|
|
793
|
+
`The object store failed ${this.#maxAttempts} attempt(s): ${failures.join(", ")}.`,
|
|
794
|
+
this.#maxAttempts
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
#datetime() {
|
|
798
|
+
return this.#signingClock.now().toISOString().replaceAll(/[:-]|\.\d{3}/g, "");
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
var SAFE_TENANT_SEGMENT = /^[A-Za-z0-9._~-]+$/;
|
|
802
|
+
function assertKeySegmentTenant(tenant) {
|
|
803
|
+
if (SAFE_TENANT_SEGMENT.test(tenant) && !tenant.startsWith(".")) return;
|
|
804
|
+
throw new R2BlobStoreError(
|
|
805
|
+
"storage_tenant_key_unsafe",
|
|
806
|
+
`Tenant id ${JSON.stringify(tenant)} is not a single safe object-key segment, so no key can be built for it.`
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
function resolveKeyPrefix(keyPrefix) {
|
|
810
|
+
return keyPrefix === void 0 ? void 0 : objectKeyPrefix(keyPrefix);
|
|
811
|
+
}
|
|
812
|
+
function assertPresignTtl(seconds) {
|
|
813
|
+
if (Number.isInteger(seconds) && seconds >= MIN_PRESIGN_TTL_SECONDS && seconds <= MAX_PRESIGN_TTL_SECONDS) {
|
|
814
|
+
return seconds;
|
|
815
|
+
}
|
|
816
|
+
throw new R2BlobStoreError(
|
|
817
|
+
"storage_presign_ttl_invalid",
|
|
818
|
+
`A presign lifetime of ${String(seconds)} is not a whole number of seconds in [${String(MIN_PRESIGN_TTL_SECONDS)}, ${String(MAX_PRESIGN_TTL_SECONDS)}], which is the range R2 will honor.`
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
var LIST_OBJECTS_XML = new XMLParser({
|
|
822
|
+
ignoreAttributes: true,
|
|
823
|
+
parseTagValue: false,
|
|
824
|
+
trimValues: true,
|
|
825
|
+
processEntities: false
|
|
826
|
+
});
|
|
827
|
+
function parseListObjectsV2(xml, prefix, attempts) {
|
|
828
|
+
let parsed;
|
|
829
|
+
try {
|
|
830
|
+
parsed = LIST_OBJECTS_XML.parse(xml);
|
|
831
|
+
} catch (cause) {
|
|
832
|
+
throw new ObjectStoreRequestError(
|
|
833
|
+
"ListObjectsV2 returned malformed XML.",
|
|
834
|
+
attempts,
|
|
835
|
+
void 0,
|
|
836
|
+
{ cause }
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
const document = asRecord(parsed);
|
|
840
|
+
const root = asRecord(document?.ListBucketResult);
|
|
841
|
+
if (root === void 0) {
|
|
842
|
+
throw new ObjectStoreRequestError(
|
|
843
|
+
"ListObjectsV2 XML omitted ListBucketResult.",
|
|
844
|
+
attempts
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
const isTruncated = requiredText(root, "IsTruncated", attempts);
|
|
848
|
+
if (isTruncated !== "true" && isTruncated !== "false") {
|
|
849
|
+
throw new ObjectStoreRequestError(
|
|
850
|
+
`ListObjectsV2 XML contained invalid IsTruncated=${JSON.stringify(isTruncated)}.`,
|
|
851
|
+
attempts
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
const rawContents = root.Contents === void 0 ? [] : Array.isArray(root.Contents) ? root.Contents : [root.Contents];
|
|
855
|
+
const objects = rawContents.map((raw, index) => {
|
|
856
|
+
const content = asRecord(raw);
|
|
857
|
+
if (content === void 0) {
|
|
858
|
+
throw new ObjectStoreRequestError(
|
|
859
|
+
`ListObjectsV2 XML contained a non-object Contents entry at index ${String(index)}.`,
|
|
860
|
+
attempts
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
const key = requiredText(content, "Key", attempts);
|
|
864
|
+
const sizeText = requiredText(content, "Size", attempts);
|
|
865
|
+
let byteSize;
|
|
866
|
+
try {
|
|
867
|
+
byteSize = BigInt(sizeText);
|
|
868
|
+
} catch (cause) {
|
|
869
|
+
throw new ObjectStoreRequestError(
|
|
870
|
+
`ListObjectsV2 XML contained invalid Size=${JSON.stringify(sizeText)}.`,
|
|
871
|
+
attempts,
|
|
872
|
+
void 0,
|
|
873
|
+
{ cause }
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
if (byteSize < 0n) {
|
|
877
|
+
throw new ObjectStoreRequestError(
|
|
878
|
+
`ListObjectsV2 XML contained negative Size=${sizeText}.`,
|
|
879
|
+
attempts
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
const suffix = key.startsWith(prefix) ? key.slice(prefix.length) : "";
|
|
883
|
+
return {
|
|
884
|
+
key,
|
|
885
|
+
byteSize,
|
|
886
|
+
...HASH_KEY_SUFFIX.test(suffix) ? { hash: contentHash(`sha256:${suffix}`) } : {}
|
|
887
|
+
};
|
|
888
|
+
});
|
|
889
|
+
if (isTruncated === "false") return { objects };
|
|
890
|
+
const nextContinuationToken = requiredText(root, "NextContinuationToken", attempts);
|
|
891
|
+
if (nextContinuationToken.length === 0) {
|
|
892
|
+
throw new ObjectStoreRequestError(
|
|
893
|
+
"ListObjectsV2 XML was truncated but its continuation token was empty.",
|
|
894
|
+
attempts
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
return { objects, nextContinuationToken };
|
|
898
|
+
}
|
|
899
|
+
var HASH_KEY_SUFFIX = /^[0-9a-f]{64}$/;
|
|
900
|
+
function asRecord(value) {
|
|
901
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
902
|
+
}
|
|
903
|
+
function requiredText(record, field, attempts) {
|
|
904
|
+
const value = record[field];
|
|
905
|
+
if (typeof value === "string") return value;
|
|
906
|
+
throw new ObjectStoreRequestError(
|
|
907
|
+
`ListObjectsV2 XML omitted text field ${field}.`,
|
|
908
|
+
attempts
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// src/stores/devices.ts
|
|
913
|
+
function toRecord(row) {
|
|
914
|
+
return {
|
|
915
|
+
tenantId: row.tenant_id,
|
|
916
|
+
productId: row.product_id,
|
|
917
|
+
deviceId: row.device_id,
|
|
918
|
+
deviceName: row.device_name,
|
|
919
|
+
devicePublicKey: row.device_public_key,
|
|
920
|
+
proofKeyId: row.proof_key_id,
|
|
921
|
+
proofKeyEpoch: row.proof_key_epoch,
|
|
922
|
+
revoked: row.revoked
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
var SELECT_COLUMNS = "tenant_id, device_id, product_id, device_name, device_public_key, proof_key_id, proof_key_epoch, revoked";
|
|
926
|
+
var PostgresDeviceDirectory = class {
|
|
927
|
+
#pool;
|
|
928
|
+
constructor(pool) {
|
|
929
|
+
this.#pool = pool;
|
|
930
|
+
}
|
|
931
|
+
async register(tenant, input) {
|
|
932
|
+
const result = await this.#pool.query(
|
|
933
|
+
`INSERT INTO device (
|
|
934
|
+
tenant_id, device_id, product_id, device_name, device_public_key,
|
|
935
|
+
proof_key_id, proof_key_epoch, revoked
|
|
936
|
+
)
|
|
937
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, false)
|
|
938
|
+
ON CONFLICT (tenant_id, device_id) DO UPDATE
|
|
939
|
+
SET product_id = EXCLUDED.product_id,
|
|
940
|
+
device_name = EXCLUDED.device_name,
|
|
941
|
+
device_public_key = EXCLUDED.device_public_key,
|
|
942
|
+
proof_key_id = EXCLUDED.proof_key_id,
|
|
943
|
+
proof_key_epoch = EXCLUDED.proof_key_epoch,
|
|
944
|
+
revoked = false
|
|
945
|
+
RETURNING ${SELECT_COLUMNS}`,
|
|
946
|
+
[
|
|
947
|
+
tenant,
|
|
948
|
+
input.deviceId,
|
|
949
|
+
input.productId,
|
|
950
|
+
input.deviceName,
|
|
951
|
+
input.devicePublicKey,
|
|
952
|
+
input.proofKeyId,
|
|
953
|
+
input.proofKeyEpoch
|
|
954
|
+
]
|
|
955
|
+
);
|
|
956
|
+
return toRecord(result.rows[0]);
|
|
957
|
+
}
|
|
958
|
+
async get(tenant, deviceId) {
|
|
959
|
+
const result = await this.#pool.query(
|
|
960
|
+
`SELECT ${SELECT_COLUMNS} FROM device WHERE tenant_id = $1 AND device_id = $2`,
|
|
961
|
+
[tenant, deviceId]
|
|
962
|
+
);
|
|
963
|
+
const row = result.rows[0];
|
|
964
|
+
return row === void 0 ? void 0 : toRecord(row);
|
|
965
|
+
}
|
|
966
|
+
async revoke(tenant, deviceId) {
|
|
967
|
+
await this.#pool.query("UPDATE device SET revoked = true WHERE tenant_id = $1 AND device_id = $2", [
|
|
968
|
+
tenant,
|
|
969
|
+
deviceId
|
|
970
|
+
]);
|
|
971
|
+
}
|
|
972
|
+
async list(tenant) {
|
|
973
|
+
const result = await this.#pool.query(
|
|
974
|
+
`SELECT ${SELECT_COLUMNS} FROM device WHERE tenant_id = $1 ORDER BY device_id`,
|
|
975
|
+
[tenant]
|
|
976
|
+
);
|
|
977
|
+
return result.rows.map(toRecord);
|
|
978
|
+
}
|
|
979
|
+
async resolveByDeviceId(deviceId) {
|
|
980
|
+
const result = await this.#pool.query(
|
|
981
|
+
`SELECT ${SELECT_COLUMNS} FROM device WHERE device_id = $1`,
|
|
982
|
+
[deviceId]
|
|
983
|
+
);
|
|
984
|
+
const row = result.rows[0];
|
|
985
|
+
return row === void 0 ? void 0 : toRecord(row);
|
|
986
|
+
}
|
|
987
|
+
};
|
|
988
|
+
var PostgresInboundDedupStore = class {
|
|
989
|
+
#pool;
|
|
990
|
+
#capacity;
|
|
991
|
+
constructor(pool, capacity = DEDUP_RING_CAPACITY) {
|
|
992
|
+
this.#pool = pool;
|
|
993
|
+
this.#capacity = capacity;
|
|
994
|
+
}
|
|
995
|
+
async checkAndRecord(tenant, deviceId, envelopeId) {
|
|
996
|
+
const inserted = await this.#pool.query(
|
|
997
|
+
`INSERT INTO inbound_dedup (tenant_id, device_id, envelope_id)
|
|
998
|
+
VALUES ($1, $2, $3)
|
|
999
|
+
ON CONFLICT (tenant_id, device_id, envelope_id) DO NOTHING
|
|
1000
|
+
RETURNING 1`,
|
|
1001
|
+
[tenant, deviceId, envelopeId]
|
|
1002
|
+
);
|
|
1003
|
+
if (inserted.rowCount === 0) return true;
|
|
1004
|
+
await this.#pool.query(
|
|
1005
|
+
`DELETE FROM inbound_dedup
|
|
1006
|
+
WHERE tenant_id = $1 AND device_id = $2
|
|
1007
|
+
AND recorded_seq <= (
|
|
1008
|
+
SELECT recorded_seq FROM inbound_dedup
|
|
1009
|
+
WHERE tenant_id = $1 AND device_id = $2
|
|
1010
|
+
ORDER BY recorded_seq DESC
|
|
1011
|
+
OFFSET $3 LIMIT 1)`,
|
|
1012
|
+
[tenant, deviceId, this.#capacity]
|
|
1013
|
+
);
|
|
1014
|
+
return false;
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
var NONCE_BYTES = 24;
|
|
1018
|
+
var PostgresNonceStore = class {
|
|
1019
|
+
#pool;
|
|
1020
|
+
#clock;
|
|
1021
|
+
#crypto;
|
|
1022
|
+
#ttlMs;
|
|
1023
|
+
constructor(pool, clock, crypto, ttlMs = NONCE_TTL_MS) {
|
|
1024
|
+
this.#pool = pool;
|
|
1025
|
+
this.#clock = clock;
|
|
1026
|
+
this.#crypto = crypto;
|
|
1027
|
+
this.#ttlMs = ttlMs;
|
|
1028
|
+
}
|
|
1029
|
+
async issue(tenant, deviceId) {
|
|
1030
|
+
const nowMs = this.#clock.now().getTime();
|
|
1031
|
+
const now = new Date(nowMs).toISOString();
|
|
1032
|
+
await this.#pool.query(
|
|
1033
|
+
"DELETE FROM auth_nonce WHERE tenant_id = $1 AND device_id = $2 AND (used OR expires_at < $3)",
|
|
1034
|
+
[tenant, deviceId, now]
|
|
1035
|
+
);
|
|
1036
|
+
const nonce = this.#crypto.randomToken(NONCE_BYTES);
|
|
1037
|
+
await this.#pool.query(
|
|
1038
|
+
`INSERT INTO auth_nonce (tenant_id, device_id, nonce, expires_at, used)
|
|
1039
|
+
VALUES ($1, $2, $3, $4, false)`,
|
|
1040
|
+
[tenant, deviceId, nonce, new Date(nowMs + this.#ttlMs).toISOString()]
|
|
1041
|
+
);
|
|
1042
|
+
return nonce;
|
|
1043
|
+
}
|
|
1044
|
+
async validate(tenant, deviceId, nonce) {
|
|
1045
|
+
const result = await this.#pool.query(
|
|
1046
|
+
`SELECT 1 FROM auth_nonce
|
|
1047
|
+
WHERE tenant_id = $1 AND device_id = $2 AND nonce = $3
|
|
1048
|
+
AND used = false AND expires_at >= $4`,
|
|
1049
|
+
[tenant, deviceId, nonce, this.#clock.now().toISOString()]
|
|
1050
|
+
);
|
|
1051
|
+
return result.rowCount === 1;
|
|
1052
|
+
}
|
|
1053
|
+
async markUsed(tenant, nonce) {
|
|
1054
|
+
await this.#pool.query("UPDATE auth_nonce SET used = true WHERE tenant_id = $1 AND nonce = $2", [
|
|
1055
|
+
tenant,
|
|
1056
|
+
nonce
|
|
1057
|
+
]);
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
// src/stores/pairing-codes.ts
|
|
1062
|
+
var PostgresPairingCodeStore = class {
|
|
1063
|
+
#pool;
|
|
1064
|
+
#clock;
|
|
1065
|
+
constructor(pool, clock) {
|
|
1066
|
+
this.#pool = pool;
|
|
1067
|
+
this.#clock = clock;
|
|
1068
|
+
}
|
|
1069
|
+
async issue(tenant, input) {
|
|
1070
|
+
await this.#pool.query(
|
|
1071
|
+
`INSERT INTO pairing_code (code, tenant_id, product_id, expires_at, redeemed_at)
|
|
1072
|
+
VALUES ($1, $2, $3, $4, NULL)
|
|
1073
|
+
ON CONFLICT (code) DO UPDATE
|
|
1074
|
+
SET tenant_id = EXCLUDED.tenant_id,
|
|
1075
|
+
product_id = EXCLUDED.product_id,
|
|
1076
|
+
expires_at = EXCLUDED.expires_at,
|
|
1077
|
+
redeemed_at = NULL`,
|
|
1078
|
+
[input.code, tenant, input.productId, input.expiresAt]
|
|
1079
|
+
);
|
|
1080
|
+
return { code: input.code, expiresAt: input.expiresAt };
|
|
1081
|
+
}
|
|
1082
|
+
async redeem(code) {
|
|
1083
|
+
const now = this.#clock.now().toISOString();
|
|
1084
|
+
const result = await this.#pool.query(
|
|
1085
|
+
`UPDATE pairing_code
|
|
1086
|
+
SET redeemed_at = $2
|
|
1087
|
+
WHERE code = $1 AND redeemed_at IS NULL AND expires_at >= $2
|
|
1088
|
+
RETURNING tenant_id, product_id`,
|
|
1089
|
+
[code, now]
|
|
1090
|
+
);
|
|
1091
|
+
const row = result.rows[0];
|
|
1092
|
+
if (row === void 0) return void 0;
|
|
1093
|
+
return { tenantId: row.tenant_id, productId: row.product_id };
|
|
1094
|
+
}
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1097
|
+
// src/stores/receipts.ts
|
|
1098
|
+
var SELECT_COLUMNS2 = "tenant_id, key, body, recorded_at";
|
|
1099
|
+
function toReceipt(row) {
|
|
1100
|
+
return {
|
|
1101
|
+
tenantId: row.tenant_id,
|
|
1102
|
+
key: row.key,
|
|
1103
|
+
body: row.body,
|
|
1104
|
+
recordedAt: row.recorded_at.toISOString()
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
var PostgresRequestReceiptStore = class {
|
|
1108
|
+
#pool;
|
|
1109
|
+
#clock;
|
|
1110
|
+
constructor(pool, clock) {
|
|
1111
|
+
this.#pool = pool;
|
|
1112
|
+
this.#clock = clock;
|
|
1113
|
+
}
|
|
1114
|
+
async record(tenant, input) {
|
|
1115
|
+
const inserted = await this.#pool.query(
|
|
1116
|
+
`INSERT INTO device_request_receipts (tenant_id, key, body, recorded_at)
|
|
1117
|
+
VALUES ($1, $2, $3, $4)
|
|
1118
|
+
ON CONFLICT (tenant_id, key) DO NOTHING
|
|
1119
|
+
RETURNING ${SELECT_COLUMNS2}`,
|
|
1120
|
+
[tenant, input.key, input.body, this.#clock.now().toISOString()]
|
|
1121
|
+
);
|
|
1122
|
+
const created = inserted.rows[0];
|
|
1123
|
+
if (created !== void 0) return { receipt: toReceipt(created), created: true };
|
|
1124
|
+
const existing = await this.get(tenant, input.key);
|
|
1125
|
+
if (existing === void 0) throw new Error(`receipt ${input.key} vanished during record`);
|
|
1126
|
+
return { receipt: existing, created: false };
|
|
1127
|
+
}
|
|
1128
|
+
async get(tenant, key) {
|
|
1129
|
+
const result = await this.#pool.query(
|
|
1130
|
+
`SELECT ${SELECT_COLUMNS2} FROM device_request_receipts WHERE tenant_id = $1 AND key = $2`,
|
|
1131
|
+
[tenant, key]
|
|
1132
|
+
);
|
|
1133
|
+
const row = result.rows[0];
|
|
1134
|
+
return row === void 0 ? void 0 : toReceipt(row);
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1137
|
+
|
|
1138
|
+
// src/stores/proof-receipts.ts
|
|
1139
|
+
var SELECT_COLUMNS3 = `tenant_id, device_id, request_id, operation, resource,
|
|
1140
|
+
body_sha256, body_size, response_status, response_body, recorded_at`;
|
|
1141
|
+
function toReceipt2(row) {
|
|
1142
|
+
return {
|
|
1143
|
+
tenantId: row.tenant_id,
|
|
1144
|
+
deviceId: row.device_id,
|
|
1145
|
+
requestId: row.request_id,
|
|
1146
|
+
operation: row.operation,
|
|
1147
|
+
resource: row.resource,
|
|
1148
|
+
bodySha256: row.body_sha256,
|
|
1149
|
+
bodySize: BigInt(row.body_size),
|
|
1150
|
+
responseStatus: row.response_status,
|
|
1151
|
+
responseBody: row.response_body,
|
|
1152
|
+
recordedAt: row.recorded_at.toISOString()
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
var PostgresProofRequestReceiptStore = class {
|
|
1156
|
+
#pool;
|
|
1157
|
+
#clock;
|
|
1158
|
+
constructor(pool, clock) {
|
|
1159
|
+
this.#pool = pool;
|
|
1160
|
+
this.#clock = clock;
|
|
1161
|
+
}
|
|
1162
|
+
async record(tenant, input) {
|
|
1163
|
+
const result = await this.#pool.query(
|
|
1164
|
+
`INSERT INTO proof_request_receipt (
|
|
1165
|
+
tenant_id, device_id, request_id, operation, resource, body_sha256,
|
|
1166
|
+
body_size, response_status, response_body, recorded_at
|
|
1167
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
1168
|
+
ON CONFLICT (tenant_id, device_id, request_id) DO NOTHING
|
|
1169
|
+
RETURNING ${SELECT_COLUMNS3}`,
|
|
1170
|
+
[
|
|
1171
|
+
tenant,
|
|
1172
|
+
input.deviceId,
|
|
1173
|
+
input.requestId,
|
|
1174
|
+
input.operation,
|
|
1175
|
+
input.resource,
|
|
1176
|
+
input.bodySha256,
|
|
1177
|
+
input.bodySize.toString(),
|
|
1178
|
+
input.responseStatus,
|
|
1179
|
+
input.responseBody,
|
|
1180
|
+
this.#clock.now().toISOString()
|
|
1181
|
+
]
|
|
1182
|
+
);
|
|
1183
|
+
const inserted = result.rows[0];
|
|
1184
|
+
if (inserted !== void 0) return { receipt: toReceipt2(inserted), created: true };
|
|
1185
|
+
const existing = await this.get(tenant, input.deviceId, input.requestId);
|
|
1186
|
+
if (existing === void 0) throw new Error(`proof receipt ${input.requestId} vanished during record`);
|
|
1187
|
+
return { receipt: existing, created: false };
|
|
1188
|
+
}
|
|
1189
|
+
async get(tenant, deviceId, requestId) {
|
|
1190
|
+
const result = await this.#pool.query(
|
|
1191
|
+
`SELECT ${SELECT_COLUMNS3}
|
|
1192
|
+
FROM proof_request_receipt
|
|
1193
|
+
WHERE tenant_id = $1 AND device_id = $2 AND request_id = $3`,
|
|
1194
|
+
[tenant, deviceId, requestId]
|
|
1195
|
+
);
|
|
1196
|
+
const row = result.rows[0];
|
|
1197
|
+
return row === void 0 ? void 0 : toReceipt2(row);
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
|
|
1201
|
+
// src/stores/task-attempts.ts
|
|
1202
|
+
var SELECT_COLUMNS4 = "tenant_id, task_id, device_id, owner_device_id, status, updated_at";
|
|
1203
|
+
function toAttempt(row) {
|
|
1204
|
+
return {
|
|
1205
|
+
tenantId: row.tenant_id,
|
|
1206
|
+
taskId: row.task_id,
|
|
1207
|
+
deviceId: row.device_id,
|
|
1208
|
+
// `exactOptionalPropertyTypes` is off here, but an explicit absent key is
|
|
1209
|
+
// still what the in-memory reference produces for an unclaimed attempt, and
|
|
1210
|
+
// `toEqual` in the suite treats `undefined` and absent alike only for the
|
|
1211
|
+
// former.
|
|
1212
|
+
...row.owner_device_id === null ? {} : { ownerDeviceId: row.owner_device_id },
|
|
1213
|
+
status: row.status,
|
|
1214
|
+
updatedAt: row.updated_at.toISOString()
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
var PostgresTaskAttemptStore = class {
|
|
1218
|
+
#pool;
|
|
1219
|
+
#clock;
|
|
1220
|
+
constructor(pool, clock) {
|
|
1221
|
+
this.#pool = pool;
|
|
1222
|
+
this.#clock = clock;
|
|
1223
|
+
}
|
|
1224
|
+
async open(tenant, input) {
|
|
1225
|
+
const inserted = await this.#pool.query(
|
|
1226
|
+
`INSERT INTO task (tenant_id, task_id, device_id, owner_device_id, status, updated_at)
|
|
1227
|
+
VALUES ($1, $2, $3, NULL, 'offered', $4)
|
|
1228
|
+
ON CONFLICT (tenant_id, task_id) DO NOTHING
|
|
1229
|
+
RETURNING ${SELECT_COLUMNS4}`,
|
|
1230
|
+
[tenant, input.taskId, input.deviceId, this.#now()]
|
|
1231
|
+
);
|
|
1232
|
+
const created = inserted.rows[0];
|
|
1233
|
+
if (created !== void 0) return toAttempt(created);
|
|
1234
|
+
const existing = await this.get(tenant, input.taskId);
|
|
1235
|
+
if (existing === void 0) throw new Error(`task ${input.taskId} vanished during open`);
|
|
1236
|
+
return existing;
|
|
1237
|
+
}
|
|
1238
|
+
async get(tenant, taskId) {
|
|
1239
|
+
const result = await this.#pool.query(
|
|
1240
|
+
`SELECT ${SELECT_COLUMNS4} FROM task WHERE tenant_id = $1 AND task_id = $2`,
|
|
1241
|
+
[tenant, taskId]
|
|
1242
|
+
);
|
|
1243
|
+
const row = result.rows[0];
|
|
1244
|
+
return row === void 0 ? void 0 : toAttempt(row);
|
|
1245
|
+
}
|
|
1246
|
+
async claim(tenant, input) {
|
|
1247
|
+
const claimed = await this.#pool.query(
|
|
1248
|
+
`UPDATE task
|
|
1249
|
+
SET owner_device_id = $3, status = 'claimed', updated_at = $4
|
|
1250
|
+
WHERE tenant_id = $1 AND task_id = $2 AND owner_device_id IS NULL
|
|
1251
|
+
RETURNING ${SELECT_COLUMNS4}`,
|
|
1252
|
+
[tenant, input.taskId, input.deviceId, this.#now()]
|
|
1253
|
+
);
|
|
1254
|
+
const won = claimed.rows[0];
|
|
1255
|
+
if (won !== void 0) return toAttempt(won);
|
|
1256
|
+
return this.get(tenant, input.taskId);
|
|
1257
|
+
}
|
|
1258
|
+
async recordStatus(tenant, input) {
|
|
1259
|
+
const result = await this.#pool.query(
|
|
1260
|
+
`UPDATE task
|
|
1261
|
+
SET status = $3, updated_at = $4
|
|
1262
|
+
WHERE tenant_id = $1 AND task_id = $2
|
|
1263
|
+
RETURNING ${SELECT_COLUMNS4}`,
|
|
1264
|
+
[tenant, input.taskId, input.status, this.#now()]
|
|
1265
|
+
);
|
|
1266
|
+
const row = result.rows[0];
|
|
1267
|
+
return row === void 0 ? void 0 : toAttempt(row);
|
|
1268
|
+
}
|
|
1269
|
+
#now() {
|
|
1270
|
+
return this.#clock.now().toISOString();
|
|
1271
|
+
}
|
|
1272
|
+
};
|
|
1273
|
+
|
|
1274
|
+
// src/stores/index.ts
|
|
1275
|
+
function createPostgresCloudStores(options) {
|
|
1276
|
+
const { pool, clock, crypto } = options;
|
|
1277
|
+
return {
|
|
1278
|
+
devices: new PostgresDeviceDirectory(pool),
|
|
1279
|
+
pairingCodes: new PostgresPairingCodeStore(pool, clock),
|
|
1280
|
+
nonces: new PostgresNonceStore(pool, clock, crypto),
|
|
1281
|
+
dedup: new PostgresInboundDedupStore(pool),
|
|
1282
|
+
tasks: new PostgresTaskAttemptStore(pool, clock),
|
|
1283
|
+
receipts: new PostgresRequestReceiptStore(pool, clock),
|
|
1284
|
+
proofReceipts: new PostgresProofRequestReceiptStore(pool, clock),
|
|
1285
|
+
// A second `PostgresObjectStore` instance, not a shared one: it is a
|
|
1286
|
+
// stateless wrapper over the pool, so the two read and write the same rows
|
|
1287
|
+
// under the same locks, and requiring the caller to build the core
|
|
1288
|
+
// composition first would be an ordering dependency bought for nothing.
|
|
1289
|
+
blobs: new R2CloudBlobStore({
|
|
1290
|
+
...options.objectStorage,
|
|
1291
|
+
objects: new PostgresObjectStore(pool, clock)
|
|
1292
|
+
}),
|
|
1293
|
+
rateLimiter: new AllowAllRateLimiter()
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
var PRESENCE_COLUMNS = "tenant_id, device_id, level, detail, configured_toolsets, observed_at, expires_at";
|
|
1297
|
+
function toHint(row) {
|
|
1298
|
+
return {
|
|
1299
|
+
tenantId: row.tenant_id,
|
|
1300
|
+
deviceId: row.device_id,
|
|
1301
|
+
level: row.level,
|
|
1302
|
+
...row.detail === null ? {} : { detail: row.detail },
|
|
1303
|
+
...row.configured_toolsets === null ? {} : { configuredToolsets: Object.freeze([...row.configured_toolsets]) },
|
|
1304
|
+
observedAt: row.observed_at,
|
|
1305
|
+
expiresAt: row.expires_at
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
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
|
+
function assertTtl(ttlMs) {
|
|
1319
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
|
1320
|
+
throw new ByokCoreError(
|
|
1321
|
+
"hint_ttl_invalid",
|
|
1322
|
+
`Hint ttl must be a positive number of milliseconds, received ${String(ttlMs)}.`
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
function assertMinimumInterval(minimumIntervalMs) {
|
|
1327
|
+
if (!Number.isFinite(minimumIntervalMs) || minimumIntervalMs < 0) {
|
|
1328
|
+
throw new ByokCoreError(
|
|
1329
|
+
"hint_ttl_invalid",
|
|
1330
|
+
`Hint minimum interval must be a non-negative number of milliseconds, received ${String(minimumIntervalMs)}.`
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
var PostgresPresenceStore = class {
|
|
1335
|
+
#pool;
|
|
1336
|
+
#clock;
|
|
1337
|
+
constructor(pool, clock) {
|
|
1338
|
+
this.#pool = pool;
|
|
1339
|
+
this.#clock = clock;
|
|
1340
|
+
}
|
|
1341
|
+
async publish(tenant, input) {
|
|
1342
|
+
assertTtl(input.ttlMs);
|
|
1343
|
+
assertMinimumInterval(input.minimumIntervalMs);
|
|
1344
|
+
const now = this.#clock.now();
|
|
1345
|
+
const observedAt = now.toISOString();
|
|
1346
|
+
const allowedBefore = new Date(now.getTime() - input.minimumIntervalMs).toISOString();
|
|
1347
|
+
const result = await this.#pool.query(
|
|
1348
|
+
`INSERT INTO device_presence (${PRESENCE_COLUMNS})
|
|
1349
|
+
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)
|
|
1350
|
+
ON CONFLICT (tenant_id, device_id) DO UPDATE
|
|
1351
|
+
SET level = EXCLUDED.level,
|
|
1352
|
+
detail = EXCLUDED.detail,
|
|
1353
|
+
configured_toolsets = EXCLUDED.configured_toolsets,
|
|
1354
|
+
observed_at = EXCLUDED.observed_at,
|
|
1355
|
+
expires_at = EXCLUDED.expires_at
|
|
1356
|
+
WHERE device_presence.expires_at <= EXCLUDED.observed_at
|
|
1357
|
+
OR device_presence.observed_at <= $8
|
|
1358
|
+
RETURNING ${PRESENCE_COLUMNS}`,
|
|
1359
|
+
[
|
|
1360
|
+
tenant,
|
|
1361
|
+
input.deviceId,
|
|
1362
|
+
input.level,
|
|
1363
|
+
input.detail ?? null,
|
|
1364
|
+
input.configuredToolsets === void 0 ? null : JSON.stringify(input.configuredToolsets),
|
|
1365
|
+
observedAt,
|
|
1366
|
+
new Date(now.getTime() + input.ttlMs).toISOString(),
|
|
1367
|
+
allowedBefore
|
|
1368
|
+
]
|
|
1369
|
+
);
|
|
1370
|
+
const row = result.rows[0];
|
|
1371
|
+
if (row === void 0) {
|
|
1372
|
+
throw new ByokCoreError(
|
|
1373
|
+
"hint_rate_limited",
|
|
1374
|
+
`Presence for ${input.deviceId} was published more recently than the configured minimum interval.`
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
return toHint(row);
|
|
1378
|
+
}
|
|
1379
|
+
async read(tenant, deviceId) {
|
|
1380
|
+
const result = await this.#pool.query(
|
|
1381
|
+
`SELECT ${PRESENCE_COLUMNS} FROM device_presence
|
|
1382
|
+
WHERE tenant_id = $1 AND device_id = $2 AND expires_at > $3`,
|
|
1383
|
+
[tenant, deviceId, this.#now()]
|
|
1384
|
+
);
|
|
1385
|
+
const row = result.rows[0];
|
|
1386
|
+
return row === void 0 ? void 0 : toHint(row);
|
|
1387
|
+
}
|
|
1388
|
+
async list(tenant) {
|
|
1389
|
+
const result = await this.#pool.query(
|
|
1390
|
+
`SELECT ${PRESENCE_COLUMNS} FROM device_presence
|
|
1391
|
+
WHERE tenant_id = $1 AND expires_at > $2
|
|
1392
|
+
ORDER BY device_id COLLATE "C"`,
|
|
1393
|
+
[tenant, this.#now()]
|
|
1394
|
+
);
|
|
1395
|
+
return result.rows.map(toHint);
|
|
1396
|
+
}
|
|
1397
|
+
#expiry(ttlMs) {
|
|
1398
|
+
return new Date(this.#clock.now().getTime() + ttlMs).toISOString();
|
|
1399
|
+
}
|
|
1400
|
+
#now() {
|
|
1401
|
+
return this.#clock.now().toISOString();
|
|
1402
|
+
}
|
|
1403
|
+
};
|
|
1404
|
+
var PostgresActivityStore = class {
|
|
1405
|
+
#pool;
|
|
1406
|
+
#clock;
|
|
1407
|
+
constructor(pool, clock) {
|
|
1408
|
+
this.#pool = pool;
|
|
1409
|
+
this.#clock = clock;
|
|
1410
|
+
}
|
|
1411
|
+
async append(tenant, input) {
|
|
1412
|
+
assertTtl(input.ttlMs);
|
|
1413
|
+
const capacity = input.capacity ?? DEFAULT_ACTIVITY_CAPACITY;
|
|
1414
|
+
if (!Number.isSafeInteger(capacity) || capacity <= 0) {
|
|
1415
|
+
throw new ByokCoreError(
|
|
1416
|
+
"activity_capacity_invalid",
|
|
1417
|
+
`Activity capacity must be a positive integer, received ${String(capacity)}.`
|
|
1418
|
+
);
|
|
1419
|
+
}
|
|
1420
|
+
if (input.details.length === 0 || !Number.isSafeInteger(input.dropped) || input.dropped < 0) {
|
|
1421
|
+
throw new ByokCoreError(
|
|
1422
|
+
"activity_batch_invalid",
|
|
1423
|
+
"Activity batches require at least one detail and a non-negative integer dropped count."
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1426
|
+
const now = this.#now();
|
|
1427
|
+
const incoming = input.details.map((detail) => ({ at: now, detail }));
|
|
1428
|
+
const result = await this.#pool.query(
|
|
1429
|
+
`WITH trimmed AS (
|
|
1430
|
+
SELECT COALESCE(
|
|
1431
|
+
(SELECT jsonb_agg(entry ORDER BY ordinality)
|
|
1432
|
+
FROM jsonb_array_elements($4::jsonb)
|
|
1433
|
+
WITH ORDINALITY AS element(entry, ordinality)
|
|
1434
|
+
WHERE ordinality > GREATEST(jsonb_array_length($4::jsonb) - $6, 0)),
|
|
1435
|
+
'[]'::jsonb) AS entries,
|
|
1436
|
+
$5::integer + GREATEST(jsonb_array_length($4::jsonb) - $6, 0) AS dropped
|
|
1437
|
+
)
|
|
1438
|
+
INSERT INTO activity_tail (tenant_id, task_id, entries, dropped, capacity, expires_at)
|
|
1439
|
+
SELECT $1, $2, trimmed.entries, trimmed.dropped, $6, $7 FROM trimmed
|
|
1440
|
+
ON CONFLICT (tenant_id, task_id) DO UPDATE
|
|
1441
|
+
SET entries = (
|
|
1442
|
+
SELECT COALESCE(jsonb_agg(entry ORDER BY ordinality), '[]'::jsonb)
|
|
1443
|
+
FROM jsonb_array_elements(
|
|
1444
|
+
(CASE WHEN activity_tail.expires_at > $3
|
|
1445
|
+
THEN activity_tail.entries ELSE '[]'::jsonb END) || $4::jsonb
|
|
1446
|
+
) WITH ORDINALITY AS element(entry, ordinality)
|
|
1447
|
+
WHERE ordinality > GREATEST(
|
|
1448
|
+
jsonb_array_length(
|
|
1449
|
+
(CASE WHEN activity_tail.expires_at > $3
|
|
1450
|
+
THEN activity_tail.entries ELSE '[]'::jsonb END) || $4::jsonb
|
|
1451
|
+
) - $6,
|
|
1452
|
+
0
|
|
1453
|
+
)
|
|
1454
|
+
),
|
|
1455
|
+
dropped = (CASE WHEN activity_tail.expires_at > $3
|
|
1456
|
+
THEN activity_tail.dropped ELSE 0 END)
|
|
1457
|
+
+ $5::integer
|
|
1458
|
+
+ GREATEST(
|
|
1459
|
+
jsonb_array_length(
|
|
1460
|
+
(CASE WHEN activity_tail.expires_at > $3
|
|
1461
|
+
THEN activity_tail.entries ELSE '[]'::jsonb END) || $4::jsonb
|
|
1462
|
+
) - $6,
|
|
1463
|
+
0
|
|
1464
|
+
),
|
|
1465
|
+
capacity = EXCLUDED.capacity,
|
|
1466
|
+
expires_at = EXCLUDED.expires_at
|
|
1467
|
+
RETURNING tenant_id, task_id, entries, dropped, capacity, expires_at`,
|
|
1468
|
+
[
|
|
1469
|
+
tenant,
|
|
1470
|
+
input.taskId,
|
|
1471
|
+
now,
|
|
1472
|
+
JSON.stringify(incoming),
|
|
1473
|
+
input.dropped,
|
|
1474
|
+
capacity,
|
|
1475
|
+
this.#expiry(input.ttlMs)
|
|
1476
|
+
]
|
|
1477
|
+
);
|
|
1478
|
+
return toTail(result.rows[0]);
|
|
1479
|
+
}
|
|
1480
|
+
async read(tenant, taskId) {
|
|
1481
|
+
const result = await this.#pool.query(
|
|
1482
|
+
`SELECT tenant_id, task_id, entries, dropped, capacity, expires_at
|
|
1483
|
+
FROM activity_tail
|
|
1484
|
+
WHERE tenant_id = $1 AND task_id = $2 AND expires_at > $3`,
|
|
1485
|
+
[tenant, taskId, this.#now()]
|
|
1486
|
+
);
|
|
1487
|
+
const row = result.rows[0];
|
|
1488
|
+
return row === void 0 ? void 0 : toTail(row);
|
|
1489
|
+
}
|
|
1490
|
+
#expiry(ttlMs) {
|
|
1491
|
+
return new Date(this.#clock.now().getTime() + ttlMs).toISOString();
|
|
1492
|
+
}
|
|
1493
|
+
#now() {
|
|
1494
|
+
return this.#clock.now().toISOString();
|
|
1495
|
+
}
|
|
1496
|
+
};
|
|
1497
|
+
var DEFAULT_LIST_LIMIT2 = 50;
|
|
1498
|
+
var BOARD_COLUMNS = "tenant_id, item_id, channel, title, status, holder_id, held_since, board_seq, created_at, updated_at";
|
|
1499
|
+
function toItem(row) {
|
|
1500
|
+
return {
|
|
1501
|
+
tenantId: row.tenant_id,
|
|
1502
|
+
itemId: row.item_id,
|
|
1503
|
+
channel: row.channel,
|
|
1504
|
+
title: row.title,
|
|
1505
|
+
status: row.status,
|
|
1506
|
+
// An unheld item has NO assignee rather than an assignee with an empty
|
|
1507
|
+
// holder, which is what lets `expect(item.assignee).toBeUndefined()` mean
|
|
1508
|
+
// "nobody holds this" in both compositions.
|
|
1509
|
+
...row.holder_id === null || row.held_since === null ? {} : { assignee: { holderId: row.holder_id, heldSince: row.held_since } },
|
|
1510
|
+
boardSeq: Number(row.board_seq),
|
|
1511
|
+
createdAt: row.created_at,
|
|
1512
|
+
updatedAt: row.updated_at
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
var PostgresBoardStore = class {
|
|
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) {
|
|
1542
|
+
const result = await this.#pool.query(
|
|
1543
|
+
`SELECT ${BOARD_COLUMNS} FROM board_item WHERE tenant_id = $1 AND item_id = $2`,
|
|
1544
|
+
[tenant, itemId]
|
|
1545
|
+
);
|
|
1546
|
+
const row = result.rows[0];
|
|
1547
|
+
return row === void 0 ? void 0 : toItem(row);
|
|
1548
|
+
}
|
|
1549
|
+
async list(tenant, query) {
|
|
1550
|
+
const afterSeq = query.afterSeq ?? 0;
|
|
1551
|
+
const limit = query.limit ?? DEFAULT_LIST_LIMIT2;
|
|
1552
|
+
const result = await this.#pool.query(
|
|
1553
|
+
`SELECT ${BOARD_COLUMNS} FROM board_item
|
|
1554
|
+
WHERE tenant_id = $1
|
|
1555
|
+
AND board_seq > $2::bigint
|
|
1556
|
+
AND ($3::text IS NULL OR channel = $3::text)
|
|
1557
|
+
AND ($4::text IS NULL OR status = $4::text)
|
|
1558
|
+
ORDER BY board_seq
|
|
1559
|
+
LIMIT $5`,
|
|
1560
|
+
[tenant, afterSeq, query.channel ?? null, query.status ?? null, limit + 1]
|
|
1561
|
+
);
|
|
1562
|
+
const page = result.rows.slice(0, limit).map(toItem);
|
|
1563
|
+
return {
|
|
1564
|
+
items: page,
|
|
1565
|
+
nextSeq: page.at(-1)?.boardSeq ?? afterSeq,
|
|
1566
|
+
hasMore: result.rows.length > page.length
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
async claim(tenant, input) {
|
|
1570
|
+
const expectedStatus = input.expectedStatus ?? "todo";
|
|
1571
|
+
const now = this.#now();
|
|
1572
|
+
const boardSeq = await this.#allocateSeq(tenant);
|
|
1573
|
+
const claimed = await this.#pool.query(
|
|
1574
|
+
`UPDATE board_item
|
|
1575
|
+
SET status = CASE WHEN status = 'todo' THEN 'in_progress' ELSE status END,
|
|
1576
|
+
holder_id = $3,
|
|
1577
|
+
held_since = $5,
|
|
1578
|
+
board_seq = $6::bigint,
|
|
1579
|
+
updated_at = $5
|
|
1580
|
+
WHERE tenant_id = $1 AND item_id = $2
|
|
1581
|
+
AND holder_id IS NULL
|
|
1582
|
+
AND status = $4::text
|
|
1583
|
+
AND status IN ('todo', 'in_progress')
|
|
1584
|
+
RETURNING ${BOARD_COLUMNS}`,
|
|
1585
|
+
[tenant, input.itemId, input.holderId, expectedStatus, now, boardSeq]
|
|
1586
|
+
);
|
|
1587
|
+
const won = claimed.rows[0];
|
|
1588
|
+
if (won !== void 0) return toItem(won);
|
|
1589
|
+
const current = await this.get(tenant, input.itemId);
|
|
1590
|
+
if (current === void 0) throw this.#itemNotFound(input.itemId);
|
|
1591
|
+
if (current.assignee !== void 0) {
|
|
1592
|
+
if (current.assignee.holderId === input.holderId) {
|
|
1593
|
+
if (input.expectedStatus !== void 0 && current.status !== input.expectedStatus) {
|
|
1594
|
+
throw this.#statusConflict(input.itemId, current, input.expectedStatus);
|
|
1595
|
+
}
|
|
1596
|
+
return current;
|
|
1597
|
+
}
|
|
1598
|
+
throw new CoreConflictError(
|
|
1599
|
+
"board_claim_conflict",
|
|
1600
|
+
`Board item ${input.itemId} is held by ${current.assignee.holderId}.`,
|
|
1601
|
+
current,
|
|
1602
|
+
this.#now()
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
if (current.status !== expectedStatus) {
|
|
1606
|
+
throw this.#statusConflict(input.itemId, current, expectedStatus);
|
|
1607
|
+
}
|
|
1608
|
+
throw new CoreConflictError(
|
|
1609
|
+
"board_transition_invalid",
|
|
1610
|
+
`Board item ${input.itemId} cannot be claimed from ${current.status}.`,
|
|
1611
|
+
current,
|
|
1612
|
+
this.#now()
|
|
1613
|
+
);
|
|
1614
|
+
}
|
|
1615
|
+
async unclaim(tenant, input) {
|
|
1616
|
+
const now = this.#now();
|
|
1617
|
+
const boardSeq = await this.#allocateSeq(tenant);
|
|
1618
|
+
const released = await this.#pool.query(
|
|
1619
|
+
`UPDATE board_item
|
|
1620
|
+
SET status = CASE WHEN status = 'in_progress' THEN 'todo' ELSE status END,
|
|
1621
|
+
holder_id = NULL,
|
|
1622
|
+
held_since = NULL,
|
|
1623
|
+
board_seq = $4::bigint,
|
|
1624
|
+
updated_at = $5
|
|
1625
|
+
WHERE tenant_id = $1 AND item_id = $2 AND holder_id = $3
|
|
1626
|
+
RETURNING ${BOARD_COLUMNS}`,
|
|
1627
|
+
[tenant, input.itemId, input.holderId, boardSeq, now]
|
|
1628
|
+
);
|
|
1629
|
+
const row = released.rows[0];
|
|
1630
|
+
if (row !== void 0) return toItem(row);
|
|
1631
|
+
const current = await this.get(tenant, input.itemId);
|
|
1632
|
+
if (current === void 0) throw this.#itemNotFound(input.itemId);
|
|
1633
|
+
if (current.assignee === void 0) {
|
|
1634
|
+
throw new ByokCoreError(
|
|
1635
|
+
"board_not_held",
|
|
1636
|
+
`Board item ${input.itemId} is not held by anyone.`
|
|
1637
|
+
);
|
|
1638
|
+
}
|
|
1639
|
+
throw new CoreConflictError(
|
|
1640
|
+
"board_claim_conflict",
|
|
1641
|
+
`Board item ${input.itemId} is held by ${current.assignee.holderId}, not ${input.holderId}.`,
|
|
1642
|
+
current,
|
|
1643
|
+
this.#now()
|
|
1644
|
+
);
|
|
1645
|
+
}
|
|
1646
|
+
async updateStatus(tenant, input) {
|
|
1647
|
+
if (isLegalBoardTransition(input.expectedStatus, input.status)) {
|
|
1648
|
+
const now = this.#now();
|
|
1649
|
+
const boardSeq = await this.#allocateSeq(tenant);
|
|
1650
|
+
const updated = await this.#pool.query(
|
|
1651
|
+
`UPDATE board_item
|
|
1652
|
+
SET status = $4::text, board_seq = $6::bigint, updated_at = $7
|
|
1653
|
+
WHERE tenant_id = $1 AND item_id = $2
|
|
1654
|
+
AND status = $3::text
|
|
1655
|
+
AND ($5::text IS NULL OR holder_id = $5::text)
|
|
1656
|
+
RETURNING ${BOARD_COLUMNS}`,
|
|
1657
|
+
[
|
|
1658
|
+
tenant,
|
|
1659
|
+
input.itemId,
|
|
1660
|
+
input.expectedStatus,
|
|
1661
|
+
input.status,
|
|
1662
|
+
input.holderId ?? null,
|
|
1663
|
+
boardSeq,
|
|
1664
|
+
now
|
|
1665
|
+
]
|
|
1666
|
+
);
|
|
1667
|
+
const row = updated.rows[0];
|
|
1668
|
+
if (row !== void 0) return toItem(row);
|
|
1669
|
+
}
|
|
1670
|
+
const current = await this.get(tenant, input.itemId);
|
|
1671
|
+
if (current === void 0) throw this.#itemNotFound(input.itemId);
|
|
1672
|
+
if (current.status !== input.expectedStatus) {
|
|
1673
|
+
throw this.#statusConflict(input.itemId, current, input.expectedStatus);
|
|
1674
|
+
}
|
|
1675
|
+
if (input.holderId !== void 0 && current.assignee?.holderId !== input.holderId) {
|
|
1676
|
+
throw new CoreConflictError(
|
|
1677
|
+
"board_claim_conflict",
|
|
1678
|
+
`Board item ${input.itemId} is not held by ${input.holderId}.`,
|
|
1679
|
+
current,
|
|
1680
|
+
this.#now()
|
|
1681
|
+
);
|
|
1682
|
+
}
|
|
1683
|
+
if (!isLegalBoardTransition(input.expectedStatus, input.status)) {
|
|
1684
|
+
throw new CoreConflictError(
|
|
1685
|
+
"board_transition_invalid",
|
|
1686
|
+
`${input.expectedStatus} to ${input.status} is not a legal board transition.`,
|
|
1687
|
+
current,
|
|
1688
|
+
this.#now()
|
|
1689
|
+
);
|
|
1690
|
+
}
|
|
1691
|
+
throw this.#statusConflict(input.itemId, current, input.expectedStatus);
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* One statement, its own transaction, lock released immediately. See the file
|
|
1695
|
+
* header for why this is not a CTE inside the write it feeds.
|
|
1696
|
+
*/
|
|
1697
|
+
async #allocateSeq(tenant) {
|
|
1698
|
+
const result = await this.#pool.query(
|
|
1699
|
+
`INSERT INTO tenant_stream (tenant_id, board_seq)
|
|
1700
|
+
VALUES ($1, 1)
|
|
1701
|
+
ON CONFLICT (tenant_id) DO UPDATE SET board_seq = tenant_stream.board_seq + 1
|
|
1702
|
+
RETURNING board_seq`,
|
|
1703
|
+
[tenant]
|
|
1704
|
+
);
|
|
1705
|
+
return result.rows[0].board_seq;
|
|
1706
|
+
}
|
|
1707
|
+
#itemNotFound(itemId) {
|
|
1708
|
+
return new ByokCoreError(
|
|
1709
|
+
"board_item_not_found",
|
|
1710
|
+
`Board item ${itemId} does not exist in this tenant.`
|
|
1711
|
+
);
|
|
1712
|
+
}
|
|
1713
|
+
#statusConflict(itemId, current, expected) {
|
|
1714
|
+
return new CoreConflictError(
|
|
1715
|
+
"board_status_conflict",
|
|
1716
|
+
`Board item ${itemId} is ${current.status}, not ${expected}.`,
|
|
1717
|
+
current,
|
|
1718
|
+
this.#now()
|
|
1719
|
+
);
|
|
1720
|
+
}
|
|
1721
|
+
#now() {
|
|
1722
|
+
return this.#clock.now().toISOString();
|
|
1723
|
+
}
|
|
1724
|
+
};
|
|
1725
|
+
|
|
1726
|
+
// src/stores/core/mailbox-sequence.ts
|
|
1727
|
+
async function allocateMailboxSequence(client, tenant, deviceId, now) {
|
|
1728
|
+
const allocation = await client.query(
|
|
1729
|
+
`INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
|
|
1730
|
+
VALUES ($1, $2, 2, 0, $3)
|
|
1731
|
+
ON CONFLICT (tenant_id, device_id) DO UPDATE
|
|
1732
|
+
SET next_seq = device_stream.next_seq + 1
|
|
1733
|
+
RETURNING next_seq - 1 AS seq`,
|
|
1734
|
+
[tenant, deviceId, now]
|
|
1735
|
+
);
|
|
1736
|
+
return Number(allocation.rows[0].seq);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// src/stores/core/mailbox.ts
|
|
1740
|
+
var DEFAULT_READ_LIMIT = 50;
|
|
1741
|
+
var OUTBOX_COLUMNS = "tenant_id, device_id, seq, message_id, body, body_hash, byte_size, state, appended_at";
|
|
1742
|
+
function toMessage(row) {
|
|
1743
|
+
return {
|
|
1744
|
+
tenantId: row.tenant_id,
|
|
1745
|
+
deviceId: row.device_id,
|
|
1746
|
+
// `seq` is bigint in the column and `number` on the port, because it is the
|
|
1747
|
+
// envelope `seq` on the wire. The column is wide so the counter cannot wrap
|
|
1748
|
+
// into a redelivery bug; the narrowing happens once, here.
|
|
1749
|
+
seq: Number(row.seq),
|
|
1750
|
+
messageId: row.message_id,
|
|
1751
|
+
body: row.body,
|
|
1752
|
+
bodyHash: row.body_hash,
|
|
1753
|
+
byteSize: row.byte_size,
|
|
1754
|
+
state: row.state,
|
|
1755
|
+
appendedAt: row.appended_at
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
var PostgresMailboxStore = class {
|
|
1759
|
+
#pool;
|
|
1760
|
+
#clock;
|
|
1761
|
+
constructor(pool, clock) {
|
|
1762
|
+
this.#pool = pool;
|
|
1763
|
+
this.#clock = clock;
|
|
1764
|
+
}
|
|
1765
|
+
async append(tenant, input) {
|
|
1766
|
+
this.#requireDeviceId(input.deviceId);
|
|
1767
|
+
const client = await this.#pool.connect();
|
|
1768
|
+
try {
|
|
1769
|
+
await client.query("BEGIN");
|
|
1770
|
+
const existing = await client.query(
|
|
1771
|
+
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1772
|
+
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
1773
|
+
[tenant, input.deviceId, input.messageId]
|
|
1774
|
+
);
|
|
1775
|
+
const replayed = existing.rows[0];
|
|
1776
|
+
if (replayed !== void 0) {
|
|
1777
|
+
await client.query("COMMIT");
|
|
1778
|
+
return toMessage(replayed);
|
|
1779
|
+
}
|
|
1780
|
+
const seq = await allocateMailboxSequence(client, tenant, input.deviceId, this.#now());
|
|
1781
|
+
const serializedExisting = await client.query(
|
|
1782
|
+
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1783
|
+
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
1784
|
+
[tenant, input.deviceId, input.messageId]
|
|
1785
|
+
);
|
|
1786
|
+
const winnerAfterLock = serializedExisting.rows[0];
|
|
1787
|
+
if (winnerAfterLock !== void 0) {
|
|
1788
|
+
await client.query("ROLLBACK");
|
|
1789
|
+
return toMessage(winnerAfterLock);
|
|
1790
|
+
}
|
|
1791
|
+
const materialized = await input.materialize(seq);
|
|
1792
|
+
const now = this.#now();
|
|
1793
|
+
const inserted = await client.query(
|
|
1794
|
+
`INSERT INTO outbox (${OUTBOX_COLUMNS})
|
|
1795
|
+
VALUES ($1, $2, $3::bigint, $4, $5, $6, $7::bigint, 'pending', $8)
|
|
1796
|
+
ON CONFLICT (tenant_id, device_id, message_id) DO NOTHING
|
|
1797
|
+
RETURNING ${OUTBOX_COLUMNS}`,
|
|
1798
|
+
[
|
|
1799
|
+
tenant,
|
|
1800
|
+
input.deviceId,
|
|
1801
|
+
seq,
|
|
1802
|
+
input.messageId,
|
|
1803
|
+
materialized.body,
|
|
1804
|
+
materialized.bodyHash,
|
|
1805
|
+
materialized.byteSize,
|
|
1806
|
+
now
|
|
1807
|
+
]
|
|
1808
|
+
);
|
|
1809
|
+
const row = inserted.rows[0];
|
|
1810
|
+
if (row !== void 0) {
|
|
1811
|
+
await client.query("COMMIT");
|
|
1812
|
+
return toMessage(row);
|
|
1813
|
+
}
|
|
1814
|
+
const winner = await client.query(
|
|
1815
|
+
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1816
|
+
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
1817
|
+
[tenant, input.deviceId, input.messageId]
|
|
1818
|
+
);
|
|
1819
|
+
await client.query("ROLLBACK");
|
|
1820
|
+
const won = winner.rows[0];
|
|
1821
|
+
if (won === void 0) {
|
|
1822
|
+
throw new ByokCoreError(
|
|
1823
|
+
"mailbox_message_not_found",
|
|
1824
|
+
`Message ${input.messageId} vanished during an idempotent append.`
|
|
1825
|
+
);
|
|
1826
|
+
}
|
|
1827
|
+
return toMessage(won);
|
|
1828
|
+
} catch (cause) {
|
|
1829
|
+
await client.query("ROLLBACK").catch(() => {
|
|
1830
|
+
});
|
|
1831
|
+
throw cause;
|
|
1832
|
+
} finally {
|
|
1833
|
+
client.release();
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
async readAfter(tenant, query) {
|
|
1837
|
+
const limit = query.limit ?? DEFAULT_READ_LIMIT;
|
|
1838
|
+
const result = await this.#pool.query(
|
|
1839
|
+
`SELECT ${OUTBOX_COLUMNS} FROM outbox
|
|
1840
|
+
WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending' AND seq > $3::bigint
|
|
1841
|
+
ORDER BY seq
|
|
1842
|
+
LIMIT $4`,
|
|
1843
|
+
[tenant, query.deviceId, query.afterSeq, limit + 1]
|
|
1844
|
+
);
|
|
1845
|
+
const page = result.rows.slice(0, limit).map(toMessage);
|
|
1846
|
+
return {
|
|
1847
|
+
messages: page,
|
|
1848
|
+
// Nothing above was mutated, so an identical call replays the same page.
|
|
1849
|
+
// The returned position is a READ cursor and moves no ack.
|
|
1850
|
+
nextSeq: page.at(-1)?.seq ?? query.afterSeq,
|
|
1851
|
+
hasMore: result.rows.length > page.length
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
async advanceCursor(tenant, input) {
|
|
1855
|
+
this.#requireDeviceId(input.deviceId);
|
|
1856
|
+
const now = this.#now();
|
|
1857
|
+
const moved = await this.#pool.query(
|
|
1858
|
+
`WITH moved AS (
|
|
1859
|
+
INSERT INTO device_stream (tenant_id, device_id, next_seq, acked_seq, acked_at)
|
|
1860
|
+
VALUES ($1, $2, 1, $3::bigint, $4)
|
|
1861
|
+
ON CONFLICT (tenant_id, device_id) DO UPDATE
|
|
1862
|
+
SET acked_seq = EXCLUDED.acked_seq, acked_at = EXCLUDED.acked_at
|
|
1863
|
+
WHERE device_stream.acked_seq <= EXCLUDED.acked_seq
|
|
1864
|
+
RETURNING acked_seq, acked_at
|
|
1865
|
+
), marked AS (
|
|
1866
|
+
UPDATE outbox
|
|
1867
|
+
SET state = 'acked'
|
|
1868
|
+
WHERE tenant_id = $1 AND device_id = $2 AND state = 'pending'
|
|
1869
|
+
AND seq <= (SELECT acked_seq FROM moved)
|
|
1870
|
+
RETURNING 1
|
|
1871
|
+
)
|
|
1872
|
+
SELECT acked_seq, acked_at FROM moved`,
|
|
1873
|
+
[tenant, input.deviceId, input.ackedSeq, now]
|
|
1874
|
+
);
|
|
1875
|
+
const row = moved.rows[0];
|
|
1876
|
+
if (row !== void 0) {
|
|
1877
|
+
return {
|
|
1878
|
+
tenantId: tenant,
|
|
1879
|
+
deviceId: input.deviceId,
|
|
1880
|
+
ackedSeq: Number(row.acked_seq),
|
|
1881
|
+
updatedAt: row.acked_at ?? now
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
const current = await this.readCursor(tenant, input.deviceId);
|
|
1885
|
+
throw new CoreConflictError(
|
|
1886
|
+
"mailbox_cursor_regression",
|
|
1887
|
+
`Cursor for device ${input.deviceId} is at ${current.ackedSeq}; refusing to move it back to ${input.ackedSeq}.`,
|
|
1888
|
+
current,
|
|
1889
|
+
this.#now()
|
|
1890
|
+
);
|
|
1891
|
+
}
|
|
1892
|
+
async readCursor(tenant, deviceId) {
|
|
1893
|
+
const result = await this.#pool.query(
|
|
1894
|
+
`SELECT acked_seq, acked_at FROM device_stream
|
|
1895
|
+
WHERE tenant_id = $1 AND device_id = $2`,
|
|
1896
|
+
[tenant, deviceId]
|
|
1897
|
+
);
|
|
1898
|
+
const row = result.rows[0];
|
|
1899
|
+
return {
|
|
1900
|
+
tenantId: tenant,
|
|
1901
|
+
deviceId,
|
|
1902
|
+
ackedSeq: row === void 0 ? 0 : Number(row.acked_seq),
|
|
1903
|
+
updatedAt: row?.acked_at ?? this.#now()
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
async collectRetired(tenant, input) {
|
|
1907
|
+
assertCanonicalTimestamp(input.ackedBefore, "ackedBefore");
|
|
1908
|
+
assertCanonicalTimestamp(input.expireUnackedBefore, "expireUnackedBefore");
|
|
1909
|
+
const swept = await this.#pool.query(
|
|
1910
|
+
`WITH deleted AS (
|
|
1911
|
+
DELETE FROM outbox
|
|
1912
|
+
WHERE tenant_id = $1
|
|
1913
|
+
AND ($2::text IS NULL OR device_id = $2::text)
|
|
1914
|
+
AND state = 'acked'
|
|
1915
|
+
AND appended_at < $3
|
|
1916
|
+
RETURNING byte_size
|
|
1917
|
+
), expired AS (
|
|
1918
|
+
UPDATE outbox
|
|
1919
|
+
SET state = 'expired'
|
|
1920
|
+
WHERE tenant_id = $1
|
|
1921
|
+
AND ($2::text IS NULL OR device_id = $2::text)
|
|
1922
|
+
AND state = 'pending'
|
|
1923
|
+
AND appended_at < $4
|
|
1924
|
+
RETURNING 1
|
|
1925
|
+
)
|
|
1926
|
+
SELECT (SELECT count(*) FROM deleted) AS deleted_count,
|
|
1927
|
+
(SELECT count(*) FROM expired) AS expired_count,
|
|
1928
|
+
(SELECT COALESCE(SUM(byte_size), 0) FROM deleted)::bigint AS released_bytes`,
|
|
1929
|
+
[tenant, input.deviceId ?? null, input.ackedBefore, input.expireUnackedBefore]
|
|
1930
|
+
);
|
|
1931
|
+
const row = swept.rows[0];
|
|
1932
|
+
return {
|
|
1933
|
+
deletedCount: Number(row.deleted_count),
|
|
1934
|
+
expiredCount: Number(row.expired_count),
|
|
1935
|
+
releasedBytes: row.released_bytes
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
/**
|
|
1939
|
+
* The in-memory reference refuses an empty device id rather than opening a
|
|
1940
|
+
* mailbox nothing can address. Kept here so the two compositions answer the
|
|
1941
|
+
* same way; the table itself would happily store the row.
|
|
1942
|
+
*/
|
|
1943
|
+
#requireDeviceId(deviceId) {
|
|
1944
|
+
if (deviceId.length === 0) {
|
|
1945
|
+
throw new ByokCoreError("mailbox_message_not_found", "Device id must not be empty.");
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
#now() {
|
|
1949
|
+
return this.#clock.now().toISOString();
|
|
1950
|
+
}
|
|
1951
|
+
};
|
|
1952
|
+
var WARNING_NUMERATOR = 80n;
|
|
1953
|
+
var WARNING_DENOMINATOR = 100n;
|
|
1954
|
+
var ENTITLEMENT_COLUMNS = "tenant_id, version, hard_limit_bytes, max_object_bytes, max_inline_bytes, mailbox_limit_bytes, retention_policy_id, downgrade_grace_until";
|
|
1955
|
+
var RESERVATION_COLUMNS = "tenant_id, reservation_id, state, kind, expected_bytes, content_hash, content_type, created_at, expires_at, settled_at, deduplicated";
|
|
1956
|
+
var QUALIFIED_RESERVATION_COLUMNS = RESERVATION_COLUMNS.split(", ").map((column) => `r.${column}`).join(", ");
|
|
1957
|
+
function toEntitlement(row) {
|
|
1958
|
+
return {
|
|
1959
|
+
tenantId: row.tenant_id,
|
|
1960
|
+
version: row.version,
|
|
1961
|
+
hardLimitBytes: row.hard_limit_bytes,
|
|
1962
|
+
maxObjectBytes: row.max_object_bytes,
|
|
1963
|
+
maxInlineBytes: row.max_inline_bytes,
|
|
1964
|
+
mailboxLimitBytes: row.mailbox_limit_bytes,
|
|
1965
|
+
retentionPolicyId: row.retention_policy_id,
|
|
1966
|
+
...row.downgrade_grace_until === null ? {} : { downgradeGraceUntil: row.downgrade_grace_until }
|
|
1967
|
+
};
|
|
1968
|
+
}
|
|
1969
|
+
function toReservation(row) {
|
|
1970
|
+
return {
|
|
1971
|
+
tenantId: row.tenant_id,
|
|
1972
|
+
reservationId: row.reservation_id,
|
|
1973
|
+
state: row.state,
|
|
1974
|
+
kind: row.kind,
|
|
1975
|
+
expectedBytes: row.expected_bytes,
|
|
1976
|
+
contentHash: row.content_hash,
|
|
1977
|
+
contentType: row.content_type,
|
|
1978
|
+
createdAt: row.created_at,
|
|
1979
|
+
expiresAt: row.expires_at,
|
|
1980
|
+
...row.settled_at === null ? {} : { settledAt: row.settled_at }
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
function toUsage(row) {
|
|
1984
|
+
return {
|
|
1985
|
+
committedObjectBytes: row.committed_object_bytes,
|
|
1986
|
+
committedInlineBytes: row.committed_inline_bytes,
|
|
1987
|
+
reservedBytes: row.reserved_bytes,
|
|
1988
|
+
mailboxBytes: row.mailbox_bytes,
|
|
1989
|
+
objectCount: row.object_count,
|
|
1990
|
+
updatedAt: row.updated_at
|
|
1991
|
+
};
|
|
1992
|
+
}
|
|
1993
|
+
var USAGE_SQL = `
|
|
1994
|
+
SELECT
|
|
1995
|
+
COALESCE(u.committed_object_bytes, 0)::bigint AS committed_object_bytes,
|
|
1996
|
+
COALESCE(u.committed_inline_bytes, 0)::bigint AS committed_inline_bytes,
|
|
1997
|
+
COALESCE(u.mailbox_bytes, 0)::bigint AS mailbox_bytes,
|
|
1998
|
+
COALESCE(u.object_count, 0)::bigint AS object_count,
|
|
1999
|
+
COALESCE(u.updated_at, $2) AS updated_at,
|
|
2000
|
+
COALESCE((SELECT SUM(r.expected_bytes) FROM storage_reservation r
|
|
2001
|
+
WHERE r.tenant_id = $1 AND r.state = 'reserved'), 0)::bigint AS reserved_bytes
|
|
2002
|
+
FROM (SELECT $1::text AS tenant_id) AS scope
|
|
2003
|
+
LEFT JOIN storage_usage u ON u.tenant_id = scope.tenant_id`;
|
|
2004
|
+
var PostgresQuotaStore = class {
|
|
2005
|
+
#pool;
|
|
2006
|
+
#clock;
|
|
2007
|
+
constructor(pool, clock) {
|
|
2008
|
+
this.#pool = pool;
|
|
2009
|
+
this.#clock = clock;
|
|
2010
|
+
}
|
|
2011
|
+
async readEntitlement(tenant) {
|
|
2012
|
+
const result = await this.#pool.query(
|
|
2013
|
+
`SELECT ${ENTITLEMENT_COLUMNS} FROM storage_entitlement WHERE tenant_id = $1`,
|
|
2014
|
+
[tenant]
|
|
2015
|
+
);
|
|
2016
|
+
const row = result.rows[0];
|
|
2017
|
+
return row === void 0 ? void 0 : toEntitlement(row);
|
|
2018
|
+
}
|
|
2019
|
+
async writeEntitlement(tenant, input) {
|
|
2020
|
+
if (input.downgradeGraceUntil !== void 0) {
|
|
2021
|
+
assertCanonicalTimestamp(input.downgradeGraceUntil, "downgradeGraceUntil");
|
|
2022
|
+
}
|
|
2023
|
+
const applied = await this.#pool.query(
|
|
2024
|
+
`WITH applied AS (
|
|
2025
|
+
INSERT INTO storage_entitlement (${ENTITLEMENT_COLUMNS})
|
|
2026
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
2027
|
+
ON CONFLICT (tenant_id) DO UPDATE
|
|
2028
|
+
SET version = EXCLUDED.version,
|
|
2029
|
+
hard_limit_bytes = EXCLUDED.hard_limit_bytes,
|
|
2030
|
+
max_object_bytes = EXCLUDED.max_object_bytes,
|
|
2031
|
+
max_inline_bytes = EXCLUDED.max_inline_bytes,
|
|
2032
|
+
mailbox_limit_bytes = EXCLUDED.mailbox_limit_bytes,
|
|
2033
|
+
retention_policy_id = EXCLUDED.retention_policy_id,
|
|
2034
|
+
downgrade_grace_until = EXCLUDED.downgrade_grace_until
|
|
2035
|
+
WHERE storage_entitlement.version < EXCLUDED.version
|
|
2036
|
+
RETURNING ${ENTITLEMENT_COLUMNS}
|
|
2037
|
+
), seeded AS (
|
|
2038
|
+
INSERT INTO storage_usage (tenant_id, updated_at)
|
|
2039
|
+
SELECT $1, $9 FROM applied
|
|
2040
|
+
ON CONFLICT (tenant_id) DO NOTHING
|
|
2041
|
+
RETURNING 1
|
|
2042
|
+
)
|
|
2043
|
+
SELECT ${ENTITLEMENT_COLUMNS} FROM applied`,
|
|
2044
|
+
[
|
|
2045
|
+
tenant,
|
|
2046
|
+
input.version,
|
|
2047
|
+
input.hardLimitBytes,
|
|
2048
|
+
input.maxObjectBytes,
|
|
2049
|
+
input.maxInlineBytes,
|
|
2050
|
+
input.mailboxLimitBytes,
|
|
2051
|
+
input.retentionPolicyId,
|
|
2052
|
+
input.downgradeGraceUntil ?? null,
|
|
2053
|
+
this.#now()
|
|
2054
|
+
]
|
|
2055
|
+
);
|
|
2056
|
+
const row = applied.rows[0];
|
|
2057
|
+
if (row !== void 0) return toEntitlement(row);
|
|
2058
|
+
const current = await this.readEntitlement(tenant);
|
|
2059
|
+
if (current === void 0) {
|
|
2060
|
+
throw new Error(`entitlement for ${tenant} vanished during a version CAS`);
|
|
2061
|
+
}
|
|
2062
|
+
throw new CoreConflictError(
|
|
2063
|
+
"storage_entitlement_version_conflict",
|
|
2064
|
+
`Entitlement is at version ${String(current.version)}; refusing to apply version ${String(input.version)}.`,
|
|
2065
|
+
current,
|
|
2066
|
+
this.#now()
|
|
2067
|
+
);
|
|
2068
|
+
}
|
|
2069
|
+
async readUsage(tenant) {
|
|
2070
|
+
const result = await this.#pool.query(USAGE_SQL, [tenant, this.#now()]);
|
|
2071
|
+
return toUsage(result.rows[0]);
|
|
2072
|
+
}
|
|
2073
|
+
async readStatus(tenant) {
|
|
2074
|
+
const entitlement = await this.readEntitlement(tenant);
|
|
2075
|
+
if (entitlement === void 0) throw this.#entitlementMissing();
|
|
2076
|
+
const usage = await this.readUsage(tenant);
|
|
2077
|
+
const used = usedBytes(usage);
|
|
2078
|
+
const graceActive = this.#graceActive(entitlement);
|
|
2079
|
+
return {
|
|
2080
|
+
entitlement,
|
|
2081
|
+
usage,
|
|
2082
|
+
posture: posture(entitlement, usage, graceActive),
|
|
2083
|
+
availableBytes: used >= entitlement.hardLimitBytes ? 0n : entitlement.hardLimitBytes - used,
|
|
2084
|
+
graceActive
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
async readReservation(tenant, reservationId) {
|
|
2088
|
+
return (await this.#readReservation(tenant, reservationId))?.reservation;
|
|
2089
|
+
}
|
|
2090
|
+
async reserve(tenant, input) {
|
|
2091
|
+
const client = await this.#pool.connect();
|
|
2092
|
+
let admitted;
|
|
2093
|
+
let rejection;
|
|
2094
|
+
try {
|
|
2095
|
+
await client.query("BEGIN");
|
|
2096
|
+
const entitlement = await this.#lockEntitlement(client, tenant);
|
|
2097
|
+
if (entitlement === void 0) {
|
|
2098
|
+
rejection = this.#entitlementMissing();
|
|
2099
|
+
} else {
|
|
2100
|
+
const now = this.#now();
|
|
2101
|
+
await client.query(
|
|
2102
|
+
`UPDATE storage_reservation
|
|
2103
|
+
SET state = 'expired', settled_at = $2
|
|
2104
|
+
WHERE tenant_id = $1 AND state = 'reserved' AND expires_at <= $2`,
|
|
2105
|
+
[tenant, now]
|
|
2106
|
+
);
|
|
2107
|
+
const inserted = await client.query(
|
|
2108
|
+
`WITH ent AS (
|
|
2109
|
+
SELECT hard_limit_bytes, max_object_bytes, max_inline_bytes, downgrade_grace_until
|
|
2110
|
+
FROM storage_entitlement WHERE tenant_id = $1
|
|
2111
|
+
), used AS (
|
|
2112
|
+
SELECT COALESCE((SELECT committed_object_bytes + committed_inline_bytes
|
|
2113
|
+
FROM storage_usage WHERE tenant_id = $1), 0)::bigint
|
|
2114
|
+
+ COALESCE((SELECT SUM(expected_bytes) FROM storage_reservation
|
|
2115
|
+
WHERE tenant_id = $1 AND state = 'reserved'), 0)::bigint
|
|
2116
|
+
AS used_bytes
|
|
2117
|
+
)
|
|
2118
|
+
INSERT INTO storage_reservation
|
|
2119
|
+
(tenant_id, reservation_id, state, kind, expected_bytes,
|
|
2120
|
+
content_hash, content_type, created_at, expires_at)
|
|
2121
|
+
SELECT $1, $2, 'reserved', $3::text, $4::bigint, $5, $6, $7, $8
|
|
2122
|
+
FROM ent, used
|
|
2123
|
+
WHERE $4::bigint <= (CASE WHEN $3::text = 'object'
|
|
2124
|
+
THEN ent.max_object_bytes
|
|
2125
|
+
ELSE ent.max_inline_bytes END)
|
|
2126
|
+
AND used.used_bytes + $4::bigint <= ent.hard_limit_bytes
|
|
2127
|
+
AND ($3::text <> 'object' OR NOT EXISTS (
|
|
2128
|
+
SELECT 1 FROM object_manifest m
|
|
2129
|
+
WHERE m.tenant_id = $1 AND m.hash = $5
|
|
2130
|
+
AND m.state = 'delete_pending'
|
|
2131
|
+
))
|
|
2132
|
+
AND NOT (used.used_bytes >= ent.hard_limit_bytes
|
|
2133
|
+
AND ent.downgrade_grace_until IS NOT NULL
|
|
2134
|
+
AND ent.downgrade_grace_until <= $9)
|
|
2135
|
+
ON CONFLICT (tenant_id, reservation_id) DO NOTHING
|
|
2136
|
+
RETURNING ${RESERVATION_COLUMNS}`,
|
|
2137
|
+
[
|
|
2138
|
+
tenant,
|
|
2139
|
+
input.reservationId,
|
|
2140
|
+
input.kind,
|
|
2141
|
+
input.expectedBytes,
|
|
2142
|
+
input.contentHash,
|
|
2143
|
+
input.contentType,
|
|
2144
|
+
now,
|
|
2145
|
+
this.#expiry(input.ttlMs),
|
|
2146
|
+
now
|
|
2147
|
+
]
|
|
2148
|
+
);
|
|
2149
|
+
const row = inserted.rows[0];
|
|
2150
|
+
if (row !== void 0) {
|
|
2151
|
+
admitted = toReservation(row);
|
|
2152
|
+
} else {
|
|
2153
|
+
const outcome = await this.#explainRefusedReservation(
|
|
2154
|
+
client,
|
|
2155
|
+
tenant,
|
|
2156
|
+
input,
|
|
2157
|
+
entitlement
|
|
2158
|
+
);
|
|
2159
|
+
if (outcome instanceof ByokCoreError) rejection = outcome;
|
|
2160
|
+
else admitted = outcome;
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
await client.query("COMMIT");
|
|
2164
|
+
} catch (error) {
|
|
2165
|
+
await client.query("ROLLBACK").catch(() => {
|
|
2166
|
+
});
|
|
2167
|
+
throw error;
|
|
2168
|
+
} finally {
|
|
2169
|
+
client.release();
|
|
2170
|
+
}
|
|
2171
|
+
if (rejection !== void 0) throw rejection;
|
|
2172
|
+
return admitted;
|
|
2173
|
+
}
|
|
2174
|
+
async finalizeReservation(tenant, input) {
|
|
2175
|
+
const existing = await this.#readReservation(tenant, input.reservationId);
|
|
2176
|
+
if (existing === void 0) throw this.#reservationMissing(input.reservationId);
|
|
2177
|
+
if (existing.reservation.state === "committed") {
|
|
2178
|
+
return {
|
|
2179
|
+
reservation: existing.reservation,
|
|
2180
|
+
usage: await this.readUsage(tenant),
|
|
2181
|
+
deduplicated: existing.deduplicated
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
if (existing.reservation.state !== "reserved") {
|
|
2185
|
+
throw new ByokCoreError(
|
|
2186
|
+
"storage_reservation_expired",
|
|
2187
|
+
`Reservation ${input.reservationId} is ${existing.reservation.state}.`
|
|
2188
|
+
);
|
|
2189
|
+
}
|
|
2190
|
+
if (this.#now() >= existing.reservation.expiresAt) {
|
|
2191
|
+
await this.#settle(tenant, input.reservationId, "expired");
|
|
2192
|
+
throw new ByokCoreError(
|
|
2193
|
+
"storage_reservation_expired",
|
|
2194
|
+
`Reservation ${input.reservationId} expired at ${existing.reservation.expiresAt}.`
|
|
2195
|
+
);
|
|
2196
|
+
}
|
|
2197
|
+
if (input.observedByteSize !== existing.reservation.expectedBytes || input.observedContentType !== existing.reservation.contentType) {
|
|
2198
|
+
await this.#settle(tenant, input.reservationId, "aborted");
|
|
2199
|
+
throw new ByokCoreError(
|
|
2200
|
+
"storage_integrity_mismatch",
|
|
2201
|
+
`Observed object does not match reservation ${input.reservationId}.`
|
|
2202
|
+
);
|
|
2203
|
+
}
|
|
2204
|
+
const settled = await this.#pool.query(
|
|
2205
|
+
`WITH candidate AS MATERIALIZED (
|
|
2206
|
+
SELECT r.tenant_id,
|
|
2207
|
+
r.reservation_id,
|
|
2208
|
+
r.kind,
|
|
2209
|
+
r.content_hash,
|
|
2210
|
+
r.expected_bytes,
|
|
2211
|
+
r.content_type,
|
|
2212
|
+
m.state AS manifest_state,
|
|
2213
|
+
(m.tenant_id IS NOT NULL
|
|
2214
|
+
AND m.state IN ('pending', 'committed')
|
|
2215
|
+
AND m.byte_size = $4::bigint
|
|
2216
|
+
AND m.content_type = $5) AS manifest_valid,
|
|
2217
|
+
EXISTS (
|
|
2218
|
+
SELECT 1 FROM storage_reservation p
|
|
2219
|
+
WHERE p.tenant_id = r.tenant_id
|
|
2220
|
+
AND p.content_hash = r.content_hash
|
|
2221
|
+
AND p.state = 'committed'
|
|
2222
|
+
) AS inline_deduplicated
|
|
2223
|
+
FROM storage_reservation r
|
|
2224
|
+
LEFT JOIN object_manifest m
|
|
2225
|
+
ON m.tenant_id = r.tenant_id AND m.hash = r.content_hash
|
|
2226
|
+
WHERE r.tenant_id = $1
|
|
2227
|
+
AND r.reservation_id = $2
|
|
2228
|
+
AND r.state = 'reserved'
|
|
2229
|
+
), committed_manifest AS (
|
|
2230
|
+
UPDATE object_manifest m
|
|
2231
|
+
SET state = 'committed', updated_at = $3
|
|
2232
|
+
FROM candidate c
|
|
2233
|
+
WHERE c.kind = 'object'
|
|
2234
|
+
AND c.manifest_valid
|
|
2235
|
+
AND m.state = 'pending'
|
|
2236
|
+
AND m.tenant_id = c.tenant_id
|
|
2237
|
+
AND m.hash = c.content_hash
|
|
2238
|
+
RETURNING 1
|
|
2239
|
+
), settled AS (
|
|
2240
|
+
UPDATE storage_reservation r
|
|
2241
|
+
SET state = CASE
|
|
2242
|
+
WHEN c.kind = 'object' AND NOT c.manifest_valid
|
|
2243
|
+
THEN 'aborted'
|
|
2244
|
+
ELSE 'committed'
|
|
2245
|
+
END,
|
|
2246
|
+
settled_at = $3,
|
|
2247
|
+
deduplicated = CASE
|
|
2248
|
+
WHEN c.kind = 'object'
|
|
2249
|
+
THEN NOT EXISTS (SELECT 1 FROM committed_manifest)
|
|
2250
|
+
ELSE c.inline_deduplicated
|
|
2251
|
+
END
|
|
2252
|
+
FROM candidate c,
|
|
2253
|
+
(SELECT COUNT(*) FROM committed_manifest) AS manifest_barrier
|
|
2254
|
+
WHERE r.tenant_id = c.tenant_id
|
|
2255
|
+
AND r.reservation_id = c.reservation_id
|
|
2256
|
+
AND r.state = 'reserved'
|
|
2257
|
+
RETURNING ${QUALIFIED_RESERVATION_COLUMNS}
|
|
2258
|
+
), accounted AS (
|
|
2259
|
+
UPDATE storage_usage u
|
|
2260
|
+
SET committed_object_bytes = u.committed_object_bytes
|
|
2261
|
+
+ CASE WHEN s.kind = 'object' AND NOT s.deduplicated
|
|
2262
|
+
THEN s.expected_bytes ELSE 0 END,
|
|
2263
|
+
committed_inline_bytes = u.committed_inline_bytes
|
|
2264
|
+
+ CASE WHEN s.kind = 'inline' AND NOT s.deduplicated
|
|
2265
|
+
THEN s.expected_bytes ELSE 0 END,
|
|
2266
|
+
object_count = u.object_count
|
|
2267
|
+
+ CASE WHEN s.kind = 'object' AND NOT s.deduplicated THEN 1 ELSE 0 END,
|
|
2268
|
+
updated_at = $3
|
|
2269
|
+
FROM settled s
|
|
2270
|
+
WHERE u.tenant_id = $1 AND s.state = 'committed'
|
|
2271
|
+
RETURNING 1
|
|
2272
|
+
)
|
|
2273
|
+
SELECT ${RESERVATION_COLUMNS} FROM settled`,
|
|
2274
|
+
[
|
|
2275
|
+
tenant,
|
|
2276
|
+
input.reservationId,
|
|
2277
|
+
this.#now(),
|
|
2278
|
+
input.observedByteSize,
|
|
2279
|
+
input.observedContentType
|
|
2280
|
+
]
|
|
2281
|
+
);
|
|
2282
|
+
const row = settled.rows[0];
|
|
2283
|
+
if (row === void 0) {
|
|
2284
|
+
const raced = await this.#readReservation(tenant, input.reservationId);
|
|
2285
|
+
if (raced !== void 0 && raced.reservation.state === "committed") {
|
|
2286
|
+
return {
|
|
2287
|
+
reservation: raced.reservation,
|
|
2288
|
+
usage: await this.readUsage(tenant),
|
|
2289
|
+
deduplicated: raced.deduplicated
|
|
2290
|
+
};
|
|
2291
|
+
}
|
|
2292
|
+
throw new ByokCoreError(
|
|
2293
|
+
"storage_reservation_expired",
|
|
2294
|
+
`Reservation ${input.reservationId} was settled concurrently.`
|
|
2295
|
+
);
|
|
2296
|
+
}
|
|
2297
|
+
const reservation = toReservation(row);
|
|
2298
|
+
if (reservation.state === "aborted") {
|
|
2299
|
+
throw new ByokCoreError(
|
|
2300
|
+
"storage_integrity_mismatch",
|
|
2301
|
+
`Reservation ${input.reservationId} has no matching committable object manifest.`
|
|
2302
|
+
);
|
|
2303
|
+
}
|
|
2304
|
+
return {
|
|
2305
|
+
reservation,
|
|
2306
|
+
usage: await this.readUsage(tenant),
|
|
2307
|
+
deduplicated: row.deduplicated
|
|
2308
|
+
};
|
|
2309
|
+
}
|
|
2310
|
+
async abortReservation(tenant, reservationId) {
|
|
2311
|
+
const existing = await this.#readReservation(tenant, reservationId);
|
|
2312
|
+
if (existing === void 0) throw this.#reservationMissing(reservationId);
|
|
2313
|
+
if (existing.reservation.state !== "reserved") return existing.reservation;
|
|
2314
|
+
const settled = await this.#settle(tenant, reservationId, "aborted");
|
|
2315
|
+
if (settled !== void 0) return settled;
|
|
2316
|
+
const raced = await this.#readReservation(tenant, reservationId);
|
|
2317
|
+
if (raced === void 0) throw this.#reservationMissing(reservationId);
|
|
2318
|
+
return raced.reservation;
|
|
2319
|
+
}
|
|
2320
|
+
async expireReservations(tenant) {
|
|
2321
|
+
const expired = await this.#pool.query(
|
|
2322
|
+
`UPDATE storage_reservation
|
|
2323
|
+
SET state = 'expired', settled_at = $2
|
|
2324
|
+
WHERE tenant_id = $1 AND state = 'reserved' AND expires_at <= $2
|
|
2325
|
+
RETURNING ${RESERVATION_COLUMNS}`,
|
|
2326
|
+
[tenant, this.#now()]
|
|
2327
|
+
);
|
|
2328
|
+
return expired.rows.map(toReservation).sort((left, right) => left.reservationId.localeCompare(right.reservationId));
|
|
2329
|
+
}
|
|
2330
|
+
async applyMailboxDelta(tenant, input) {
|
|
2331
|
+
const applied = await this.#pool.query(
|
|
2332
|
+
`WITH ent AS (
|
|
2333
|
+
SELECT mailbox_limit_bytes FROM storage_entitlement WHERE tenant_id = $1
|
|
2334
|
+
)
|
|
2335
|
+
UPDATE storage_usage u
|
|
2336
|
+
SET mailbox_bytes = GREATEST(u.mailbox_bytes + $2::bigint, 0::bigint),
|
|
2337
|
+
updated_at = $3
|
|
2338
|
+
FROM ent
|
|
2339
|
+
WHERE u.tenant_id = $1
|
|
2340
|
+
AND ($2::bigint <= 0 OR u.mailbox_bytes + $2::bigint <= ent.mailbox_limit_bytes)
|
|
2341
|
+
RETURNING 1`,
|
|
2342
|
+
[tenant, input.deltaBytes, this.#now()]
|
|
2343
|
+
);
|
|
2344
|
+
if (applied.rowCount === 0) {
|
|
2345
|
+
const entitlement = await this.readEntitlement(tenant);
|
|
2346
|
+
if (entitlement === void 0) throw this.#entitlementMissing();
|
|
2347
|
+
const usage = await this.readUsage(tenant);
|
|
2348
|
+
throw new ByokCoreError(
|
|
2349
|
+
"storage_quota_exceeded",
|
|
2350
|
+
`Mailbox would reach ${String(usage.mailboxBytes + input.deltaBytes)} bytes, over the limit of ${String(entitlement.mailboxLimitBytes)} bytes.`
|
|
2351
|
+
);
|
|
2352
|
+
}
|
|
2353
|
+
return this.readUsage(tenant);
|
|
2354
|
+
}
|
|
2355
|
+
async #lockEntitlement(client, tenant) {
|
|
2356
|
+
const result = await client.query(
|
|
2357
|
+
`SELECT ${ENTITLEMENT_COLUMNS} FROM storage_entitlement WHERE tenant_id = $1 FOR UPDATE`,
|
|
2358
|
+
[tenant]
|
|
2359
|
+
);
|
|
2360
|
+
const row = result.rows[0];
|
|
2361
|
+
return row === void 0 ? void 0 : toEntitlement(row);
|
|
2362
|
+
}
|
|
2363
|
+
/**
|
|
2364
|
+
* Names the refusal. Runs only after the guarded insert has already declined,
|
|
2365
|
+
* inside the same locked transaction, so the state it reads is the state that
|
|
2366
|
+
* refused. The order matches the contract's precedence: an existing
|
|
2367
|
+
* reservation is an idempotent answer, not a rejection; a suspended tenant is
|
|
2368
|
+
* read-only (423) before it is over-quota (507); a single oversized write is
|
|
2369
|
+
* 413 regardless of how much room is left.
|
|
2370
|
+
*/
|
|
2371
|
+
async #explainRefusedReservation(client, tenant, input, entitlement) {
|
|
2372
|
+
const existing = await client.query(
|
|
2373
|
+
`SELECT ${RESERVATION_COLUMNS} FROM storage_reservation
|
|
2374
|
+
WHERE tenant_id = $1 AND reservation_id = $2`,
|
|
2375
|
+
[tenant, input.reservationId]
|
|
2376
|
+
);
|
|
2377
|
+
const held = existing.rows[0];
|
|
2378
|
+
if (held !== void 0) {
|
|
2379
|
+
if (held.state === "reserved") {
|
|
2380
|
+
if (held.kind !== input.kind || held.expected_bytes !== input.expectedBytes || held.content_hash !== input.contentHash || held.content_type !== input.contentType) {
|
|
2381
|
+
return new ByokCoreError(
|
|
2382
|
+
"storage_integrity_mismatch",
|
|
2383
|
+
`Reservation ${input.reservationId} already binds a different storage declaration.`
|
|
2384
|
+
);
|
|
2385
|
+
}
|
|
2386
|
+
return toReservation(held);
|
|
2387
|
+
}
|
|
2388
|
+
return new ByokCoreError(
|
|
2389
|
+
"storage_reservation_expired",
|
|
2390
|
+
`Reservation ${input.reservationId} is already ${held.state}.`
|
|
2391
|
+
);
|
|
2392
|
+
}
|
|
2393
|
+
if (input.kind === "object") {
|
|
2394
|
+
const tombstone = await client.query(
|
|
2395
|
+
`SELECT state FROM object_manifest
|
|
2396
|
+
WHERE tenant_id = $1 AND hash = $2 AND state = 'delete_pending'`,
|
|
2397
|
+
[tenant, input.contentHash]
|
|
2398
|
+
);
|
|
2399
|
+
if (tombstone.rows[0] !== void 0) {
|
|
2400
|
+
return new ByokCoreError(
|
|
2401
|
+
"object_state_invalid",
|
|
2402
|
+
`Object ${input.contentHash} is pending deletion and cannot accept a new reservation.`
|
|
2403
|
+
);
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
const usageResult = await client.query(USAGE_SQL, [tenant, this.#now()]);
|
|
2407
|
+
const usage = toUsage(usageResult.rows[0]);
|
|
2408
|
+
if (posture(entitlement, usage, this.#graceActive(entitlement)) === "suspended") {
|
|
2409
|
+
return new ByokCoreError(
|
|
2410
|
+
"storage_write_suspended",
|
|
2411
|
+
"Tenant is over its hard limit and its downgrade grace has ended; durable writes are suspended."
|
|
2412
|
+
);
|
|
2413
|
+
}
|
|
2414
|
+
const perWriteLimit = input.kind === "object" ? entitlement.maxObjectBytes : entitlement.maxInlineBytes;
|
|
2415
|
+
if (input.expectedBytes > perWriteLimit) {
|
|
2416
|
+
return new ByokCoreError(
|
|
2417
|
+
"storage_object_too_large",
|
|
2418
|
+
`${String(input.expectedBytes)} bytes exceeds the ${input.kind} limit of ${String(perWriteLimit)} bytes.`
|
|
2419
|
+
);
|
|
2420
|
+
}
|
|
2421
|
+
return new ByokCoreError(
|
|
2422
|
+
"storage_quota_exceeded",
|
|
2423
|
+
`Reserving ${String(input.expectedBytes)} bytes would exceed the hard limit of ${String(entitlement.hardLimitBytes)} bytes.`
|
|
2424
|
+
);
|
|
2425
|
+
}
|
|
2426
|
+
async #readReservation(tenant, reservationId) {
|
|
2427
|
+
const result = await this.#pool.query(
|
|
2428
|
+
`SELECT ${RESERVATION_COLUMNS} FROM storage_reservation
|
|
2429
|
+
WHERE tenant_id = $1 AND reservation_id = $2`,
|
|
2430
|
+
[tenant, reservationId]
|
|
2431
|
+
);
|
|
2432
|
+
const row = result.rows[0];
|
|
2433
|
+
if (row === void 0) return void 0;
|
|
2434
|
+
return { reservation: toReservation(row), deduplicated: row.deduplicated };
|
|
2435
|
+
}
|
|
2436
|
+
/**
|
|
2437
|
+
* The settle guard. `WHERE state = 'reserved'` is what makes two settlements
|
|
2438
|
+
* of the same reservation produce one winner; the loser gets zero rows and
|
|
2439
|
+
* re-reads rather than stamping a second `settled_at` over the first.
|
|
2440
|
+
* Releasing the reserved bytes needs no second write: they were never a
|
|
2441
|
+
* counter, only the sum of rows in this state.
|
|
2442
|
+
*/
|
|
2443
|
+
async #settle(tenant, reservationId, state) {
|
|
2444
|
+
const result = await this.#pool.query(
|
|
2445
|
+
`UPDATE storage_reservation
|
|
2446
|
+
SET state = $3, settled_at = $4
|
|
2447
|
+
WHERE tenant_id = $1 AND reservation_id = $2 AND state = 'reserved'
|
|
2448
|
+
RETURNING ${RESERVATION_COLUMNS}`,
|
|
2449
|
+
[tenant, reservationId, state, this.#now()]
|
|
2450
|
+
);
|
|
2451
|
+
const row = result.rows[0];
|
|
2452
|
+
return row === void 0 ? void 0 : toReservation(row);
|
|
2453
|
+
}
|
|
2454
|
+
#graceActive(entitlement) {
|
|
2455
|
+
return entitlement.downgradeGraceUntil !== void 0 && this.#now() < entitlement.downgradeGraceUntil;
|
|
2456
|
+
}
|
|
2457
|
+
#entitlementMissing() {
|
|
2458
|
+
return new ByokCoreError(
|
|
2459
|
+
"storage_entitlement_missing",
|
|
2460
|
+
"No storage entitlement has been issued for this tenant."
|
|
2461
|
+
);
|
|
2462
|
+
}
|
|
2463
|
+
#reservationMissing(reservationId) {
|
|
2464
|
+
return new ByokCoreError(
|
|
2465
|
+
"storage_reservation_not_found",
|
|
2466
|
+
`Reservation ${reservationId} does not exist in this tenant.`
|
|
2467
|
+
);
|
|
2468
|
+
}
|
|
2469
|
+
#expiry(ttlMs) {
|
|
2470
|
+
return new Date(this.#clock.now().getTime() + ttlMs).toISOString();
|
|
2471
|
+
}
|
|
2472
|
+
#now() {
|
|
2473
|
+
return this.#clock.now().toISOString();
|
|
2474
|
+
}
|
|
2475
|
+
};
|
|
2476
|
+
function usedBytes(usage) {
|
|
2477
|
+
return usage.committedObjectBytes + usage.committedInlineBytes + usage.reservedBytes;
|
|
2478
|
+
}
|
|
2479
|
+
function posture(entitlement, usage, graceActive) {
|
|
2480
|
+
const used = usedBytes(usage);
|
|
2481
|
+
if (used >= entitlement.hardLimitBytes) {
|
|
2482
|
+
const graceConfigured = entitlement.downgradeGraceUntil !== void 0;
|
|
2483
|
+
return graceConfigured && !graceActive ? "suspended" : "blocked";
|
|
2484
|
+
}
|
|
2485
|
+
if (entitlement.hardLimitBytes > 0n && used * WARNING_DENOMINATOR >= entitlement.hardLimitBytes * WARNING_NUMERATOR) {
|
|
2486
|
+
return "warning";
|
|
2487
|
+
}
|
|
2488
|
+
return "normal";
|
|
2489
|
+
}
|
|
2490
|
+
var DEFAULT_LIST_LIMIT3 = 50;
|
|
2491
|
+
function toManifest(pack, files) {
|
|
2492
|
+
return {
|
|
2493
|
+
schema: SKILL_PACK_MANIFEST_SCHEMA_ID,
|
|
2494
|
+
name: pack.name,
|
|
2495
|
+
version: pack.version,
|
|
2496
|
+
description: pack.description,
|
|
2497
|
+
files: files.map(toFile),
|
|
2498
|
+
contentHash: pack.content_hash
|
|
2499
|
+
};
|
|
2500
|
+
}
|
|
2501
|
+
function toFile(row) {
|
|
2502
|
+
return {
|
|
2503
|
+
path: row.path,
|
|
2504
|
+
contentHash: row.content_hash,
|
|
2505
|
+
byteSize: row.byte_size
|
|
2506
|
+
};
|
|
2507
|
+
}
|
|
2508
|
+
var PostgresSkillPackStore = class {
|
|
2509
|
+
#pool;
|
|
2510
|
+
constructor(pool) {
|
|
2511
|
+
this.#pool = pool;
|
|
2512
|
+
}
|
|
2513
|
+
async publish(tenant, input) {
|
|
2514
|
+
const { manifest } = input;
|
|
2515
|
+
const structural = checkSkillPackManifest(manifest);
|
|
2516
|
+
if (!structural.ok) {
|
|
2517
|
+
throw new ByokCoreError(
|
|
2518
|
+
"skill_pack_manifest_invalid",
|
|
2519
|
+
`Refusing to publish ${JSON.stringify(manifest.name)}: ${structural.reason} \u2014 ${structural.detail}`
|
|
2520
|
+
);
|
|
2521
|
+
}
|
|
2522
|
+
const contents = /* @__PURE__ */ new Map();
|
|
2523
|
+
for (const file of input.files) {
|
|
2524
|
+
if (contents.has(file.path)) {
|
|
2525
|
+
throw new ByokCoreError(
|
|
2526
|
+
"skill_pack_manifest_invalid",
|
|
2527
|
+
`Refusing to publish ${JSON.stringify(manifest.name)}: ${JSON.stringify(file.path)} was supplied twice.`
|
|
2528
|
+
);
|
|
2529
|
+
}
|
|
2530
|
+
contents.set(file.path, file.content);
|
|
2531
|
+
}
|
|
2532
|
+
for (const declared of manifest.files) {
|
|
2533
|
+
const content = contents.get(declared.path);
|
|
2534
|
+
if (content === void 0) {
|
|
2535
|
+
throw new ByokCoreError(
|
|
2536
|
+
"skill_pack_manifest_invalid",
|
|
2537
|
+
`Refusing to publish ${JSON.stringify(manifest.name)}: ${JSON.stringify(declared.path)} is declared but was not supplied.`
|
|
2538
|
+
);
|
|
2539
|
+
}
|
|
2540
|
+
const bytes = new TextEncoder().encode(content).length;
|
|
2541
|
+
if (bytes !== declared.byteSize) {
|
|
2542
|
+
throw new ByokCoreError(
|
|
2543
|
+
"skill_pack_manifest_invalid",
|
|
2544
|
+
`Refusing to publish ${JSON.stringify(manifest.name)}: ${JSON.stringify(declared.path)} declares ${declared.byteSize} bytes and supplies ${bytes}.`
|
|
2545
|
+
);
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
if (contents.size !== manifest.files.length) {
|
|
2549
|
+
throw new ByokCoreError(
|
|
2550
|
+
"skill_pack_manifest_invalid",
|
|
2551
|
+
`Refusing to publish ${JSON.stringify(manifest.name)}: ${contents.size} files were supplied for ${manifest.files.length} declared rows.`
|
|
2552
|
+
);
|
|
2553
|
+
}
|
|
2554
|
+
const entry = checkSkillPackEntry(manifest, contents.get(SKILL_PACK_ENTRY_PATH));
|
|
2555
|
+
if (!entry.ok) {
|
|
2556
|
+
throw new ByokCoreError(
|
|
2557
|
+
"skill_pack_manifest_invalid",
|
|
2558
|
+
`Refusing to publish ${JSON.stringify(manifest.name)}: ${entry.reason} \u2014 ${entry.detail}`
|
|
2559
|
+
);
|
|
2560
|
+
}
|
|
2561
|
+
await this.#inTransaction(async (client) => {
|
|
2562
|
+
await client.query(
|
|
2563
|
+
`INSERT INTO skill_pack (tenant_id, name, version, description, content_hash)
|
|
2564
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
2565
|
+
ON CONFLICT (tenant_id, name) DO UPDATE
|
|
2566
|
+
SET version = EXCLUDED.version,
|
|
2567
|
+
description = EXCLUDED.description,
|
|
2568
|
+
content_hash = EXCLUDED.content_hash`,
|
|
2569
|
+
[tenant, manifest.name, manifest.version, manifest.description, manifest.contentHash]
|
|
2570
|
+
);
|
|
2571
|
+
await client.query(`DELETE FROM skill_pack_file WHERE tenant_id = $1 AND pack_name = $2`, [
|
|
2572
|
+
tenant,
|
|
2573
|
+
manifest.name
|
|
2574
|
+
]);
|
|
2575
|
+
for (const declared of manifest.files) {
|
|
2576
|
+
await client.query(
|
|
2577
|
+
`INSERT INTO skill_pack_file (tenant_id, pack_name, path, content_hash, byte_size, content)
|
|
2578
|
+
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
2579
|
+
[
|
|
2580
|
+
tenant,
|
|
2581
|
+
manifest.name,
|
|
2582
|
+
declared.path,
|
|
2583
|
+
declared.contentHash,
|
|
2584
|
+
declared.byteSize,
|
|
2585
|
+
contents.get(declared.path)
|
|
2586
|
+
]
|
|
2587
|
+
);
|
|
2588
|
+
}
|
|
2589
|
+
});
|
|
2590
|
+
return manifest;
|
|
2591
|
+
}
|
|
2592
|
+
async get(tenant, name) {
|
|
2593
|
+
const packResult = await this.#pool.query(
|
|
2594
|
+
`SELECT name, version, description, content_hash
|
|
2595
|
+
FROM skill_pack WHERE tenant_id = $1 AND name = $2`,
|
|
2596
|
+
[tenant, name]
|
|
2597
|
+
);
|
|
2598
|
+
const pack = packResult.rows[0];
|
|
2599
|
+
if (pack === void 0) return void 0;
|
|
2600
|
+
const files = await this.#filesFor(tenant, [name]);
|
|
2601
|
+
return toManifest(pack, files);
|
|
2602
|
+
}
|
|
2603
|
+
async list(tenant, query) {
|
|
2604
|
+
const packs = await this.#pool.query(
|
|
2605
|
+
`SELECT name, version, description, content_hash
|
|
2606
|
+
FROM skill_pack WHERE tenant_id = $1
|
|
2607
|
+
ORDER BY name COLLATE "C"
|
|
2608
|
+
LIMIT $2`,
|
|
2609
|
+
[tenant, query.limit ?? DEFAULT_LIST_LIMIT3]
|
|
2610
|
+
);
|
|
2611
|
+
if (packs.rows.length === 0) return [];
|
|
2612
|
+
const files = await this.#filesFor(
|
|
2613
|
+
tenant,
|
|
2614
|
+
packs.rows.map((row) => row.name)
|
|
2615
|
+
);
|
|
2616
|
+
const byPack = /* @__PURE__ */ new Map();
|
|
2617
|
+
for (const file of files) {
|
|
2618
|
+
const bucket = byPack.get(file.pack_name);
|
|
2619
|
+
if (bucket === void 0) byPack.set(file.pack_name, [file]);
|
|
2620
|
+
else bucket.push(file);
|
|
2621
|
+
}
|
|
2622
|
+
return packs.rows.map((pack) => toManifest(pack, byPack.get(pack.name) ?? []));
|
|
2623
|
+
}
|
|
2624
|
+
async readFile(tenant, name, path) {
|
|
2625
|
+
const result = await this.#pool.query(
|
|
2626
|
+
`SELECT pack_name, path, content_hash, byte_size, content
|
|
2627
|
+
FROM skill_pack_file
|
|
2628
|
+
WHERE tenant_id = $1 AND pack_name = $2 AND path = $3`,
|
|
2629
|
+
[tenant, name, path]
|
|
2630
|
+
);
|
|
2631
|
+
const row = result.rows[0];
|
|
2632
|
+
if (row === void 0) return void 0;
|
|
2633
|
+
return {
|
|
2634
|
+
path: row.path,
|
|
2635
|
+
contentHash: row.content_hash,
|
|
2636
|
+
byteSize: row.byte_size,
|
|
2637
|
+
content: row.content
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
/** Every file row for the named packs, in byte order of (pack_name, path). */
|
|
2641
|
+
async #filesFor(tenant, names) {
|
|
2642
|
+
const result = await this.#pool.query(
|
|
2643
|
+
`SELECT pack_name, path, content_hash, byte_size, content
|
|
2644
|
+
FROM skill_pack_file
|
|
2645
|
+
WHERE tenant_id = $1 AND pack_name = ANY($2::text[])
|
|
2646
|
+
ORDER BY pack_name COLLATE "C", path COLLATE "C"`,
|
|
2647
|
+
[tenant, names]
|
|
2648
|
+
);
|
|
2649
|
+
return result.rows;
|
|
2650
|
+
}
|
|
2651
|
+
async #inTransaction(run) {
|
|
2652
|
+
const client = await this.#pool.connect();
|
|
2653
|
+
try {
|
|
2654
|
+
await client.query("BEGIN");
|
|
2655
|
+
await run(client);
|
|
2656
|
+
await client.query("COMMIT");
|
|
2657
|
+
} catch (error) {
|
|
2658
|
+
await client.query("ROLLBACK").catch(() => {
|
|
2659
|
+
});
|
|
2660
|
+
throw error;
|
|
2661
|
+
} finally {
|
|
2662
|
+
client.release();
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
};
|
|
2666
|
+
var DEFAULT_MANIFEST_LIMIT = 100;
|
|
2667
|
+
var RECORD_COLUMNS = "tenant_id, kind, subject_id, rev, content_hash, byte_size, body_kind, body_inline, body_object_hash, label, request_id, written_at";
|
|
2668
|
+
function toBody(row) {
|
|
2669
|
+
return row.body_kind === "inline" ? { kind: "inline", body: row.body_inline ?? "" } : { kind: "object", hash: row.body_object_hash ?? "" };
|
|
2670
|
+
}
|
|
2671
|
+
function toRecord2(row) {
|
|
2672
|
+
return {
|
|
2673
|
+
tenantId: row.tenant_id,
|
|
2674
|
+
kind: row.kind,
|
|
2675
|
+
recordKey: row.subject_id,
|
|
2676
|
+
rev: row.rev,
|
|
2677
|
+
contentHash: row.content_hash,
|
|
2678
|
+
byteSize: row.byte_size,
|
|
2679
|
+
body: toBody(row),
|
|
2680
|
+
...row.label === null ? {} : { label: row.label },
|
|
2681
|
+
...row.request_id === null ? {} : { requestId: row.request_id },
|
|
2682
|
+
writtenAt: row.written_at
|
|
2683
|
+
};
|
|
2684
|
+
}
|
|
2685
|
+
function bodyColumns(body) {
|
|
2686
|
+
return body.kind === "inline" ? ["inline", body.body, null] : ["object", null, body.hash];
|
|
2687
|
+
}
|
|
2688
|
+
var PostgresTruthStore = class {
|
|
2689
|
+
#pool;
|
|
2690
|
+
#clock;
|
|
2691
|
+
constructor(pool, clock) {
|
|
2692
|
+
this.#pool = pool;
|
|
2693
|
+
this.#clock = clock;
|
|
2694
|
+
}
|
|
2695
|
+
async writeTerminal(tenant, input) {
|
|
2696
|
+
const [bodyKind, bodyInline, bodyObjectHash] = bodyColumns(input.body);
|
|
2697
|
+
const inserted = await this.#pool.query(
|
|
2698
|
+
`INSERT INTO attested_record (${RECORD_COLUMNS})
|
|
2699
|
+
VALUES ($1, 'task.terminal', $2, 1, $3, $4::bigint, $5, $6, $7, $8, $9, $10)
|
|
2700
|
+
ON CONFLICT (tenant_id, kind, subject_id) DO NOTHING
|
|
2701
|
+
RETURNING ${RECORD_COLUMNS}`,
|
|
2702
|
+
[
|
|
2703
|
+
tenant,
|
|
2704
|
+
input.taskId,
|
|
2705
|
+
input.contentHash,
|
|
2706
|
+
input.byteSize,
|
|
2707
|
+
bodyKind,
|
|
2708
|
+
bodyInline,
|
|
2709
|
+
bodyObjectHash,
|
|
2710
|
+
input.label ?? null,
|
|
2711
|
+
input.requestId ?? null,
|
|
2712
|
+
this.#now()
|
|
2713
|
+
]
|
|
2714
|
+
);
|
|
2715
|
+
const row = inserted.rows[0];
|
|
2716
|
+
if (row !== void 0) return toRecord2(row);
|
|
2717
|
+
const existing = await this.getRecord(tenant, {
|
|
2718
|
+
kind: "task.terminal",
|
|
2719
|
+
recordKey: input.taskId
|
|
2720
|
+
});
|
|
2721
|
+
if (existing === void 0) {
|
|
2722
|
+
throw new Error(`terminal record for ${input.taskId} vanished during a first write`);
|
|
2723
|
+
}
|
|
2724
|
+
if (existing.contentHash === input.contentHash) return existing;
|
|
2725
|
+
throw new CoreConflictError(
|
|
2726
|
+
"terminal_conflict",
|
|
2727
|
+
`Task ${input.taskId} already has an immutable terminal record with a different hash.`,
|
|
2728
|
+
existing,
|
|
2729
|
+
this.#now()
|
|
2730
|
+
);
|
|
2731
|
+
}
|
|
2732
|
+
async writeSnapshot(tenant, input) {
|
|
2733
|
+
const [bodyKind, bodyInline, bodyObjectHash] = bodyColumns(input.body);
|
|
2734
|
+
const now = this.#now();
|
|
2735
|
+
const written = input.expectedRev === 0 ? await this.#pool.query(
|
|
2736
|
+
`INSERT INTO attested_record (${RECORD_COLUMNS})
|
|
2737
|
+
VALUES ($1, $2, $3, 1, $4, $5::bigint, $6, $7, $8, $9, $10, $11)
|
|
2738
|
+
ON CONFLICT (tenant_id, kind, subject_id) DO NOTHING
|
|
2739
|
+
RETURNING ${RECORD_COLUMNS}`,
|
|
2740
|
+
[
|
|
2741
|
+
tenant,
|
|
2742
|
+
input.kind,
|
|
2743
|
+
input.recordKey,
|
|
2744
|
+
input.contentHash,
|
|
2745
|
+
input.byteSize,
|
|
2746
|
+
bodyKind,
|
|
2747
|
+
bodyInline,
|
|
2748
|
+
bodyObjectHash,
|
|
2749
|
+
input.label ?? null,
|
|
2750
|
+
input.requestId ?? null,
|
|
2751
|
+
now
|
|
2752
|
+
]
|
|
2753
|
+
) : await this.#pool.query(
|
|
2754
|
+
`UPDATE attested_record
|
|
2755
|
+
SET rev = rev + 1,
|
|
2756
|
+
content_hash = $4,
|
|
2757
|
+
byte_size = $5::bigint,
|
|
2758
|
+
body_kind = $6,
|
|
2759
|
+
body_inline = $7,
|
|
2760
|
+
body_object_hash = $8,
|
|
2761
|
+
label = $9,
|
|
2762
|
+
request_id = $10,
|
|
2763
|
+
written_at = $11
|
|
2764
|
+
WHERE tenant_id = $1 AND kind = $2 AND subject_id = $3 AND rev = $12
|
|
2765
|
+
RETURNING ${RECORD_COLUMNS}`,
|
|
2766
|
+
[
|
|
2767
|
+
tenant,
|
|
2768
|
+
input.kind,
|
|
2769
|
+
input.recordKey,
|
|
2770
|
+
input.contentHash,
|
|
2771
|
+
input.byteSize,
|
|
2772
|
+
bodyKind,
|
|
2773
|
+
bodyInline,
|
|
2774
|
+
bodyObjectHash,
|
|
2775
|
+
input.label ?? null,
|
|
2776
|
+
input.requestId ?? null,
|
|
2777
|
+
now,
|
|
2778
|
+
input.expectedRev
|
|
2779
|
+
]
|
|
2780
|
+
);
|
|
2781
|
+
const row = written.rows[0];
|
|
2782
|
+
if (row !== void 0) return toRecord2(row);
|
|
2783
|
+
const current = await this.getRecord(tenant, {
|
|
2784
|
+
kind: input.kind,
|
|
2785
|
+
recordKey: input.recordKey
|
|
2786
|
+
});
|
|
2787
|
+
throw new CoreConflictError(
|
|
2788
|
+
"truth_revision_conflict",
|
|
2789
|
+
`Record ${input.kind}/${input.recordKey} is at rev ${current?.rev ?? 0}, not ${input.expectedRev}.`,
|
|
2790
|
+
current,
|
|
2791
|
+
this.#now()
|
|
2792
|
+
);
|
|
2793
|
+
}
|
|
2794
|
+
async getRecord(tenant, selector) {
|
|
2795
|
+
const result = await this.#pool.query(
|
|
2796
|
+
`SELECT ${RECORD_COLUMNS} FROM attested_record
|
|
2797
|
+
WHERE tenant_id = $1 AND kind = $2 AND subject_id = $3`,
|
|
2798
|
+
[tenant, selector.kind, selector.recordKey]
|
|
2799
|
+
);
|
|
2800
|
+
const row = result.rows[0];
|
|
2801
|
+
return row === void 0 ? void 0 : toRecord2(row);
|
|
2802
|
+
}
|
|
2803
|
+
async listManifest(tenant, query) {
|
|
2804
|
+
const result = await this.#pool.query(
|
|
2805
|
+
`SELECT kind, subject_id, rev, content_hash, byte_size, label, written_at
|
|
2806
|
+
FROM attested_record
|
|
2807
|
+
WHERE tenant_id = $1
|
|
2808
|
+
AND ($2::text IS NULL OR kind = $2::text)
|
|
2809
|
+
AND ($3::text IS NULL OR starts_with(subject_id, $3::text))
|
|
2810
|
+
ORDER BY kind COLLATE "C", subject_id COLLATE "C"
|
|
2811
|
+
LIMIT $4`,
|
|
2812
|
+
[tenant, query.kind ?? null, query.keyPrefix ?? null, query.limit ?? DEFAULT_MANIFEST_LIMIT]
|
|
2813
|
+
);
|
|
2814
|
+
return result.rows.map((row) => ({
|
|
2815
|
+
kind: row.kind,
|
|
2816
|
+
recordKey: row.subject_id,
|
|
2817
|
+
rev: row.rev,
|
|
2818
|
+
contentHash: row.content_hash,
|
|
2819
|
+
byteSize: row.byte_size,
|
|
2820
|
+
...row.label === null ? {} : { label: row.label },
|
|
2821
|
+
updatedAt: row.written_at
|
|
2822
|
+
}));
|
|
2823
|
+
}
|
|
2824
|
+
#now() {
|
|
2825
|
+
return this.#clock.now().toISOString();
|
|
2826
|
+
}
|
|
2827
|
+
};
|
|
2828
|
+
|
|
2829
|
+
// src/stores/core/index.ts
|
|
2830
|
+
function createPostgresCoreStores(options) {
|
|
2831
|
+
const { pool, clock } = options;
|
|
2832
|
+
return {
|
|
2833
|
+
mailbox: new PostgresMailboxStore(pool, clock),
|
|
2834
|
+
board: new PostgresBoardStore(pool, clock),
|
|
2835
|
+
truth: new PostgresTruthStore(pool, clock),
|
|
2836
|
+
presence: new PostgresPresenceStore(pool, clock),
|
|
2837
|
+
activity: new PostgresActivityStore(pool, clock),
|
|
2838
|
+
objects: new PostgresObjectStore(pool, clock),
|
|
2839
|
+
quota: new PostgresQuotaStore(pool, clock),
|
|
2840
|
+
// No clock: a skill-pack manifest carries no timestamp, so this store reads
|
|
2841
|
+
// none — the same shape as the cloud-local `devices` directory.
|
|
2842
|
+
skillPacks: new PostgresSkillPackStore(pool)
|
|
2843
|
+
};
|
|
2844
|
+
}
|
|
2845
|
+
var DEFAULT_BATCH_SIZE = 100;
|
|
2846
|
+
var MAX_BATCH_SIZE = 1e3;
|
|
2847
|
+
var ADVISORY_LOCK_NAMESPACE = 1106736963;
|
|
2848
|
+
var OUTBOX_COLUMNS2 = "tenant_id, device_id, seq, message_id, body, body_hash, byte_size, state, appended_at, replay_source_seq";
|
|
2849
|
+
var CLOUD_CLEANUP_ERROR_CODES = {
|
|
2850
|
+
cleanup_invalid_input: "cleanup_invalid_input",
|
|
2851
|
+
cleanup_policy_missing: "cleanup_policy_missing",
|
|
2852
|
+
cleanup_job_running: "cleanup_job_running",
|
|
2853
|
+
cleanup_dead_letter_not_found: "cleanup_dead_letter_not_found",
|
|
2854
|
+
cleanup_accounting_drift: "cleanup_accounting_drift"
|
|
2855
|
+
};
|
|
2856
|
+
var CloudCleanupError = class extends Error {
|
|
2857
|
+
code;
|
|
2858
|
+
constructor(code, message, options) {
|
|
2859
|
+
super(message, options);
|
|
2860
|
+
this.name = "CloudCleanupError";
|
|
2861
|
+
this.code = code;
|
|
2862
|
+
}
|
|
2863
|
+
};
|
|
2864
|
+
var PostgresCloudCleanup = class {
|
|
2865
|
+
#pool;
|
|
2866
|
+
#clock;
|
|
2867
|
+
#objectStorage;
|
|
2868
|
+
#batchSize;
|
|
2869
|
+
constructor(options) {
|
|
2870
|
+
this.#pool = options.pool;
|
|
2871
|
+
this.#clock = options.clock;
|
|
2872
|
+
this.#objectStorage = options.objectStorage;
|
|
2873
|
+
this.#batchSize = assertBatchSize(options.batchSize ?? DEFAULT_BATCH_SIZE);
|
|
2874
|
+
}
|
|
2875
|
+
async writeRetentionPolicy(tenant, input) {
|
|
2876
|
+
assertPolicy(input);
|
|
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]);
|
|
2903
|
+
}
|
|
2904
|
+
async readRetentionPolicy(tenant) {
|
|
2905
|
+
return this.#readRetentionPolicy(this.#pool, tenant);
|
|
2906
|
+
}
|
|
2907
|
+
/** Run one bounded tenant maintenance cycle. Completed job ids are replay-safe. */
|
|
2908
|
+
async runTenant(tenant, jobId) {
|
|
2909
|
+
assertIdentifier(jobId, "jobId");
|
|
2910
|
+
const client = await this.#pool.connect();
|
|
2911
|
+
let jobStarted = false;
|
|
2912
|
+
try {
|
|
2913
|
+
const lock = await client.query(
|
|
2914
|
+
"SELECT pg_try_advisory_lock(hashtextextended($1, $2)) AS locked",
|
|
2915
|
+
[tenant, ADVISORY_LOCK_NAMESPACE]
|
|
2916
|
+
);
|
|
2917
|
+
if (lock.rows[0]?.locked !== true) {
|
|
2918
|
+
throw new CloudCleanupError(
|
|
2919
|
+
"cleanup_job_running",
|
|
2920
|
+
`A cleanup job is already running for tenant ${tenant}.`
|
|
2921
|
+
);
|
|
2922
|
+
}
|
|
2923
|
+
const replay = await this.#startJob(client, tenant, jobId);
|
|
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;
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
await this.#advanceLexicalCursor(
|
|
2963
|
+
client,
|
|
2964
|
+
tenant,
|
|
2965
|
+
"delete",
|
|
2966
|
+
pending.rows.at(-1)?.hash,
|
|
2967
|
+
pending.rows.length
|
|
2968
|
+
);
|
|
2969
|
+
await this.#reconcileManifests(client, tenant, counts);
|
|
2970
|
+
await this.#reconcileR2(client, tenant, counts);
|
|
2971
|
+
const state = counts.operationErrors === 0n ? "completed" : "completed_with_errors";
|
|
2972
|
+
return this.#finishJob(client, tenant, jobId, state, counts);
|
|
2973
|
+
} catch (cause) {
|
|
2974
|
+
if (jobStarted) {
|
|
2975
|
+
await this.#failJob(client, tenant, jobId, cause).catch(() => {
|
|
2976
|
+
});
|
|
2977
|
+
}
|
|
2978
|
+
throw cause;
|
|
2979
|
+
} finally {
|
|
2980
|
+
await client.query("SELECT pg_advisory_unlock(hashtextextended($1, $2))", [
|
|
2981
|
+
tenant,
|
|
2982
|
+
ADVISORY_LOCK_NAMESPACE
|
|
2983
|
+
]).catch(() => {
|
|
2984
|
+
});
|
|
2985
|
+
client.release();
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
async listDeadLetters(tenant, query = {}) {
|
|
2989
|
+
const limit = assertBatchSize(query.limit ?? DEFAULT_BATCH_SIZE);
|
|
2990
|
+
if (query.deviceId !== void 0) assertIdentifier(query.deviceId, "deviceId");
|
|
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
|
+
);
|
|
2997
|
+
}
|
|
2998
|
+
const listed = await this.#pool.query(
|
|
2999
|
+
`SELECT ${OUTBOX_COLUMNS2} FROM outbox
|
|
3000
|
+
WHERE tenant_id = $1
|
|
3001
|
+
AND state = 'expired'
|
|
3002
|
+
AND ($2::text IS NULL OR device_id = $2::text)
|
|
3003
|
+
AND (device_id > $3 OR (device_id = $3 AND seq > $4::bigint))
|
|
3004
|
+
ORDER BY device_id, seq
|
|
3005
|
+
LIMIT $5`,
|
|
3006
|
+
[
|
|
3007
|
+
tenant,
|
|
3008
|
+
query.deviceId ?? null,
|
|
3009
|
+
query.after?.deviceId ?? "",
|
|
3010
|
+
query.after?.seq ?? 0,
|
|
3011
|
+
limit + 1
|
|
3012
|
+
]
|
|
3013
|
+
);
|
|
3014
|
+
return {
|
|
3015
|
+
messages: listed.rows.slice(0, limit).map(toMailboxMessage),
|
|
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);
|
|
3039
|
+
} else {
|
|
3040
|
+
const existingResult = await client.query(
|
|
3041
|
+
`SELECT ${OUTBOX_COLUMNS2} FROM outbox
|
|
3042
|
+
WHERE tenant_id = $1 AND device_id = $2 AND message_id = $3`,
|
|
3043
|
+
[tenant, input.deviceId, input.replayMessageId]
|
|
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]
|
|
3069
|
+
);
|
|
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
|
+
}
|
|
3143
|
+
}
|
|
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
|
+
}
|
|
3153
|
+
if (rejection !== void 0) throw rejection;
|
|
3154
|
+
return result;
|
|
3155
|
+
}
|
|
3156
|
+
/** Explicit operator discard. Automatic retention never deletes dead letters. */
|
|
3157
|
+
async discardDeadLetter(tenant, ref) {
|
|
3158
|
+
assertDeadLetterRef(ref);
|
|
3159
|
+
const client = await this.#pool.connect();
|
|
3160
|
+
let row;
|
|
3161
|
+
let rejection;
|
|
3162
|
+
try {
|
|
3163
|
+
await client.query("BEGIN");
|
|
3164
|
+
const existing = await client.query(
|
|
3165
|
+
`SELECT ${OUTBOX_COLUMNS2} FROM outbox
|
|
3166
|
+
WHERE tenant_id = $1 AND device_id = $2 AND seq = $3::bigint
|
|
3167
|
+
AND state = 'expired'
|
|
3168
|
+
FOR UPDATE`,
|
|
3169
|
+
[tenant, ref.deviceId, ref.seq]
|
|
3170
|
+
);
|
|
3171
|
+
const deadLetter = existing.rows[0];
|
|
3172
|
+
const usage = await client.query(
|
|
3173
|
+
"SELECT mailbox_bytes FROM storage_usage WHERE tenant_id = $1 FOR UPDATE",
|
|
3174
|
+
[tenant]
|
|
3175
|
+
);
|
|
3176
|
+
if (deadLetter === void 0) {
|
|
3177
|
+
rejection = deadLetterMissing(ref);
|
|
3178
|
+
} else if (usage.rows[0] === void 0 || usage.rows[0].mailbox_bytes < deadLetter.byte_size) {
|
|
3179
|
+
rejection = new CloudCleanupError(
|
|
3180
|
+
"cleanup_accounting_drift",
|
|
3181
|
+
`Mailbox accounting cannot release dead letter ${ref.deviceId}/${String(ref.seq)}.`
|
|
3182
|
+
);
|
|
3183
|
+
} else {
|
|
3184
|
+
const removed = await client.query(
|
|
3185
|
+
`DELETE FROM outbox
|
|
3186
|
+
WHERE tenant_id = $1 AND device_id = $2 AND seq = $3::bigint
|
|
3187
|
+
AND state = 'expired'
|
|
3188
|
+
RETURNING ${OUTBOX_COLUMNS2}`,
|
|
3189
|
+
[tenant, ref.deviceId, ref.seq]
|
|
3190
|
+
);
|
|
3191
|
+
await client.query(
|
|
3192
|
+
`UPDATE storage_usage
|
|
3193
|
+
SET mailbox_bytes = mailbox_bytes - $2::bigint, updated_at = $3
|
|
3194
|
+
WHERE tenant_id = $1`,
|
|
3195
|
+
[tenant, deadLetter.byte_size, this.#now()]
|
|
3196
|
+
);
|
|
3197
|
+
row = removed.rows[0];
|
|
3198
|
+
}
|
|
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
|
+
}
|
|
3208
|
+
if (rejection !== void 0) throw rejection;
|
|
3209
|
+
return toMailboxMessage(row);
|
|
3210
|
+
}
|
|
3211
|
+
/**
|
|
3212
|
+
* Explicit recovery operation: rebuild object accounting from committed
|
|
3213
|
+
* Postgres manifests. Reconciliation must run first; R2 LIST is never used as
|
|
3214
|
+
* billing authority and inline/mailbox usage is left untouched.
|
|
3215
|
+
*/
|
|
3216
|
+
async rebuildObjectUsage(tenant) {
|
|
3217
|
+
const client = await this.#pool.connect();
|
|
3218
|
+
try {
|
|
3219
|
+
await client.query("BEGIN");
|
|
3220
|
+
const locked = await client.query(
|
|
3221
|
+
"SELECT 1 FROM storage_usage WHERE tenant_id = $1 FOR UPDATE",
|
|
3222
|
+
[tenant]
|
|
3223
|
+
);
|
|
3224
|
+
if (locked.rowCount === 0) {
|
|
3225
|
+
throw new CloudCleanupError(
|
|
3226
|
+
"cleanup_policy_missing",
|
|
3227
|
+
`Tenant ${tenant} has no storage usage row to rebuild.`
|
|
3228
|
+
);
|
|
3229
|
+
}
|
|
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
|
+
}
|
|
3260
|
+
}
|
|
3261
|
+
async #readRetentionPolicy(queryable, tenant) {
|
|
3262
|
+
const result = await queryable.query(
|
|
3263
|
+
`SELECT p.tenant_id, p.policy_id, p.mailbox_acked_retention_ms,
|
|
3264
|
+
p.mailbox_unacked_retention_ms, p.request_receipt_retention_ms,
|
|
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`,
|
|
3270
|
+
[tenant]
|
|
3271
|
+
);
|
|
3272
|
+
const row = result.rows[0];
|
|
3273
|
+
if (row === void 0) {
|
|
3274
|
+
throw new CloudCleanupError(
|
|
3275
|
+
"cleanup_policy_missing",
|
|
3276
|
+
`Tenant ${tenant} has no retention policy matching its entitlement.`
|
|
3277
|
+
);
|
|
3278
|
+
}
|
|
3279
|
+
const policy = toPolicy(row);
|
|
3280
|
+
assertPolicy(policy);
|
|
3281
|
+
return policy;
|
|
3282
|
+
}
|
|
3283
|
+
async #startJob(client, tenant, jobId) {
|
|
3284
|
+
const now = this.#now();
|
|
3285
|
+
const started = await client.query(
|
|
3286
|
+
`INSERT INTO cleanup_job (tenant_id, job_id, kind, state, started_at)
|
|
3287
|
+
VALUES ($1, $2, 'tenant_cleanup', 'running', $3)
|
|
3288
|
+
ON CONFLICT (tenant_id, job_id) DO UPDATE
|
|
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]
|
|
3294
|
+
);
|
|
3295
|
+
if (started.rows[0] !== void 0) return void 0;
|
|
3296
|
+
const existing = await this.#readJob(client, tenant, jobId);
|
|
3297
|
+
return toCleanupResult(existing);
|
|
3298
|
+
}
|
|
3299
|
+
async #runRetention(client, tenant, policy) {
|
|
3300
|
+
const ackedBefore = cutoff(this.#clock.now(), policy.mailboxAckedRetentionMs);
|
|
3301
|
+
const expireBefore = cutoff(this.#clock.now(), policy.mailboxUnackedRetentionMs);
|
|
3302
|
+
const receiptBefore = cutoff(this.#clock.now(), policy.requestReceiptRetentionMs);
|
|
3303
|
+
const now = this.#now();
|
|
3304
|
+
try {
|
|
3305
|
+
await client.query("BEGIN");
|
|
3306
|
+
const swept = await client.query(
|
|
3307
|
+
`WITH deleted AS (
|
|
3308
|
+
DELETE FROM outbox
|
|
3309
|
+
WHERE tenant_id = $1 AND state = 'acked' AND appended_at < $2
|
|
3310
|
+
RETURNING byte_size
|
|
3311
|
+
), released AS MATERIALIZED (
|
|
3312
|
+
SELECT COALESCE(SUM(byte_size), 0)::bigint AS bytes FROM deleted
|
|
3313
|
+
), expired AS (
|
|
3314
|
+
UPDATE outbox SET state = 'expired'
|
|
3315
|
+
WHERE tenant_id = $1 AND state = 'pending' AND appended_at < $3
|
|
3316
|
+
RETURNING 1
|
|
3317
|
+
), reservations AS (
|
|
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}.`
|
|
3366
|
+
);
|
|
3367
|
+
}
|
|
3368
|
+
await client.query("COMMIT");
|
|
3369
|
+
return result;
|
|
3370
|
+
} catch (cause) {
|
|
3371
|
+
await client.query("ROLLBACK").catch(() => {
|
|
3372
|
+
});
|
|
3373
|
+
throw cause;
|
|
3374
|
+
}
|
|
3375
|
+
}
|
|
3376
|
+
async #markTombstones(client, tenant, orphanCutoff) {
|
|
3377
|
+
try {
|
|
3378
|
+
await client.query("BEGIN");
|
|
3379
|
+
await client.query(
|
|
3380
|
+
"SELECT 1 FROM storage_entitlement WHERE tenant_id = $1 FOR UPDATE",
|
|
3381
|
+
[tenant]
|
|
3382
|
+
);
|
|
3383
|
+
const marked = await client.query(
|
|
3384
|
+
`WITH candidates AS MATERIALIZED (
|
|
3385
|
+
SELECT m.tenant_id, m.hash
|
|
3386
|
+
FROM object_manifest m
|
|
3387
|
+
WHERE m.tenant_id = $1
|
|
3388
|
+
AND m.state IN ('pending', 'committed')
|
|
3389
|
+
AND m.ref_count = 0
|
|
3390
|
+
AND m.updated_at < $2
|
|
3391
|
+
AND NOT EXISTS (
|
|
3392
|
+
SELECT 1 FROM object_reference r
|
|
3393
|
+
WHERE r.tenant_id = m.tenant_id AND r.hash = m.hash
|
|
3394
|
+
)
|
|
3395
|
+
AND NOT EXISTS (
|
|
3396
|
+
SELECT 1 FROM storage_reservation s
|
|
3397
|
+
WHERE s.tenant_id = m.tenant_id AND s.content_hash = m.hash
|
|
3398
|
+
AND s.state = 'reserved'
|
|
3399
|
+
)
|
|
3400
|
+
ORDER BY m.updated_at, m.hash
|
|
3401
|
+
LIMIT $3
|
|
3402
|
+
FOR UPDATE OF m SKIP LOCKED
|
|
3403
|
+
)
|
|
3404
|
+
UPDATE object_manifest m
|
|
3405
|
+
SET gc_accounted_bytes = CASE WHEN m.state = 'committed' THEN m.byte_size ELSE 0 END,
|
|
3406
|
+
gc_accounted_object = (m.state = 'committed'),
|
|
3407
|
+
state = 'delete_pending', delete_pending_at = $4, updated_at = $4
|
|
3408
|
+
FROM candidates c
|
|
3409
|
+
WHERE m.tenant_id = c.tenant_id AND m.hash = c.hash
|
|
3410
|
+
RETURNING m.hash`,
|
|
3411
|
+
[tenant, orphanCutoff, this.#batchSize, this.#now()]
|
|
3412
|
+
);
|
|
3413
|
+
await client.query("COMMIT");
|
|
3414
|
+
return BigInt(marked.rowCount ?? 0);
|
|
3415
|
+
} catch (cause) {
|
|
3416
|
+
await client.query("ROLLBACK").catch(() => {
|
|
3417
|
+
});
|
|
3418
|
+
throw cause;
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
async #settleDeleted(client, tenant, hash) {
|
|
3422
|
+
const settled = await client.query(
|
|
3423
|
+
`WITH candidate AS MATERIALIZED (
|
|
3424
|
+
SELECT m.gc_accounted_bytes, m.gc_accounted_object
|
|
3425
|
+
FROM object_manifest m
|
|
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,
|
|
3449
|
+
updated_at = $3
|
|
3450
|
+
FROM moved
|
|
3451
|
+
WHERE u.tenant_id = $1
|
|
3452
|
+
RETURNING moved.gc_accounted_bytes
|
|
3453
|
+
)
|
|
3454
|
+
SELECT gc_accounted_bytes FROM accounted`,
|
|
3455
|
+
[tenant, hash, this.#now()]
|
|
3456
|
+
);
|
|
3457
|
+
const row = settled.rows[0];
|
|
3458
|
+
if (row !== void 0) return row.gc_accounted_bytes;
|
|
3459
|
+
const current = await client.query(
|
|
3460
|
+
"SELECT state FROM object_manifest WHERE tenant_id = $1 AND hash = $2",
|
|
3461
|
+
[tenant, hash]
|
|
3462
|
+
);
|
|
3463
|
+
if (current.rows[0]?.state === "deleted") return void 0;
|
|
3464
|
+
throw new CloudCleanupError(
|
|
3465
|
+
"cleanup_accounting_drift",
|
|
3466
|
+
`Object ${hash} could not settle its delete tombstone against storage usage.`
|
|
3467
|
+
);
|
|
3468
|
+
}
|
|
3469
|
+
async #reconcileManifests(client, tenant, counts) {
|
|
3470
|
+
const cursor = await this.#readCursor(client, tenant, "manifest");
|
|
3471
|
+
const page = await client.query(
|
|
3472
|
+
`SELECT hash, byte_size, content_type, state,
|
|
3473
|
+
gc_accounted_bytes, gc_accounted_object
|
|
3474
|
+
FROM object_manifest
|
|
3475
|
+
WHERE tenant_id = $1
|
|
3476
|
+
AND state IN ('committed', 'delete_pending')
|
|
3477
|
+
AND hash > $2
|
|
3478
|
+
ORDER BY hash
|
|
3479
|
+
LIMIT $3`,
|
|
3480
|
+
[tenant, cursor ?? "", this.#batchSize]
|
|
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'
|
|
3555
|
+
RETURNING 1`,
|
|
3556
|
+
[
|
|
3557
|
+
tenant,
|
|
3558
|
+
object.hash,
|
|
3559
|
+
observed.observedByteSize,
|
|
3560
|
+
observed.observedContentType,
|
|
3561
|
+
this.#now()
|
|
3562
|
+
]
|
|
3563
|
+
);
|
|
3564
|
+
counts.orphanWitnessesCreated += BigInt(witnessed.rowCount ?? 0);
|
|
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);
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
async #readCursor(client, tenant, kind) {
|
|
3580
|
+
const result = await client.query(
|
|
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
|
+
);
|
|
3645
|
+
}
|
|
3646
|
+
async #readJob(client, tenant, jobId) {
|
|
3647
|
+
const result = await client.query(
|
|
3648
|
+
`SELECT ${JOB_COLUMNS} FROM cleanup_job WHERE tenant_id = $1 AND job_id = $2`,
|
|
3649
|
+
[tenant, jobId]
|
|
3650
|
+
);
|
|
3651
|
+
return result.rows[0];
|
|
3652
|
+
}
|
|
3653
|
+
#now() {
|
|
3654
|
+
return this.#clock.now().toISOString();
|
|
3655
|
+
}
|
|
3656
|
+
};
|
|
3657
|
+
function createPostgresCloudMaintenance(options) {
|
|
3658
|
+
const objectStorage = new R2ObjectMaintenanceStore(options.objectStorage);
|
|
3659
|
+
return new PostgresCloudCleanup({
|
|
3660
|
+
pool: options.pool,
|
|
3661
|
+
clock: options.clock,
|
|
3662
|
+
objectStorage,
|
|
3663
|
+
...options.batchSize === void 0 ? {} : { batchSize: options.batchSize }
|
|
3664
|
+
});
|
|
3665
|
+
}
|
|
3666
|
+
var JOB_COLUMNS = [
|
|
3667
|
+
"tenant_id",
|
|
3668
|
+
"job_id",
|
|
3669
|
+
"state",
|
|
3670
|
+
"started_at",
|
|
3671
|
+
"finished_at",
|
|
3672
|
+
"mailbox_deleted_count",
|
|
3673
|
+
"mailbox_expired_count",
|
|
3674
|
+
"mailbox_released_bytes",
|
|
3675
|
+
"reservations_expired",
|
|
3676
|
+
"ttl_rows_deleted",
|
|
3677
|
+
"objects_tombstoned",
|
|
3678
|
+
"objects_deleted",
|
|
3679
|
+
"object_released_bytes",
|
|
3680
|
+
"orphan_witnesses_created",
|
|
3681
|
+
"missing_objects",
|
|
3682
|
+
"shape_drift",
|
|
3683
|
+
"invalid_object_keys",
|
|
3684
|
+
"operation_errors",
|
|
3685
|
+
"error_message"
|
|
3686
|
+
].join(", ");
|
|
3687
|
+
function emptyCounts() {
|
|
3688
|
+
return {
|
|
3689
|
+
mailboxDeletedCount: 0n,
|
|
3690
|
+
mailboxExpiredCount: 0n,
|
|
3691
|
+
mailboxReleasedBytes: 0n,
|
|
3692
|
+
reservationsExpired: 0n,
|
|
3693
|
+
ttlRowsDeleted: 0n,
|
|
3694
|
+
objectsTombstoned: 0n,
|
|
3695
|
+
objectsDeleted: 0n,
|
|
3696
|
+
objectReleasedBytes: 0n,
|
|
3697
|
+
orphanWitnessesCreated: 0n,
|
|
3698
|
+
missingObjects: 0n,
|
|
3699
|
+
shapeDrift: 0n,
|
|
3700
|
+
invalidObjectKeys: 0n,
|
|
3701
|
+
operationErrors: 0n
|
|
3702
|
+
};
|
|
3703
|
+
}
|
|
3704
|
+
function toPolicy(row) {
|
|
3705
|
+
return {
|
|
3706
|
+
tenantId: tenantId(row.tenant_id),
|
|
3707
|
+
policyId: row.policy_id,
|
|
3708
|
+
mailboxAckedRetentionMs: row.mailbox_acked_retention_ms,
|
|
3709
|
+
mailboxUnackedRetentionMs: row.mailbox_unacked_retention_ms,
|
|
3710
|
+
requestReceiptRetentionMs: row.request_receipt_retention_ms,
|
|
3711
|
+
objectOrphanGraceMs: row.object_orphan_grace_ms,
|
|
3712
|
+
updatedAt: row.updated_at
|
|
3713
|
+
};
|
|
3714
|
+
}
|
|
3715
|
+
function toCleanupResult(row) {
|
|
3716
|
+
return {
|
|
3717
|
+
tenantId: tenantId(row.tenant_id),
|
|
3718
|
+
jobId: row.job_id,
|
|
3719
|
+
state: row.state,
|
|
3720
|
+
startedAt: row.started_at,
|
|
3721
|
+
...row.finished_at === null ? {} : { finishedAt: row.finished_at },
|
|
3722
|
+
mailboxDeletedCount: row.mailbox_deleted_count,
|
|
3723
|
+
mailboxExpiredCount: row.mailbox_expired_count,
|
|
3724
|
+
mailboxReleasedBytes: row.mailbox_released_bytes,
|
|
3725
|
+
reservationsExpired: row.reservations_expired,
|
|
3726
|
+
ttlRowsDeleted: row.ttl_rows_deleted,
|
|
3727
|
+
objectsTombstoned: row.objects_tombstoned,
|
|
3728
|
+
objectsDeleted: row.objects_deleted,
|
|
3729
|
+
objectReleasedBytes: row.object_released_bytes,
|
|
3730
|
+
orphanWitnessesCreated: row.orphan_witnesses_created,
|
|
3731
|
+
missingObjects: row.missing_objects,
|
|
3732
|
+
shapeDrift: row.shape_drift,
|
|
3733
|
+
invalidObjectKeys: row.invalid_object_keys,
|
|
3734
|
+
operationErrors: row.operation_errors,
|
|
3735
|
+
...row.error_message === null ? {} : { errorMessage: row.error_message }
|
|
3736
|
+
};
|
|
3737
|
+
}
|
|
3738
|
+
function toMailboxMessage(row) {
|
|
3739
|
+
return {
|
|
3740
|
+
tenantId: tenantId(row.tenant_id),
|
|
3741
|
+
deviceId: row.device_id,
|
|
3742
|
+
seq: Number(row.seq),
|
|
3743
|
+
messageId: row.message_id,
|
|
3744
|
+
body: row.body,
|
|
3745
|
+
bodyHash: row.body_hash,
|
|
3746
|
+
byteSize: row.byte_size,
|
|
3747
|
+
state: row.state,
|
|
3748
|
+
appendedAt: row.appended_at
|
|
3749
|
+
};
|
|
3750
|
+
}
|
|
3751
|
+
function materializeReplayBody(original, seq) {
|
|
3752
|
+
try {
|
|
3753
|
+
const envelope = decodeEnvelope(original.body);
|
|
3754
|
+
if (!isServerToDaemonType(envelope.type)) {
|
|
3755
|
+
throw new Error(`Envelope type ${envelope.type} is not server-to-daemon.`);
|
|
3756
|
+
}
|
|
3757
|
+
const rebound = EnvelopeSchema.parse({ ...envelope, seq });
|
|
3758
|
+
const body = encodeEnvelope(rebound);
|
|
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
|
+
);
|
|
3771
|
+
}
|
|
3772
|
+
}
|
|
3773
|
+
function replayMatches(row, original) {
|
|
3774
|
+
if (row.replay_source_seq !== original.seq) return false;
|
|
3775
|
+
const expected = materializeReplayBody(original, Number(row.seq));
|
|
3776
|
+
return row.body === expected.body && row.body_hash === expected.bodyHash && row.byte_size === expected.byteSize;
|
|
3777
|
+
}
|
|
3778
|
+
function assertPolicy(input) {
|
|
3779
|
+
assertIdentifier(input.policyId, "policyId");
|
|
3780
|
+
for (const [field, value] of [
|
|
3781
|
+
["mailboxAckedRetentionMs", input.mailboxAckedRetentionMs],
|
|
3782
|
+
["mailboxUnackedRetentionMs", input.mailboxUnackedRetentionMs],
|
|
3783
|
+
["requestReceiptRetentionMs", input.requestReceiptRetentionMs],
|
|
3784
|
+
["objectOrphanGraceMs", input.objectOrphanGraceMs]
|
|
3785
|
+
]) {
|
|
3786
|
+
if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
3787
|
+
throw new CloudCleanupError(
|
|
3788
|
+
"cleanup_invalid_input",
|
|
3789
|
+
`${field} must be a non-negative duration no larger than Number.MAX_SAFE_INTEGER milliseconds.`
|
|
3790
|
+
);
|
|
3791
|
+
}
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
3794
|
+
function assertBatchSize(value) {
|
|
3795
|
+
if (!Number.isInteger(value) || value < 1 || value > MAX_BATCH_SIZE) {
|
|
3796
|
+
throw new CloudCleanupError(
|
|
3797
|
+
"cleanup_invalid_input",
|
|
3798
|
+
`batchSize/limit must be a whole number in [1, ${String(MAX_BATCH_SIZE)}].`
|
|
3799
|
+
);
|
|
3800
|
+
}
|
|
3801
|
+
return value;
|
|
3802
|
+
}
|
|
3803
|
+
function assertIdentifier(value, field) {
|
|
3804
|
+
if (value.length === 0 || value.length > 256 || value.trim() !== value) {
|
|
3805
|
+
throw new CloudCleanupError(
|
|
3806
|
+
"cleanup_invalid_input",
|
|
3807
|
+
`${field} must be a non-empty, unpadded string no longer than 256 characters.`
|
|
3808
|
+
);
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
function assertDeadLetterRef(ref) {
|
|
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);
|
|
3890
|
+
}
|
|
3891
|
+
async commit(tenant, input) {
|
|
3892
|
+
await this.#validateInput(input);
|
|
3893
|
+
const client = await this.#pool.connect();
|
|
3894
|
+
try {
|
|
3895
|
+
await client.query("BEGIN");
|
|
3896
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
|
|
3897
|
+
JSON.stringify(["truth-receipt", tenant, input.deviceId, input.requestId])
|
|
3898
|
+
]);
|
|
3899
|
+
const replay = await this.#readReceipt(client, tenant, input.deviceId, input.requestId);
|
|
3900
|
+
if (replay !== void 0) {
|
|
3901
|
+
if (!sameBinding(replay, input)) {
|
|
3902
|
+
throw new TruthCommitError(
|
|
3903
|
+
"proof_request_conflict",
|
|
3904
|
+
`Request ${input.requestId} was already used with a different binding.`
|
|
3905
|
+
);
|
|
3906
|
+
}
|
|
3907
|
+
const response2 = TruthCommitResponseSchema.parse(JSON.parse(replay.responseBody));
|
|
3908
|
+
await client.query("COMMIT");
|
|
3909
|
+
return { response: response2, replayed: true };
|
|
3910
|
+
}
|
|
3911
|
+
const before = await this.#lockCurrentRecords(client, tenant, input.writes);
|
|
3912
|
+
this.#assertWritePreconditions(input.writes, before);
|
|
3913
|
+
await this.#lockAndVerifyObjects(client, tenant, input.writes, before);
|
|
3914
|
+
const inlineAffected = input.writes.some((write) => {
|
|
3915
|
+
const current = before.get(writeKey(write));
|
|
3916
|
+
if (write.kind === "task.terminal" && current !== void 0) return false;
|
|
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
|
+
]
|
|
3941
|
+
);
|
|
3942
|
+
await client.query("COMMIT");
|
|
3943
|
+
return { response, replayed: false };
|
|
3944
|
+
} catch (error) {
|
|
3945
|
+
await client.query("ROLLBACK").catch(() => void 0);
|
|
3946
|
+
throw error;
|
|
3947
|
+
} finally {
|
|
3948
|
+
client.release();
|
|
3949
|
+
}
|
|
3950
|
+
}
|
|
3951
|
+
async #validateInput(input) {
|
|
3952
|
+
if (input.requestId.length === 0 || input.requestId.length > TRUTH_REQUEST_ID_MAX_LENGTH) {
|
|
3953
|
+
throw new TruthCommitError("proof_request_conflict", "Request id is outside the record contract.");
|
|
3954
|
+
}
|
|
3955
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3956
|
+
const objectSizes = /* @__PURE__ */ new Map();
|
|
3957
|
+
for (const write of input.writes) {
|
|
3958
|
+
const key = writeKey(write);
|
|
3959
|
+
if (seen.has(key)) {
|
|
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
|
+
]);
|
|
4001
|
+
}
|
|
4002
|
+
for (const write of ordered) {
|
|
4003
|
+
const result = await client.query(
|
|
4004
|
+
`SELECT ${RECORD_COLUMNS2} FROM attested_record
|
|
4005
|
+
WHERE tenant_id = $1 AND kind = $2 AND subject_id = $3
|
|
4006
|
+
FOR UPDATE`,
|
|
4007
|
+
[tenant, write.kind, write.recordKey]
|
|
4008
|
+
);
|
|
4009
|
+
current.set(
|
|
4010
|
+
writeKey(write),
|
|
4011
|
+
result.rows[0] === void 0 ? void 0 : toRecord3(tenant, result.rows[0])
|
|
4012
|
+
);
|
|
4013
|
+
}
|
|
4014
|
+
return current;
|
|
4015
|
+
}
|
|
4016
|
+
#assertWritePreconditions(writes, current) {
|
|
4017
|
+
for (const write of writes) {
|
|
4018
|
+
const before = current.get(writeKey(write));
|
|
4019
|
+
if (write.kind === "task.terminal") {
|
|
4020
|
+
if (before !== void 0 && before.contentHash !== write.contentHash) {
|
|
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()
|
|
4034
|
+
);
|
|
4035
|
+
}
|
|
4036
|
+
}
|
|
4037
|
+
}
|
|
4038
|
+
async #lockAndVerifyObjects(client, tenant, writes, current) {
|
|
4039
|
+
const requested = /* @__PURE__ */ new Map();
|
|
4040
|
+
const affected = /* @__PURE__ */ new Set();
|
|
4041
|
+
for (const write of writes) {
|
|
4042
|
+
const before = current.get(writeKey(write));
|
|
4043
|
+
if (write.kind === "task.terminal" && before !== void 0) continue;
|
|
4044
|
+
if (before?.body.kind === "object") affected.add(before.body.hash);
|
|
4045
|
+
if (write.body.kind === "object") {
|
|
4046
|
+
const existing = requested.get(write.body.hash);
|
|
4047
|
+
if (existing !== void 0 && existing !== write.byteSize) {
|
|
4048
|
+
throw new ByokCoreError(
|
|
4049
|
+
"storage_integrity_mismatch",
|
|
4050
|
+
`Object ${write.body.hash} was declared with inconsistent byte sizes.`
|
|
4051
|
+
);
|
|
4052
|
+
}
|
|
4053
|
+
requested.set(write.body.hash, write.byteSize);
|
|
4054
|
+
affected.add(write.body.hash);
|
|
4055
|
+
}
|
|
4056
|
+
}
|
|
4057
|
+
for (const hash of [...affected].sort()) {
|
|
4058
|
+
const result = await client.query(
|
|
4059
|
+
`SELECT hash, byte_size, state FROM object_manifest
|
|
4060
|
+
WHERE tenant_id = $1 AND hash = $2 FOR UPDATE`,
|
|
4061
|
+
[tenant, hash]
|
|
4062
|
+
);
|
|
4063
|
+
const manifest = result.rows[0];
|
|
4064
|
+
const byteSize = requested.get(hash);
|
|
4065
|
+
if (manifest === void 0 || byteSize !== void 0 && (manifest.state !== "committed" || manifest.byte_size !== byteSize)) {
|
|
4066
|
+
throw new TruthCommitError(
|
|
4067
|
+
"truth_object_not_committed",
|
|
4068
|
+
`Object ${hash} is not a committed matching manifest.`
|
|
4069
|
+
);
|
|
4070
|
+
}
|
|
4071
|
+
}
|
|
4072
|
+
}
|
|
4073
|
+
async #prepareInlineAccounting(client, tenant, writes, current) {
|
|
4074
|
+
const entitlementResult = await client.query(
|
|
4075
|
+
`SELECT hard_limit_bytes, max_inline_bytes, downgrade_grace_until
|
|
4076
|
+
FROM storage_entitlement WHERE tenant_id = $1 FOR UPDATE`,
|
|
4077
|
+
[tenant]
|
|
4078
|
+
);
|
|
4079
|
+
const entitlement = entitlementResult.rows[0];
|
|
4080
|
+
if (entitlement === void 0) {
|
|
4081
|
+
throw new ByokCoreError("storage_entitlement_missing", "Tenant has no storage entitlement.");
|
|
4082
|
+
}
|
|
4083
|
+
const now = this.#now();
|
|
4084
|
+
await client.query(
|
|
4085
|
+
`UPDATE storage_reservation SET state = 'expired', settled_at = $2
|
|
4086
|
+
WHERE tenant_id = $1 AND state = 'reserved' AND expires_at <= $2`,
|
|
4087
|
+
[tenant, now]
|
|
4088
|
+
);
|
|
4089
|
+
const usageResult = await client.query(
|
|
4090
|
+
`SELECT u.committed_object_bytes, u.committed_inline_bytes,
|
|
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;
|
|
4192
|
+
}
|
|
4193
|
+
const [bodyKind, bodyInline, bodyObjectHash] = bodyColumns2(write.body);
|
|
4194
|
+
const values = [
|
|
4195
|
+
tenant,
|
|
4196
|
+
write.kind,
|
|
4197
|
+
write.recordKey,
|
|
4198
|
+
write.contentHash,
|
|
4199
|
+
write.byteSize,
|
|
4200
|
+
bodyKind,
|
|
4201
|
+
bodyInline,
|
|
4202
|
+
bodyObjectHash,
|
|
4203
|
+
write.label ?? null,
|
|
4204
|
+
input.requestId,
|
|
4205
|
+
this.#now()
|
|
4206
|
+
];
|
|
4207
|
+
const result = before === void 0 ? await client.query(
|
|
4208
|
+
`INSERT INTO attested_record (${RECORD_COLUMNS2})
|
|
4209
|
+
VALUES ($1, $2, $3, 1, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
4210
|
+
RETURNING ${RECORD_COLUMNS2}`,
|
|
4211
|
+
values
|
|
4212
|
+
) : await client.query(
|
|
4213
|
+
`UPDATE attested_record
|
|
4214
|
+
SET rev = rev + 1, content_hash = $4, byte_size = $5,
|
|
4215
|
+
body_kind = $6, body_inline = $7, body_object_hash = $8,
|
|
4216
|
+
label = $9, request_id = $10, written_at = $11
|
|
4217
|
+
WHERE tenant_id = $1 AND kind = $2 AND subject_id = $3
|
|
4218
|
+
RETURNING ${RECORD_COLUMNS2}`,
|
|
4219
|
+
values
|
|
4220
|
+
);
|
|
4221
|
+
applied.push({
|
|
4222
|
+
input: write,
|
|
4223
|
+
before,
|
|
4224
|
+
record: toRecord3(tenant, result.rows[0]),
|
|
4225
|
+
mutated: true
|
|
4226
|
+
});
|
|
4227
|
+
}
|
|
4228
|
+
return applied;
|
|
4229
|
+
}
|
|
4230
|
+
async #replaceObjectReferences(client, tenant, applied) {
|
|
4231
|
+
const affected = /* @__PURE__ */ new Set();
|
|
4232
|
+
for (const entry of applied) {
|
|
4233
|
+
if (!entry.mutated) continue;
|
|
4234
|
+
const refId = referenceId(entry.input);
|
|
4235
|
+
if (entry.input.body.kind === "object") {
|
|
4236
|
+
affected.add(entry.input.body.hash);
|
|
4237
|
+
await client.query(
|
|
4238
|
+
`INSERT INTO object_reference (tenant_id, hash, ref_kind, ref_id, created_at)
|
|
4239
|
+
VALUES ($1, $2, 'truth', $3, $4)
|
|
4240
|
+
ON CONFLICT (tenant_id, hash, ref_kind, ref_id) DO NOTHING`,
|
|
4241
|
+
[tenant, entry.input.body.hash, refId, this.#now()]
|
|
4242
|
+
);
|
|
4243
|
+
}
|
|
4244
|
+
if (entry.before?.body.kind === "object" && (entry.input.body.kind !== "object" || entry.input.body.hash !== entry.before.body.hash)) {
|
|
4245
|
+
affected.add(entry.before.body.hash);
|
|
4246
|
+
await client.query(
|
|
4247
|
+
`DELETE FROM object_reference
|
|
4248
|
+
WHERE tenant_id = $1 AND hash = $2 AND ref_kind = 'truth' AND ref_id = $3`,
|
|
4249
|
+
[tenant, entry.before.body.hash, refId]
|
|
4250
|
+
);
|
|
4251
|
+
}
|
|
4252
|
+
}
|
|
4253
|
+
for (const hash of [...affected].sort()) {
|
|
4254
|
+
await client.query(
|
|
4255
|
+
`UPDATE object_manifest
|
|
4256
|
+
SET ref_count = (SELECT count(*) FROM object_reference r
|
|
4257
|
+
WHERE r.tenant_id = $1 AND r.hash = $2),
|
|
4258
|
+
updated_at = $3
|
|
4259
|
+
WHERE tenant_id = $1 AND hash = $2`,
|
|
4260
|
+
[tenant, hash, this.#now()]
|
|
4261
|
+
);
|
|
4262
|
+
}
|
|
4263
|
+
}
|
|
4264
|
+
async #settleInlineAccounting(client, tenant, delta) {
|
|
4265
|
+
if (delta !== 0n) {
|
|
4266
|
+
const updated = await client.query(
|
|
4267
|
+
`UPDATE storage_usage
|
|
4268
|
+
SET committed_inline_bytes = committed_inline_bytes + $2::bigint,
|
|
4269
|
+
updated_at = $3
|
|
4270
|
+
WHERE tenant_id = $1 AND committed_inline_bytes + $2::bigint >= 0
|
|
4271
|
+
RETURNING 1`,
|
|
4272
|
+
[tenant, delta, this.#now()]
|
|
4273
|
+
);
|
|
4274
|
+
if (updated.rowCount !== 1) throw new Error("inline accounting would become negative");
|
|
4275
|
+
}
|
|
4276
|
+
}
|
|
4277
|
+
#now() {
|
|
4278
|
+
return this.#clock.now().toISOString();
|
|
4279
|
+
}
|
|
4280
|
+
};
|
|
4281
|
+
|
|
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 };
|
|
4283
|
+
//# sourceMappingURL=index.js.map
|
|
4284
|
+
//# sourceMappingURL=index.js.map
|