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.
- package/README.md +73 -11
- package/README.zh-CN.md +63 -9
- package/benchmark/recent-log.js +181 -0
- package/bin/driftseal-mcp.js +117 -4
- package/bin/driftseal.js +1034 -251
- package/lib/outcome-fold.js +377 -0
- package/lib/outcome-index-sqlite.js +802 -0
- package/lib/sqlite-runtime.js +71 -0
- package/package.json +7 -2
- package/skills/use-driftseal/SKILL.md +4 -1
- package/test/package-smoke.js +150 -0
|
@@ -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
|
+
};
|