lody 0.76.0 → 0.77.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.
@@ -0,0 +1,1552 @@
1
+ import { parentPort } from "node:worker_threads";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdirSync, statSync } from "node:fs";
4
+ import path__default from "node:path";
5
+ import { performance } from "node:perf_hooks";
6
+ import Database from "better-sqlite3";
7
+ import * as zlib from "node:zlib";
8
+ const DEFAULT_RETENTION_DAYS = 100;
9
+ const DEFAULT_MAX_STORAGE_BYTES = 1024 ** 3;
10
+ const DEFAULT_GC_TARGET_BYTES = 900 * 1024 ** 2;
11
+ const REQUIRED_NODE_API_VERSION = 10;
12
+ const SUPPORTED_SQLITE_ARCHS = ["x64", "arm64"];
13
+ function describeUnsupportedTurnDiffSqliteRuntime(runtime) {
14
+ if (!SUPPORTED_SQLITE_ARCHS.includes(runtime.arch)) {
15
+ return `@lody/turn-diff-store does not support the ${runtime.arch} architecture: better-sqlite3 ships prebuilt binaries for ${SUPPORTED_SQLITE_ARCHS.join(" and ")} only.`;
16
+ }
17
+ const napi = Number.parseInt(runtime.napi ?? "", 10);
18
+ if (!Number.isFinite(napi) || napi < REQUIRED_NODE_API_VERSION) {
19
+ return `@lody/turn-diff-store needs Node-API ${REQUIRED_NODE_API_VERSION} (Node.js 22.14.0 or newer); received ${runtime.nodeVersion} with Node-API ${runtime.napi ?? "unknown"}.`;
20
+ }
21
+ return void 0;
22
+ }
23
+ function assertTurnDiffSqliteRuntimeSupported() {
24
+ const problem = describeUnsupportedTurnDiffSqliteRuntime({
25
+ napi: process.versions.napi,
26
+ arch: process.arch,
27
+ nodeVersion: process.version
28
+ });
29
+ if (problem) throw new Error(problem);
30
+ }
31
+ assertTurnDiffSqliteRuntimeSupported();
32
+ const CODEC_RAW = 0;
33
+ const CODEC_ZSTD = 1;
34
+ const CODEC_GZIP = 2;
35
+ const COMPRESSION_LEVEL = 1;
36
+ const COMPRESSION_MIN_BYTES = 8 * 1024;
37
+ const COMPRESSION_MIN_SAVINGS_BYTES = 64;
38
+ const zstdCompressSync = zlib.zstdCompressSync;
39
+ const zstdDecompressSync = zlib.zstdDecompressSync;
40
+ const zstdCompressionLevelParameter = zlib.constants.ZSTD_c_compressionLevel;
41
+ function compressChunk(bytes, preference) {
42
+ const raw = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
43
+ if (bytes.byteLength < COMPRESSION_MIN_BYTES) {
44
+ return { codec: CODEC_RAW, payload: raw };
45
+ }
46
+ let codec;
47
+ let compressed;
48
+ if (preference === "zstd" && zstdCompressSync !== void 0 && zstdCompressionLevelParameter !== void 0) {
49
+ compressed = zstdCompressSync(bytes, {
50
+ params: { [zstdCompressionLevelParameter]: COMPRESSION_LEVEL }
51
+ });
52
+ codec = CODEC_ZSTD;
53
+ } else {
54
+ compressed = zlib.gzipSync(bytes, { level: COMPRESSION_LEVEL });
55
+ codec = CODEC_GZIP;
56
+ }
57
+ return compressed.byteLength + COMPRESSION_MIN_SAVINGS_BYTES < raw.byteLength ? { codec, payload: compressed } : { codec: CODEC_RAW, payload: raw };
58
+ }
59
+ function decompressChunk(codec, payload) {
60
+ if (codec === CODEC_RAW) {
61
+ return Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength);
62
+ }
63
+ if (codec === CODEC_ZSTD) {
64
+ if (zstdDecompressSync === void 0) {
65
+ throw new Error("This Node.js runtime cannot decompress a stored zstd turn-diff chunk.");
66
+ }
67
+ return zstdDecompressSync(payload);
68
+ }
69
+ if (codec === CODEC_GZIP) {
70
+ return zlib.gunzipSync(payload);
71
+ }
72
+ throw new Error(`Unknown turn-diff chunk codec ${codec}.`);
73
+ }
74
+ const MASK_HEX = [
75
+ "0",
76
+ "0",
77
+ "0",
78
+ "0",
79
+ "0",
80
+ "0000000001804110",
81
+ "0000000001803110",
82
+ "0000000018035100",
83
+ "0000001800035300",
84
+ "0000019000353000",
85
+ "0000590003530000",
86
+ "0000d90003530000",
87
+ "0000d90103530000",
88
+ "0000d90303530000",
89
+ "0000d90313530000",
90
+ "0000d90f03530000",
91
+ "0000d90303537000",
92
+ "0000d90703537000",
93
+ "0000d90707537000",
94
+ "0000d91707537000",
95
+ "0000d91747537000",
96
+ "0000d91767537000",
97
+ "0000d93767537000",
98
+ "0000d93777537000",
99
+ "0000d93777577000",
100
+ "0000db3777577000"
101
+ ];
102
+ const UINT32_BASE = 4294967296;
103
+ function splitHex64(value) {
104
+ const padded = value.padStart(16, "0");
105
+ return {
106
+ hi: Number.parseInt(padded.slice(0, 8), 16) >>> 0,
107
+ lo: Number.parseInt(padded.slice(8), 16) >>> 0
108
+ };
109
+ }
110
+ function shiftLeftOne(hi, lo) {
111
+ return {
112
+ hi: (hi << 1 | lo >>> 31) >>> 0,
113
+ lo: lo << 1 >>> 0
114
+ };
115
+ }
116
+ function generateTables() {
117
+ const gearHi = new Uint32Array(256);
118
+ const gearLo = new Uint32Array(256);
119
+ const gearLsHi = new Uint32Array(256);
120
+ const gearLsLo = new Uint32Array(256);
121
+ for (let index = 0; index < 256; index += 1) {
122
+ const digest = createHash("md5").update(Buffer.alloc(64, index)).digest();
123
+ const hi = digest.readUInt32BE(0);
124
+ const lo = digest.readUInt32BE(4);
125
+ const shifted = shiftLeftOne(hi, lo);
126
+ gearHi[index] = hi;
127
+ gearLo[index] = lo;
128
+ gearLsHi[index] = shifted.hi;
129
+ gearLsLo[index] = shifted.lo;
130
+ }
131
+ return { gearHi, gearLo, gearLsHi, gearLsLo };
132
+ }
133
+ const TABLES = generateTables();
134
+ function createConfig(minSize, avgSize, maxSize, normalization = 1) {
135
+ const bits = 31 - Math.clz32(avgSize);
136
+ const maskSHex = MASK_HEX[bits + normalization];
137
+ const maskLHex = MASK_HEX[bits - normalization];
138
+ if (maskSHex === void 0 || maskLHex === void 0) {
139
+ throw new Error(`FastCDC mask is unavailable for average size ${avgSize}.`);
140
+ }
141
+ const maskS = splitHex64(maskSHex);
142
+ const maskL = splitHex64(maskLHex);
143
+ const maskSLs = shiftLeftOne(maskS.hi, maskS.lo);
144
+ const maskLLs = shiftLeftOne(maskL.hi, maskL.lo);
145
+ return {
146
+ minSize,
147
+ avgSize,
148
+ maxSize,
149
+ maskSHi: maskS.hi,
150
+ maskSLo: maskS.lo,
151
+ maskLHi: maskL.hi,
152
+ maskLLo: maskL.lo,
153
+ maskSLsHi: maskSLs.hi,
154
+ maskSLsLo: maskSLs.lo,
155
+ maskLLsHi: maskLLs.hi,
156
+ maskLLsLo: maskLLs.lo
157
+ };
158
+ }
159
+ const DEFAULT_CONFIG = createConfig(8 * 1024, 32 * 1024, 128 * 1024, 1);
160
+ function cutLength(bytes, start, available, config) {
161
+ let remaining = available;
162
+ if (remaining <= config.minSize) {
163
+ return remaining;
164
+ }
165
+ let center = config.avgSize;
166
+ if (remaining > config.maxSize) {
167
+ remaining = config.maxSize;
168
+ } else if (remaining < center) {
169
+ center = remaining;
170
+ }
171
+ let index = config.minSize >>> 1;
172
+ let hi = 0;
173
+ let lo = 0;
174
+ const centerHalf = center >>> 1;
175
+ const remainingHalf = remaining >>> 1;
176
+ const { gearHi, gearLo, gearLsHi, gearLsLo } = TABLES;
177
+ while (index < centerHalf) {
178
+ const relativeOffset = index << 1;
179
+ const first = bytes[start + relativeOffset];
180
+ const shiftedHi = (hi << 2 | lo >>> 30) >>> 0;
181
+ const shiftedLo = lo << 2 >>> 0;
182
+ const lowSum = shiftedLo + gearLsLo[first];
183
+ lo = lowSum >>> 0;
184
+ hi = shiftedHi + gearLsHi[first] + (lowSum >= UINT32_BASE ? 1 : 0) >>> 0;
185
+ if ((hi & config.maskSLsHi) === 0 && (lo & config.maskSLsLo) === 0) {
186
+ return relativeOffset;
187
+ }
188
+ const second = bytes[start + relativeOffset + 1];
189
+ const secondLowSum = lo + gearLo[second];
190
+ lo = secondLowSum >>> 0;
191
+ hi = hi + gearHi[second] + (secondLowSum >= UINT32_BASE ? 1 : 0) >>> 0;
192
+ if ((hi & config.maskSHi) === 0 && (lo & config.maskSLo) === 0) {
193
+ return relativeOffset + 1;
194
+ }
195
+ index += 1;
196
+ }
197
+ while (index < remainingHalf) {
198
+ const relativeOffset = index << 1;
199
+ const first = bytes[start + relativeOffset];
200
+ const shiftedHi = (hi << 2 | lo >>> 30) >>> 0;
201
+ const shiftedLo = lo << 2 >>> 0;
202
+ const lowSum = shiftedLo + gearLsLo[first];
203
+ lo = lowSum >>> 0;
204
+ hi = shiftedHi + gearLsHi[first] + (lowSum >= UINT32_BASE ? 1 : 0) >>> 0;
205
+ if ((hi & config.maskLLsHi) === 0 && (lo & config.maskLLsLo) === 0) {
206
+ return relativeOffset;
207
+ }
208
+ const second = bytes[start + relativeOffset + 1];
209
+ const secondLowSum = lo + gearLo[second];
210
+ lo = secondLowSum >>> 0;
211
+ hi = hi + gearHi[second] + (secondLowSum >= UINT32_BASE ? 1 : 0) >>> 0;
212
+ if ((hi & config.maskLHi) === 0 && (lo & config.maskLLo) === 0) {
213
+ return relativeOffset + 1;
214
+ }
215
+ index += 1;
216
+ }
217
+ return remaining;
218
+ }
219
+ function fastCdcV2020(bytes) {
220
+ const chunks = [];
221
+ let offset = 0;
222
+ while (offset < bytes.length) {
223
+ const length = cutLength(bytes, offset, bytes.length - offset, DEFAULT_CONFIG);
224
+ if (length <= 0) {
225
+ throw new Error(`FastCDC produced a zero-length chunk at byte ${offset}.`);
226
+ }
227
+ chunks.push({ offset, length });
228
+ offset += length;
229
+ }
230
+ return chunks;
231
+ }
232
+ const TURN_DIFF_APPLICATION_ID = 1280590897;
233
+ const SCHEMA_VERSION = 2;
234
+ const DAY_MS = 24 * 60 * 60 * 1e3;
235
+ const MIN_RETENTION_DAYS = 1;
236
+ const MAX_RETENTION_DAYS = 365;
237
+ const GC_TURN_BATCH_SIZE = 128;
238
+ const GC_VACUUM_PAGE_BATCH_SIZE = 1024;
239
+ const LEGACY_TABLE_NAMES = ["diff_docs", "diff_doc_updates", "diff_events"];
240
+ const STORE_TABLE_NAMES = [
241
+ "store_counters",
242
+ "turns",
243
+ "chunks",
244
+ "snapshots",
245
+ "snapshot_chunks",
246
+ "turn_files",
247
+ "path_heads"
248
+ ];
249
+ const LEGACY_INDEX_NAMES = [
250
+ "diff_events_owner_path_expires_idx",
251
+ "diff_events_owner_expires_idx",
252
+ "diff_doc_updates_doc_created_idx"
253
+ ];
254
+ const STORE_INDEX_NAMES = [
255
+ "turns_owner_expiry_idx",
256
+ "turns_evicted_order_idx",
257
+ "turn_files_path_idx",
258
+ "turn_files_old_snapshot_idx",
259
+ "turn_files_new_snapshot_idx",
260
+ "snapshot_chunks_hash_idx",
261
+ "path_heads_snapshot_idx",
262
+ "path_heads_source_turn_idx"
263
+ ];
264
+ const KNOWN_SCHEMA_OBJECT_NAMES = /* @__PURE__ */ new Set([
265
+ ...LEGACY_TABLE_NAMES,
266
+ ...LEGACY_INDEX_NAMES,
267
+ ...STORE_TABLE_NAMES,
268
+ ...STORE_INDEX_NAMES
269
+ ]);
270
+ const LEGACY_SCHEMA_SQL = `
271
+ CREATE TABLE diff_docs (
272
+ doc_id TEXT PRIMARY KEY,
273
+ workspace_id TEXT NOT NULL,
274
+ owner_session_id TEXT NOT NULL,
275
+ path TEXT NOT NULL,
276
+ created_at_ms INTEGER NOT NULL,
277
+ updated_at_ms INTEGER NOT NULL,
278
+ snapshot BLOB,
279
+ snapshot_frontiers_json TEXT,
280
+ snapshot_at_ms INTEGER,
281
+ UNIQUE(workspace_id, owner_session_id, path)
282
+ );
283
+
284
+ CREATE TABLE diff_doc_updates (
285
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
286
+ doc_id TEXT NOT NULL,
287
+ update_bytes BLOB NOT NULL,
288
+ frontiers_json TEXT NOT NULL,
289
+ created_at_ms INTEGER NOT NULL,
290
+ FOREIGN KEY(doc_id) REFERENCES diff_docs(doc_id) ON DELETE CASCADE
291
+ );
292
+
293
+ CREATE TABLE diff_events (
294
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
295
+ workspace_id TEXT NOT NULL,
296
+ owner_session_id TEXT NOT NULL,
297
+ turn_id TEXT NOT NULL,
298
+ path TEXT NOT NULL,
299
+ doc_id TEXT NOT NULL,
300
+ old_frontiers_json TEXT NOT NULL,
301
+ new_frontiers_json TEXT NOT NULL,
302
+ old_present INTEGER NOT NULL,
303
+ new_present INTEGER NOT NULL,
304
+ add_count INTEGER,
305
+ del_count INTEGER,
306
+ source TEXT NOT NULL,
307
+ captured_at_ms INTEGER NOT NULL,
308
+ expires_at_ms INTEGER NOT NULL,
309
+ UNIQUE(workspace_id, owner_session_id, turn_id, path),
310
+ FOREIGN KEY(doc_id) REFERENCES diff_docs(doc_id) ON DELETE CASCADE
311
+ );
312
+
313
+ CREATE INDEX diff_events_owner_path_expires_idx
314
+ ON diff_events(workspace_id, owner_session_id, path, expires_at_ms, captured_at_ms, id);
315
+ CREATE INDEX diff_events_owner_expires_idx
316
+ ON diff_events(workspace_id, owner_session_id, expires_at_ms, path);
317
+ CREATE INDEX diff_doc_updates_doc_created_idx
318
+ ON diff_doc_updates(doc_id, created_at_ms, id);
319
+ `;
320
+ const CURRENT_SCHEMA_SQL = `
321
+ CREATE TABLE store_counters (
322
+ name TEXT PRIMARY KEY,
323
+ value INTEGER NOT NULL CHECK(value >= 0)
324
+ ) WITHOUT ROWID;
325
+
326
+ CREATE TABLE turns (
327
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
328
+ owner_id TEXT NOT NULL,
329
+ turn_key TEXT NOT NULL,
330
+ order_key TEXT NOT NULL,
331
+ captured_at_ms INTEGER NOT NULL,
332
+ expires_at_ms INTEGER NOT NULL,
333
+ evicted INTEGER NOT NULL DEFAULT 0 CHECK(evicted IN (0, 1)),
334
+ UNIQUE(owner_id, turn_key),
335
+ UNIQUE(owner_id, order_key)
336
+ );
337
+
338
+ CREATE TABLE chunks (
339
+ hash BLOB PRIMARY KEY CHECK(length(hash) = 32),
340
+ codec INTEGER NOT NULL CHECK(codec IN (0, 1, 2)),
341
+ raw_size INTEGER NOT NULL CHECK(raw_size >= 0),
342
+ payload BLOB NOT NULL,
343
+ ref_count INTEGER NOT NULL DEFAULT 0 CHECK(ref_count >= 0)
344
+ ) WITHOUT ROWID;
345
+
346
+ CREATE TABLE snapshots (
347
+ id INTEGER PRIMARY KEY,
348
+ content_hash BLOB NOT NULL UNIQUE CHECK(length(content_hash) = 32),
349
+ raw_size INTEGER NOT NULL CHECK(raw_size >= 0),
350
+ ref_count INTEGER NOT NULL DEFAULT 0 CHECK(ref_count >= 0)
351
+ );
352
+
353
+ CREATE TABLE snapshot_chunks (
354
+ snapshot_id INTEGER NOT NULL REFERENCES snapshots(id) ON DELETE CASCADE,
355
+ ordinal INTEGER NOT NULL,
356
+ chunk_hash BLOB NOT NULL REFERENCES chunks(hash),
357
+ raw_size INTEGER NOT NULL CHECK(raw_size >= 0),
358
+ PRIMARY KEY(snapshot_id, ordinal)
359
+ ) WITHOUT ROWID;
360
+
361
+ CREATE TABLE turn_files (
362
+ id INTEGER PRIMARY KEY,
363
+ turn_id INTEGER NOT NULL REFERENCES turns(id) ON DELETE CASCADE,
364
+ path TEXT NOT NULL,
365
+ old_snapshot_id INTEGER REFERENCES snapshots(id),
366
+ new_snapshot_id INTEGER REFERENCES snapshots(id),
367
+ add_count INTEGER NOT NULL CHECK(add_count >= 0),
368
+ del_count INTEGER NOT NULL CHECK(del_count >= 0),
369
+ UNIQUE(turn_id, path)
370
+ );
371
+
372
+ CREATE TABLE path_heads (
373
+ owner_id TEXT NOT NULL,
374
+ path TEXT NOT NULL,
375
+ snapshot_id INTEGER REFERENCES snapshots(id),
376
+ source_turn_id INTEGER NOT NULL,
377
+ updated_order_key TEXT NOT NULL,
378
+ head_proof INTEGER NOT NULL CHECK(head_proof > 0),
379
+ PRIMARY KEY(owner_id, path)
380
+ ) WITHOUT ROWID;
381
+
382
+ CREATE INDEX turns_owner_expiry_idx
383
+ ON turns(owner_id, evicted, expires_at_ms, order_key, id);
384
+ CREATE INDEX turns_evicted_order_idx
385
+ ON turns(evicted, order_key, id);
386
+ CREATE INDEX turn_files_path_idx
387
+ ON turn_files(path, turn_id);
388
+ CREATE INDEX turn_files_old_snapshot_idx
389
+ ON turn_files(old_snapshot_id) WHERE old_snapshot_id IS NOT NULL;
390
+ CREATE INDEX turn_files_new_snapshot_idx
391
+ ON turn_files(new_snapshot_id) WHERE new_snapshot_id IS NOT NULL;
392
+ CREATE INDEX snapshot_chunks_hash_idx
393
+ ON snapshot_chunks(chunk_hash);
394
+ CREATE INDEX path_heads_snapshot_idx
395
+ ON path_heads(snapshot_id) WHERE snapshot_id IS NOT NULL;
396
+ CREATE INDEX path_heads_source_turn_idx
397
+ ON path_heads(source_turn_id);
398
+ `;
399
+ class SqliteTurnDiffStore {
400
+ db;
401
+ options;
402
+ closed = false;
403
+ constructor(options) {
404
+ this.options = normalizeOptions(options);
405
+ mkdirSync(path__default.dirname(this.options.dbPath), { recursive: true });
406
+ this.db = new Database(this.options.dbPath, { timeout: 5e3 });
407
+ try {
408
+ this.initializeSchema();
409
+ } catch (error) {
410
+ this.db.close();
411
+ throw error;
412
+ }
413
+ }
414
+ close() {
415
+ if (this.closed) return;
416
+ this.closed = true;
417
+ this.db.close();
418
+ }
419
+ allocateHeadProof() {
420
+ this.assertOpen();
421
+ const allocate = this.db.transaction(() => {
422
+ const row = this.db.prepare(
423
+ `
424
+ UPDATE store_counters
425
+ SET value = value + 1
426
+ WHERE name = 'head_proof'
427
+ RETURNING value AS id
428
+ `
429
+ ).get();
430
+ if (!row || !Number.isSafeInteger(row.id) || row.id <= 0) {
431
+ throw new Error("Failed to allocate a turn-diff head proof token.");
432
+ }
433
+ return row.id;
434
+ });
435
+ return allocate.immediate();
436
+ }
437
+ recordTurn(input) {
438
+ this.assertOpen();
439
+ validateRecordInput(input);
440
+ const startedAt = performance.now();
441
+ const recordedAtMs = input.recordedAtMs;
442
+ const capturedAtMs = input.capturedAtMs;
443
+ const orderKey = input.orderKey ?? defaultTurnOrderKey(capturedAtMs, input.turnId);
444
+ const expiresAtMs = capturedAtMs + this.options.retentionDays * DAY_MS;
445
+ const metrics = {
446
+ rawBytes: 0,
447
+ newChunks: 0,
448
+ reusedChunks: 0,
449
+ encodeMs: 0,
450
+ chunkingMs: 0,
451
+ hashingMs: 0,
452
+ compressionMs: 0
453
+ };
454
+ const existingTurn = this.db.prepare(
455
+ `
456
+ SELECT id, captured_at_ms, order_key, expires_at_ms, evicted
457
+ FROM turns
458
+ WHERE owner_id = ? AND turn_key = ?
459
+ `
460
+ ).get(input.ownerId, input.turnId);
461
+ if (existingTurn !== void 0 && (existingTurn.evicted !== 0 || existingTurn.expires_at_ms <= recordedAtMs) || existingTurn === void 0 && expiresAtMs <= recordedAtMs) {
462
+ return {
463
+ files: [],
464
+ metrics: {
465
+ ...metrics,
466
+ transactionMs: 0,
467
+ totalMs: performance.now() - startedAt
468
+ },
469
+ gcScheduled: existingTurn !== void 0 && existingTurn.expires_at_ms <= recordedAtMs || this.shouldRunGc(recordedAtMs)
470
+ };
471
+ }
472
+ const existingPaths = new Set(
473
+ existingTurn === void 0 ? [] : this.db.prepare("SELECT path FROM turn_files WHERE turn_id = ?").all(existingTurn.id).map((row) => row.path)
474
+ );
475
+ const pendingEvents = input.events.filter((event) => !existingPaths.has(event.path));
476
+ if (pendingEvents.length === 0) {
477
+ const files2 = existingTurn ? this.listTurnFiles({
478
+ ownerId: input.ownerId,
479
+ turnId: input.turnId,
480
+ nowMs: recordedAtMs
481
+ }) : [];
482
+ return {
483
+ files: files2,
484
+ metrics: {
485
+ ...metrics,
486
+ transactionMs: 0,
487
+ totalMs: performance.now() - startedAt
488
+ },
489
+ gcScheduled: this.shouldRunGc(recordedAtMs)
490
+ };
491
+ }
492
+ const snapshotCache = /* @__PURE__ */ new Map();
493
+ const chunkCache = /* @__PURE__ */ new Map();
494
+ const preparedEvents = pendingEvents.map((event) => ({
495
+ path: event.path,
496
+ oldSnapshot: this.prepareSnapshot(event.oldText, snapshotCache, chunkCache, metrics),
497
+ newSnapshot: this.prepareSnapshot(event.newText, snapshotCache, chunkCache, metrics),
498
+ newIsCurrent: event.newIsCurrent,
499
+ headProof: event.headProof,
500
+ add: event.add,
501
+ del: event.del
502
+ }));
503
+ const transactionStartedAt = performance.now();
504
+ const commit = this.db.transaction(() => {
505
+ const turn = this.getOrCreateTurn(
506
+ input.ownerId,
507
+ input.turnId,
508
+ orderKey,
509
+ capturedAtMs,
510
+ expiresAtMs
511
+ );
512
+ if (turn.evicted !== 0 || turn.expires_at_ms <= recordedAtMs) {
513
+ return false;
514
+ }
515
+ const materializedSnapshots = /* @__PURE__ */ new Map();
516
+ const releasedSnapshotIds = /* @__PURE__ */ new Set();
517
+ for (const event of preparedEvents) {
518
+ const existing = this.db.prepare("SELECT id FROM turn_files WHERE turn_id = ? AND path = ?").get(turn.id, event.path);
519
+ if (existing) continue;
520
+ const oldSnapshotId = this.materializeSnapshot(event.oldSnapshot, materializedSnapshots);
521
+ const newSnapshotId = this.materializeSnapshot(event.newSnapshot, materializedSnapshots);
522
+ this.db.prepare(
523
+ `
524
+ INSERT INTO turn_files (
525
+ turn_id, path, old_snapshot_id, new_snapshot_id, add_count, del_count
526
+ ) VALUES (?, ?, ?, ?, ?, ?)
527
+ `
528
+ ).run(turn.id, event.path, oldSnapshotId, newSnapshotId, event.add, event.del);
529
+ this.changeSnapshotRefCount(oldSnapshotId, 1);
530
+ this.changeSnapshotRefCount(newSnapshotId, 1);
531
+ if (event.newIsCurrent) {
532
+ if (event.headProof === null) {
533
+ throw new Error(`Missing head proof for current turn-diff path ${event.path}.`);
534
+ }
535
+ const releasedSnapshotId = this.updatePathHead(
536
+ input.ownerId,
537
+ event.path,
538
+ newSnapshotId,
539
+ turn.id,
540
+ turn.order_key,
541
+ event.headProof
542
+ );
543
+ if (releasedSnapshotId !== void 0) {
544
+ releasedSnapshotIds.add(releasedSnapshotId);
545
+ }
546
+ }
547
+ }
548
+ this.deleteUnreferencedSnapshotsAndChunks(releasedSnapshotIds);
549
+ return true;
550
+ });
551
+ commit.immediate();
552
+ const transactionMs = performance.now() - transactionStartedAt;
553
+ const files = this.listTurnFiles({
554
+ ownerId: input.ownerId,
555
+ turnId: input.turnId,
556
+ nowMs: recordedAtMs
557
+ });
558
+ const gcScheduled = this.shouldRunGc(recordedAtMs);
559
+ return {
560
+ files,
561
+ metrics: {
562
+ ...metrics,
563
+ transactionMs,
564
+ totalMs: performance.now() - startedAt
565
+ },
566
+ gcScheduled
567
+ };
568
+ }
569
+ listChangedPaths(input) {
570
+ this.assertOpen();
571
+ const rows = this.db.prepare(
572
+ `
573
+ SELECT DISTINCT tf.path
574
+ FROM turn_files AS tf
575
+ JOIN turns AS t ON t.id = tf.turn_id
576
+ WHERE t.owner_id = ? AND t.evicted = 0 AND t.expires_at_ms > ?
577
+ ORDER BY tf.path ASC
578
+ `
579
+ ).all(input.ownerId, input.nowMs);
580
+ return rows.map((row) => row.path);
581
+ }
582
+ getEarliestOldSnapshot(input) {
583
+ this.assertOpen();
584
+ assertSnapshotReadLimit(input.maxRawBytes);
585
+ return this.db.transaction(() => {
586
+ const row = this.db.prepare(
587
+ `
588
+ SELECT tf.old_snapshot_id AS snapshot_id, COALESCE(s.raw_size, 0) AS raw_size
589
+ FROM turn_files AS tf
590
+ JOIN turns AS t ON t.id = tf.turn_id
591
+ LEFT JOIN snapshots AS s ON s.id = tf.old_snapshot_id
592
+ WHERE t.owner_id = ? AND t.evicted = 0 AND tf.path = ? AND t.expires_at_ms > ?
593
+ ORDER BY t.order_key ASC, t.id ASC
594
+ LIMIT 1
595
+ `
596
+ ).get(input.ownerId, input.path, input.nowMs);
597
+ if (!row) return { status: "unavailable" };
598
+ if (exceedsSnapshotReadLimit(row.raw_size, input.maxRawBytes)) {
599
+ return { status: "too_large", rawBytes: row.raw_size };
600
+ }
601
+ return { status: "ready", text: this.readSnapshotText(row.snapshot_id) };
602
+ })();
603
+ }
604
+ getTurnSnapshot(input) {
605
+ this.assertOpen();
606
+ assertSnapshotReadLimit(input.maxRawBytes);
607
+ return this.db.transaction(() => {
608
+ const row = this.db.prepare(
609
+ `
610
+ SELECT
611
+ tf.old_snapshot_id,
612
+ tf.new_snapshot_id,
613
+ COALESCE(old_snapshot.raw_size, 0) AS old_raw_size,
614
+ COALESCE(new_snapshot.raw_size, 0) AS new_raw_size
615
+ FROM turn_files AS tf
616
+ JOIN turns AS t ON t.id = tf.turn_id
617
+ LEFT JOIN snapshots AS old_snapshot ON old_snapshot.id = tf.old_snapshot_id
618
+ LEFT JOIN snapshots AS new_snapshot ON new_snapshot.id = tf.new_snapshot_id
619
+ WHERE t.owner_id = ?
620
+ AND t.turn_key = ?
621
+ AND t.evicted = 0
622
+ AND tf.path = ?
623
+ AND t.expires_at_ms > ?
624
+ LIMIT 1
625
+ `
626
+ ).get(input.ownerId, input.turnId, input.path, input.nowMs);
627
+ if (!row) return { status: "unavailable" };
628
+ const rawBytes = row.old_raw_size + row.new_raw_size;
629
+ if (exceedsSnapshotReadLimit(rawBytes, input.maxRawBytes)) {
630
+ return { status: "too_large", rawBytes };
631
+ }
632
+ return {
633
+ status: "ready",
634
+ oldText: this.readSnapshotText(row.old_snapshot_id),
635
+ newText: this.readSnapshotText(row.new_snapshot_id)
636
+ };
637
+ })();
638
+ }
639
+ getLatestText(input) {
640
+ this.assertOpen();
641
+ assertSnapshotReadLimit(input.maxRawBytes);
642
+ return this.db.transaction(() => {
643
+ const row = this.db.prepare(
644
+ `
645
+ SELECT ph.snapshot_id, COALESCE(s.raw_size, 0) AS raw_size
646
+ FROM path_heads AS ph
647
+ LEFT JOIN snapshots AS s ON s.id = ph.snapshot_id
648
+ WHERE ph.owner_id = ? AND ph.path = ?
649
+ `
650
+ ).get(input.ownerId, input.path);
651
+ if (!row) return { status: "untracked" };
652
+ if (exceedsSnapshotReadLimit(row.raw_size, input.maxRawBytes)) {
653
+ return { status: "too_large", rawBytes: row.raw_size };
654
+ }
655
+ return { status: "tracked", text: this.readSnapshotText(row.snapshot_id) };
656
+ })();
657
+ }
658
+ listTurnFiles(input) {
659
+ this.assertOpen();
660
+ const rows = this.db.prepare(
661
+ `
662
+ SELECT tf.path, tf.add_count, tf.del_count
663
+ FROM turn_files AS tf
664
+ JOIN turns AS t ON t.id = tf.turn_id
665
+ WHERE t.owner_id = ?
666
+ AND t.turn_key = ?
667
+ AND t.evicted = 0
668
+ AND t.expires_at_ms > ?
669
+ ORDER BY tf.path ASC
670
+ `
671
+ ).all(input.ownerId, input.turnId, input.nowMs);
672
+ return rows.map((row) => ({ path: row.path, add: row.add_count, del: row.del_count }));
673
+ }
674
+ shouldRunGc(nowMs) {
675
+ this.assertOpen();
676
+ const expired = this.db.prepare("SELECT 1 AS id FROM turns WHERE expires_at_ms <= ? LIMIT 1").get(nowMs);
677
+ return expired !== void 0 || this.storageBytes().total > this.options.maxStorageBytes;
678
+ }
679
+ gc(nowMs) {
680
+ const cursor = this.beginGc(nowMs);
681
+ while (true) {
682
+ const result = this.gcStep(cursor);
683
+ if (result !== null) return result;
684
+ }
685
+ }
686
+ beginGc(nowMs) {
687
+ this.assertOpen();
688
+ const before = this.storageBytes(true);
689
+ return {
690
+ nowMs,
691
+ before,
692
+ sizeGcTriggered: before.total > this.options.maxStorageBytes,
693
+ phase: "expired",
694
+ deletedTurns: 0,
695
+ deletedSnapshots: 0,
696
+ deletedChunks: 0
697
+ };
698
+ }
699
+ gcStep(cursor) {
700
+ this.assertOpen();
701
+ if (cursor.phase === "expired") {
702
+ const expiredIds = this.db.prepare("SELECT id FROM turns WHERE expires_at_ms <= ? ORDER BY id ASC LIMIT ?").all(cursor.nowMs, GC_TURN_BATCH_SIZE).map((row) => row.id);
703
+ if (expiredIds.length === 0) {
704
+ cursor.phase = cursor.sizeGcTriggered ? "turns" : "vacuum";
705
+ return null;
706
+ }
707
+ this.accumulateDeletedTurns(cursor, expiredIds, "delete");
708
+ return null;
709
+ }
710
+ if (cursor.phase === "turns") {
711
+ if (this.estimatedCompactedStorageBytes() <= this.options.gcTargetBytes) {
712
+ cursor.phase = "vacuum";
713
+ return null;
714
+ }
715
+ const oldestIds = this.db.prepare(
716
+ `
717
+ SELECT id
718
+ FROM turns
719
+ WHERE evicted = 0
720
+ AND id != (
721
+ SELECT id
722
+ FROM turns
723
+ WHERE evicted = 0
724
+ ORDER BY order_key DESC, id DESC
725
+ LIMIT 1
726
+ )
727
+ ORDER BY order_key ASC, id ASC
728
+ LIMIT ?
729
+ `
730
+ ).all(GC_TURN_BATCH_SIZE).map((row) => row.id);
731
+ if (oldestIds.length === 0) {
732
+ cursor.phase = "heads";
733
+ return null;
734
+ }
735
+ this.accumulateDeletedTurns(cursor, oldestIds, "evict");
736
+ return null;
737
+ }
738
+ if (cursor.phase === "heads") {
739
+ if (this.estimatedCompactedStorageBytes() <= this.options.gcTargetBytes) {
740
+ cursor.phase = "vacuum";
741
+ return null;
742
+ }
743
+ const oldestHeads = this.db.prepare(
744
+ `
745
+ SELECT owner_id, path, snapshot_id, source_turn_id, head_proof
746
+ FROM path_heads
747
+ WHERE source_turn_id IS NOT (
748
+ SELECT id
749
+ FROM turns
750
+ WHERE evicted = 0
751
+ ORDER BY order_key DESC, id DESC
752
+ LIMIT 1
753
+ )
754
+ ORDER BY head_proof ASC, source_turn_id ASC, owner_id ASC, path ASC
755
+ LIMIT ?
756
+ `
757
+ ).all(GC_TURN_BATCH_SIZE);
758
+ if (oldestHeads.length === 0) {
759
+ cursor.phase = "vacuum";
760
+ return null;
761
+ }
762
+ const deleted = this.deletePathHeads(oldestHeads);
763
+ cursor.deletedSnapshots += deleted.snapshots;
764
+ cursor.deletedChunks += deleted.chunks;
765
+ return null;
766
+ }
767
+ if (cursor.phase === "vacuum") {
768
+ const freePages = this.db.pragma("freelist_count", { simple: true });
769
+ if (freePages === 0) {
770
+ cursor.phase = "checkpoint";
771
+ return null;
772
+ }
773
+ this.db.pragma(`incremental_vacuum(${Math.min(freePages, GC_VACUUM_PAGE_BATCH_SIZE)})`);
774
+ return null;
775
+ }
776
+ if (cursor.phase === "checkpoint") {
777
+ const after = this.storageBytes(true);
778
+ cursor.phase = "done";
779
+ return {
780
+ deletedTurns: cursor.deletedTurns,
781
+ deletedSnapshots: cursor.deletedSnapshots,
782
+ deletedChunks: cursor.deletedChunks,
783
+ before: cursor.before,
784
+ after,
785
+ blockedByLiveData: cursor.sizeGcTriggered && after.total > this.options.gcTargetBytes
786
+ };
787
+ }
788
+ throw new Error("Turn-diff GC cursor has already completed.");
789
+ }
790
+ stats() {
791
+ this.assertOpen();
792
+ const row = this.db.prepare(
793
+ `
794
+ SELECT
795
+ (SELECT COUNT(*) FROM turns WHERE evicted = 0) AS turns,
796
+ (SELECT COUNT(*) FROM turn_files) AS files,
797
+ (SELECT COUNT(*) FROM snapshots) AS snapshots,
798
+ (SELECT COUNT(*) FROM chunks) AS chunks,
799
+ (SELECT COALESCE(SUM(ref_count), 0) FROM snapshots) AS snapshot_references,
800
+ (SELECT COALESCE(SUM(ref_count), 0) FROM chunks) AS chunk_references,
801
+ (SELECT COALESCE(SUM(raw_size), 0) FROM chunks) AS raw_chunk_bytes,
802
+ (SELECT COALESCE(SUM(length(payload)), 0) FROM chunks) AS stored_chunk_bytes,
803
+ (
804
+ SELECT COUNT(*)
805
+ FROM snapshots AS s
806
+ WHERE s.ref_count != (
807
+ (SELECT COUNT(*) FROM turn_files WHERE old_snapshot_id = s.id) +
808
+ (SELECT COUNT(*) FROM turn_files WHERE new_snapshot_id = s.id) +
809
+ (SELECT COUNT(*) FROM path_heads WHERE snapshot_id = s.id)
810
+ )
811
+ ) AS invalid_snapshot_ref_counts,
812
+ (
813
+ SELECT COUNT(*)
814
+ FROM chunks AS c
815
+ WHERE c.ref_count != (
816
+ SELECT COUNT(*) FROM snapshot_chunks WHERE chunk_hash = c.hash
817
+ )
818
+ ) AS invalid_chunk_ref_counts
819
+ `
820
+ ).get();
821
+ const integrity = this.db.prepare("PRAGMA integrity_check").get();
822
+ return {
823
+ turns: row.turns,
824
+ files: row.files,
825
+ snapshots: row.snapshots,
826
+ chunks: row.chunks,
827
+ snapshotReferences: row.snapshot_references,
828
+ chunkReferences: row.chunk_references,
829
+ rawChunkBytes: row.raw_chunk_bytes,
830
+ storedChunkBytes: row.stored_chunk_bytes,
831
+ invalidSnapshotRefCounts: row.invalid_snapshot_ref_counts,
832
+ invalidChunkRefCounts: row.invalid_chunk_ref_counts,
833
+ integrity: integrity.integrity_check,
834
+ storage: this.storageBytes()
835
+ };
836
+ }
837
+ initializeSchema() {
838
+ this.db.pragma("busy_timeout = 5000");
839
+ this.db.pragma("foreign_keys = OFF");
840
+ const initialize = this.db.transaction(() => {
841
+ const version = this.db.pragma("user_version", { simple: true });
842
+ const applicationId = this.db.pragma("application_id", { simple: true });
843
+ const schemaObjects = readSchemaObjects(this.db);
844
+ const fingerprint = schemaFingerprint(schemaObjects);
845
+ const objectNames = new Set(schemaObjects.map((row) => row.name));
846
+ const isFreshDatabase = applicationId === 0 && version === 0 && schemaObjects.length === 0;
847
+ const isLegacyDatabase = applicationId === 0 && version === 0 && fingerprint === expectedSchemaFingerprint(LEGACY_SCHEMA_SQL);
848
+ const isOwnedDatabase = applicationId === TURN_DIFF_APPLICATION_ID;
849
+ if (!isFreshDatabase && !isLegacyDatabase && !isOwnedDatabase) {
850
+ throw this.unknownSchemaError();
851
+ }
852
+ if (isOwnedDatabase) {
853
+ const unknownObjects = [...objectNames].filter(
854
+ (name) => !KNOWN_SCHEMA_OBJECT_NAMES.has(name)
855
+ );
856
+ if (unknownObjects.length > 0) throw this.unknownSchemaError();
857
+ if (version < 0 || version > SCHEMA_VERSION) {
858
+ throw new Error(`Unsupported turn-diff store schema version ${version}.`);
859
+ }
860
+ }
861
+ const hasCurrentSchema = isOwnedDatabase && version === SCHEMA_VERSION && fingerprint === expectedSchemaFingerprint(CURRENT_SCHEMA_SQL);
862
+ if (!hasCurrentSchema) {
863
+ this.dropKnownSchema();
864
+ this.createSchema();
865
+ }
866
+ this.db.prepare("INSERT OR IGNORE INTO store_counters(name, value) VALUES ('head_proof', 0)").run();
867
+ this.db.pragma(`application_id = ${TURN_DIFF_APPLICATION_ID}`);
868
+ this.db.pragma(`user_version = ${SCHEMA_VERSION}`);
869
+ });
870
+ initialize.immediate();
871
+ const autoVacuum = this.db.pragma("auto_vacuum", { simple: true });
872
+ if (autoVacuum !== 2) {
873
+ this.db.pragma("auto_vacuum = INCREMENTAL");
874
+ this.db.exec("VACUUM");
875
+ }
876
+ this.db.pragma("journal_mode = WAL");
877
+ this.db.pragma("synchronous = FULL");
878
+ this.db.pragma("foreign_keys = ON");
879
+ this.db.pragma("wal_autocheckpoint = 1000");
880
+ }
881
+ unknownSchemaError() {
882
+ return new Error(
883
+ `Turn-diff store ${this.options.dbPath} has an unknown SQLite schema and was not modified.`
884
+ );
885
+ }
886
+ dropKnownSchema() {
887
+ this.db.exec(`
888
+ DROP TABLE IF EXISTS path_heads;
889
+ DROP TABLE IF EXISTS turn_files;
890
+ DROP TABLE IF EXISTS snapshot_chunks;
891
+ DROP TABLE IF EXISTS snapshots;
892
+ DROP TABLE IF EXISTS chunks;
893
+ DROP TABLE IF EXISTS turns;
894
+ DROP TABLE IF EXISTS store_counters;
895
+ DROP TABLE IF EXISTS diff_events;
896
+ DROP TABLE IF EXISTS diff_doc_updates;
897
+ DROP TABLE IF EXISTS diff_docs;
898
+ `);
899
+ }
900
+ createSchema() {
901
+ this.db.exec(CURRENT_SCHEMA_SQL);
902
+ }
903
+ getOrCreateTurn(ownerId, turnKey, orderKey, capturedAtMs, expiresAtMs) {
904
+ this.db.prepare(
905
+ `
906
+ INSERT INTO turns(owner_id, turn_key, order_key, captured_at_ms, expires_at_ms)
907
+ VALUES (?, ?, ?, ?, ?)
908
+ ON CONFLICT(owner_id, turn_key) DO NOTHING
909
+ `
910
+ ).run(ownerId, turnKey, orderKey, capturedAtMs, expiresAtMs);
911
+ const row = this.db.prepare(
912
+ `
913
+ SELECT id, captured_at_ms, order_key, expires_at_ms, evicted
914
+ FROM turns
915
+ WHERE owner_id = ? AND turn_key = ?
916
+ `
917
+ ).get(ownerId, turnKey);
918
+ if (!row) throw new Error(`Failed to create turn-diff turn ${turnKey}.`);
919
+ return row;
920
+ }
921
+ prepareSnapshot(text, snapshotCache, chunkCache, metrics) {
922
+ if (text === null) return null;
923
+ const encodeStartedAt = performance.now();
924
+ const bytes = Buffer.from(text, "utf8");
925
+ metrics.encodeMs += performance.now() - encodeStartedAt;
926
+ metrics.rawBytes += bytes.byteLength;
927
+ const fullHashStartedAt = performance.now();
928
+ const contentHash = createHash("sha256").update(bytes).digest();
929
+ metrics.hashingMs += performance.now() - fullHashStartedAt;
930
+ const hashKey = contentHash.toString("hex");
931
+ const cached = snapshotCache.get(hashKey);
932
+ if (cached) return cached;
933
+ const existing = this.db.prepare("SELECT id, raw_size FROM snapshots WHERE content_hash = ?").get(contentHash);
934
+ if (existing) {
935
+ if (existing.raw_size !== bytes.byteLength) {
936
+ throw new Error("SHA-256 snapshot collision detected while storing turn diff.");
937
+ }
938
+ const count = this.db.prepare("SELECT COUNT(*) AS count FROM snapshot_chunks WHERE snapshot_id = ?").get(existing.id);
939
+ metrics.reusedChunks += count.count;
940
+ const prepared2 = {
941
+ contentHash,
942
+ hashKey,
943
+ rawSize: bytes.byteLength,
944
+ existingId: existing.id,
945
+ chunks: []
946
+ };
947
+ snapshotCache.set(hashKey, prepared2);
948
+ return prepared2;
949
+ }
950
+ const chunkingStartedAt = performance.now();
951
+ const boundaries = fastCdcV2020(bytes);
952
+ metrics.chunkingMs += performance.now() - chunkingStartedAt;
953
+ const chunks = [];
954
+ for (const boundary of boundaries) {
955
+ const view = bytes.subarray(boundary.offset, boundary.offset + boundary.length);
956
+ const hashStartedAt = performance.now();
957
+ const hash = createHash("sha256").update(view).digest();
958
+ metrics.hashingMs += performance.now() - hashStartedAt;
959
+ const chunkHashKey = hash.toString("hex");
960
+ const requestCached = chunkCache.get(chunkHashKey);
961
+ if (requestCached) {
962
+ if (requestCached.rawSize !== view.byteLength) {
963
+ throw new Error("SHA-256 chunk collision detected within a turn-diff request.");
964
+ }
965
+ metrics.reusedChunks += 1;
966
+ chunks.push(requestCached);
967
+ continue;
968
+ }
969
+ const stored = this.db.prepare("SELECT raw_size FROM chunks WHERE hash = ?").get(hash);
970
+ if (stored) {
971
+ if (stored.raw_size !== view.byteLength) {
972
+ throw new Error("SHA-256 chunk collision detected while storing turn diff.");
973
+ }
974
+ const prepared3 = {
975
+ hash,
976
+ hashKey: chunkHashKey,
977
+ rawSize: view.byteLength,
978
+ codec: null,
979
+ payload: null
980
+ };
981
+ metrics.reusedChunks += 1;
982
+ chunkCache.set(chunkHashKey, prepared3);
983
+ chunks.push(prepared3);
984
+ continue;
985
+ }
986
+ const compressionStartedAt = performance.now();
987
+ const encoded = compressChunk(view, this.options.compression);
988
+ metrics.compressionMs += performance.now() - compressionStartedAt;
989
+ const prepared2 = {
990
+ hash,
991
+ hashKey: chunkHashKey,
992
+ rawSize: view.byteLength,
993
+ codec: encoded.codec,
994
+ payload: encoded.payload
995
+ };
996
+ metrics.newChunks += 1;
997
+ chunkCache.set(chunkHashKey, prepared2);
998
+ chunks.push(prepared2);
999
+ }
1000
+ const prepared = {
1001
+ contentHash,
1002
+ hashKey,
1003
+ rawSize: bytes.byteLength,
1004
+ existingId: null,
1005
+ chunks
1006
+ };
1007
+ snapshotCache.set(hashKey, prepared);
1008
+ return prepared;
1009
+ }
1010
+ materializeSnapshot(snapshot, materialized) {
1011
+ if (snapshot === null) return null;
1012
+ const cachedId = materialized.get(snapshot.hashKey);
1013
+ if (cachedId !== void 0) return cachedId;
1014
+ if (snapshot.existingId !== null) {
1015
+ materialized.set(snapshot.hashKey, snapshot.existingId);
1016
+ return snapshot.existingId;
1017
+ }
1018
+ const existing = this.db.prepare("SELECT id, raw_size FROM snapshots WHERE content_hash = ?").get(snapshot.contentHash);
1019
+ if (existing) {
1020
+ if (existing.raw_size !== snapshot.rawSize) {
1021
+ throw new Error("SHA-256 snapshot collision detected during turn-diff commit.");
1022
+ }
1023
+ materialized.set(snapshot.hashKey, existing.id);
1024
+ return existing.id;
1025
+ }
1026
+ const insert = this.db.prepare("INSERT INTO snapshots(content_hash, raw_size, ref_count) VALUES (?, ?, 0)").run(snapshot.contentHash, snapshot.rawSize);
1027
+ const snapshotId = Number(insert.lastInsertRowid);
1028
+ for (let ordinal = 0; ordinal < snapshot.chunks.length; ordinal += 1) {
1029
+ const chunk = snapshot.chunks[ordinal];
1030
+ if (!chunk) throw new Error(`Missing prepared chunk ${ordinal}.`);
1031
+ if (chunk.payload !== null && chunk.codec !== null) {
1032
+ this.db.prepare(
1033
+ `
1034
+ INSERT INTO chunks(hash, codec, raw_size, payload, ref_count)
1035
+ VALUES (?, ?, ?, ?, 0)
1036
+ ON CONFLICT(hash) DO NOTHING
1037
+ `
1038
+ ).run(chunk.hash, chunk.codec, chunk.rawSize, chunk.payload);
1039
+ }
1040
+ this.db.prepare(
1041
+ `
1042
+ INSERT INTO snapshot_chunks(snapshot_id, ordinal, chunk_hash, raw_size)
1043
+ VALUES (?, ?, ?, ?)
1044
+ `
1045
+ ).run(snapshotId, ordinal, chunk.hash, chunk.rawSize);
1046
+ const updated = this.db.prepare("UPDATE chunks SET ref_count = ref_count + 1 WHERE hash = ?").run(chunk.hash);
1047
+ if (updated.changes !== 1) {
1048
+ throw new Error(`Failed to reference turn-diff chunk ${chunk.hashKey}.`);
1049
+ }
1050
+ }
1051
+ materialized.set(snapshot.hashKey, snapshotId);
1052
+ return snapshotId;
1053
+ }
1054
+ updatePathHead(ownerId, workspacePath, newSnapshotId, sourceTurnId, orderKey, headProof) {
1055
+ const previous = this.db.prepare(
1056
+ `
1057
+ SELECT snapshot_id, source_turn_id, updated_order_key, head_proof
1058
+ FROM path_heads
1059
+ WHERE owner_id = ? AND path = ?
1060
+ `
1061
+ ).get(ownerId, workspacePath);
1062
+ if (previous !== void 0 && headProof <= previous.head_proof) return void 0;
1063
+ const preserveNewerSource = previous !== void 0 && previous.snapshot_id === newSnapshotId && previous.updated_order_key > orderKey;
1064
+ const nextSourceTurnId = preserveNewerSource ? previous.source_turn_id : sourceTurnId;
1065
+ const nextOrderKey = preserveNewerSource ? previous.updated_order_key : orderKey;
1066
+ const releasedSnapshotId = previous?.snapshot_id !== newSnapshotId ? previous?.snapshot_id ?? void 0 : void 0;
1067
+ if (previous?.snapshot_id !== newSnapshotId) {
1068
+ this.changeSnapshotRefCount(previous?.snapshot_id ?? null, -1);
1069
+ this.changeSnapshotRefCount(newSnapshotId, 1);
1070
+ }
1071
+ this.db.prepare(
1072
+ `
1073
+ INSERT INTO path_heads(
1074
+ owner_id, path, snapshot_id, source_turn_id, updated_order_key, head_proof
1075
+ ) VALUES (?, ?, ?, ?, ?, ?)
1076
+ ON CONFLICT(owner_id, path) DO UPDATE SET
1077
+ snapshot_id = excluded.snapshot_id,
1078
+ source_turn_id = excluded.source_turn_id,
1079
+ updated_order_key = excluded.updated_order_key,
1080
+ head_proof = excluded.head_proof
1081
+ `
1082
+ ).run(ownerId, workspacePath, newSnapshotId, nextSourceTurnId, nextOrderKey, headProof);
1083
+ return releasedSnapshotId;
1084
+ }
1085
+ changeSnapshotRefCount(snapshotId, delta) {
1086
+ if (snapshotId === null || delta === 0) return;
1087
+ const updated = this.db.prepare("UPDATE snapshots SET ref_count = ref_count + ? WHERE id = ?").run(delta, snapshotId);
1088
+ if (updated.changes !== 1) {
1089
+ throw new Error(`Failed to update turn-diff snapshot reference ${snapshotId}.`);
1090
+ }
1091
+ }
1092
+ readSnapshotText(snapshotId) {
1093
+ if (snapshotId === null) return null;
1094
+ const snapshot = this.db.prepare("SELECT id, content_hash, raw_size FROM snapshots WHERE id = ?").get(snapshotId);
1095
+ if (!snapshot) throw new Error(`Missing turn-diff snapshot ${snapshotId}.`);
1096
+ const output = Buffer.allocUnsafe(snapshot.raw_size);
1097
+ const rows = this.db.prepare(
1098
+ `
1099
+ SELECT c.codec, sc.raw_size, c.payload
1100
+ FROM snapshot_chunks AS sc
1101
+ JOIN chunks AS c ON c.hash = sc.chunk_hash
1102
+ WHERE sc.snapshot_id = ?
1103
+ ORDER BY sc.ordinal ASC
1104
+ `
1105
+ ).all(snapshotId);
1106
+ let offset = 0;
1107
+ for (const row of rows) {
1108
+ const decoded = decompressChunk(row.codec, row.payload);
1109
+ if (decoded.byteLength !== row.raw_size) {
1110
+ throw new Error(`Turn-diff chunk length mismatch in snapshot ${snapshotId}.`);
1111
+ }
1112
+ decoded.copy(output, offset);
1113
+ offset += decoded.byteLength;
1114
+ }
1115
+ if (offset !== snapshot.raw_size) {
1116
+ throw new Error(`Turn-diff snapshot ${snapshotId} reconstructed ${offset} bytes.`);
1117
+ }
1118
+ const contentHash = createHash("sha256").update(output).digest();
1119
+ if (!contentHash.equals(snapshot.content_hash)) {
1120
+ throw new Error(`Turn-diff snapshot ${snapshotId} hash mismatch.`);
1121
+ }
1122
+ return output.toString("utf8");
1123
+ }
1124
+ deleteTurns(turnIds) {
1125
+ if (turnIds.length === 0) return { snapshots: 0, chunks: 0 };
1126
+ const placeholders = turnIds.map(() => "?").join(", ");
1127
+ const run = this.db.transaction(() => {
1128
+ const refs = this.db.prepare(
1129
+ `
1130
+ SELECT snapshot_id AS id, COUNT(*) AS reference_count
1131
+ FROM (
1132
+ SELECT old_snapshot_id AS snapshot_id
1133
+ FROM turn_files
1134
+ WHERE turn_id IN (${placeholders}) AND old_snapshot_id IS NOT NULL
1135
+ UNION ALL
1136
+ SELECT new_snapshot_id AS snapshot_id
1137
+ FROM turn_files
1138
+ WHERE turn_id IN (${placeholders}) AND new_snapshot_id IS NOT NULL
1139
+ )
1140
+ GROUP BY snapshot_id
1141
+ `
1142
+ ).all(...turnIds, ...turnIds);
1143
+ for (const ref of refs) {
1144
+ this.changeSnapshotRefCount(ref.id, -ref.reference_count);
1145
+ }
1146
+ this.db.prepare(`DELETE FROM turns WHERE id IN (${placeholders})`).run(...turnIds);
1147
+ return this.deleteUnreferencedSnapshotsAndChunks(refs.map((ref) => ref.id));
1148
+ });
1149
+ return run.immediate();
1150
+ }
1151
+ accumulateDeletedTurns(cursor, turnIds, mode) {
1152
+ const deleted = mode === "delete" ? this.deleteTurns(turnIds) : this.evictTurns(turnIds);
1153
+ cursor.deletedTurns += turnIds.length;
1154
+ cursor.deletedSnapshots += deleted.snapshots;
1155
+ cursor.deletedChunks += deleted.chunks;
1156
+ }
1157
+ evictTurns(turnIds) {
1158
+ if (turnIds.length === 0) return { snapshots: 0, chunks: 0 };
1159
+ const placeholders = turnIds.map(() => "?").join(", ");
1160
+ const run = this.db.transaction(() => {
1161
+ const refs = this.db.prepare(
1162
+ `
1163
+ SELECT snapshot_id AS id, COUNT(*) AS reference_count
1164
+ FROM (
1165
+ SELECT old_snapshot_id AS snapshot_id
1166
+ FROM turn_files
1167
+ WHERE turn_id IN (${placeholders}) AND old_snapshot_id IS NOT NULL
1168
+ UNION ALL
1169
+ SELECT new_snapshot_id AS snapshot_id
1170
+ FROM turn_files
1171
+ WHERE turn_id IN (${placeholders}) AND new_snapshot_id IS NOT NULL
1172
+ )
1173
+ GROUP BY snapshot_id
1174
+ `
1175
+ ).all(...turnIds, ...turnIds);
1176
+ for (const ref of refs) {
1177
+ this.changeSnapshotRefCount(ref.id, -ref.reference_count);
1178
+ }
1179
+ this.db.prepare(`DELETE FROM turn_files WHERE turn_id IN (${placeholders})`).run(...turnIds);
1180
+ this.db.prepare(`UPDATE turns SET evicted = 1 WHERE id IN (${placeholders})`).run(...turnIds);
1181
+ return this.deleteUnreferencedSnapshotsAndChunks(refs.map((ref) => ref.id));
1182
+ });
1183
+ return run.immediate();
1184
+ }
1185
+ deletePathHeads(pathHeads) {
1186
+ const run = this.db.transaction(() => {
1187
+ const snapshotRefs = /* @__PURE__ */ new Map();
1188
+ for (const head of pathHeads) {
1189
+ const deleted = this.db.prepare(
1190
+ `
1191
+ DELETE FROM path_heads
1192
+ WHERE owner_id = ?
1193
+ AND path = ?
1194
+ AND source_turn_id = ?
1195
+ AND head_proof = ?
1196
+ AND snapshot_id IS ?
1197
+ `
1198
+ ).run(head.owner_id, head.path, head.source_turn_id, head.head_proof, head.snapshot_id);
1199
+ if (deleted.changes === 1 && head.snapshot_id !== null) {
1200
+ snapshotRefs.set(head.snapshot_id, (snapshotRefs.get(head.snapshot_id) ?? 0) + 1);
1201
+ }
1202
+ }
1203
+ for (const [snapshotId, referenceCount] of snapshotRefs) {
1204
+ this.changeSnapshotRefCount(snapshotId, -referenceCount);
1205
+ }
1206
+ return this.deleteUnreferencedSnapshotsAndChunks(snapshotRefs.keys());
1207
+ });
1208
+ return run.immediate();
1209
+ }
1210
+ deleteUnreferencedSnapshotsAndChunks(candidateSnapshotIds) {
1211
+ const snapshotIds = [...new Set(candidateSnapshotIds)];
1212
+ let deletedSnapshots = 0;
1213
+ let deletedChunks = 0;
1214
+ for (const snapshotId of snapshotIds) {
1215
+ const unreferenced = this.db.prepare("SELECT id FROM snapshots WHERE id = ? AND ref_count = 0").get(snapshotId);
1216
+ if (!unreferenced) continue;
1217
+ const chunkRefs = this.db.prepare(
1218
+ `
1219
+ SELECT chunk_hash AS hash, COUNT(*) AS reference_count
1220
+ FROM snapshot_chunks
1221
+ WHERE snapshot_id = ?
1222
+ GROUP BY chunk_hash
1223
+ `
1224
+ ).all(snapshotId);
1225
+ for (const ref of chunkRefs) {
1226
+ const updated = this.db.prepare("UPDATE chunks SET ref_count = ref_count - ? WHERE hash = ?").run(ref.reference_count, ref.hash);
1227
+ if (updated.changes !== 1) {
1228
+ throw new Error("Failed to release a turn-diff chunk reference.");
1229
+ }
1230
+ }
1231
+ const deletedSnapshot = this.db.prepare("DELETE FROM snapshots WHERE id = ? AND ref_count = 0").run(snapshotId);
1232
+ if (deletedSnapshot.changes !== 1) {
1233
+ throw new Error(`Failed to delete unreferenced turn-diff snapshot ${snapshotId}.`);
1234
+ }
1235
+ deletedSnapshots += 1;
1236
+ for (const ref of chunkRefs) {
1237
+ deletedChunks += this.db.prepare("DELETE FROM chunks WHERE hash = ? AND ref_count = 0").run(ref.hash).changes;
1238
+ }
1239
+ }
1240
+ return { snapshots: deletedSnapshots, chunks: deletedChunks };
1241
+ }
1242
+ estimatedCompactedStorageBytes() {
1243
+ const pageCount = this.db.pragma("page_count", { simple: true });
1244
+ const freePages = this.db.pragma("freelist_count", { simple: true });
1245
+ const pageSize = this.db.pragma("page_size", { simple: true });
1246
+ return (pageCount - freePages) * pageSize + fileSize(`${this.options.dbPath}-shm`);
1247
+ }
1248
+ storageBytes(checkpoint = false) {
1249
+ if (checkpoint) this.db.pragma("wal_checkpoint(TRUNCATE)");
1250
+ const database = fileSize(this.options.dbPath);
1251
+ const wal = fileSize(`${this.options.dbPath}-wal`);
1252
+ const shm = fileSize(`${this.options.dbPath}-shm`);
1253
+ return { database, wal, shm, total: database + wal + shm };
1254
+ }
1255
+ assertOpen() {
1256
+ if (this.closed) throw new Error("Turn-diff store is closed.");
1257
+ }
1258
+ }
1259
+ function assertSnapshotReadLimit(maxRawBytes) {
1260
+ if (maxRawBytes !== void 0 && (Number.isNaN(maxRawBytes) || maxRawBytes < 0 || !Number.isSafeInteger(maxRawBytes))) {
1261
+ throw new RangeError("Turn-diff snapshot maxRawBytes must be a non-negative safe integer.");
1262
+ }
1263
+ }
1264
+ function exceedsSnapshotReadLimit(rawBytes, maxRawBytes) {
1265
+ return maxRawBytes !== void 0 && rawBytes > maxRawBytes;
1266
+ }
1267
+ const expectedSchemaFingerprints = /* @__PURE__ */ new Map();
1268
+ function readSchemaObjects(db) {
1269
+ return db.prepare(
1270
+ `
1271
+ SELECT type, name, tbl_name AS table_name, sql
1272
+ FROM sqlite_master
1273
+ WHERE substr(name, 1, 7) != 'sqlite_' AND sql IS NOT NULL
1274
+ ORDER BY type ASC, name ASC
1275
+ `
1276
+ ).all();
1277
+ }
1278
+ function schemaFingerprint(rows) {
1279
+ return rows.map((row) => `${row.type}:${row.name}:${row.table_name}:${normalizeSchemaSql(row.sql ?? "")}`).join("\n");
1280
+ }
1281
+ function normalizeSchemaSql(sql) {
1282
+ return sql.replace(/\s+/g, " ").trim().replace(/;$/, "").toLowerCase();
1283
+ }
1284
+ function expectedSchemaFingerprint(schemaSql) {
1285
+ const cached = expectedSchemaFingerprints.get(schemaSql);
1286
+ if (cached !== void 0) return cached;
1287
+ const database = new Database(":memory:");
1288
+ try {
1289
+ database.exec(schemaSql);
1290
+ const fingerprint = schemaFingerprint(readSchemaObjects(database));
1291
+ expectedSchemaFingerprints.set(schemaSql, fingerprint);
1292
+ return fingerprint;
1293
+ } finally {
1294
+ database.close();
1295
+ }
1296
+ }
1297
+ function normalizeOptions(options) {
1298
+ const retentionDays = clampInteger(
1299
+ options.retentionDays ?? DEFAULT_RETENTION_DAYS,
1300
+ MIN_RETENTION_DAYS,
1301
+ MAX_RETENTION_DAYS
1302
+ );
1303
+ const maxStorageBytes = positiveInteger(
1304
+ options.maxStorageBytes ?? DEFAULT_MAX_STORAGE_BYTES,
1305
+ "maxStorageBytes"
1306
+ );
1307
+ const defaultTarget = options.maxStorageBytes === void 0 ? DEFAULT_GC_TARGET_BYTES : Math.max(1, Math.floor(maxStorageBytes * 0.9));
1308
+ const gcTargetBytes = positiveInteger(options.gcTargetBytes ?? defaultTarget, "gcTargetBytes");
1309
+ if (gcTargetBytes >= maxStorageBytes) {
1310
+ throw new Error("gcTargetBytes must be smaller than maxStorageBytes.");
1311
+ }
1312
+ return {
1313
+ dbPath: options.dbPath,
1314
+ retentionDays,
1315
+ maxStorageBytes,
1316
+ gcTargetBytes,
1317
+ // gzip is readable by every Node version supported by the CLI. zstd remains
1318
+ // an explicit opt-in because Node 22.14 cannot read codec-1 chunks.
1319
+ compression: options.compression ?? "gzip"
1320
+ };
1321
+ }
1322
+ function validateRecordInput(input) {
1323
+ if (!input.ownerId) throw new Error("Turn-diff ownerId must not be empty.");
1324
+ if (!input.turnId) throw new Error("Turn-diff turnId must not be empty.");
1325
+ if (input.orderKey !== void 0 && (input.orderKey.length === 0 || input.orderKey.length > 1024 || input.orderKey.includes("\0"))) {
1326
+ throw new Error("Turn-diff orderKey must contain 1-1024 non-NUL characters.");
1327
+ }
1328
+ for (const [name, value] of [
1329
+ ["capturedAtMs", input.capturedAtMs],
1330
+ ["recordedAtMs", input.recordedAtMs]
1331
+ ]) {
1332
+ if (!Number.isSafeInteger(value) || value < 0) {
1333
+ throw new Error(`Turn-diff ${name} must be a non-negative safe integer.`);
1334
+ }
1335
+ }
1336
+ const paths = /* @__PURE__ */ new Set();
1337
+ for (const event of input.events) {
1338
+ if (!event.path || event.path.includes("\0")) {
1339
+ throw new Error("Turn-diff paths must be non-empty and contain no NUL.");
1340
+ }
1341
+ if (paths.has(event.path)) {
1342
+ throw new Error(`Turn-diff record contains duplicate path ${event.path}.`);
1343
+ }
1344
+ paths.add(event.path);
1345
+ if (typeof event.newIsCurrent !== "boolean") {
1346
+ throw new Error(`Turn-diff event newIsCurrent must be boolean for ${event.path}.`);
1347
+ }
1348
+ if (event.newIsCurrent && (!Number.isSafeInteger(event.headProof) || (event.headProof ?? 0) <= 0) || !event.newIsCurrent && event.headProof !== null) {
1349
+ throw new Error(`Turn-diff event headProof does not match newIsCurrent for ${event.path}.`);
1350
+ }
1351
+ if (!Number.isSafeInteger(event.add) || event.add < 0) {
1352
+ throw new Error(`Invalid added-line count for ${event.path}.`);
1353
+ }
1354
+ if (!Number.isSafeInteger(event.del) || event.del < 0) {
1355
+ throw new Error(`Invalid deleted-line count for ${event.path}.`);
1356
+ }
1357
+ }
1358
+ }
1359
+ function defaultTurnOrderKey(capturedAtMs, turnId) {
1360
+ return `${capturedAtMs.toString().padStart(16, "0")}:${turnId}`;
1361
+ }
1362
+ function clampInteger(value, min, max) {
1363
+ if (!Number.isFinite(value)) return min;
1364
+ return Math.min(max, Math.max(min, Math.trunc(value)));
1365
+ }
1366
+ function positiveInteger(value, name) {
1367
+ if (!Number.isSafeInteger(value) || value <= 0) {
1368
+ throw new Error(`${name} must be a positive safe integer.`);
1369
+ }
1370
+ return value;
1371
+ }
1372
+ function fileSize(filePath) {
1373
+ try {
1374
+ return statSync(filePath).size;
1375
+ } catch {
1376
+ return 0;
1377
+ }
1378
+ }
1379
+ function runTurnDiffStoreWorker() {
1380
+ if (parentPort === null) {
1381
+ throw new Error("Turn-diff store worker must run inside a Node.js Worker.");
1382
+ }
1383
+ const port = parentPort;
1384
+ let store;
1385
+ let gcScheduled = false;
1386
+ let gcRequested = false;
1387
+ let gcCursor;
1388
+ let gcRunId = 0;
1389
+ let gcNowMs = 0;
1390
+ const cancelScheduledGc = () => {
1391
+ gcRunId += 1;
1392
+ gcScheduled = false;
1393
+ gcRequested = false;
1394
+ gcCursor = void 0;
1395
+ };
1396
+ const scheduleGc = (nowMs) => {
1397
+ if (store === void 0) return;
1398
+ gcNowMs = Math.max(gcNowMs, nowMs);
1399
+ if (gcScheduled) {
1400
+ gcRequested = true;
1401
+ return;
1402
+ }
1403
+ gcScheduled = true;
1404
+ gcRequested = false;
1405
+ const runId = ++gcRunId;
1406
+ const runStep = () => {
1407
+ if (runId !== gcRunId || store === void 0) return;
1408
+ try {
1409
+ gcCursor ??= store.beginGc(gcNowMs);
1410
+ const result = store.gcStep(gcCursor);
1411
+ if (result === null) {
1412
+ setImmediate(runStep);
1413
+ return;
1414
+ }
1415
+ const shouldRunAgain = gcRequested;
1416
+ gcScheduled = false;
1417
+ gcRequested = false;
1418
+ gcCursor = void 0;
1419
+ port.postMessage({ id: 0, backgroundGc: result });
1420
+ if (shouldRunAgain) scheduleGc(gcNowMs);
1421
+ } catch (error) {
1422
+ gcScheduled = false;
1423
+ gcRequested = false;
1424
+ gcCursor = void 0;
1425
+ port.postMessage({
1426
+ id: 0,
1427
+ backgroundError: formatError(error)
1428
+ });
1429
+ }
1430
+ };
1431
+ setImmediate(runStep);
1432
+ };
1433
+ port.on("message", (request) => {
1434
+ try {
1435
+ if (request.kind === "init") {
1436
+ if (store !== void 0) throw new Error("Turn-diff store worker is already initialized.");
1437
+ store = new SqliteTurnDiffStore(request.options);
1438
+ const shouldRunGc = store.shouldRunGc(request.nowMs);
1439
+ port.postMessage({
1440
+ id: request.id,
1441
+ kind: request.kind,
1442
+ result: null
1443
+ });
1444
+ if (shouldRunGc) scheduleGc(request.nowMs);
1445
+ return;
1446
+ }
1447
+ if (store === void 0) throw new Error("Turn-diff store worker is not initialized.");
1448
+ if (request.kind === "allocate-head-proof") {
1449
+ port.postMessage({
1450
+ id: request.id,
1451
+ kind: request.kind,
1452
+ result: store.allocateHeadProof()
1453
+ });
1454
+ return;
1455
+ }
1456
+ if (request.kind === "record") {
1457
+ const result = store.recordTurn(request.input);
1458
+ port.postMessage({
1459
+ id: request.id,
1460
+ kind: request.kind,
1461
+ result
1462
+ });
1463
+ if (result.gcScheduled) {
1464
+ scheduleGc(request.input.recordedAtMs);
1465
+ }
1466
+ return;
1467
+ }
1468
+ if (request.kind === "list-changed-paths") {
1469
+ port.postMessage({
1470
+ id: request.id,
1471
+ kind: request.kind,
1472
+ result: store.listChangedPaths(request.input)
1473
+ });
1474
+ return;
1475
+ }
1476
+ if (request.kind === "earliest-old") {
1477
+ port.postMessage({
1478
+ id: request.id,
1479
+ kind: request.kind,
1480
+ result: store.getEarliestOldSnapshot(request.input)
1481
+ });
1482
+ return;
1483
+ }
1484
+ if (request.kind === "turn-snapshot") {
1485
+ port.postMessage({
1486
+ id: request.id,
1487
+ kind: request.kind,
1488
+ result: store.getTurnSnapshot(request.input)
1489
+ });
1490
+ return;
1491
+ }
1492
+ if (request.kind === "latest-text") {
1493
+ port.postMessage({
1494
+ id: request.id,
1495
+ kind: request.kind,
1496
+ result: store.getLatestText(request.input)
1497
+ });
1498
+ return;
1499
+ }
1500
+ if (request.kind === "list-turn-files") {
1501
+ port.postMessage({
1502
+ id: request.id,
1503
+ kind: request.kind,
1504
+ result: store.listTurnFiles(request.input)
1505
+ });
1506
+ return;
1507
+ }
1508
+ if (request.kind === "gc") {
1509
+ cancelScheduledGc();
1510
+ port.postMessage({
1511
+ id: request.id,
1512
+ kind: request.kind,
1513
+ result: store.gc(request.nowMs)
1514
+ });
1515
+ return;
1516
+ }
1517
+ if (request.kind === "stats") {
1518
+ port.postMessage({
1519
+ id: request.id,
1520
+ kind: request.kind,
1521
+ result: store.stats()
1522
+ });
1523
+ return;
1524
+ }
1525
+ if (request.kind === "close") {
1526
+ cancelScheduledGc();
1527
+ store.close();
1528
+ store = void 0;
1529
+ port.postMessage({
1530
+ id: request.id,
1531
+ kind: request.kind,
1532
+ result: null
1533
+ });
1534
+ return;
1535
+ }
1536
+ assertNever(request);
1537
+ } catch (error) {
1538
+ port.postMessage({
1539
+ id: request.id,
1540
+ kind: request.kind,
1541
+ error: formatError(error)
1542
+ });
1543
+ }
1544
+ });
1545
+ }
1546
+ function assertNever(value) {
1547
+ throw new Error(`Unhandled turn-diff worker request: ${JSON.stringify(value)}`);
1548
+ }
1549
+ function formatError(error) {
1550
+ return error instanceof Error ? error.stack ?? error.message : String(error);
1551
+ }
1552
+ runTurnDiffStoreWorker();