@terminus-ai/cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1055 -0
  3. package/bin/agent-discovery.mjs +71 -0
  4. package/bin/agent-icon.mjs +77 -0
  5. package/bin/agent-models.mjs +77 -0
  6. package/bin/agent-type.mjs +51 -0
  7. package/bin/agentdev.mjs +657 -0
  8. package/bin/app-route-script.mjs +59 -0
  9. package/bin/app-runtime-contract.mjs +2 -0
  10. package/bin/appdev-remote.mjs +346 -0
  11. package/bin/appdev.mjs +4446 -0
  12. package/bin/apps.mjs +5512 -0
  13. package/bin/capability-calls.mjs +437 -0
  14. package/bin/capsule-data.mjs +260 -0
  15. package/bin/client.mjs +189 -0
  16. package/bin/commands.mjs +1194 -0
  17. package/bin/dev-capsules.mjs +1599 -0
  18. package/bin/dev-contract.mjs +262 -0
  19. package/bin/dev-data.mjs +287 -0
  20. package/bin/dev-members.mjs +18 -0
  21. package/bin/dev-net.mjs +316 -0
  22. package/bin/dev-notification-popup.mjs +628 -0
  23. package/bin/dev-ports.mjs +567 -0
  24. package/bin/dev-server-binding.mjs +35 -0
  25. package/bin/dev-server-ops.mjs +1086 -0
  26. package/bin/dev-ui/IoskeleyMono-400.woff2 +0 -0
  27. package/bin/dev-ui/IoskeleyMono-600.woff2 +0 -0
  28. package/bin/dev-ui/OFL.txt +92 -0
  29. package/bin/dev-ui/agent-robot.webp +0 -0
  30. package/bin/dev-ui/app.js +5217 -0
  31. package/bin/dev-ui/highlight.js +195 -0
  32. package/bin/dev-ui/index.html +34 -0
  33. package/bin/dev-ui/style.css +3640 -0
  34. package/bin/devlint.mjs +112 -0
  35. package/bin/devserver.mjs +2127 -0
  36. package/bin/devtriggers.mjs +367 -0
  37. package/bin/endpoints.mjs +156 -0
  38. package/bin/errors.mjs +61 -0
  39. package/bin/files.mjs +169 -0
  40. package/bin/horizontal-capabilities/v1/contract.json +280 -0
  41. package/bin/http.mjs +500 -0
  42. package/bin/lint-manifests/justbash-commands.json +88 -0
  43. package/bin/lint-manifests/python-stdlib.json +295 -0
  44. package/bin/login-page.mjs +488 -0
  45. package/bin/schedules.mjs +664 -0
  46. package/bin/server-sandbox.mjs +204 -0
  47. package/bin/servicedev.mjs +425 -0
  48. package/bin/sync.mjs +357 -0
  49. package/bin/terminus.js +3666 -0
  50. package/bin/toolchain.mjs +125 -0
  51. package/bin/vendor/app-runtime-v1/app-host.json +124 -0
  52. package/bin/vendor/app-runtime-v1/capability-calls.json +412 -0
  53. package/bin/vendor/app-runtime-v1/doors.json +2867 -0
  54. package/bin/vendor/appd/node-harness.mjs +209 -0
  55. package/bin/vendor/appd/python-harness.py +12 -0
  56. package/bin/vendor/appd/server-protocol.json +84 -0
  57. package/bin/vendor/where.mjs +541 -0
  58. package/bin/versioning.mjs +72 -0
  59. package/bin/write-rules.mjs +398 -0
  60. package/package.json +41 -0
