driftseal 2.0.0 → 3.0.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,802 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const crypto = require('node:crypto');
6
+ const {
7
+ SqliteUnavailableError,
8
+ getDatabaseSync,
9
+ } = require('./sqlite-runtime.js');
10
+
11
+ const INDEX_SCHEMA_VERSION = 5;
12
+
13
+ function canonicalJson(value) {
14
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
15
+ if (value && typeof value === 'object') {
16
+ return `{${Object.keys(value)
17
+ .sort()
18
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
19
+ .join(',')}}`;
20
+ }
21
+ return JSON.stringify(value);
22
+ }
23
+
24
+ function eventHash(event) {
25
+ return crypto.createHash('sha256').update(canonicalJson(event)).digest();
26
+ }
27
+
28
+ function digestRow(value) {
29
+ return crypto.createHash('sha256').update(JSON.stringify(value)).digest();
30
+ }
31
+
32
+ function sameBlob(actual, expected) {
33
+ return actual instanceof Uint8Array && Buffer.from(actual).equals(expected);
34
+ }
35
+
36
+ function outcomeRowHash(row) {
37
+ return digestRow({
38
+ id: row.id,
39
+ ordinal: Number(row.ordinal),
40
+ lane: row.lane,
41
+ status: row.status,
42
+ reclaimed: Number(row.reclaimed),
43
+ recordJson: row.record_json,
44
+ firstByte: Number(row.first_byte),
45
+ lastByte: Number(row.last_byte),
46
+ });
47
+ }
48
+
49
+ function laneRowHash(row) {
50
+ return digestRow({
51
+ name: row.name,
52
+ description: row.description || null,
53
+ addedAt: row.added_at || null,
54
+ inferred: Number(row.inferred),
55
+ sequence: Number(row.sequence),
56
+ count: Number(row.outcome_count),
57
+ visible: Number(row.visible_count),
58
+ });
59
+ }
60
+
61
+ class OutcomeIndexError extends Error {
62
+ constructor(message, cause) {
63
+ super(message, cause ? { cause } : undefined);
64
+ this.name = 'OutcomeIndexError';
65
+ }
66
+ }
67
+
68
+ function parseJson(value, label) {
69
+ try {
70
+ return JSON.parse(value);
71
+ } catch (error) {
72
+ throw new OutcomeIndexError(`invalid JSON in SQLite outcome index ${label}`, error);
73
+ }
74
+ }
75
+
76
+ function laneRow(row) {
77
+ return {
78
+ name: row.name,
79
+ description: row.description || null,
80
+ addedAt: row.added_at || null,
81
+ head: null,
82
+ inferred: row.inferred === 1,
83
+ count: Number(row.outcome_count || 0),
84
+ visible: Number(row.visible_count || 0),
85
+ };
86
+ }
87
+
88
+ class OutcomeIndex {
89
+ constructor(file, { readOnly = false } = {}) {
90
+ const DatabaseSync = getDatabaseSync();
91
+ this.file = file;
92
+ this.readOnly = readOnly;
93
+ this.db = new DatabaseSync(file, {
94
+ open: true,
95
+ readOnly,
96
+ enableForeignKeyConstraints: false,
97
+ });
98
+ try {
99
+ if (readOnly) {
100
+ const version = Number(this.db.prepare('PRAGMA user_version').get().user_version);
101
+ if (version !== INDEX_SCHEMA_VERSION) {
102
+ throw new OutcomeIndexError(`unsupported SQLite outcome index schema: ${version}`);
103
+ }
104
+ } else {
105
+ this.initialize();
106
+ }
107
+ } catch (error) {
108
+ this.db.close();
109
+ this.db = null;
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ initialize() {
115
+ const currentVersion = Number(
116
+ this.db.prepare('PRAGMA user_version').get().user_version
117
+ );
118
+ if (currentVersion !== 0 && currentVersion !== INDEX_SCHEMA_VERSION) {
119
+ throw new OutcomeIndexError(
120
+ `unsupported SQLite outcome index schema: ${currentVersion}`
121
+ );
122
+ }
123
+ if (currentVersion === INDEX_SCHEMA_VERSION) {
124
+ return;
125
+ }
126
+ this.db.exec(`
127
+ PRAGMA journal_mode = DELETE;
128
+ PRAGMA synchronous = FULL;
129
+ PRAGMA temp_store = MEMORY;
130
+ CREATE TABLE IF NOT EXISTS meta (
131
+ key TEXT PRIMARY KEY,
132
+ value TEXT NOT NULL
133
+ ) WITHOUT ROWID;
134
+ CREATE TABLE IF NOT EXISTS lanes (
135
+ name TEXT PRIMARY KEY,
136
+ description TEXT,
137
+ added_at TEXT,
138
+ inferred INTEGER NOT NULL CHECK (inferred IN (0, 1)),
139
+ sequence INTEGER NOT NULL UNIQUE,
140
+ outcome_count INTEGER NOT NULL DEFAULT 0,
141
+ visible_count INTEGER NOT NULL DEFAULT 0,
142
+ row_hash BLOB NOT NULL
143
+ );
144
+ CREATE TABLE IF NOT EXISTS outcomes (
145
+ id TEXT PRIMARY KEY,
146
+ ordinal INTEGER NOT NULL UNIQUE,
147
+ lane TEXT NOT NULL,
148
+ status TEXT NOT NULL,
149
+ reclaimed INTEGER NOT NULL CHECK (reclaimed IN (0, 1)),
150
+ record_json TEXT NOT NULL,
151
+ first_byte INTEGER NOT NULL,
152
+ last_byte INTEGER NOT NULL,
153
+ row_hash BLOB NOT NULL
154
+ );
155
+ CREATE TABLE IF NOT EXISTS reconciliations (
156
+ id TEXT PRIMARY KEY,
157
+ outcome_id TEXT NOT NULL,
158
+ data_json TEXT NOT NULL
159
+ ) WITHOUT ROWID;
160
+ CREATE TABLE IF NOT EXISTS events (
161
+ start_byte INTEGER PRIMARY KEY,
162
+ end_byte INTEGER NOT NULL,
163
+ type TEXT NOT NULL,
164
+ outcome_id TEXT,
165
+ ts TEXT,
166
+ event_hash BLOB NOT NULL
167
+ );
168
+ CREATE INDEX IF NOT EXISTS outcomes_lane_visible_ordinal
169
+ ON outcomes(lane, reclaimed, ordinal DESC);
170
+ CREATE INDEX IF NOT EXISTS outcomes_open_ordinal
171
+ ON outcomes(status, ordinal);
172
+ CREATE INDEX IF NOT EXISTS events_identity
173
+ ON events(type, outcome_id, ts, start_byte);
174
+ INSERT INTO meta(key, value) VALUES ('projectionDirty', 'false')
175
+ ON CONFLICT(key) DO NOTHING;
176
+ CREATE TRIGGER IF NOT EXISTS lanes_dirty_insert AFTER INSERT ON lanes
177
+ WHEN (SELECT value FROM meta WHERE key = 'projectionDirty') != 'true'
178
+ BEGIN UPDATE meta SET value = 'true' WHERE key = 'projectionDirty'; END;
179
+ CREATE TRIGGER IF NOT EXISTS lanes_dirty_update AFTER UPDATE ON lanes
180
+ WHEN (SELECT value FROM meta WHERE key = 'projectionDirty') != 'true'
181
+ BEGIN UPDATE meta SET value = 'true' WHERE key = 'projectionDirty'; END;
182
+ CREATE TRIGGER IF NOT EXISTS lanes_dirty_delete AFTER DELETE ON lanes
183
+ WHEN (SELECT value FROM meta WHERE key = 'projectionDirty') != 'true'
184
+ BEGIN UPDATE meta SET value = 'true' WHERE key = 'projectionDirty'; END;
185
+ CREATE TRIGGER IF NOT EXISTS outcomes_dirty_insert AFTER INSERT ON outcomes
186
+ WHEN (SELECT value FROM meta WHERE key = 'projectionDirty') != 'true'
187
+ BEGIN UPDATE meta SET value = 'true' WHERE key = 'projectionDirty'; END;
188
+ CREATE TRIGGER IF NOT EXISTS outcomes_dirty_update AFTER UPDATE ON outcomes
189
+ WHEN (SELECT value FROM meta WHERE key = 'projectionDirty') != 'true'
190
+ BEGIN UPDATE meta SET value = 'true' WHERE key = 'projectionDirty'; END;
191
+ CREATE TRIGGER IF NOT EXISTS outcomes_dirty_delete AFTER DELETE ON outcomes
192
+ WHEN (SELECT value FROM meta WHERE key = 'projectionDirty') != 'true'
193
+ BEGIN UPDATE meta SET value = 'true' WHERE key = 'projectionDirty'; END;
194
+ CREATE TRIGGER IF NOT EXISTS reconciliations_dirty_insert AFTER INSERT ON reconciliations
195
+ WHEN (SELECT value FROM meta WHERE key = 'projectionDirty') != 'true'
196
+ BEGIN UPDATE meta SET value = 'true' WHERE key = 'projectionDirty'; END;
197
+ CREATE TRIGGER IF NOT EXISTS reconciliations_dirty_update AFTER UPDATE ON reconciliations
198
+ WHEN (SELECT value FROM meta WHERE key = 'projectionDirty') != 'true'
199
+ BEGIN UPDATE meta SET value = 'true' WHERE key = 'projectionDirty'; END;
200
+ CREATE TRIGGER IF NOT EXISTS reconciliations_dirty_delete AFTER DELETE ON reconciliations
201
+ WHEN (SELECT value FROM meta WHERE key = 'projectionDirty') != 'true'
202
+ BEGIN UPDATE meta SET value = 'true' WHERE key = 'projectionDirty'; END;
203
+ CREATE TRIGGER IF NOT EXISTS events_dirty_insert AFTER INSERT ON events
204
+ WHEN (SELECT value FROM meta WHERE key = 'projectionDirty') != 'true'
205
+ BEGIN UPDATE meta SET value = 'true' WHERE key = 'projectionDirty'; END;
206
+ CREATE TRIGGER IF NOT EXISTS events_dirty_update AFTER UPDATE ON events
207
+ WHEN (SELECT value FROM meta WHERE key = 'projectionDirty') != 'true'
208
+ BEGIN UPDATE meta SET value = 'true' WHERE key = 'projectionDirty'; END;
209
+ CREATE TRIGGER IF NOT EXISTS events_dirty_delete AFTER DELETE ON events
210
+ WHEN (SELECT value FROM meta WHERE key = 'projectionDirty') != 'true'
211
+ BEGIN UPDATE meta SET value = 'true' WHERE key = 'projectionDirty'; END;
212
+ PRAGMA user_version = ${INDEX_SCHEMA_VERSION};
213
+ `);
214
+ const version = Number(this.db.prepare('PRAGMA user_version').get().user_version);
215
+ if (version !== INDEX_SCHEMA_VERSION) {
216
+ throw new Error(`unsupported SQLite outcome index schema: ${version}`);
217
+ }
218
+ }
219
+
220
+ close() {
221
+ if (!this.db) return;
222
+ this.db.close();
223
+ this.db = null;
224
+ }
225
+
226
+ transaction(action) {
227
+ this.db.exec(`
228
+ PRAGMA synchronous = FULL;
229
+ PRAGMA temp_store = MEMORY;
230
+ `);
231
+ this.db.exec('BEGIN IMMEDIATE');
232
+ try {
233
+ const result = action();
234
+ this.db.exec('COMMIT');
235
+ return result;
236
+ } catch (error) {
237
+ try {
238
+ this.db.exec('ROLLBACK');
239
+ } catch {
240
+ // Preserve the original failure.
241
+ }
242
+ throw error;
243
+ }
244
+ }
245
+
246
+ integrityCheck() {
247
+ const row = this.db.prepare('PRAGMA quick_check').get();
248
+ return row && row.quick_check === 'ok';
249
+ }
250
+
251
+ metadata(key) {
252
+ const row = this.db.prepare('SELECT value FROM meta WHERE key = ?').get(key);
253
+ return row ? parseJson(row.value, `metadata ${key}`) : null;
254
+ }
255
+
256
+ setMetadata(key, value) {
257
+ this.db
258
+ .prepare(
259
+ `INSERT INTO meta(key, value) VALUES (?, ?)
260
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
261
+ )
262
+ .run(key, JSON.stringify(value));
263
+ }
264
+
265
+ source() {
266
+ return this.metadata('source');
267
+ }
268
+
269
+ setSource(source, lastBuild) {
270
+ this.setMetadata('source', source);
271
+ this.setMetadata('lastBuild', lastBuild);
272
+ }
273
+
274
+ projectionTrusted() {
275
+ return this.metadata('projectionDirty') === false;
276
+ }
277
+
278
+ acceptProjection() {
279
+ this.setMetadata('projectionDirty', false);
280
+ }
281
+
282
+ clear() {
283
+ this.db.exec(`
284
+ DELETE FROM reconciliations;
285
+ DELETE FROM events;
286
+ DELETE FROM outcomes;
287
+ DELETE FROM lanes;
288
+ DELETE FROM meta;
289
+ `);
290
+ }
291
+
292
+ replaceFromFoldState(state, indexedEvents) {
293
+ this.clear();
294
+ const counts = new Map(
295
+ [...state.lanes.keys()].map((name) => [name, { count: 0, visible: 0 }])
296
+ );
297
+ for (const record of state.records.values()) {
298
+ if (!counts.has(record.lane)) counts.set(record.lane, { count: 0, visible: 0 });
299
+ const lane = counts.get(record.lane);
300
+ lane.count += 1;
301
+ if (!record.reclaimed) lane.visible += 1;
302
+ }
303
+ const insertLane = this.db.prepare(
304
+ `INSERT INTO lanes(
305
+ name, description, added_at, inferred, sequence,
306
+ outcome_count, visible_count, row_hash
307
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
308
+ );
309
+ let sequence = 0;
310
+ for (const lane of state.lanes.values()) {
311
+ const summary = counts.get(lane.name) || { count: 0, visible: 0 };
312
+ const row = {
313
+ name: lane.name,
314
+ description: lane.description || null,
315
+ added_at: lane.addedAt || null,
316
+ inferred: lane.inferred === true ? 1 : 0,
317
+ sequence: sequence++,
318
+ outcome_count: summary.count,
319
+ visible_count: summary.visible,
320
+ };
321
+ insertLane.run(
322
+ row.name,
323
+ row.description,
324
+ row.added_at,
325
+ row.inferred,
326
+ row.sequence,
327
+ row.outcome_count,
328
+ row.visible_count,
329
+ laneRowHash(row)
330
+ );
331
+ }
332
+
333
+ const ranges = new Map();
334
+ const insertEvent = this.db.prepare(
335
+ `INSERT INTO events(start_byte, end_byte, type, outcome_id, ts, event_hash)
336
+ VALUES (?, ?, ?, ?, ?, ?)`
337
+ );
338
+ let migration = null;
339
+ for (const indexed of indexedEvents) {
340
+ const { event, startByte, endByte } = indexed;
341
+ if (event.type === 'begin' || event.type === 'import') {
342
+ ranges.set(event.id, { firstByte: startByte, lastByte: endByte });
343
+ } else if (event.id && ranges.has(event.id)) {
344
+ ranges.get(event.id).lastByte = endByte;
345
+ }
346
+ insertEvent.run(
347
+ startByte,
348
+ endByte,
349
+ event.type,
350
+ event.id || null,
351
+ event.ts || null,
352
+ eventHash(event)
353
+ );
354
+ if (event.type === 'migration' && event.id === 'v1-to-v2') migration = event;
355
+ }
356
+
357
+ const insertOutcome = this.db.prepare(
358
+ `INSERT INTO outcomes(
359
+ id, ordinal, lane, status, reclaimed, record_json,
360
+ first_byte, last_byte, row_hash
361
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
362
+ );
363
+ for (let ordinal = 0; ordinal < state.order.length; ordinal += 1) {
364
+ const id = state.order[ordinal];
365
+ const record = state.records.get(id);
366
+ const range = ranges.get(id);
367
+ const row = {
368
+ id,
369
+ ordinal,
370
+ lane: record.lane,
371
+ status: record.status,
372
+ reclaimed: record.reclaimed ? 1 : 0,
373
+ record_json: JSON.stringify(record),
374
+ first_byte: range.firstByte,
375
+ last_byte: range.lastByte,
376
+ };
377
+ insertOutcome.run(
378
+ row.id,
379
+ row.ordinal,
380
+ row.lane,
381
+ row.status,
382
+ row.reclaimed,
383
+ row.record_json,
384
+ row.first_byte,
385
+ row.last_byte,
386
+ outcomeRowHash(row)
387
+ );
388
+ }
389
+
390
+ const insertReconciliation = this.db.prepare(
391
+ `INSERT INTO reconciliations(id, outcome_id, data_json)
392
+ VALUES (?, ?, ?)`
393
+ );
394
+ for (const [id, reconciliation] of state.reconciliations) {
395
+ insertReconciliation.run(
396
+ id,
397
+ reconciliation.prepare.id,
398
+ JSON.stringify(reconciliation)
399
+ );
400
+ }
401
+ if (migration) this.setMetadata('migration', migration);
402
+ }
403
+
404
+ laneCatalog() {
405
+ const rows = this.db
406
+ .prepare(
407
+ `SELECT name, description, added_at, inferred, sequence,
408
+ outcome_count, visible_count, row_hash
409
+ FROM lanes
410
+ ORDER BY sequence`
411
+ )
412
+ .all();
413
+ for (const row of rows) {
414
+ if (!sameBlob(row.row_hash, laneRowHash(row))) {
415
+ throw new OutcomeIndexError(`invalid SQLite outcome index lane row: ${row.name}`);
416
+ }
417
+ }
418
+ return new Map(rows.map((row) => [row.name, laneRow(row)]));
419
+ }
420
+
421
+ refreshLaneHash(name) {
422
+ const row = this.db
423
+ .prepare(
424
+ `SELECT name, description, added_at, inferred, sequence,
425
+ outcome_count, visible_count
426
+ FROM lanes
427
+ WHERE name = ?`
428
+ )
429
+ .get(name);
430
+ if (!row) throw new OutcomeIndexError(`missing SQLite outcome index lane row: ${name}`);
431
+ this.db
432
+ .prepare('UPDATE lanes SET row_hash = ? WHERE name = ?')
433
+ .run(laneRowHash(row), name);
434
+ }
435
+
436
+ loadFoldState(event, defaultLane) {
437
+ const lanes = new Map(
438
+ [...this.laneCatalog()].map(([name, lane]) => [
439
+ name,
440
+ {
441
+ name,
442
+ description: lane.description || null,
443
+ addedAt: lane.addedAt || null,
444
+ head: null,
445
+ inferred: lane.inferred === true,
446
+ },
447
+ ])
448
+ );
449
+ if (!lanes.has(defaultLane)) {
450
+ lanes.set(defaultLane, {
451
+ name: defaultLane,
452
+ description: null,
453
+ addedAt: null,
454
+ head: null,
455
+ });
456
+ }
457
+ const records = new Map();
458
+ const order = [];
459
+ if (event.id) {
460
+ const row = this.db
461
+ .prepare(
462
+ `SELECT id, ordinal, lane, status, reclaimed, record_json,
463
+ first_byte, last_byte, row_hash
464
+ FROM outcomes
465
+ WHERE id = ?`
466
+ )
467
+ .get(event.id);
468
+ if (row) {
469
+ records.set(event.id, this.recordForRow(row));
470
+ order.push(event.id);
471
+ }
472
+ }
473
+ const reconciliations = new Map();
474
+ if (event.reconciliationId) {
475
+ const row = this.db
476
+ .prepare('SELECT data_json FROM reconciliations WHERE id = ?')
477
+ .get(event.reconciliationId);
478
+ if (row) {
479
+ reconciliations.set(
480
+ event.reconciliationId,
481
+ parseJson(row.data_json, `reconciliation ${event.reconciliationId}`)
482
+ );
483
+ }
484
+ }
485
+ return { records, reconciliations, order, lanes };
486
+ }
487
+
488
+ persistFoldState(state, event, startByte, endByte) {
489
+ const touchedLanes = new Set();
490
+ if (event.type === 'lane_add') touchedLanes.add(event.lane);
491
+ if (
492
+ event.id &&
493
+ state.records.has(event.id) &&
494
+ ['begin', 'import', 'lane_assign'].includes(event.type)
495
+ ) {
496
+ touchedLanes.add(state.records.get(event.id).lane);
497
+ }
498
+ const lanesToHash = new Set(touchedLanes);
499
+ for (const name of touchedLanes) {
500
+ const lane = state.lanes.get(name);
501
+ this.db
502
+ .prepare(
503
+ `INSERT INTO lanes(
504
+ name, description, added_at, inferred, sequence,
505
+ outcome_count, visible_count, row_hash
506
+ ) VALUES (
507
+ ?, ?, ?, ?,
508
+ COALESCE((SELECT MAX(sequence) + 1 FROM lanes), 0),
509
+ 0, 0, zeroblob(32)
510
+ )
511
+ ON CONFLICT(name) DO UPDATE SET
512
+ description = excluded.description,
513
+ added_at = excluded.added_at,
514
+ inferred = excluded.inferred`
515
+ )
516
+ .run(
517
+ lane.name,
518
+ lane.description || null,
519
+ lane.addedAt || null,
520
+ lane.inferred === true ? 1 : 0
521
+ );
522
+ }
523
+ if (event.id && state.records.has(event.id)) {
524
+ const record = state.records.get(event.id);
525
+ const existing = this.db
526
+ .prepare(
527
+ 'SELECT ordinal, lane, reclaimed, first_byte FROM outcomes WHERE id = ?'
528
+ )
529
+ .get(event.id);
530
+ const ordinal = existing
531
+ ? Number(existing.ordinal)
532
+ : Number(
533
+ this.db
534
+ .prepare('SELECT COALESCE(MAX(ordinal), -1) + 1 AS next FROM outcomes')
535
+ .get().next
536
+ );
537
+ const firstByte = existing ? Number(existing.first_byte) : startByte;
538
+ const row = {
539
+ id: record.id,
540
+ ordinal,
541
+ lane: record.lane,
542
+ status: record.status,
543
+ reclaimed: record.reclaimed ? 1 : 0,
544
+ record_json: JSON.stringify(record),
545
+ first_byte: firstByte,
546
+ last_byte: endByte,
547
+ };
548
+ this.db
549
+ .prepare(
550
+ `INSERT INTO outcomes(
551
+ id, ordinal, lane, status, reclaimed, record_json,
552
+ first_byte, last_byte, row_hash
553
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
554
+ ON CONFLICT(id) DO UPDATE SET
555
+ lane = excluded.lane,
556
+ status = excluded.status,
557
+ reclaimed = excluded.reclaimed,
558
+ record_json = excluded.record_json,
559
+ last_byte = excluded.last_byte,
560
+ row_hash = excluded.row_hash`
561
+ )
562
+ .run(
563
+ row.id,
564
+ row.ordinal,
565
+ row.lane,
566
+ row.status,
567
+ row.reclaimed,
568
+ row.record_json,
569
+ row.first_byte,
570
+ row.last_byte,
571
+ outcomeRowHash(row)
572
+ );
573
+ if (!existing) {
574
+ lanesToHash.add(record.lane);
575
+ this.db
576
+ .prepare(
577
+ `UPDATE lanes
578
+ SET outcome_count = outcome_count + 1,
579
+ visible_count = visible_count + ?
580
+ WHERE name = ?`
581
+ )
582
+ .run(record.reclaimed ? 0 : 1, record.lane);
583
+ } else if (existing.lane !== record.lane) {
584
+ lanesToHash.add(existing.lane);
585
+ lanesToHash.add(record.lane);
586
+ this.db
587
+ .prepare(
588
+ `UPDATE lanes
589
+ SET outcome_count = outcome_count - 1,
590
+ visible_count = visible_count - ?
591
+ WHERE name = ?`
592
+ )
593
+ .run(existing.reclaimed === 1 ? 0 : 1, existing.lane);
594
+ this.db
595
+ .prepare(
596
+ `UPDATE lanes
597
+ SET outcome_count = outcome_count + 1,
598
+ visible_count = visible_count + ?
599
+ WHERE name = ?`
600
+ )
601
+ .run(record.reclaimed ? 0 : 1, record.lane);
602
+ } else if (existing.reclaimed !== (record.reclaimed ? 1 : 0)) {
603
+ lanesToHash.add(record.lane);
604
+ this.db
605
+ .prepare(
606
+ `UPDATE lanes
607
+ SET visible_count = visible_count + ?
608
+ WHERE name = ?`
609
+ )
610
+ .run(record.reclaimed ? -1 : 1, record.lane);
611
+ }
612
+ }
613
+ for (const name of lanesToHash) this.refreshLaneHash(name);
614
+ for (const [id, reconciliation] of state.reconciliations) {
615
+ this.db
616
+ .prepare(
617
+ `INSERT INTO reconciliations(id, outcome_id, data_json)
618
+ VALUES (?, ?, ?)
619
+ ON CONFLICT(id) DO UPDATE SET
620
+ outcome_id = excluded.outcome_id,
621
+ data_json = excluded.data_json`
622
+ )
623
+ .run(id, reconciliation.prepare.id, JSON.stringify(reconciliation));
624
+ }
625
+ this.db
626
+ .prepare(
627
+ `INSERT INTO events(start_byte, end_byte, type, outcome_id, ts, event_hash)
628
+ VALUES (?, ?, ?, ?, ?, ?)`
629
+ )
630
+ .run(
631
+ startByte,
632
+ endByte,
633
+ event.type,
634
+ event.id || null,
635
+ event.ts || null,
636
+ eventHash(event)
637
+ );
638
+ }
639
+
640
+ applyEvent(event, startByte, endByte, { applyFoldEvent, defaultLane }) {
641
+ const state = this.loadFoldState(event, defaultLane);
642
+ applyFoldEvent(state, event);
643
+ this.persistFoldState(state, event, startByte, endByte);
644
+ if (event.type === 'migration' && event.id === 'v1-to-v2') {
645
+ this.setMetadata('migration', event);
646
+ }
647
+ }
648
+
649
+ recordForRow(row, { includeOrdinal = false } = {}) {
650
+ if (!sameBlob(row.row_hash, outcomeRowHash(row))) {
651
+ throw new OutcomeIndexError(`invalid SQLite outcome index row: ${row.id}`);
652
+ }
653
+ const record = parseJson(row.record_json, `outcome ${row.id}`);
654
+ if (
655
+ record.id !== row.id ||
656
+ record.lane !== row.lane ||
657
+ record.status !== row.status ||
658
+ Boolean(record.reclaimed) !== (Number(row.reclaimed) === 1)
659
+ ) {
660
+ throw new OutcomeIndexError(`inconsistent SQLite outcome index row: ${row.id}`);
661
+ }
662
+ if (includeOrdinal) record.ordinal = Number(row.ordinal);
663
+ return record;
664
+ }
665
+
666
+ recordsForRows(rows) {
667
+ return rows.map((row) => this.recordForRow(row, { includeOrdinal: true }));
668
+ }
669
+
670
+ queryAll() {
671
+ return this.recordsForRows(
672
+ this.db
673
+ .prepare(
674
+ `SELECT id, ordinal, lane, status, reclaimed, record_json,
675
+ first_byte, last_byte, row_hash
676
+ FROM outcomes
677
+ ORDER BY ordinal`
678
+ )
679
+ .all()
680
+ );
681
+ }
682
+
683
+ queryRecent(lane, count, { includeReclaimed = false } = {}) {
684
+ const laneRows = this.db
685
+ .prepare(
686
+ `SELECT id, ordinal, lane, status, reclaimed, record_json,
687
+ first_byte, last_byte, row_hash
688
+ FROM outcomes
689
+ WHERE lane = ? AND (? = 1 OR reclaimed = 0)
690
+ ORDER BY ordinal DESC
691
+ LIMIT ?`
692
+ )
693
+ .all(lane, includeReclaimed ? 1 : 0, count)
694
+ .reverse();
695
+ const kept = new Set(laneRows.map((row) => row.id));
696
+ const openRows = this.db
697
+ .prepare(
698
+ `SELECT id, ordinal, lane, status, reclaimed, record_json,
699
+ first_byte, last_byte, row_hash
700
+ FROM outcomes
701
+ WHERE status = 'in_progress'
702
+ ORDER BY ordinal`
703
+ )
704
+ .all()
705
+ .filter((row) => !kept.has(row.id));
706
+ return this.recordsForRows([...laneRows, ...openRows].sort((left, right) => left.ordinal - right.ordinal));
707
+ }
708
+
709
+ migrationEvent() {
710
+ return this.metadata('migration');
711
+ }
712
+
713
+ outcomeStartEvents() {
714
+ return this.db
715
+ .prepare(
716
+ `SELECT type, outcome_id
717
+ FROM events
718
+ WHERE type IN ('begin', 'import')
719
+ ORDER BY start_byte`
720
+ )
721
+ .all()
722
+ .map((row) => ({ type: row.type, id: row.outcome_id }));
723
+ }
724
+
725
+ containsEventSequence(events) {
726
+ if (!Array.isArray(events) || events.length === 0) return false;
727
+ const head = events[0];
728
+ const candidates = this.db
729
+ .prepare(
730
+ `SELECT start_byte
731
+ FROM events
732
+ WHERE type = ?
733
+ AND outcome_id IS ?
734
+ AND ts IS ?
735
+ ORDER BY start_byte DESC`
736
+ )
737
+ .all(head.type, head.id || null, head.ts || null);
738
+ const statement = this.db.prepare(
739
+ `SELECT event_hash
740
+ FROM events
741
+ WHERE start_byte >= ?
742
+ ORDER BY start_byte
743
+ LIMIT ?`
744
+ );
745
+ const expected = events.map(eventHash);
746
+ for (const candidate of candidates) {
747
+ const stored = statement
748
+ .all(candidate.start_byte, events.length)
749
+ .map((row) => row.event_hash);
750
+ if (stored.some((hash) => !(hash instanceof Uint8Array))) {
751
+ throw new OutcomeIndexError('invalid SQLite outcome index event hash');
752
+ }
753
+ if (
754
+ stored.length === events.length &&
755
+ stored.every((hash, index) => sameBlob(hash, expected[index]))
756
+ ) {
757
+ return true;
758
+ }
759
+ }
760
+ return false;
761
+ }
762
+
763
+ explainRecent() {
764
+ return this.db
765
+ .prepare(
766
+ `EXPLAIN QUERY PLAN
767
+ SELECT id, ordinal, record_json
768
+ FROM outcomes
769
+ WHERE lane = ? AND reclaimed = 0
770
+ ORDER BY ordinal DESC
771
+ LIMIT ?`
772
+ )
773
+ .all('main', 3);
774
+ }
775
+ }
776
+
777
+ function openOutcomeIndex(file, options) {
778
+ return new OutcomeIndex(file, options);
779
+ }
780
+
781
+ function temporaryIndexPath(file) {
782
+ return path.join(
783
+ path.dirname(file),
784
+ `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`
785
+ );
786
+ }
787
+
788
+ function removeIndexFiles(file) {
789
+ for (const suffix of ['', '-journal', '-wal', '-shm']) {
790
+ fs.rmSync(`${file}${suffix}`, { force: true });
791
+ }
792
+ }
793
+
794
+ module.exports = {
795
+ INDEX_SCHEMA_VERSION,
796
+ OutcomeIndexError,
797
+ SqliteUnavailableError,
798
+ OutcomeIndex,
799
+ openOutcomeIndex,
800
+ removeIndexFiles,
801
+ temporaryIndexPath,
802
+ };