@@ -0,0 +1,1599 @@
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ fsyncSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ renameSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import path from "node:path";
13
+ import { DatabaseSync } from "node:sqlite";
14
+
15
+ import { LIMITS } from "./dev-contract.mjs";
16
+ import { sha256 } from "./files.mjs";
17
+
18
+ const CAPSULE_FORMAT_VERSION = 1;
19
+ /** Rows kept per collection scope before older cursors expire — the
20
+ * backend's COLLECTION_CHANGE_RETENTION_ROWS. */
21
+ export const CHANGE_RETENTION = 10_000;
22
+
23
+ function safeSegment(value, fallback = "item") {
24
+ const normalized = String(value ?? "")
25
+ .normalize("NFKC")
26
+ .replace(/^@/, "")
27
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
28
+ .replace(/^-+|-+$/g, "")
29
+ .slice(0, 80);
30
+ return normalized || fallback;
31
+ }
32
+
33
+ function appDirectoryName(appId) {
34
+ const digest = sha256(String(appId)).slice(0, 12);
35
+ return `${safeSegment(String(appId).split("/").at(-1), "app")}-${digest}`;
36
+ }
37
+
38
+ export function devCapsuleDirectory(rootDir, appId, member) {
39
+ return path.join(
40
+ rootDir,
41
+ "users",
42
+ safeSegment(member, "user"),
43
+ "apps",
44
+ appDirectoryName(appId),
45
+ );
46
+ }
47
+
48
+ function atomicWriteBytes(target, bytes) {
49
+ mkdirSync(path.dirname(target), { recursive: true });
50
+ const temporary = `${target}.tmp-${process.pid}-${crypto.randomUUID()}`;
51
+ const descriptor = openSync(temporary, "wx", 0o600);
52
+ try {
53
+ writeFileSync(descriptor, bytes);
54
+ fsyncSync(descriptor);
55
+ } finally {
56
+ closeSync(descriptor);
57
+ }
58
+ try {
59
+ renameSync(temporary, target);
60
+ } catch (error) {
61
+ rmSync(temporary, { force: true });
62
+ if (!existsSync(target)) throw error;
63
+ }
64
+ try {
65
+ const directory = openSync(path.dirname(target), "r");
66
+ try {
67
+ fsyncSync(directory);
68
+ } finally {
69
+ closeSync(directory);
70
+ }
71
+ } catch {
72
+ // Some platforms do not permit fsync on a directory. The file itself was
73
+ // still fsynced before its atomic rename.
74
+ }
75
+ }
76
+
77
+ function configure(db) {
78
+ db.exec("PRAGMA journal_mode = WAL");
79
+ db.exec("PRAGMA synchronous = FULL");
80
+ db.exec("PRAGMA foreign_keys = ON");
81
+ db.exec("PRAGMA busy_timeout = 5000");
82
+ }
83
+
84
+ function transaction(db, task) {
85
+ db.exec("BEGIN IMMEDIATE");
86
+ try {
87
+ const result = task();
88
+ db.exec("COMMIT");
89
+ return result;
90
+ } catch (error) {
91
+ try {
92
+ db.exec("ROLLBACK");
93
+ } catch {
94
+ // Preserve the original failure.
95
+ }
96
+ throw error;
97
+ }
98
+ }
99
+
100
+ function parseJson(value, fallback = null) {
101
+ if (value === null || value === undefined || value === "") return fallback;
102
+ return JSON.parse(String(value));
103
+ }
104
+
105
+ /** Add the columns a table declares that an older local database lacks, so a
106
+ * folder's fixtures outlive a CLI upgrade. The table's CREATE statement stays
107
+ * the one schema; this only closes the gap to it. */
108
+ function ensureColumns(db, table, columns) {
109
+ const present = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((row) => String(row.name)));
110
+ for (const [name, definition] of Object.entries(columns)) {
111
+ if (!present.has(name)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
112
+ }
113
+ }
114
+
115
+ /** JSON values equal as values: objects as maps, arrays in order. */
116
+ function jsonEqual(left, right) {
117
+ if (left === right) return true;
118
+ if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) return false;
119
+ if (Array.isArray(left) !== Array.isArray(right)) return false;
120
+ if (Array.isArray(left)) {
121
+ return left.length === right.length && left.every((item, index) => jsonEqual(item, right[index]));
122
+ }
123
+ const keys = Object.keys(left);
124
+ return keys.length === Object.keys(right).length
125
+ && keys.every((key) => Object.hasOwn(right, key) && jsonEqual(left[key], right[key]));
126
+ }
127
+
128
+ /** The top-level fields a change touched, as the platform lists them
129
+ * (`changed_record_fields`): none when nothing changed, `$` when either side
130
+ * is not an object or the record is past 64 fields, else the differing keys
131
+ * in byte order. */
132
+ export function changedRecordFields(before, after) {
133
+ if (jsonEqual(before ?? null, after ?? null)) return [];
134
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
135
+ if (!object(before) || !object(after)) return ["$"];
136
+ const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])];
137
+ if (keys.length > 64) return ["$"];
138
+ return keys
139
+ .filter((key) => !jsonEqual(before[key], after[key]))
140
+ .sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
141
+ }
142
+
143
+ function likePrefix(prefix) {
144
+ return `${prefix.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
145
+ }
146
+
147
+ export class DevSystemStore {
148
+ constructor(rootDir, appId) {
149
+ mkdirSync(rootDir, { recursive: true });
150
+ this.rootDir = rootDir;
151
+ this.appId = appId;
152
+ this.file = path.join(rootDir, "system.sqlite");
153
+ this.db = new DatabaseSync(this.file);
154
+ configure(this.db);
155
+ this.db.exec(`
156
+ CREATE TABLE IF NOT EXISTS dev_spaces (
157
+ id TEXT PRIMARY KEY,
158
+ name TEXT NOT NULL,
159
+ members_json TEXT NOT NULL,
160
+ conversation_kind TEXT CHECK (conversation_kind IN ('direct', 'group')),
161
+ roles_json TEXT CHECK (roles_json IS NULL OR json_valid(roles_json)),
162
+ parent_id TEXT,
163
+ meta_json TEXT CHECK (meta_json IS NULL OR json_valid(meta_json)),
164
+ joined_json TEXT CHECK (joined_json IS NULL OR json_valid(joined_json)),
165
+ created_at TEXT NOT NULL,
166
+ updated_at TEXT NOT NULL
167
+ );
168
+
169
+ CREATE TABLE IF NOT EXISTS dev_user_blocks (
170
+ app_id TEXT NOT NULL,
171
+ blocker_id TEXT NOT NULL,
172
+ blocked_id TEXT NOT NULL,
173
+ created_at TEXT NOT NULL,
174
+ PRIMARY KEY (app_id, blocker_id, blocked_id),
175
+ CHECK (blocker_id <> blocked_id)
176
+ );
177
+
178
+ CREATE TABLE IF NOT EXISTS dev_space_invitations (
179
+ id TEXT PRIMARY KEY,
180
+ app_id TEXT NOT NULL,
181
+ space_id TEXT NOT NULL,
182
+ inviter_id TEXT NOT NULL,
183
+ invitee_id TEXT NOT NULL,
184
+ role TEXT NOT NULL,
185
+ status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'declined')),
186
+ created_at TEXT NOT NULL,
187
+ expires_at TEXT NOT NULL,
188
+ resolved_at TEXT
189
+ );
190
+
191
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_dev_space_invitations_one_pending
192
+ ON dev_space_invitations (app_id, space_id, invitee_id)
193
+ WHERE status = 'pending';
194
+
195
+ CREATE INDEX IF NOT EXISTS idx_dev_space_invitations_invitee_pending
196
+ ON dev_space_invitations (app_id, invitee_id, created_at)
197
+ WHERE status = 'pending';
198
+
199
+ CREATE INDEX IF NOT EXISTS idx_dev_space_invitations_space_pending
200
+ ON dev_space_invitations (app_id, space_id, created_at)
201
+ WHERE status = 'pending';
202
+
203
+ CREATE TABLE IF NOT EXISTS collection_definitions (
204
+ app_id TEXT NOT NULL,
205
+ name TEXT NOT NULL,
206
+ definition_json TEXT NOT NULL,
207
+ created_at TEXT NOT NULL,
208
+ PRIMARY KEY (app_id, name)
209
+ );
210
+
211
+ CREATE TABLE IF NOT EXISTS space_delivery_sequences (
212
+ app_id TEXT NOT NULL,
213
+ space_id TEXT NOT NULL,
214
+ head_sequence INTEGER NOT NULL DEFAULT 0 CHECK (head_sequence >= 0),
215
+ PRIMARY KEY (app_id, space_id)
216
+ );
217
+
218
+ CREATE TABLE IF NOT EXISTS record_deliveries (
219
+ app_id TEXT NOT NULL,
220
+ space_id TEXT NOT NULL,
221
+ collection_name TEXT NOT NULL,
222
+ mutation_id TEXT NOT NULL,
223
+ record_id TEXT NOT NULL,
224
+ request_sha256 TEXT NOT NULL,
225
+ sequence INTEGER NOT NULL CHECK (sequence > 0),
226
+ recipients_json TEXT NOT NULL CHECK (json_valid(recipients_json)),
227
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'committed')),
228
+ response_json TEXT CHECK (response_json IS NULL OR json_valid(response_json)),
229
+ created_at TEXT NOT NULL,
230
+ updated_at TEXT NOT NULL,
231
+ PRIMARY KEY (app_id, space_id, collection_name, mutation_id)
232
+ );
233
+
234
+ CREATE TABLE IF NOT EXISTS space_member_cursors (
235
+ app_id TEXT NOT NULL,
236
+ space_id TEXT NOT NULL,
237
+ member_id TEXT NOT NULL,
238
+ delivered_through INTEGER NOT NULL DEFAULT 0 CHECK (delivered_through >= 0),
239
+ read_through INTEGER NOT NULL DEFAULT 0 CHECK (read_through >= 0),
240
+ updated_at TEXT NOT NULL,
241
+ PRIMARY KEY (app_id, space_id, member_id),
242
+ CHECK (read_through <= delivered_through)
243
+ );
244
+
245
+ CREATE TABLE IF NOT EXISTS collaboration_streams (
246
+ app_id TEXT NOT NULL,
247
+ scope_key TEXT NOT NULL,
248
+ document_id TEXT NOT NULL,
249
+ head_sequence INTEGER NOT NULL DEFAULT 0 CHECK (head_sequence >= 0),
250
+ retained_after_sequence INTEGER NOT NULL DEFAULT 0 CHECK (retained_after_sequence >= 0),
251
+ snapshot_sequence INTEGER NOT NULL DEFAULT 0 CHECK (snapshot_sequence >= 0),
252
+ snapshot_json TEXT CHECK (snapshot_json IS NULL OR json_valid(snapshot_json)),
253
+ updated_at TEXT NOT NULL,
254
+ PRIMARY KEY (app_id, scope_key, document_id)
255
+ );
256
+
257
+ CREATE TABLE IF NOT EXISTS collaboration_operations (
258
+ app_id TEXT NOT NULL,
259
+ scope_key TEXT NOT NULL,
260
+ document_id TEXT NOT NULL,
261
+ sequence INTEGER NOT NULL CHECK (sequence > 0),
262
+ actor_user_id TEXT NOT NULL,
263
+ actor_session_id TEXT NOT NULL,
264
+ operation_id TEXT NOT NULL,
265
+ operation_json TEXT NOT NULL CHECK (json_valid(operation_json)),
266
+ committed_at TEXT NOT NULL,
267
+ PRIMARY KEY (app_id, scope_key, document_id, sequence),
268
+ UNIQUE (app_id, scope_key, document_id, actor_user_id, operation_id)
269
+ );
270
+
271
+ CREATE INDEX IF NOT EXISTS idx_collaboration_operations_stream
272
+ ON collaboration_operations (app_id, scope_key, document_id, sequence);
273
+
274
+ -- An operation's receipt outlives it: compaction moves the operations
275
+ -- a snapshot covers here, so a retried id still answers its committed
276
+ -- position and never lands twice (the platform's operation receipts).
277
+ CREATE TABLE IF NOT EXISTS collaboration_receipts (
278
+ app_id TEXT NOT NULL,
279
+ scope_key TEXT NOT NULL,
280
+ document_id TEXT NOT NULL,
281
+ sequence INTEGER NOT NULL CHECK (sequence > 0),
282
+ actor_user_id TEXT NOT NULL,
283
+ actor_session_id TEXT NOT NULL,
284
+ operation_id TEXT NOT NULL,
285
+ operation_json TEXT NOT NULL CHECK (json_valid(operation_json)),
286
+ committed_at TEXT NOT NULL,
287
+ PRIMARY KEY (app_id, scope_key, document_id, actor_user_id, operation_id)
288
+ );
289
+
290
+ -- The records plane behind capabilities.server, as the platform keeps
291
+ -- it: only the app's server code reads or writes it. A global row's
292
+ -- installation_id is empty; an installation row names its install.
293
+ CREATE TABLE IF NOT EXISTS app_server_records (
294
+ app_id TEXT NOT NULL,
295
+ scope TEXT NOT NULL CHECK (scope IN ('global', 'installation')),
296
+ installation_id TEXT NOT NULL,
297
+ collection TEXT NOT NULL,
298
+ doc_id TEXT NOT NULL,
299
+ data_json TEXT NOT NULL CHECK (json_valid(data_json)),
300
+ stored_bytes INTEGER NOT NULL CHECK (stored_bytes >= 0),
301
+ version INTEGER NOT NULL CHECK (version > 0),
302
+ created_at TEXT NOT NULL,
303
+ updated_at TEXT NOT NULL,
304
+ PRIMARY KEY (app_id, scope, installation_id, collection, doc_id),
305
+ CHECK ((scope = 'installation') = (installation_id <> ''))
306
+ );
307
+
308
+ CREATE TABLE IF NOT EXISTS app_server_record_totals (
309
+ app_id TEXT NOT NULL,
310
+ scope TEXT NOT NULL CHECK (scope IN ('global', 'installation')),
311
+ installation_id TEXT NOT NULL,
312
+ total_bytes INTEGER NOT NULL DEFAULT 0,
313
+ record_count INTEGER NOT NULL DEFAULT 0,
314
+ PRIMARY KEY (app_id, scope, installation_id)
315
+ );
316
+
317
+ CREATE TABLE IF NOT EXISTS app_server_fetch_counters (
318
+ app_id TEXT NOT NULL,
319
+ installation_id TEXT NOT NULL,
320
+ day TEXT NOT NULL,
321
+ requests INTEGER NOT NULL,
322
+ PRIMARY KEY (app_id, installation_id, day)
323
+ );
324
+ `);
325
+ ensureColumns(this.db, "dev_spaces", {
326
+ conversation_kind: "TEXT CHECK (conversation_kind IN ('direct', 'group'))",
327
+ roles_json: "TEXT CHECK (roles_json IS NULL OR json_valid(roles_json))",
328
+ parent_id: "TEXT",
329
+ meta_json: "TEXT CHECK (meta_json IS NULL OR json_valid(meta_json))",
330
+ joined_json: "TEXT CHECK (joined_json IS NULL OR json_valid(joined_json))",
331
+ });
332
+ }
333
+
334
+ /** Spaces as the harness keeps them: `kind` direct or group, members by
335
+ * handle with their roles and when they joined, an optional parent and
336
+ * app-defined meta. */
337
+ loadSpaces() {
338
+ return this.db.prepare(`
339
+ SELECT id, name, members_json, conversation_kind, roles_json, parent_id, meta_json,
340
+ joined_json, created_at, updated_at
341
+ FROM dev_spaces ORDER BY created_at, id
342
+ `).all().map((row) => ({
343
+ id: String(row.id),
344
+ name: String(row.name),
345
+ members: parseJson(row.members_json, []),
346
+ kind: row.conversation_kind === null ? "group" : String(row.conversation_kind),
347
+ roles: parseJson(row.roles_json, {}),
348
+ parentId: row.parent_id === null ? null : String(row.parent_id),
349
+ meta: parseJson(row.meta_json, null),
350
+ joined: parseJson(row.joined_json, {}),
351
+ createdAt: String(row.created_at),
352
+ updatedAt: String(row.updated_at),
353
+ }));
354
+ }
355
+
356
+ saveSpaces(spaces) {
357
+ const now = new Date().toISOString();
358
+ transaction(this.db, () => {
359
+ const ids = spaces.map((space) => String(space.id));
360
+ if (ids.length) {
361
+ const placeholders = ids.map(() => "?").join(", ");
362
+ this.db.prepare(`DELETE FROM dev_spaces WHERE id NOT IN (${placeholders})`).run(...ids);
363
+ } else {
364
+ this.db.exec("DELETE FROM dev_spaces");
365
+ }
366
+ const upsert = this.db.prepare(`
367
+ INSERT INTO dev_spaces
368
+ (id, name, members_json, conversation_kind, roles_json, parent_id, meta_json,
369
+ joined_json, created_at, updated_at)
370
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
371
+ ON CONFLICT (id) DO UPDATE SET
372
+ name = excluded.name,
373
+ members_json = excluded.members_json,
374
+ conversation_kind = excluded.conversation_kind,
375
+ roles_json = excluded.roles_json,
376
+ parent_id = excluded.parent_id,
377
+ meta_json = excluded.meta_json,
378
+ joined_json = excluded.joined_json,
379
+ updated_at = excluded.updated_at
380
+ `);
381
+ for (const space of spaces) {
382
+ upsert.run(
383
+ String(space.id),
384
+ String(space.name),
385
+ JSON.stringify(space.members ?? []),
386
+ space.kind ?? "group",
387
+ JSON.stringify(space.roles ?? {}),
388
+ space.parentId ?? null,
389
+ space.meta === null || space.meta === undefined ? null : JSON.stringify(space.meta),
390
+ JSON.stringify(space.joined ?? {}),
391
+ space.createdAt ?? now,
392
+ space.updatedAt ?? now,
393
+ );
394
+ }
395
+ });
396
+ }
397
+
398
+ loadSpaceInvitations() {
399
+ return this.db.prepare(`
400
+ SELECT id, space_id, inviter_id, invitee_id, role, status,
401
+ created_at, expires_at, resolved_at
402
+ FROM dev_space_invitations
403
+ WHERE app_id = ?
404
+ ORDER BY created_at, id
405
+ `).all(this.appId).map((row) => ({
406
+ id: String(row.id),
407
+ spaceId: String(row.space_id),
408
+ inviter: String(row.inviter_id),
409
+ invitee: String(row.invitee_id),
410
+ role: String(row.role),
411
+ status: String(row.status),
412
+ createdAt: String(row.created_at),
413
+ expiresAt: String(row.expires_at),
414
+ resolvedAt: row.resolved_at === null ? null : String(row.resolved_at),
415
+ }));
416
+ }
417
+
418
+ saveSpaceInvitations(invitations) {
419
+ transaction(this.db, () => {
420
+ this.db.prepare("DELETE FROM dev_space_invitations WHERE app_id = ?").run(this.appId);
421
+ const insert = this.db.prepare(`
422
+ INSERT INTO dev_space_invitations
423
+ (id, app_id, space_id, inviter_id, invitee_id, role, status,
424
+ created_at, expires_at, resolved_at)
425
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
426
+ `);
427
+ for (const invitation of invitations) {
428
+ insert.run(
429
+ invitation.id,
430
+ this.appId,
431
+ invitation.spaceId,
432
+ invitation.inviter,
433
+ invitation.invitee,
434
+ invitation.role,
435
+ invitation.status,
436
+ invitation.createdAt,
437
+ invitation.expiresAt,
438
+ invitation.resolvedAt,
439
+ );
440
+ }
441
+ });
442
+ }
443
+
444
+ loadUserBlocks() {
445
+ return this.db.prepare(`
446
+ SELECT blocker_id, blocked_id
447
+ FROM dev_user_blocks
448
+ WHERE app_id = ?
449
+ ORDER BY blocker_id, blocked_id
450
+ `).all(this.appId).map((row) => ({
451
+ blocker: String(row.blocker_id),
452
+ blocked: String(row.blocked_id),
453
+ }));
454
+ }
455
+
456
+ saveUserBlocks(blocks) {
457
+ transaction(this.db, () => {
458
+ this.db.prepare("DELETE FROM dev_user_blocks WHERE app_id = ?").run(this.appId);
459
+ const insert = this.db.prepare(`
460
+ INSERT INTO dev_user_blocks (app_id, blocker_id, blocked_id, created_at)
461
+ VALUES (?, ?, ?, ?)
462
+ `);
463
+ const now = new Date().toISOString();
464
+ for (const block of blocks) {
465
+ insert.run(this.appId, block.blocker, block.blocked, now);
466
+ }
467
+ });
468
+ }
469
+
470
+ collectionDefinitions() {
471
+ return this.db.prepare(
472
+ "SELECT name, definition_json FROM collection_definitions WHERE app_id = ? ORDER BY name",
473
+ ).all(this.appId).map((row) => [String(row.name), parseJson(row.definition_json, {})]);
474
+ }
475
+
476
+ defineCollection(name, definition) {
477
+ const encoded = JSON.stringify(definition);
478
+ const row = this.db.prepare(`
479
+ SELECT definition_json FROM collection_definitions WHERE app_id = ? AND name = ?
480
+ `).get(this.appId, name);
481
+ if (row) return { existing: parseJson(row.definition_json, {}), created: false };
482
+ this.db.prepare(`
483
+ INSERT INTO collection_definitions (app_id, name, definition_json, created_at)
484
+ VALUES (?, ?, ?, ?)
485
+ `).run(this.appId, name, encoded, new Date().toISOString());
486
+ return { existing: definition, created: true };
487
+ }
488
+
489
+ /** Everything the system database keeps for one space, gone with it: its
490
+ * invitations, delivery sequence and cursors, and its collaboration
491
+ * journals. (The space row itself leaves with the next saveSpaces.) */
492
+ deleteSpace(spaceId) {
493
+ transaction(this.db, () => {
494
+ for (const table of ["dev_space_invitations", "space_delivery_sequences", "record_deliveries", "space_member_cursors"]) {
495
+ this.db.prepare(`DELETE FROM ${table} WHERE app_id = ? AND space_id = ?`).run(this.appId, spaceId);
496
+ }
497
+ for (const table of ["collaboration_operations", "collaboration_receipts", "collaboration_streams"]) {
498
+ this.db.prepare(`DELETE FROM ${table} WHERE app_id = ? AND scope_key = ?`)
499
+ .run(this.appId, `space:${spaceId}`);
500
+ }
501
+ });
502
+ }
503
+
504
+ /** The receipt a delivery left under `mutationId`, or null: what it named,
505
+ * its sequence and recipients, and its answer once it committed. */
506
+ deliveryReceipt({ spaceId, collection, mutationId }) {
507
+ const row = this.db.prepare(`
508
+ SELECT record_id, request_sha256, sequence, recipients_json, status, response_json
509
+ FROM record_deliveries
510
+ WHERE app_id = ? AND space_id = ? AND collection_name = ? AND mutation_id = ?
511
+ `).get(this.appId, spaceId, collection, mutationId);
512
+ return row
513
+ ? {
514
+ recordId: String(row.record_id),
515
+ requestSha256: String(row.request_sha256),
516
+ sequence: Number(row.sequence),
517
+ recipients: parseJson(row.recipients_json, []),
518
+ response: row.status === "committed" ? parseJson(row.response_json, {}) : null,
519
+ }
520
+ : null;
521
+ }
522
+
523
+ beginDelivery({ spaceId, collection, mutationId, recordId, requestSha256, recipients }) {
524
+ return transaction(this.db, () => {
525
+ const existing = this.deliveryReceipt({ spaceId, collection, mutationId });
526
+ if (existing) {
527
+ if (existing.recordId !== recordId || existing.requestSha256 !== requestSha256) {
528
+ return {
529
+ conflict: "Idempotency-Key was already used for a different delivery",
530
+ code: "idempotency_conflict",
531
+ };
532
+ }
533
+ return { sequence: existing.sequence, recipients: existing.recipients, response: existing.response };
534
+ }
535
+ this.db.prepare(`
536
+ INSERT INTO space_delivery_sequences (app_id, space_id, head_sequence)
537
+ VALUES (?, ?, 0) ON CONFLICT (app_id, space_id) DO NOTHING
538
+ `).run(this.appId, spaceId);
539
+ this.db.prepare(`
540
+ UPDATE space_delivery_sequences SET head_sequence = head_sequence + 1
541
+ WHERE app_id = ? AND space_id = ?
542
+ `).run(this.appId, spaceId);
543
+ const sequence = Number(this.db.prepare(`
544
+ SELECT head_sequence FROM space_delivery_sequences WHERE app_id = ? AND space_id = ?
545
+ `).get(this.appId, spaceId).head_sequence);
546
+ const now = new Date().toISOString();
547
+ this.db.prepare(`
548
+ INSERT INTO record_deliveries
549
+ (app_id, space_id, collection_name, mutation_id, record_id,
550
+ request_sha256, sequence, recipients_json, created_at, updated_at)
551
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
552
+ `).run(
553
+ this.appId,
554
+ spaceId,
555
+ collection,
556
+ mutationId,
557
+ recordId,
558
+ requestSha256,
559
+ sequence,
560
+ JSON.stringify(recipients),
561
+ now,
562
+ now,
563
+ );
564
+ return { sequence, recipients, response: null };
565
+ });
566
+ }
567
+
568
+ commitDelivery({ spaceId, collection, mutationId, response }) {
569
+ transaction(this.db, () => {
570
+ const now = new Date().toISOString();
571
+ this.db.prepare(`
572
+ UPDATE record_deliveries
573
+ SET status = 'committed', response_json = ?, updated_at = ?
574
+ WHERE app_id = ? AND space_id = ? AND collection_name = ? AND mutation_id = ?
575
+ `).run(JSON.stringify(response), now, this.appId, spaceId, collection, mutationId);
576
+ const upsert = this.db.prepare(`
577
+ INSERT INTO space_member_cursors
578
+ (app_id, space_id, member_id, delivered_through, read_through, updated_at)
579
+ VALUES (?, ?, ?, ?, 0, ?)
580
+ ON CONFLICT (app_id, space_id, member_id) DO UPDATE SET
581
+ delivered_through = MAX(space_member_cursors.delivered_through, excluded.delivered_through),
582
+ updated_at = excluded.updated_at
583
+ `);
584
+ for (const member of response.recipients ?? []) {
585
+ upsert.run(this.appId, spaceId, member, response.sequence, now);
586
+ }
587
+ });
588
+ }
589
+
590
+ /** Delivery and read marks: current `members` (with `roles`), then any
591
+ * former member who still has a row, with role null. */
592
+ spaceCursors(spaceId, members, roles = {}) {
593
+ const head = Number(this.db.prepare(`
594
+ SELECT head_sequence FROM space_delivery_sequences WHERE app_id = ? AND space_id = ?
595
+ `).get(this.appId, spaceId)?.head_sequence ?? 0);
596
+ const rows = this.db.prepare(`
597
+ SELECT member_id, delivered_through, read_through FROM space_member_cursors
598
+ WHERE app_id = ? AND space_id = ?
599
+ `).all(this.appId, spaceId);
600
+ const byMember = new Map(rows.map((row) => [String(row.member_id), row]));
601
+ const participants = [...new Set([...members, ...byMember.keys()])];
602
+ return {
603
+ head_sequence: head,
604
+ members: participants.map((member) => {
605
+ const cursor = byMember.get(member) ?? {};
606
+ return {
607
+ user_id: member,
608
+ role: members.includes(member) ? (roles[member] ?? null) : null,
609
+ delivered_through: Number(cursor.delivered_through ?? 0),
610
+ read_through: Number(cursor.read_through ?? 0),
611
+ };
612
+ }),
613
+ };
614
+ }
615
+
616
+ /** Move a member's cursors forward (never back). Answers the door's body
617
+ * and, apart, whether either value actually increased — only a real move
618
+ * is worth telling the space about. */
619
+ updateSpaceCursor(spaceId, member, { deliveredThrough, readThrough }) {
620
+ return transaction(this.db, () => {
621
+ const state = this.spaceCursors(spaceId, [member]);
622
+ const current = state.members[0];
623
+ const delivered = Math.max(current.delivered_through, deliveredThrough ?? current.delivered_through);
624
+ const read = Math.max(current.read_through, readThrough ?? current.read_through);
625
+ if (delivered > state.head_sequence || read > delivered) {
626
+ return { conflict: "cursor cannot pass the delivered space frontier" };
627
+ }
628
+ const advanced = delivered > current.delivered_through || read > current.read_through;
629
+ this.db.prepare(`
630
+ INSERT INTO space_member_cursors
631
+ (app_id, space_id, member_id, delivered_through, read_through, updated_at)
632
+ VALUES (?, ?, ?, ?, ?, ?)
633
+ ON CONFLICT (app_id, space_id, member_id) DO UPDATE SET
634
+ delivered_through = excluded.delivered_through,
635
+ read_through = excluded.read_through,
636
+ updated_at = excluded.updated_at
637
+ `).run(this.appId, spaceId, member, delivered, read, new Date().toISOString());
638
+ return {
639
+ cursor: {
640
+ delivered_through: delivered,
641
+ read_through: read,
642
+ head_sequence: state.head_sequence,
643
+ },
644
+ advanced,
645
+ };
646
+ });
647
+ }
648
+
649
+ appendCollaboration({ scopeKey, documentId, actor, sessionId, operations }) {
650
+ return transaction(this.db, () => {
651
+ const now = new Date().toISOString();
652
+ this.db.prepare(`
653
+ INSERT INTO collaboration_streams
654
+ (app_id, scope_key, document_id, updated_at)
655
+ VALUES (?, ?, ?, ?)
656
+ ON CONFLICT (app_id, scope_key, document_id) DO NOTHING
657
+ `).run(this.appId, scopeKey, documentId, now);
658
+ for (const item of operations) {
659
+ const encoded = JSON.stringify(item.operation);
660
+ const committed = (table) => this.db.prepare(`
661
+ SELECT operation_json FROM ${table}
662
+ WHERE app_id = ? AND scope_key = ? AND document_id = ?
663
+ AND actor_user_id = ? AND operation_id = ?
664
+ `).get(this.appId, scopeKey, documentId, actor, item.id);
665
+ const existing = committed("collaboration_operations") ?? committed("collaboration_receipts");
666
+ if (existing) {
667
+ if (String(existing.operation_json) !== encoded) {
668
+ return {
669
+ conflict: `operation id '${item.id}' was reused with different content`,
670
+ code: "idempotency_conflict",
671
+ };
672
+ }
673
+ continue;
674
+ }
675
+ this.db.prepare(`
676
+ UPDATE collaboration_streams
677
+ SET head_sequence = head_sequence + 1, updated_at = ?
678
+ WHERE app_id = ? AND scope_key = ? AND document_id = ?
679
+ `).run(now, this.appId, scopeKey, documentId);
680
+ const sequence = Number(this.db.prepare(`
681
+ SELECT head_sequence FROM collaboration_streams
682
+ WHERE app_id = ? AND scope_key = ? AND document_id = ?
683
+ `).get(this.appId, scopeKey, documentId).head_sequence);
684
+ this.db.prepare(`
685
+ INSERT INTO collaboration_operations
686
+ (app_id, scope_key, document_id, sequence, actor_user_id,
687
+ actor_session_id, operation_id, operation_json, committed_at)
688
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
689
+ `).run(
690
+ this.appId,
691
+ scopeKey,
692
+ documentId,
693
+ sequence,
694
+ actor,
695
+ sessionId,
696
+ item.id,
697
+ encoded,
698
+ now,
699
+ );
700
+ }
701
+ return this.listCollaboration({ scopeKey, documentId, actor, operationIds: operations.map((item) => item.id) });
702
+ });
703
+ }
704
+
705
+ listCollaboration({ scopeKey, documentId, after = 0, limit = LIMITS.page_size, actor, operationIds }) {
706
+ const stream = this.db.prepare(`
707
+ SELECT head_sequence, retained_after_sequence, snapshot_sequence, snapshot_json
708
+ FROM collaboration_streams
709
+ WHERE app_id = ? AND scope_key = ? AND document_id = ?
710
+ `).get(this.appId, scopeKey, documentId) ?? {
711
+ head_sequence: 0,
712
+ retained_after_sequence: 0,
713
+ snapshot_sequence: 0,
714
+ snapshot_json: null,
715
+ };
716
+ let rows;
717
+ if (actor && operationIds) {
718
+ if (!operationIds.length) rows = [];
719
+ else {
720
+ // An append's answer: where each of its ids committed, compacted or not.
721
+ const placeholders = operationIds.map(() => "?").join(", ");
722
+ const select = (table) => `
723
+ SELECT sequence, actor_user_id, actor_session_id, operation_id,
724
+ operation_json, committed_at
725
+ FROM ${table}
726
+ WHERE app_id = ? AND scope_key = ? AND document_id = ?
727
+ AND actor_user_id = ? AND operation_id IN (${placeholders})`;
728
+ const bind = [this.appId, scopeKey, documentId, actor, ...operationIds];
729
+ rows = this.db.prepare(`
730
+ ${select("collaboration_operations")}
731
+ UNION ALL ${select("collaboration_receipts")}
732
+ ORDER BY sequence
733
+ `).all(...bind, ...bind);
734
+ }
735
+ } else {
736
+ rows = this.db.prepare(`
737
+ SELECT sequence, actor_user_id, actor_session_id, operation_id,
738
+ operation_json, committed_at
739
+ FROM collaboration_operations
740
+ WHERE app_id = ? AND scope_key = ? AND document_id = ? AND sequence > ?
741
+ ORDER BY sequence LIMIT ?
742
+ `).all(this.appId, scopeKey, documentId, after, Math.max(1, Math.min(LIMITS.page_size, limit)) + 1);
743
+ }
744
+ return {
745
+ head: Number(stream.head_sequence),
746
+ retainedAfter: Number(stream.retained_after_sequence),
747
+ snapshotSequence: Number(stream.snapshot_sequence),
748
+ snapshot: parseJson(stream.snapshot_json, null),
749
+ hasMore: !operationIds && rows.length > Math.max(1, Math.min(LIMITS.page_size, limit)),
750
+ operations: rows.slice(0, operationIds ? rows.length : Math.max(1, Math.min(LIMITS.page_size, limit))).map((row) => ({
751
+ cursor: Number(row.sequence),
752
+ actor_user_id: String(row.actor_user_id),
753
+ actor_session_id: String(row.actor_session_id),
754
+ id: String(row.operation_id),
755
+ operation: parseJson(row.operation_json, null),
756
+ committed_at: String(row.committed_at),
757
+ })),
758
+ };
759
+ }
760
+
761
+ /**
762
+ * The platform's collaboration compactor (terminus-backend
763
+ * services/app_collaboration.rs): for up to `streams` journals whose
764
+ * snapshot has moved past their reset frontier, the operations the
765
+ * snapshot covers — at most `operations` each — leave the journal for its
766
+ * receipts, and the frontier moves up to them, so a reader behind it gets
767
+ * the snapshot and the tail. Answers how many operations moved.
768
+ */
769
+ compactCollaboration({ streams = 20, operations = 2000 } = {}) {
770
+ return transaction(this.db, () => {
771
+ const due = this.db.prepare(`
772
+ SELECT scope_key, document_id, snapshot_sequence FROM collaboration_streams
773
+ WHERE app_id = ? AND snapshot_json IS NOT NULL
774
+ AND snapshot_sequence > retained_after_sequence
775
+ ORDER BY updated_at LIMIT ?
776
+ `).all(this.appId, streams);
777
+ let moved = 0;
778
+ for (const stream of due) {
779
+ const key = [this.appId, stream.scope_key, stream.document_id];
780
+ const through = this.db.prepare(`
781
+ SELECT MAX(sequence) AS through FROM (
782
+ SELECT sequence FROM collaboration_operations
783
+ WHERE app_id = ? AND scope_key = ? AND document_id = ? AND sequence <= ?
784
+ ORDER BY sequence LIMIT ?
785
+ )
786
+ `).get(...key, stream.snapshot_sequence, operations)?.through;
787
+ if (through === null || through === undefined) continue;
788
+ this.db.prepare(`
789
+ INSERT OR IGNORE INTO collaboration_receipts
790
+ (app_id, scope_key, document_id, sequence, actor_user_id,
791
+ actor_session_id, operation_id, operation_json, committed_at)
792
+ SELECT app_id, scope_key, document_id, sequence, actor_user_id,
793
+ actor_session_id, operation_id, operation_json, committed_at
794
+ FROM collaboration_operations
795
+ WHERE app_id = ? AND scope_key = ? AND document_id = ? AND sequence <= ?
796
+ `).run(...key, through);
797
+ moved += Number(this.db.prepare(`
798
+ DELETE FROM collaboration_operations
799
+ WHERE app_id = ? AND scope_key = ? AND document_id = ? AND sequence <= ?
800
+ `).run(...key, through).changes);
801
+ this.db.prepare(`
802
+ UPDATE collaboration_streams
803
+ SET retained_after_sequence = MAX(retained_after_sequence, ?), updated_at = ?
804
+ WHERE app_id = ? AND scope_key = ? AND document_id = ?
805
+ `).run(through, new Date().toISOString(), ...key);
806
+ }
807
+ return moved;
808
+ });
809
+ }
810
+
811
+ snapshotCollaboration({ scopeKey, documentId, through, snapshot }) {
812
+ return transaction(this.db, () => {
813
+ const stream = this.db.prepare(`
814
+ SELECT head_sequence, snapshot_sequence FROM collaboration_streams
815
+ WHERE app_id = ? AND scope_key = ? AND document_id = ?
816
+ `).get(this.appId, scopeKey, documentId);
817
+ if (!stream || through < Number(stream.snapshot_sequence) || through > Number(stream.head_sequence)) {
818
+ return { conflict: "snapshot cursor is behind the current snapshot or ahead of the stream" };
819
+ }
820
+ this.db.prepare(`
821
+ UPDATE collaboration_streams
822
+ SET snapshot_sequence = ?, snapshot_json = ?, updated_at = ?
823
+ WHERE app_id = ? AND scope_key = ? AND document_id = ?
824
+ `).run(
825
+ through,
826
+ JSON.stringify(snapshot),
827
+ new Date().toISOString(),
828
+ this.appId,
829
+ scopeKey,
830
+ documentId,
831
+ );
832
+ return { head: Number(stream.head_sequence), through };
833
+ });
834
+ }
835
+
836
+ /** One app-server record, or null. */
837
+ serverRecord({ scope, installationId, collection, docId }) {
838
+ const row = this.db.prepare(`
839
+ SELECT data_json, version FROM app_server_records
840
+ WHERE app_id = ? AND scope = ? AND installation_id = ? AND collection = ? AND doc_id = ?
841
+ `).get(this.appId, scope, installationId ?? "", collection, docId);
842
+ return row ? { data: JSON.parse(String(row.data_json)), version: Number(row.version) } : null;
843
+ }
844
+
845
+ /** Every record in one collection of one pool, in no order: the caller
846
+ * filters and orders them the way the platform's query does. */
847
+ serverRecords({ scope, installationId, collection }) {
848
+ return this.db.prepare(`
849
+ SELECT doc_id, data_json, version FROM app_server_records
850
+ WHERE app_id = ? AND scope = ? AND installation_id = ? AND collection = ?
851
+ `).all(this.appId, scope, installationId ?? "", collection).map((row) => ({
852
+ docId: String(row.doc_id),
853
+ data: JSON.parse(String(row.data_json)),
854
+ version: Number(row.version),
855
+ }));
856
+ }
857
+
858
+ /**
859
+ * Write one record the platform's way. `ifVersion`: null = unconditional,
860
+ * 0 = must not exist, n = must be at n; a mismatch is a conflict, never a
861
+ * clobber. The pool's byte total moves as the platform's does — a new
862
+ * document at `newBytes`, the one it replaces at the size it was stored
863
+ * at — and a write that would cross `quotaBytes` never happens.
864
+ * Answers `{version}`, `{conflict}` or `{overQuota}`.
865
+ */
866
+ putServerRecord({ scope, installationId, collection, docId, dataJson, newBytes, storedBytes, ifVersion, quotaBytes }) {
867
+ const installation = installationId ?? "";
868
+ try {
869
+ return transaction(this.db, () => {
870
+ const existing = this.db.prepare(`
871
+ SELECT version, stored_bytes FROM app_server_records
872
+ WHERE app_id = ? AND scope = ? AND installation_id = ? AND collection = ? AND doc_id = ?
873
+ `).get(this.appId, scope, installation, collection, docId);
874
+ const conflict = serverVersionConflict(ifVersion, existing ? Number(existing.version) : null);
875
+ if (conflict) return { conflict };
876
+ const now = new Date().toISOString();
877
+ let version;
878
+ let byteDelta;
879
+ let countDelta;
880
+ if (existing) {
881
+ version = Number(existing.version) + 1;
882
+ byteDelta = newBytes - Number(existing.stored_bytes);
883
+ countDelta = 0;
884
+ this.db.prepare(`
885
+ UPDATE app_server_records
886
+ SET data_json = ?, stored_bytes = ?, version = ?, updated_at = ?
887
+ WHERE app_id = ? AND scope = ? AND installation_id = ? AND collection = ? AND doc_id = ?
888
+ `).run(dataJson, storedBytes, version, now, this.appId, scope, installation, collection, docId);
889
+ } else {
890
+ version = 1;
891
+ byteDelta = newBytes;
892
+ countDelta = 1;
893
+ this.db.prepare(`
894
+ INSERT INTO app_server_records
895
+ (app_id, scope, installation_id, collection, doc_id, data_json, stored_bytes, version, created_at, updated_at)
896
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
897
+ `).run(this.appId, scope, installation, collection, docId, dataJson, storedBytes, now, now);
898
+ }
899
+ const total = this.bumpServerRecordTotals(scope, installation, byteDelta, countDelta);
900
+ if (total > quotaBytes) throw new ServerQuotaExceeded(total);
901
+ return { version };
902
+ });
903
+ } catch (error) {
904
+ if (error instanceof ServerQuotaExceeded) return { overQuota: error.total };
905
+ throw error;
906
+ }
907
+ }
908
+
909
+ /** Delete one record under the same version rules. Answers `{deleted}` or `{conflict}`. */
910
+ deleteServerRecord({ scope, installationId, collection, docId, ifVersion }) {
911
+ const installation = installationId ?? "";
912
+ return transaction(this.db, () => {
913
+ const existing = this.db.prepare(`
914
+ SELECT version, stored_bytes FROM app_server_records
915
+ WHERE app_id = ? AND scope = ? AND installation_id = ? AND collection = ? AND doc_id = ?
916
+ `).get(this.appId, scope, installation, collection, docId);
917
+ const conflict = serverVersionConflict(ifVersion, existing ? Number(existing.version) : null);
918
+ if (conflict) return { conflict };
919
+ if (!existing) return { deleted: false };
920
+ this.db.prepare(`
921
+ DELETE FROM app_server_records
922
+ WHERE app_id = ? AND scope = ? AND installation_id = ? AND collection = ? AND doc_id = ?
923
+ `).run(this.appId, scope, installation, collection, docId);
924
+ this.bumpServerRecordTotals(scope, installation, -Number(existing.stored_bytes), -1);
925
+ return { deleted: true };
926
+ });
927
+ }
928
+
929
+ /** The pool's running totals, floored at zero like the platform's. */
930
+ bumpServerRecordTotals(scope, installationId, byteDelta, countDelta) {
931
+ const row = this.db.prepare(`
932
+ INSERT INTO app_server_record_totals (app_id, scope, installation_id, total_bytes, record_count)
933
+ VALUES (?, ?, ?, ?, ?)
934
+ ON CONFLICT (app_id, scope, installation_id) DO UPDATE SET
935
+ total_bytes = MAX(total_bytes + excluded.total_bytes, 0),
936
+ record_count = MAX(record_count + excluded.record_count, 0)
937
+ RETURNING total_bytes
938
+ `).get(this.appId, scope, installationId, byteDelta, countDelta);
939
+ return Number(row.total_bytes);
940
+ }
941
+
942
+ /** Count one server web fetch against an installation's UTC day; answers the day's count. */
943
+ bumpServerFetches(installationId, day) {
944
+ const row = this.db.prepare(`
945
+ INSERT INTO app_server_fetch_counters (app_id, installation_id, day, requests)
946
+ VALUES (?, ?, ?, 1)
947
+ ON CONFLICT (app_id, installation_id, day) DO UPDATE SET requests = requests + 1
948
+ RETURNING requests
949
+ `).get(this.appId, installationId, day);
950
+ return Number(row.requests);
951
+ }
952
+
953
+ close() {
954
+ this.db.close();
955
+ }
956
+ }
957
+
958
+ class ServerQuotaExceeded extends Error {
959
+ constructor(total) {
960
+ super("server records quota exceeded");
961
+ this.total = total;
962
+ }
963
+ }
964
+
965
+ /** The platform's optimistic check (records.rs `check_version`), word for word:
966
+ * apps match on "reread and retry" and "requires absence". */
967
+ function serverVersionConflict(expected, actual) {
968
+ if (expected === null || expected === undefined) return null;
969
+ if (expected === 0) {
970
+ return actual === null ? null : "the record already exists (if_version 0 requires absence)";
971
+ }
972
+ if (actual === null) return "the record no longer exists — reread and retry";
973
+ return expected === actual ? null : `the record changed (version is now ${actual}) — reread and retry`;
974
+ }
975
+
976
+ export class DevCapsuleStore {
977
+ constructor(rootDir, appId, member) {
978
+ this.appId = appId;
979
+ this.member = member;
980
+ this.directory = devCapsuleDirectory(rootDir, appId, member);
981
+ this.objectsDirectory = path.join(this.directory, "objects");
982
+ mkdirSync(this.objectsDirectory, { recursive: true });
983
+ this.file = path.join(this.directory, "data.sqlite");
984
+ this.db = new DatabaseSync(this.file);
985
+ configure(this.db);
986
+ this.db.exec(`
987
+ CREATE TABLE IF NOT EXISTS _terminus_meta (
988
+ key TEXT PRIMARY KEY,
989
+ value TEXT NOT NULL
990
+ );
991
+
992
+ CREATE TABLE IF NOT EXISTS _terminus_collection_scopes (
993
+ scope_key TEXT PRIMARY KEY,
994
+ head_cursor INTEGER NOT NULL DEFAULT 0 CHECK (head_cursor >= 0),
995
+ retained_after_cursor INTEGER NOT NULL DEFAULT 0 CHECK (retained_after_cursor >= 0),
996
+ CHECK (retained_after_cursor <= head_cursor)
997
+ );
998
+
999
+ CREATE TABLE IF NOT EXISTS _terminus_records (
1000
+ scope_key TEXT NOT NULL,
1001
+ collection_name TEXT NOT NULL,
1002
+ record_id TEXT NOT NULL,
1003
+ value_json TEXT NOT NULL CHECK (json_valid(value_json)),
1004
+ version INTEGER NOT NULL CHECK (version > 0),
1005
+ created_by_user_id TEXT NOT NULL,
1006
+ updated_by_user_id TEXT NOT NULL,
1007
+ created_at TEXT NOT NULL,
1008
+ updated_at TEXT NOT NULL,
1009
+ delivery_sequence INTEGER CHECK (delivery_sequence IS NULL OR delivery_sequence > 0),
1010
+ delivery_recipients_json TEXT CHECK (
1011
+ delivery_recipients_json IS NULL OR json_valid(delivery_recipients_json)
1012
+ ),
1013
+ PRIMARY KEY (scope_key, collection_name, record_id)
1014
+ );
1015
+
1016
+ CREATE TABLE IF NOT EXISTS _terminus_mutations (
1017
+ scope_key TEXT NOT NULL,
1018
+ collection_name TEXT NOT NULL,
1019
+ mutation_id TEXT NOT NULL,
1020
+ request_sha256 TEXT NOT NULL,
1021
+ response_json TEXT NOT NULL CHECK (json_valid(response_json)),
1022
+ committed_at TEXT NOT NULL,
1023
+ PRIMARY KEY (scope_key, collection_name, mutation_id)
1024
+ );
1025
+
1026
+ CREATE TABLE IF NOT EXISTS _terminus_transactions (
1027
+ transaction_scope TEXT NOT NULL,
1028
+ transaction_id TEXT NOT NULL,
1029
+ request_sha256 TEXT NOT NULL,
1030
+ response_json TEXT NOT NULL CHECK (json_valid(response_json)),
1031
+ committed_at TEXT NOT NULL,
1032
+ PRIMARY KEY (transaction_scope, transaction_id)
1033
+ );
1034
+
1035
+ CREATE TABLE IF NOT EXISTS _terminus_changes (
1036
+ scope_key TEXT NOT NULL,
1037
+ cursor INTEGER NOT NULL CHECK (cursor > 0),
1038
+ change_json TEXT NOT NULL CHECK (json_valid(change_json)),
1039
+ committed_at TEXT NOT NULL,
1040
+ PRIMARY KEY (scope_key, cursor)
1041
+ );
1042
+
1043
+ CREATE TABLE IF NOT EXISTS _terminus_object_refs (
1044
+ bucket_name TEXT NOT NULL,
1045
+ object_path TEXT NOT NULL,
1046
+ content_sha256 TEXT NOT NULL,
1047
+ size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0),
1048
+ media_type TEXT,
1049
+ version INTEGER NOT NULL DEFAULT 1,
1050
+ updated_at TEXT NOT NULL,
1051
+ PRIMARY KEY (bucket_name, object_path)
1052
+ );
1053
+
1054
+ CREATE INDEX IF NOT EXISTS idx_capsule_records_collection
1055
+ ON _terminus_records (scope_key, collection_name, record_id);
1056
+ CREATE INDEX IF NOT EXISTS idx_capsule_changes_scope_cursor
1057
+ ON _terminus_changes (scope_key, cursor);
1058
+ CREATE INDEX IF NOT EXISTS idx_capsule_objects_bucket_path
1059
+ ON _terminus_object_refs (bucket_name, object_path);
1060
+ `);
1061
+ ensureColumns(this.db, "_terminus_records", {
1062
+ delivery_sequence: "INTEGER",
1063
+ delivery_recipients_json: "TEXT",
1064
+ });
1065
+ ensureColumns(this.db, "_terminus_object_refs", { version: "INTEGER NOT NULL DEFAULT 1" });
1066
+ this.db.prepare(`
1067
+ INSERT INTO _terminus_meta (key, value) VALUES ('capsule_format_version', ?)
1068
+ ON CONFLICT (key) DO UPDATE SET value = excluded.value
1069
+ `).run(String(CAPSULE_FORMAT_VERSION));
1070
+ this.writeManifest();
1071
+ }
1072
+
1073
+ writeManifest() {
1074
+ const manifestPath = path.join(this.directory, "manifest.json");
1075
+ let createdAt = new Date().toISOString();
1076
+ try {
1077
+ createdAt = JSON.parse(readFileSync(manifestPath, "utf8")).created_at ?? createdAt;
1078
+ } catch {
1079
+ // First creation.
1080
+ }
1081
+ atomicWriteBytes(manifestPath, Buffer.from(`${JSON.stringify({
1082
+ capsule_format_version: CAPSULE_FORMAT_VERSION,
1083
+ app_id: this.appId,
1084
+ owner_user_id: this.member,
1085
+ database: "data.sqlite",
1086
+ objects: "objects/",
1087
+ created_at: createdAt,
1088
+ updated_at: new Date().toISOString(),
1089
+ }, null, 2)}\n`, "utf8"));
1090
+ }
1091
+
1092
+ scopeWindow(scopeKey) {
1093
+ const row = this.db.prepare(`
1094
+ SELECT head_cursor, retained_after_cursor
1095
+ FROM _terminus_collection_scopes WHERE scope_key = ?
1096
+ `).get(scopeKey);
1097
+ return row
1098
+ ? { head: Number(row.head_cursor), retainedAfter: Number(row.retained_after_cursor) }
1099
+ : { head: 0, retainedAfter: 0 };
1100
+ }
1101
+
1102
+ listRecords(scopeKey, collection) {
1103
+ return this.db.prepare(`
1104
+ SELECT record_id, value_json, version, created_by_user_id, updated_by_user_id,
1105
+ created_at, updated_at, delivery_sequence, delivery_recipients_json
1106
+ FROM _terminus_records
1107
+ WHERE scope_key = ? AND collection_name = ?
1108
+ ORDER BY record_id
1109
+ `).all(scopeKey, collection).map((row) => ({
1110
+ id: String(row.record_id),
1111
+ value: parseJson(row.value_json, {}),
1112
+ version: Number(row.version),
1113
+ created_by_user_id: String(row.created_by_user_id),
1114
+ updated_by_user_id: String(row.updated_by_user_id),
1115
+ created_at: String(row.created_at),
1116
+ updated_at: String(row.updated_at),
1117
+ delivery_sequence: row.delivery_sequence === null ? null : Number(row.delivery_sequence),
1118
+ delivery_recipients: parseJson(row.delivery_recipients_json, null),
1119
+ }));
1120
+ }
1121
+
1122
+ getRecord(scopeKey, collection, recordId) {
1123
+ const row = this.db.prepare(`
1124
+ SELECT value_json, version, created_by_user_id, updated_by_user_id,
1125
+ created_at, updated_at, delivery_sequence, delivery_recipients_json
1126
+ FROM _terminus_records
1127
+ WHERE scope_key = ? AND collection_name = ? AND record_id = ?
1128
+ `).get(scopeKey, collection, recordId);
1129
+ if (!row) return null;
1130
+ return {
1131
+ id: recordId,
1132
+ value: parseJson(row.value_json, {}),
1133
+ version: Number(row.version),
1134
+ created_by_user_id: String(row.created_by_user_id),
1135
+ updated_by_user_id: String(row.updated_by_user_id),
1136
+ created_at: String(row.created_at),
1137
+ updated_at: String(row.updated_at),
1138
+ delivery_sequence: row.delivery_sequence === null ? null : Number(row.delivery_sequence),
1139
+ delivery_recipients: parseJson(row.delivery_recipients_json, null),
1140
+ };
1141
+ }
1142
+
1143
+ /** The receipt a record write left under `mutationId`, or null. */
1144
+ mutationReceipt(scopeKey, collection, mutationId) {
1145
+ const row = this.db.prepare(`
1146
+ SELECT request_sha256, response_json
1147
+ FROM _terminus_mutations
1148
+ WHERE scope_key = ? AND collection_name = ? AND mutation_id = ?
1149
+ `).get(scopeKey, collection, mutationId);
1150
+ return row
1151
+ ? { requestSha256: String(row.request_sha256), response: parseJson(row.response_json, {}) }
1152
+ : null;
1153
+ }
1154
+
1155
+ /** The receipt a transaction left under `transactionId`, or null. */
1156
+ transactionReceipt(transactionScope, transactionId) {
1157
+ const row = this.db.prepare(`
1158
+ SELECT request_sha256, response_json
1159
+ FROM _terminus_transactions
1160
+ WHERE transaction_scope = ? AND transaction_id = ?
1161
+ `).get(transactionScope, transactionId);
1162
+ return row
1163
+ ? { requestSha256: String(row.request_sha256), response: parseJson(row.response_json, {}) }
1164
+ : null;
1165
+ }
1166
+
1167
+ mutateRecord(input) {
1168
+ return transaction(this.db, () => this._mutateRecord(input));
1169
+ }
1170
+
1171
+ _mutateRecord({
1172
+ scopeKey,
1173
+ collection,
1174
+ recordId,
1175
+ mutationId,
1176
+ requestSha256,
1177
+ operation,
1178
+ expectedVersion,
1179
+ value,
1180
+ actor,
1181
+ deliverySequence = null,
1182
+ deliveryRecipients = null,
1183
+ }) {
1184
+ const replay = this.mutationReceipt(scopeKey, collection, mutationId);
1185
+ if (replay) {
1186
+ if (replay.requestSha256 !== requestSha256) {
1187
+ return {
1188
+ conflict: "Idempotency-Key was already used for a different mutation",
1189
+ code: "idempotency_conflict",
1190
+ };
1191
+ }
1192
+ return { response: { ...replay.response, replayed: true }, changes: [] };
1193
+ }
1194
+
1195
+ const prior = this.getRecord(scopeKey, collection, recordId);
1196
+ const currentVersion = prior?.version ?? 0;
1197
+ if (
1198
+ (expectedVersion === 0 && prior !== null)
1199
+ || (expectedVersion !== null && expectedVersion > 0 && expectedVersion !== currentVersion)
1200
+ ) {
1201
+ return {
1202
+ conflict: `record version changed (current ${currentVersion}); refresh before retrying`,
1203
+ code: "version_conflict",
1204
+ };
1205
+ }
1206
+
1207
+ const now = new Date().toISOString();
1208
+ const version = currentVersion + 1;
1209
+ const createdBy = prior?.created_by_user_id ?? actor;
1210
+ const createdAt = prior?.created_at ?? now;
1211
+ if (operation === "put") {
1212
+ this.db.prepare(`
1213
+ INSERT INTO _terminus_records
1214
+ (scope_key, collection_name, record_id, value_json, version,
1215
+ created_by_user_id, updated_by_user_id, created_at, updated_at,
1216
+ delivery_sequence, delivery_recipients_json)
1217
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1218
+ ON CONFLICT (scope_key, collection_name, record_id) DO UPDATE SET
1219
+ value_json = excluded.value_json,
1220
+ version = excluded.version,
1221
+ updated_by_user_id = excluded.updated_by_user_id,
1222
+ updated_at = excluded.updated_at,
1223
+ delivery_sequence = excluded.delivery_sequence,
1224
+ delivery_recipients_json = excluded.delivery_recipients_json
1225
+ `).run(
1226
+ scopeKey,
1227
+ collection,
1228
+ recordId,
1229
+ JSON.stringify(value),
1230
+ version,
1231
+ createdBy,
1232
+ actor,
1233
+ createdAt,
1234
+ now,
1235
+ deliverySequence,
1236
+ deliveryRecipients === null ? null : JSON.stringify(deliveryRecipients),
1237
+ );
1238
+ } else {
1239
+ this.db.prepare(`
1240
+ DELETE FROM _terminus_records
1241
+ WHERE scope_key = ? AND collection_name = ? AND record_id = ?
1242
+ `).run(scopeKey, collection, recordId);
1243
+ }
1244
+
1245
+ this.db.prepare(`
1246
+ INSERT INTO _terminus_collection_scopes (scope_key, head_cursor, retained_after_cursor)
1247
+ VALUES (?, 0, 0) ON CONFLICT (scope_key) DO NOTHING
1248
+ `).run(scopeKey);
1249
+ const window = this.scopeWindow(scopeKey);
1250
+ const cursor = window.head + 1;
1251
+ const change = {
1252
+ operation,
1253
+ record_id: recordId,
1254
+ mutation_id: mutationId,
1255
+ committed_version: version,
1256
+ version: operation === "put" ? version : null,
1257
+ value: operation === "put" ? value : null,
1258
+ actor_user_id: actor,
1259
+ created_by_user_id: operation === "put" ? createdBy : null,
1260
+ updated_by_user_id: operation === "put" ? actor : null,
1261
+ policy_record: operation === "put" ? value : prior?.value ?? null,
1262
+ policy_created_by_user_id: prior?.created_by_user_id ?? (operation === "put" ? actor : null),
1263
+ policy_updated_by_user_id: operation === "put" ? actor : prior?.updated_by_user_id ?? null,
1264
+ delivery_sequence: operation === "put" ? deliverySequence : null,
1265
+ delivery_recipients: operation === "put" ? deliveryRecipients : null,
1266
+ changed_fields: changedRecordFields(prior?.value, operation === "put" ? value : undefined),
1267
+ // The platform's clock beside the change's own commit time: when the
1268
+ // record was first written, so a live projection can keep it.
1269
+ created_at: operation === "put" ? createdAt : null,
1270
+ committed_at: now,
1271
+ cursor,
1272
+ };
1273
+ this.db.prepare(`
1274
+ INSERT INTO _terminus_changes (scope_key, cursor, change_json, committed_at)
1275
+ VALUES (?, ?, ?, ?)
1276
+ `).run(scopeKey, cursor, JSON.stringify(change), now);
1277
+
1278
+ const retainedAfter = Math.max(window.retainedAfter, cursor - CHANGE_RETENTION);
1279
+ if (retainedAfter > window.retainedAfter) {
1280
+ this.db.prepare(`
1281
+ DELETE FROM _terminus_changes WHERE scope_key = ? AND cursor <= ?
1282
+ `).run(scopeKey, retainedAfter);
1283
+ }
1284
+ this.db.prepare(`
1285
+ UPDATE _terminus_collection_scopes
1286
+ SET head_cursor = ?, retained_after_cursor = ?
1287
+ WHERE scope_key = ?
1288
+ `).run(cursor, retainedAfter, scopeKey);
1289
+
1290
+ const response = operation === "put"
1291
+ ? {
1292
+ id: recordId,
1293
+ value,
1294
+ version,
1295
+ mutation_id: mutationId,
1296
+ sync_cursor: `c1_${cursor}`,
1297
+ created_at: createdAt,
1298
+ updated_at: now,
1299
+ created_by_user_id: createdBy,
1300
+ updated_by_user_id: actor,
1301
+ delivery_sequence: deliverySequence,
1302
+ delivery_recipients: deliveryRecipients,
1303
+ }
1304
+ : { ok: true, mutation_id: mutationId, sync_cursor: `c1_${cursor}` };
1305
+ this.db.prepare(`
1306
+ INSERT INTO _terminus_mutations
1307
+ (scope_key, collection_name, mutation_id, request_sha256, response_json, committed_at)
1308
+ VALUES (?, ?, ?, ?, ?, ?)
1309
+ `).run(scopeKey, collection, mutationId, requestSha256, JSON.stringify(response), now);
1310
+ return { response, changes: [change] };
1311
+ }
1312
+
1313
+ transactRecords({ transactionScope, transactionId, requestSha256, operations, actor }) {
1314
+ return transaction(this.db, () => {
1315
+ const replay = this.transactionReceipt(transactionScope, transactionId);
1316
+ if (replay) {
1317
+ if (replay.requestSha256 !== requestSha256) {
1318
+ return {
1319
+ conflict: "Idempotency-Key was already used for a different transaction",
1320
+ code: "idempotency_conflict",
1321
+ };
1322
+ }
1323
+ return { response: { ...replay.response, replayed: true }, changes: [] };
1324
+ }
1325
+
1326
+ for (const operation of operations) {
1327
+ const prior = this.getRecord(operation.scopeKey, operation.collection, operation.recordId);
1328
+ const currentVersion = prior?.version ?? 0;
1329
+ if (
1330
+ (operation.expectedVersion === 0 && prior !== null)
1331
+ || (operation.expectedVersion !== null
1332
+ && operation.expectedVersion > 0
1333
+ && operation.expectedVersion !== currentVersion)
1334
+ ) {
1335
+ return {
1336
+ conflict: `record '${operation.collection}/${operation.recordId}' changed (current ${currentVersion}); refresh before retrying`,
1337
+ code: "version_conflict",
1338
+ };
1339
+ }
1340
+ }
1341
+
1342
+ const results = [];
1343
+ const cascaded = [];
1344
+ const changes = [];
1345
+ for (const [index, operation] of operations.entries()) {
1346
+ const mutationId = operation.mutationId ?? `tx:${sha256(
1347
+ `${transactionId}:${index}:${operation.operation}:${operation.collection}:${operation.recordId}`,
1348
+ )}`;
1349
+ const mutationSignature = sha256(JSON.stringify({
1350
+ transactionId,
1351
+ index,
1352
+ operation: operation.operation,
1353
+ collection: operation.collection,
1354
+ recordId: operation.recordId,
1355
+ expectedVersion: operation.expectedVersion,
1356
+ value: operation.value,
1357
+ }));
1358
+ const result = this._mutateRecord({
1359
+ ...operation,
1360
+ mutationId,
1361
+ requestSha256: mutationSignature,
1362
+ actor,
1363
+ });
1364
+ if (result.conflict) throw Object.assign(new Error(result.conflict), { conflictCode: result.code });
1365
+ if (operation.cascaded) {
1366
+ cascaded.push({
1367
+ collection: operation.collection,
1368
+ id: operation.recordId,
1369
+ mutation_id: result.response.mutation_id,
1370
+ sync_cursor: result.response.sync_cursor,
1371
+ });
1372
+ } else {
1373
+ // TransactionOperationResult: which write, and its receipt.
1374
+ results.push({
1375
+ action: operation.operation,
1376
+ collection: operation.collection,
1377
+ id: operation.recordId,
1378
+ ...result.response,
1379
+ });
1380
+ }
1381
+ changes.push(...result.changes);
1382
+ }
1383
+ const response = {
1384
+ transaction_id: transactionId,
1385
+ replayed: false,
1386
+ results,
1387
+ cascaded,
1388
+ };
1389
+ this.db.prepare(`
1390
+ INSERT INTO _terminus_transactions
1391
+ (transaction_scope, transaction_id, request_sha256, response_json, committed_at)
1392
+ VALUES (?, ?, ?, ?, ?)
1393
+ `).run(
1394
+ transactionScope,
1395
+ transactionId,
1396
+ requestSha256,
1397
+ JSON.stringify(response),
1398
+ new Date().toISOString(),
1399
+ );
1400
+ return { response, changes };
1401
+ });
1402
+ }
1403
+
1404
+ appendReset(scopeKey, collection, actor, recordCount) {
1405
+ return transaction(this.db, () => {
1406
+ this.db.prepare(`
1407
+ INSERT INTO _terminus_collection_scopes (scope_key, head_cursor, retained_after_cursor)
1408
+ VALUES (?, 0, 0) ON CONFLICT (scope_key) DO NOTHING
1409
+ `).run(scopeKey);
1410
+ const window = this.scopeWindow(scopeKey);
1411
+ const cursor = window.head + 1;
1412
+ const now = new Date().toISOString();
1413
+ const change = {
1414
+ operation: "reset",
1415
+ record_id: "*",
1416
+ mutation_id: `rebuild-${crypto.randomUUID()}`,
1417
+ committed_version: null,
1418
+ version: null,
1419
+ value: null,
1420
+ actor_user_id: actor,
1421
+ created_by_user_id: null,
1422
+ updated_by_user_id: null,
1423
+ delivery_sequence: null,
1424
+ delivery_recipients: null,
1425
+ changed_fields: [],
1426
+ created_at: null,
1427
+ committed_at: now,
1428
+ cursor,
1429
+ };
1430
+ this.db.prepare(`
1431
+ INSERT INTO _terminus_changes (scope_key, cursor, change_json, committed_at)
1432
+ VALUES (?, ?, ?, ?)
1433
+ `).run(scopeKey, cursor, JSON.stringify(change), now);
1434
+ const retainedAfter = Math.max(window.retainedAfter, cursor - CHANGE_RETENTION);
1435
+ this.db.prepare(`
1436
+ DELETE FROM _terminus_changes WHERE scope_key = ? AND cursor <= ?
1437
+ `).run(scopeKey, retainedAfter);
1438
+ this.db.prepare(`
1439
+ UPDATE _terminus_collection_scopes
1440
+ SET head_cursor = ?, retained_after_cursor = ? WHERE scope_key = ?
1441
+ `).run(cursor, retainedAfter, scopeKey);
1442
+ return { change, rebuilt: recordCount };
1443
+ });
1444
+ }
1445
+
1446
+ /** One page of a scope's changes after `after`: the contract's
1447
+ * `changes_batch` rows at most (the backend's MAX_COLLECTION_CHANGE_BATCH). */
1448
+ changes(scopeKey, after, limit = LIMITS.changes_batch) {
1449
+ const window = this.scopeWindow(scopeKey);
1450
+ if (after < window.retainedAfter) {
1451
+ return { window, changes: [], resetRequired: true, hasMore: false };
1452
+ }
1453
+ const rows = this.db.prepare(`
1454
+ SELECT cursor, change_json FROM _terminus_changes
1455
+ WHERE scope_key = ? AND cursor > ? ORDER BY cursor LIMIT ?
1456
+ `).all(scopeKey, after, limit + 1);
1457
+ const hasMore = rows.length > limit;
1458
+ const page = rows.slice(0, limit).map((row) => ({
1459
+ ...parseJson(row.change_json, {}),
1460
+ cursor: Number(row.cursor),
1461
+ }));
1462
+ return { window, changes: page, resetRequired: false, hasMore };
1463
+ }
1464
+
1465
+ objectPath(contentSha256) {
1466
+ return path.join(
1467
+ this.objectsDirectory,
1468
+ "sha256",
1469
+ contentSha256.slice(0, 2),
1470
+ contentSha256.slice(2, 4),
1471
+ contentSha256,
1472
+ );
1473
+ }
1474
+
1475
+ /** Store one bucket object → its WriteReceipt (FileEntry of what was written). */
1476
+ putObject(bucket, objectPath, bytes, mediaType = "application/octet-stream") {
1477
+ const body = Buffer.from(bytes);
1478
+ const hash = sha256(body);
1479
+ const physical = this.objectPath(hash);
1480
+ if (!existsSync(physical)) atomicWriteBytes(physical, body);
1481
+ const prior = this.db.prepare(`
1482
+ SELECT version FROM _terminus_object_refs WHERE bucket_name = ? AND object_path = ?
1483
+ `).get(bucket, objectPath);
1484
+ const version = (prior ? Number(prior.version) : 0) + 1;
1485
+ const updatedAt = new Date().toISOString();
1486
+ this.db.prepare(`
1487
+ INSERT INTO _terminus_object_refs
1488
+ (bucket_name, object_path, content_sha256, size_bytes, media_type, version, updated_at)
1489
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1490
+ ON CONFLICT (bucket_name, object_path) DO UPDATE SET
1491
+ content_sha256 = excluded.content_sha256,
1492
+ size_bytes = excluded.size_bytes,
1493
+ media_type = excluded.media_type,
1494
+ version = excluded.version,
1495
+ updated_at = excluded.updated_at
1496
+ `).run(bucket, objectPath, hash, body.length, mediaType, version, updatedAt);
1497
+ return {
1498
+ path: objectPath,
1499
+ size_bytes: body.length,
1500
+ sha256: hash,
1501
+ media_type: mediaType,
1502
+ version,
1503
+ updated_at: updatedAt,
1504
+ };
1505
+ }
1506
+
1507
+ /** One bucket object's FileEntry, without its bytes; null when absent. */
1508
+ objectEntry(bucket, objectPath) {
1509
+ const row = this.db.prepare(`
1510
+ SELECT content_sha256, size_bytes, media_type, version, updated_at
1511
+ FROM _terminus_object_refs WHERE bucket_name = ? AND object_path = ?
1512
+ `).get(bucket, objectPath);
1513
+ return row ? objectEntry(objectPath, row) : null;
1514
+ }
1515
+
1516
+ getObject(bucket, objectPath) {
1517
+ const entry = this.objectEntry(bucket, objectPath);
1518
+ if (!entry) return null;
1519
+ return { bytes: readFileSync(this.objectPath(entry.sha256)), entry };
1520
+ }
1521
+
1522
+ /** `{files, next_cursor}`: a bucket's FileEntries under `prefix` in path
1523
+ * order, `limit` after `after`. */
1524
+ listObjects(bucket, prefix = "", { after = null, limit = 100 } = {}) {
1525
+ const rows = this.db.prepare(`
1526
+ SELECT object_path, content_sha256, size_bytes, media_type, version, updated_at
1527
+ FROM _terminus_object_refs
1528
+ WHERE bucket_name = ? AND object_path LIKE ? ESCAPE '\\' AND (? IS NULL OR object_path > ?)
1529
+ ORDER BY object_path
1530
+ LIMIT ?
1531
+ `).all(bucket, likePrefix(prefix), after || null, after || null, limit + 1);
1532
+ const page = rows.slice(0, limit).map((row) => objectEntry(String(row.object_path), row));
1533
+ return { files: page, next_cursor: rows.length > limit ? page.at(-1).path : null };
1534
+ }
1535
+
1536
+ deleteObject(bucket, objectPath) {
1537
+ const prior = this.db.prepare(`
1538
+ SELECT content_sha256 FROM _terminus_object_refs WHERE bucket_name = ? AND object_path = ?
1539
+ `).get(bucket, objectPath);
1540
+ this.db.prepare(`
1541
+ DELETE FROM _terminus_object_refs WHERE bucket_name = ? AND object_path = ?
1542
+ `).run(bucket, objectPath);
1543
+ if (prior) {
1544
+ const references = Number(this.db.prepare(`
1545
+ SELECT COUNT(*) AS n FROM _terminus_object_refs WHERE content_sha256 = ?
1546
+ `).get(prior.content_sha256).n);
1547
+ if (references === 0) rmSync(this.objectPath(String(prior.content_sha256)), { force: true });
1548
+ }
1549
+ return { ok: true };
1550
+ }
1551
+
1552
+ /** Everything this capsule keeps for one space: the space's own records
1553
+ * and a member's records in it, their receipts and change logs, and the
1554
+ * space's buckets. */
1555
+ deleteSpace(spaceId) {
1556
+ transaction(this.db, () => {
1557
+ for (const prefix of [`space:${spaceId}:`, `member:${spaceId}:`]) {
1558
+ for (const table of ["_terminus_records", "_terminus_mutations", "_terminus_changes", "_terminus_collection_scopes"]) {
1559
+ this.db.prepare(`DELETE FROM ${table} WHERE scope_key LIKE ? ESCAPE '\\'`).run(likePrefix(prefix));
1560
+ }
1561
+ }
1562
+ for (const scope of [`space:${spaceId}`, `member:${spaceId}`]) {
1563
+ this.db.prepare("DELETE FROM _terminus_transactions WHERE transaction_scope = ?").run(scope);
1564
+ }
1565
+ const orphans = this.db.prepare(`
1566
+ SELECT DISTINCT content_sha256 FROM _terminus_object_refs WHERE bucket_name LIKE ? ESCAPE '\\'
1567
+ `).all(likePrefix(`spaces/${spaceId}/`)).map((row) => String(row.content_sha256));
1568
+ this.db.prepare("DELETE FROM _terminus_object_refs WHERE bucket_name LIKE ? ESCAPE '\\'")
1569
+ .run(likePrefix(`spaces/${spaceId}/`));
1570
+ for (const hash of orphans) {
1571
+ const references = Number(this.db.prepare(
1572
+ "SELECT COUNT(*) AS n FROM _terminus_object_refs WHERE content_sha256 = ?",
1573
+ ).get(hash).n);
1574
+ if (references === 0) rmSync(this.objectPath(hash), { force: true });
1575
+ }
1576
+ });
1577
+ }
1578
+
1579
+ integrityCheck() {
1580
+ return String(this.db.prepare("PRAGMA integrity_check").get().integrity_check);
1581
+ }
1582
+
1583
+ close() {
1584
+ this.writeManifest();
1585
+ this.db.close();
1586
+ }
1587
+ }
1588
+
1589
+ /** A bucket object's FileEntry. */
1590
+ function objectEntry(objectPath, row) {
1591
+ return {
1592
+ path: objectPath,
1593
+ size_bytes: Number(row.size_bytes),
1594
+ sha256: String(row.content_sha256),
1595
+ media_type: row.media_type === null ? "application/octet-stream" : String(row.media_type),
1596
+ version: Number(row.version ?? 1),
1597
+ updated_at: String(row.updated_at),
1598
+ };
1599
+ }