@reactive-skills/runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/dist/cli/dev.d.ts +2 -0
- package/dist/cli/dev.js +114 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +215 -0
- package/dist/core/event-store.d.ts +182 -0
- package/dist/core/event-store.js +762 -0
- package/dist/core/fsm-engine.d.ts +94 -0
- package/dist/core/fsm-engine.js +648 -0
- package/dist/core/guard-evaluator.d.ts +19 -0
- package/dist/core/guard-evaluator.js +103 -0
- package/dist/core/legacy-adapter.d.ts +27 -0
- package/dist/core/legacy-adapter.js +126 -0
- package/dist/core/migration.d.ts +18 -0
- package/dist/core/migration.js +256 -0
- package/dist/core/projection-engine.d.ts +43 -0
- package/dist/core/projection-engine.js +167 -0
- package/dist/core/runtime-hooks.d.ts +51 -0
- package/dist/core/runtime-hooks.js +195 -0
- package/dist/core/types.d.ts +238 -0
- package/dist/core/types.js +55 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/mcp/server.d.ts +7 -0
- package/dist/mcp/server.js +453 -0
- package/dist/sync/cli.d.ts +1 -0
- package/dist/sync/cli.js +186 -0
- package/dist/sync/engine.d.ts +2 -0
- package/dist/sync/engine.js +366 -0
- package/dist/sync/types.d.ts +35 -0
- package/dist/sync/types.js +1 -0
- package/package.json +75 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
export const EVENT_STORE_SCHEMA_VERSION = 2;
|
|
6
|
+
export function createSortableId() {
|
|
7
|
+
const bytes = crypto.randomBytes(16);
|
|
8
|
+
const timestamp = BigInt(Date.now());
|
|
9
|
+
for (let index = 0; index < 6; index++) {
|
|
10
|
+
bytes[index] = Number((timestamp >> BigInt((5 - index) * 8)) & 0xffn);
|
|
11
|
+
}
|
|
12
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x70;
|
|
13
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
14
|
+
const hex = bytes.toString('hex');
|
|
15
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* SQLite Storage Driver for EventStore
|
|
19
|
+
* Provides ACID relational storage, indexing, and direct SQL querying
|
|
20
|
+
*/
|
|
21
|
+
export class SQLiteStorageDriver {
|
|
22
|
+
db;
|
|
23
|
+
constructor(dbPath) {
|
|
24
|
+
if (dbPath !== ':memory:') {
|
|
25
|
+
const dir = path.dirname(dbPath);
|
|
26
|
+
if (!fs.existsSync(dir)) {
|
|
27
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
this.db = new DatabaseSync(dbPath);
|
|
31
|
+
this.db.exec('PRAGMA busy_timeout = 5000');
|
|
32
|
+
this.db.exec('PRAGMA journal_mode = WAL');
|
|
33
|
+
this.initTables();
|
|
34
|
+
}
|
|
35
|
+
initTables() {
|
|
36
|
+
this.db.exec(`
|
|
37
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
38
|
+
id TEXT PRIMARY KEY,
|
|
39
|
+
event_id TEXT,
|
|
40
|
+
seq INTEGER NOT NULL,
|
|
41
|
+
timestamp TEXT NOT NULL,
|
|
42
|
+
occurred_at TEXT,
|
|
43
|
+
type TEXT NOT NULL,
|
|
44
|
+
event_type TEXT,
|
|
45
|
+
state TEXT,
|
|
46
|
+
source TEXT,
|
|
47
|
+
causation_id TEXT,
|
|
48
|
+
correlation_id TEXT,
|
|
49
|
+
request_id TEXT,
|
|
50
|
+
trace_parent TEXT,
|
|
51
|
+
skill_id TEXT,
|
|
52
|
+
run_id TEXT,
|
|
53
|
+
parent_run_id TEXT,
|
|
54
|
+
schema_version TEXT,
|
|
55
|
+
payload TEXT NOT NULL
|
|
56
|
+
);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_events_seq ON events(seq);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_events_type ON events(type);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_events_state ON events(state);
|
|
60
|
+
|
|
61
|
+
CREATE TABLE IF NOT EXISTS projections (
|
|
62
|
+
name TEXT PRIMARY KEY,
|
|
63
|
+
content TEXT NOT NULL,
|
|
64
|
+
updated_at TEXT NOT NULL
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
CREATE TABLE IF NOT EXISTS state_snapshots (
|
|
68
|
+
seq INTEGER PRIMARY KEY,
|
|
69
|
+
state TEXT NOT NULL,
|
|
70
|
+
context TEXT NOT NULL,
|
|
71
|
+
created_at TEXT NOT NULL
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE TABLE IF NOT EXISTS projection_watermarks (
|
|
75
|
+
name TEXT PRIMARY KEY,
|
|
76
|
+
event_seq INTEGER NOT NULL,
|
|
77
|
+
projection_version TEXT NOT NULL,
|
|
78
|
+
updated_at TEXT NOT NULL
|
|
79
|
+
);
|
|
80
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
81
|
+
version INTEGER PRIMARY KEY,
|
|
82
|
+
applied_at TEXT NOT NULL
|
|
83
|
+
);
|
|
84
|
+
CREATE TABLE IF NOT EXISTS seq_counter (
|
|
85
|
+
id INTEGER PRIMARY KEY,
|
|
86
|
+
last_seq INTEGER NOT NULL
|
|
87
|
+
);
|
|
88
|
+
INSERT OR IGNORE INTO seq_counter (id, last_seq) VALUES (1, 0);
|
|
89
|
+
INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (2, datetime('now'));
|
|
90
|
+
`);
|
|
91
|
+
this.ensureSchemaVersion();
|
|
92
|
+
}
|
|
93
|
+
ensureSchemaVersion() {
|
|
94
|
+
const stmt = this.db.prepare('SELECT MAX(version) as v FROM schema_version');
|
|
95
|
+
const row = stmt.get();
|
|
96
|
+
const currentVersion = row?.v ?? 0;
|
|
97
|
+
if (currentVersion < EVENT_STORE_SCHEMA_VERSION) {
|
|
98
|
+
this.runMigrations(currentVersion);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
runMigrations(fromVersion) {
|
|
102
|
+
if (fromVersion < 2) {
|
|
103
|
+
const envelopeColumns = {
|
|
104
|
+
event_id: 'TEXT',
|
|
105
|
+
occurred_at: 'TEXT',
|
|
106
|
+
event_type: 'TEXT',
|
|
107
|
+
correlation_id: 'TEXT',
|
|
108
|
+
request_id: 'TEXT',
|
|
109
|
+
trace_parent: 'TEXT',
|
|
110
|
+
skill_id: 'TEXT',
|
|
111
|
+
run_id: 'TEXT',
|
|
112
|
+
parent_run_id: 'TEXT',
|
|
113
|
+
schema_version: 'TEXT',
|
|
114
|
+
};
|
|
115
|
+
for (const [column, type] of Object.entries(envelopeColumns)) {
|
|
116
|
+
try {
|
|
117
|
+
this.db.exec(`ALTER TABLE events ADD COLUMN ${column} ${type}`);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Column already exists, skip
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
this.db.exec(`INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (2, datetime('now'))`);
|
|
124
|
+
console.warn('EventStore: Migrated schema from v' + fromVersion + ' to v2 (added event envelope fields)');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
insertEvent(event) {
|
|
128
|
+
const stmt = this.db.prepare(`
|
|
129
|
+
INSERT INTO events (id, event_id, seq, timestamp, occurred_at, type, event_type, state, source, causation_id, correlation_id, request_id, trace_parent, skill_id, run_id, parent_run_id, schema_version, payload)
|
|
130
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
131
|
+
`);
|
|
132
|
+
stmt.run(event.id, event.event_id || event.id, event.seq, event.timestamp, event.occurred_at || event.timestamp, event.type, event.event_type || event.type, event.state || null, event.source || null, event.causation_id || event.causationId || null, event.correlation_id || null, event.request_id || null, event.trace_parent || null, event.skill_id || null, event.run_id || null, event.parent_run_id || null, event.schema_version || null, JSON.stringify(event.payload));
|
|
133
|
+
}
|
|
134
|
+
beginTransaction() {
|
|
135
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
136
|
+
}
|
|
137
|
+
commitTransaction() {
|
|
138
|
+
this.db.exec('COMMIT');
|
|
139
|
+
}
|
|
140
|
+
queryEvents(options = {}) {
|
|
141
|
+
let sql = 'SELECT * FROM events WHERE 1=1';
|
|
142
|
+
const params = [];
|
|
143
|
+
if (options.type) {
|
|
144
|
+
sql += ' AND type = ?';
|
|
145
|
+
params.push(options.type);
|
|
146
|
+
}
|
|
147
|
+
if (options.state) {
|
|
148
|
+
sql += ' AND state = ?';
|
|
149
|
+
params.push(options.state);
|
|
150
|
+
}
|
|
151
|
+
if (options.sinceSeq !== undefined) {
|
|
152
|
+
sql += ' AND seq > ?';
|
|
153
|
+
params.push(options.sinceSeq);
|
|
154
|
+
}
|
|
155
|
+
sql += ' ORDER BY seq ASC';
|
|
156
|
+
if (options.limit) {
|
|
157
|
+
sql += ' LIMIT ?';
|
|
158
|
+
params.push(options.limit);
|
|
159
|
+
}
|
|
160
|
+
const stmt = this.db.prepare(sql);
|
|
161
|
+
const rows = stmt.all(...params);
|
|
162
|
+
return rows.map(row => this.rowToEvent(row));
|
|
163
|
+
}
|
|
164
|
+
getLatestSequence() {
|
|
165
|
+
const stmt = this.db.prepare('SELECT seq FROM events ORDER BY seq DESC LIMIT 1');
|
|
166
|
+
const row = stmt.get();
|
|
167
|
+
return row ? Number(row.seq) : 0;
|
|
168
|
+
}
|
|
169
|
+
nextSequence() {
|
|
170
|
+
const stmt = this.db.prepare('UPDATE seq_counter SET last_seq = last_seq + 1 WHERE id = 1');
|
|
171
|
+
stmt.run();
|
|
172
|
+
const selectStmt = this.db.prepare('SELECT last_seq FROM seq_counter WHERE id = 1');
|
|
173
|
+
const row = selectStmt.get();
|
|
174
|
+
return Number(row.last_seq);
|
|
175
|
+
}
|
|
176
|
+
getRecentEvents(limit) {
|
|
177
|
+
const boundedLimit = Math.max(1, Math.floor(limit));
|
|
178
|
+
const stmt = this.db.prepare('SELECT * FROM events ORDER BY seq DESC LIMIT ?');
|
|
179
|
+
const rows = stmt.all(boundedLimit);
|
|
180
|
+
return rows.reverse().map(row => this.rowToEvent(row));
|
|
181
|
+
}
|
|
182
|
+
querySql(sql, params = []) {
|
|
183
|
+
const normalizedSql = sql.replace(/--.*(?:\r?\n|$)/g, '').trim();
|
|
184
|
+
const statements = normalizedSql.split(';').map(statement => statement.trim()).filter(Boolean);
|
|
185
|
+
if (statements.length !== 1 || !/^(SELECT|EXPLAIN)\b/i.test(statements[0])) {
|
|
186
|
+
throw new Error('Only single-statement SELECT or EXPLAIN queries are allowed.');
|
|
187
|
+
}
|
|
188
|
+
const stmt = this.db.prepare(sql);
|
|
189
|
+
return stmt.all(...params);
|
|
190
|
+
}
|
|
191
|
+
saveSnapshot(seq, state, context) {
|
|
192
|
+
const stmt = this.db.prepare(`
|
|
193
|
+
INSERT OR REPLACE INTO state_snapshots (seq, state, context, created_at)
|
|
194
|
+
VALUES (?, ?, ?, ?)
|
|
195
|
+
`);
|
|
196
|
+
stmt.run(seq, state, JSON.stringify(context), new Date().toISOString());
|
|
197
|
+
}
|
|
198
|
+
getLatestSnapshot() {
|
|
199
|
+
const stmt = this.db.prepare(`
|
|
200
|
+
SELECT * FROM state_snapshots ORDER BY seq DESC LIMIT 1
|
|
201
|
+
`);
|
|
202
|
+
const row = stmt.get();
|
|
203
|
+
if (!row)
|
|
204
|
+
return null;
|
|
205
|
+
return {
|
|
206
|
+
seq: Number(row.seq),
|
|
207
|
+
state: row.state,
|
|
208
|
+
context: JSON.parse(row.context),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
saveProjectionWatermark(name, eventSeq, projectionVersion) {
|
|
212
|
+
const stmt = this.db.prepare(`
|
|
213
|
+
INSERT OR REPLACE INTO projection_watermarks (name, event_seq, projection_version, updated_at)
|
|
214
|
+
VALUES (?, ?, ?, ?)
|
|
215
|
+
`);
|
|
216
|
+
stmt.run(name, eventSeq, projectionVersion, new Date().toISOString());
|
|
217
|
+
}
|
|
218
|
+
getProjectionWatermark(name) {
|
|
219
|
+
const stmt = this.db.prepare('SELECT event_seq, projection_version FROM projection_watermarks WHERE name = ?');
|
|
220
|
+
const row = stmt.get(name);
|
|
221
|
+
return row
|
|
222
|
+
? { eventSeq: Number(row.event_seq), projectionVersion: row.projection_version }
|
|
223
|
+
: null;
|
|
224
|
+
}
|
|
225
|
+
saveProjection(name, content) {
|
|
226
|
+
const stmt = this.db.prepare(`
|
|
227
|
+
INSERT OR REPLACE INTO projections (name, content, updated_at)
|
|
228
|
+
VALUES (?, ?, ?)
|
|
229
|
+
`);
|
|
230
|
+
stmt.run(name, content, new Date().toISOString());
|
|
231
|
+
}
|
|
232
|
+
getProjection(name) {
|
|
233
|
+
const stmt = this.db.prepare(`
|
|
234
|
+
SELECT content FROM projections WHERE name = ?
|
|
235
|
+
`);
|
|
236
|
+
const row = stmt.get(name);
|
|
237
|
+
return row ? row.content : null;
|
|
238
|
+
}
|
|
239
|
+
close() {
|
|
240
|
+
this.db.close();
|
|
241
|
+
}
|
|
242
|
+
static getSchemaVersion(dbPath) {
|
|
243
|
+
const db = new DatabaseSync(dbPath);
|
|
244
|
+
const stmt = db.prepare('SELECT MAX(version) as v FROM schema_version');
|
|
245
|
+
const row = stmt.get();
|
|
246
|
+
db.close();
|
|
247
|
+
return row?.v ?? 0;
|
|
248
|
+
}
|
|
249
|
+
rowToEvent(row) {
|
|
250
|
+
return {
|
|
251
|
+
id: row.id,
|
|
252
|
+
seq: Number(row.seq),
|
|
253
|
+
timestamp: row.timestamp,
|
|
254
|
+
type: row.type,
|
|
255
|
+
state: row.state || undefined,
|
|
256
|
+
source: row.source || undefined,
|
|
257
|
+
causationId: row.causation_id || undefined,
|
|
258
|
+
event_id: row.event_id || row.id,
|
|
259
|
+
occurred_at: row.occurred_at || row.timestamp,
|
|
260
|
+
event_type: row.event_type || row.type,
|
|
261
|
+
causation_id: row.causation_id || undefined,
|
|
262
|
+
correlation_id: row.correlation_id || undefined,
|
|
263
|
+
request_id: row.request_id || undefined,
|
|
264
|
+
trace_parent: row.trace_parent || undefined,
|
|
265
|
+
skill_id: row.skill_id || undefined,
|
|
266
|
+
run_id: row.run_id || undefined,
|
|
267
|
+
parent_run_id: row.parent_run_id || undefined,
|
|
268
|
+
schema_version: row.schema_version || undefined,
|
|
269
|
+
payload: JSON.parse(row.payload),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
clear() {
|
|
273
|
+
this.db.exec(`
|
|
274
|
+
DELETE FROM events;
|
|
275
|
+
DELETE FROM projections;
|
|
276
|
+
DELETE FROM state_snapshots;
|
|
277
|
+
DELETE FROM projection_watermarks;
|
|
278
|
+
`);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Purge every table including seq_counter so a rebuilt store starts from a
|
|
282
|
+
* clean slate. Used by EventStore.rebuildFromJsonl().
|
|
283
|
+
*/
|
|
284
|
+
clearAll() {
|
|
285
|
+
this.db.exec(`
|
|
286
|
+
DELETE FROM events;
|
|
287
|
+
DELETE FROM projections;
|
|
288
|
+
DELETE FROM state_snapshots;
|
|
289
|
+
DELETE FROM projection_watermarks;
|
|
290
|
+
DELETE FROM seq_counter;
|
|
291
|
+
`);
|
|
292
|
+
this.db.exec(`INSERT OR IGNORE INTO seq_counter (id, last_seq) VALUES (1, 0)`);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Replace the entire events table with the supplied ordered list.
|
|
296
|
+
* Preserves caller-supplied seq numbers (JSONL heritage). seq_counter is
|
|
297
|
+
* reset to the max seq so future appends continue from the correct point.
|
|
298
|
+
*/
|
|
299
|
+
rebuildEvents(orderedEvents) {
|
|
300
|
+
this.clearAll();
|
|
301
|
+
if (orderedEvents.length === 0)
|
|
302
|
+
return;
|
|
303
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
304
|
+
try {
|
|
305
|
+
let maxSeq = 0;
|
|
306
|
+
for (const event of orderedEvents) {
|
|
307
|
+
this.insertEvent(event);
|
|
308
|
+
if (event.seq > maxSeq)
|
|
309
|
+
maxSeq = event.seq;
|
|
310
|
+
}
|
|
311
|
+
const updateStmt = this.db.prepare('UPDATE seq_counter SET last_seq = ? WHERE id = 1');
|
|
312
|
+
updateStmt.run(maxSeq);
|
|
313
|
+
this.db.exec('COMMIT');
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
this.db.exec('ROLLBACK');
|
|
317
|
+
throw err;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Immutable Append-Only Event Store
|
|
323
|
+
* All signals, tool results, guard evaluations, and state transitions are recorded here.
|
|
324
|
+
*/
|
|
325
|
+
export class EventStore {
|
|
326
|
+
events = [];
|
|
327
|
+
listeners = new Set();
|
|
328
|
+
storagePath = null;
|
|
329
|
+
sqliteDriver = null;
|
|
330
|
+
sqlitePath = null;
|
|
331
|
+
seqCounter = 0;
|
|
332
|
+
maxInMemoryEvents;
|
|
333
|
+
eventContext;
|
|
334
|
+
projectionWatermarks = new Map();
|
|
335
|
+
latestSnapshot = null;
|
|
336
|
+
maxJsonlBytes;
|
|
337
|
+
constructor(options = {}) {
|
|
338
|
+
this.maxInMemoryEvents = options.maxInMemoryEvents || 1000;
|
|
339
|
+
this.maxJsonlBytes = options.maxJsonlBytes || 10 * 1024 * 1024;
|
|
340
|
+
this.eventContext = {
|
|
341
|
+
skill_id: options.skillId,
|
|
342
|
+
run_id: options.runId || options.run_id || createSortableId(),
|
|
343
|
+
correlation_id: options.correlationId || createSortableId(),
|
|
344
|
+
request_id: options.requestId,
|
|
345
|
+
trace_parent: options.traceParent,
|
|
346
|
+
parent_run_id: options.parentRunId,
|
|
347
|
+
schema_version: options.schemaVersion || '1.0.3',
|
|
348
|
+
};
|
|
349
|
+
if (!options.inMemory) {
|
|
350
|
+
const workspaceDir = options.workspaceDir || process.cwd();
|
|
351
|
+
const scopeDir = options.skillId
|
|
352
|
+
? path.join(workspaceDir, '.reactive', 'skills', options.skillId)
|
|
353
|
+
: path.join(workspaceDir, '.reactive');
|
|
354
|
+
const runScopedDir = options.runId
|
|
355
|
+
? path.join(scopeDir, options.runId)
|
|
356
|
+
: scopeDir;
|
|
357
|
+
this.storagePath = options.storagePath || path.join(runScopedDir, 'events.jsonl');
|
|
358
|
+
if (options.enableSqlite || options.sqlitePath) {
|
|
359
|
+
const dbPath = options.sqlitePath || path.join(runScopedDir, 'events.db');
|
|
360
|
+
this.sqlitePath = dbPath;
|
|
361
|
+
this.sqliteDriver = new SQLiteStorageDriver(dbPath);
|
|
362
|
+
}
|
|
363
|
+
this.initializeStorage();
|
|
364
|
+
}
|
|
365
|
+
else if (options.enableSqlite) {
|
|
366
|
+
this.sqliteDriver = new SQLiteStorageDriver(':memory:');
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
initializeStorage() {
|
|
370
|
+
// SQLite is authoritative. If it already has events, load the recent
|
|
371
|
+
// sliding window and check JSONL for divergence before returning.
|
|
372
|
+
if (this.sqliteDriver) {
|
|
373
|
+
const sqliteLatestSeq = this.sqliteDriver.getLatestSequence();
|
|
374
|
+
if (sqliteLatestSeq > 0) {
|
|
375
|
+
this.seqCounter = sqliteLatestSeq;
|
|
376
|
+
this.events = this.sqliteDriver.getRecentEvents(this.maxInMemoryEvents);
|
|
377
|
+
// Divergence detection: JSONL may have events SQLite never saw (e.g.
|
|
378
|
+
// a prior run with SQLite disabled, or a failed SQLite write). If so,
|
|
379
|
+
// rebuild SQLite from JSONL so the authoritative store matches the
|
|
380
|
+
// audit log. Only run when JSONL and SQLite share a directory so an
|
|
381
|
+
// explicit sqlitePath in a different location isn't clobbered by a
|
|
382
|
+
// stray JSONL in the working directory.
|
|
383
|
+
if (this.storagePath && this.sqlitePath && path.dirname(this.storagePath) === path.dirname(this.sqlitePath)) {
|
|
384
|
+
const jsonlStats = this.readJsonlStats();
|
|
385
|
+
if (jsonlStats.count > 0 && jsonlStats.maxSeq > sqliteLatestSeq) {
|
|
386
|
+
console.warn('EventStore: JSONL has events beyond SQLite (jsonl max seq=' +
|
|
387
|
+
jsonlStats.maxSeq +
|
|
388
|
+
', sqlite max seq=' +
|
|
389
|
+
sqliteLatestSeq +
|
|
390
|
+
'). Rebuilding SQLite from JSONL.');
|
|
391
|
+
this.rebuildFromJsonl();
|
|
392
|
+
this.seqCounter = this.sqliteDriver.getLatestSequence();
|
|
393
|
+
this.events = this.sqliteDriver.getRecentEvents(this.maxInMemoryEvents);
|
|
394
|
+
}
|
|
395
|
+
else if (jsonlStats.count > 0 && jsonlStats.maxSeq < sqliteLatestSeq) {
|
|
396
|
+
// SQLite is authoritative; JSONL is a stale mirror. Optionally
|
|
397
|
+
// backfill the audit log so it stays complete.
|
|
398
|
+
this.syncJsonlToSqlite();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (!this.storagePath)
|
|
405
|
+
return;
|
|
406
|
+
const dir = path.dirname(this.storagePath);
|
|
407
|
+
if (!fs.existsSync(dir)) {
|
|
408
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
409
|
+
}
|
|
410
|
+
const jsonlPaths = this.getJsonlPaths();
|
|
411
|
+
if (jsonlPaths.length > 0) {
|
|
412
|
+
const fileEvents = [];
|
|
413
|
+
for (const jsonlPath of jsonlPaths) {
|
|
414
|
+
const content = fs.readFileSync(jsonlPath, 'utf8');
|
|
415
|
+
const lines = content.split('\n').filter(l => l.trim().length > 0);
|
|
416
|
+
for (const line of lines) {
|
|
417
|
+
try {
|
|
418
|
+
const event = JSON.parse(line);
|
|
419
|
+
fileEvents.push(event);
|
|
420
|
+
if (event.seq > this.seqCounter) {
|
|
421
|
+
this.seqCounter = event.seq;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
// skip malformed lines
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
this.events = fileEvents.slice(-this.maxInMemoryEvents);
|
|
430
|
+
// No SQLite yet but JSONL exists: seed SQLite from the audit log so the
|
|
431
|
+
// authoritative store is initialised from the same source of truth.
|
|
432
|
+
// Only when JSONL and SQLite share a directory (paired stores).
|
|
433
|
+
if (this.sqliteDriver && this.events.length > 0 && this.sqlitePath &&
|
|
434
|
+
path.dirname(this.storagePath) === path.dirname(this.sqlitePath)) {
|
|
435
|
+
this.rebuildFromJsonl();
|
|
436
|
+
this.seqCounter = this.sqliteDriver.getLatestSequence();
|
|
437
|
+
this.events = this.sqliteDriver.getRecentEvents(this.maxInMemoryEvents);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Scan all JSONL files for the authoritative max seq and event count.
|
|
443
|
+
* Returns { count: 0, maxSeq: 0 } when no JSONL is present.
|
|
444
|
+
*/
|
|
445
|
+
readJsonlStats() {
|
|
446
|
+
if (!this.storagePath)
|
|
447
|
+
return { count: 0, maxSeq: 0 };
|
|
448
|
+
const paths = this.getJsonlPaths();
|
|
449
|
+
let count = 0;
|
|
450
|
+
let maxSeq = 0;
|
|
451
|
+
for (const jsonlPath of paths) {
|
|
452
|
+
if (!fs.existsSync(jsonlPath))
|
|
453
|
+
continue;
|
|
454
|
+
const content = fs.readFileSync(jsonlPath, 'utf8');
|
|
455
|
+
const lines = content.split('\n').filter(l => l.trim().length > 0);
|
|
456
|
+
for (const line of lines) {
|
|
457
|
+
try {
|
|
458
|
+
const event = JSON.parse(line);
|
|
459
|
+
count += 1;
|
|
460
|
+
if (event.seq > maxSeq)
|
|
461
|
+
maxSeq = event.seq;
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
// skip malformed lines
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return { count, maxSeq };
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Rebuild SQLite from JSONL. Reads every JSONL file, deduplicates by event
|
|
472
|
+
* id (keeping the highest seq on conflict), clears SQLite, and re-inserts
|
|
473
|
+
* in seq order preserving original seq numbers. seq_counter is reset to the
|
|
474
|
+
* max seq. In-memory state is left untouched; callers must refresh it.
|
|
475
|
+
*/
|
|
476
|
+
rebuildFromJsonl() {
|
|
477
|
+
if (!this.sqliteDriver) {
|
|
478
|
+
throw new Error('rebuildFromJsonl requires a SQLite driver to be enabled');
|
|
479
|
+
}
|
|
480
|
+
if (!this.storagePath) {
|
|
481
|
+
throw new Error('rebuildFromJsonl requires a storagePath (JSONL audit log)');
|
|
482
|
+
}
|
|
483
|
+
const events = this.readAllJsonlEvents();
|
|
484
|
+
const deduped = this.deduplicateById(events);
|
|
485
|
+
deduped.sort((a, b) => a.seq - b.seq);
|
|
486
|
+
this.sqliteDriver.rebuildEvents(deduped);
|
|
487
|
+
return deduped.length;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Read every event from every JSONL file (active + archived segments).
|
|
491
|
+
*/
|
|
492
|
+
readAllJsonlEvents() {
|
|
493
|
+
if (!this.storagePath)
|
|
494
|
+
return [];
|
|
495
|
+
const paths = this.getJsonlPaths();
|
|
496
|
+
const events = [];
|
|
497
|
+
for (const jsonlPath of paths) {
|
|
498
|
+
if (!fs.existsSync(jsonlPath))
|
|
499
|
+
continue;
|
|
500
|
+
const content = fs.readFileSync(jsonlPath, 'utf8');
|
|
501
|
+
const lines = content.split('\n').filter(l => l.trim().length > 0);
|
|
502
|
+
for (const line of lines) {
|
|
503
|
+
try {
|
|
504
|
+
events.push(JSON.parse(line));
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
// skip malformed lines
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return events;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Deduplicate events by id, keeping the entry with the highest seq.
|
|
515
|
+
*/
|
|
516
|
+
deduplicateById(events) {
|
|
517
|
+
const byId = new Map();
|
|
518
|
+
for (const event of events) {
|
|
519
|
+
const existing = byId.get(event.id);
|
|
520
|
+
if (!existing || event.seq > existing.seq) {
|
|
521
|
+
byId.set(event.id, event);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return Array.from(byId.values());
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Backfill JSONL with events SQLite has that JSONL is missing. SQLite is
|
|
528
|
+
* authoritative; this keeps the audit log complete when a JSONL write
|
|
529
|
+
* failed mid-write. Best-effort: failures are logged, not thrown.
|
|
530
|
+
*/
|
|
531
|
+
syncJsonlToSqlite() {
|
|
532
|
+
if (!this.sqliteDriver || !this.storagePath)
|
|
533
|
+
return 0;
|
|
534
|
+
const sqliteEvents = this.sqliteDriver.queryEvents();
|
|
535
|
+
const sqliteIds = new Set(sqliteEvents.map(event => event.id));
|
|
536
|
+
const jsonlEvents = this.readAllJsonlEvents();
|
|
537
|
+
const jsonlIds = new Set(jsonlEvents.map(event => event.id));
|
|
538
|
+
const missing = sqliteEvents.filter(event => !jsonlIds.has(event.id));
|
|
539
|
+
if (missing.length === 0)
|
|
540
|
+
return 0;
|
|
541
|
+
try {
|
|
542
|
+
const line = missing.map(event => JSON.stringify(event)).join('\n') + '\n';
|
|
543
|
+
fs.appendFileSync(this.storagePath, line, 'utf8');
|
|
544
|
+
return missing.length;
|
|
545
|
+
}
|
|
546
|
+
catch (err) {
|
|
547
|
+
console.warn('EventStore: failed to backfill JSONL from SQLite', err);
|
|
548
|
+
return 0;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
getJsonlPaths() {
|
|
552
|
+
if (!this.storagePath)
|
|
553
|
+
return [];
|
|
554
|
+
const dir = path.dirname(this.storagePath);
|
|
555
|
+
if (!fs.existsSync(dir))
|
|
556
|
+
return [];
|
|
557
|
+
const activeName = path.basename(this.storagePath);
|
|
558
|
+
const baseName = activeName.replace(/\.jsonl$/, '');
|
|
559
|
+
const archived = fs.readdirSync(dir)
|
|
560
|
+
.filter(name => new RegExp(`^${baseName}-\\d{6}\\.jsonl$`).test(name))
|
|
561
|
+
.sort()
|
|
562
|
+
.map(name => path.join(dir, name));
|
|
563
|
+
return [...archived, ...(fs.existsSync(this.storagePath) ? [this.storagePath] : [])];
|
|
564
|
+
}
|
|
565
|
+
rotateJsonlIfNeeded(nextLineBytes) {
|
|
566
|
+
if (!this.storagePath || !fs.existsSync(this.storagePath))
|
|
567
|
+
return;
|
|
568
|
+
const currentBytes = fs.statSync(this.storagePath).size;
|
|
569
|
+
if (currentBytes === 0 || currentBytes + nextLineBytes <= this.maxJsonlBytes)
|
|
570
|
+
return;
|
|
571
|
+
const dir = path.dirname(this.storagePath);
|
|
572
|
+
const baseName = path.basename(this.storagePath).replace(/\.jsonl$/, '');
|
|
573
|
+
let segment = 1;
|
|
574
|
+
let archivePath = path.join(dir, `${baseName}-${String(segment).padStart(6, '0')}.jsonl`);
|
|
575
|
+
while (fs.existsSync(archivePath)) {
|
|
576
|
+
segment += 1;
|
|
577
|
+
archivePath = path.join(dir, `${baseName}-${String(segment).padStart(6, '0')}.jsonl`);
|
|
578
|
+
}
|
|
579
|
+
fs.renameSync(this.storagePath, archivePath);
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Append a new event to the immutable log.
|
|
583
|
+
*
|
|
584
|
+
* SQLite is the authoritative store when enabled; JSONL is an append-only
|
|
585
|
+
* audit mirror that can optionally be checked into git. If JSONL mirroring
|
|
586
|
+
* fails the run stays live because SQLite already persisted the event.
|
|
587
|
+
*/
|
|
588
|
+
append(type, payload, metadata = {}) {
|
|
589
|
+
this.seqCounter = this.sqliteDriver ? this.sqliteDriver.nextSequence() : this.seqCounter + 1;
|
|
590
|
+
const event = {
|
|
591
|
+
id: createSortableId(),
|
|
592
|
+
seq: this.seqCounter,
|
|
593
|
+
timestamp: new Date().toISOString(),
|
|
594
|
+
type,
|
|
595
|
+
payload,
|
|
596
|
+
source: metadata.source,
|
|
597
|
+
causationId: metadata.causationId,
|
|
598
|
+
state: metadata.state,
|
|
599
|
+
event_id: '',
|
|
600
|
+
occurred_at: new Date().toISOString(),
|
|
601
|
+
event_type: type,
|
|
602
|
+
causation_id: metadata.causationId,
|
|
603
|
+
correlation_id: metadata.correlationId || this.eventContext.correlation_id,
|
|
604
|
+
request_id: metadata.requestId || this.eventContext.request_id,
|
|
605
|
+
trace_parent: metadata.traceParent || this.eventContext.trace_parent,
|
|
606
|
+
skill_id: metadata.skillId || this.eventContext.skill_id,
|
|
607
|
+
run_id: metadata.runId || this.eventContext.run_id,
|
|
608
|
+
parent_run_id: metadata.parentRunId || this.eventContext.parent_run_id,
|
|
609
|
+
schema_version: this.eventContext.schema_version,
|
|
610
|
+
};
|
|
611
|
+
event.event_id = event.id;
|
|
612
|
+
// 1. Authoritative SQLite persistence
|
|
613
|
+
if (this.sqliteDriver) {
|
|
614
|
+
try {
|
|
615
|
+
this.sqliteDriver.insertEvent(event);
|
|
616
|
+
}
|
|
617
|
+
catch (err) {
|
|
618
|
+
console.error('Error writing event to SQLite driver:', err);
|
|
619
|
+
throw new Error('Event persistence failed: SQLite write did not complete.', { cause: err });
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
// 2. Append to JSONL stream
|
|
623
|
+
if (this.storagePath) {
|
|
624
|
+
try {
|
|
625
|
+
const line = JSON.stringify(event) + '\n';
|
|
626
|
+
this.rotateJsonlIfNeeded(Buffer.byteLength(line, 'utf8'));
|
|
627
|
+
fs.appendFileSync(this.storagePath, line, 'utf8');
|
|
628
|
+
}
|
|
629
|
+
catch (err) {
|
|
630
|
+
if (this.sqliteDriver) {
|
|
631
|
+
// SQLite is authoritative when enabled; keep execution live if optional JSONL mirroring fails.
|
|
632
|
+
console.warn('Warning writing event to JSONL log; SQLite persistence remains authoritative.', err);
|
|
633
|
+
}
|
|
634
|
+
else {
|
|
635
|
+
console.error('Error writing event to JSONL log:', err);
|
|
636
|
+
throw new Error('Event persistence failed: JSONL write did not complete.', { cause: err });
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
// 3. Update bounded in-memory ring-buffer
|
|
641
|
+
this.events.push(event);
|
|
642
|
+
if (this.events.length > this.maxInMemoryEvents) {
|
|
643
|
+
this.events.shift(); // Evict oldest from RAM; preserved forever in SQLite/JSONL
|
|
644
|
+
}
|
|
645
|
+
// 4. Notify live listeners
|
|
646
|
+
for (const listener of this.listeners) {
|
|
647
|
+
try {
|
|
648
|
+
listener(event);
|
|
649
|
+
}
|
|
650
|
+
catch (err) {
|
|
651
|
+
console.error('Error in event store listener:', err);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return event;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Subscribe to new events
|
|
658
|
+
*/
|
|
659
|
+
subscribe(listener) {
|
|
660
|
+
this.listeners.add(listener);
|
|
661
|
+
return () => this.listeners.delete(listener);
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Get all recorded events (delegates to SQLite if available, otherwise in-memory buffer)
|
|
665
|
+
*/
|
|
666
|
+
getAll() {
|
|
667
|
+
if (this.sqliteDriver) {
|
|
668
|
+
return this.sqliteDriver.queryEvents();
|
|
669
|
+
}
|
|
670
|
+
return [...this.events];
|
|
671
|
+
}
|
|
672
|
+
getSince(seq) {
|
|
673
|
+
if (this.sqliteDriver) {
|
|
674
|
+
return this.sqliteDriver.queryEvents({ sinceSeq: seq });
|
|
675
|
+
}
|
|
676
|
+
return this.events.filter(event => event.seq > seq);
|
|
677
|
+
}
|
|
678
|
+
getLatestSequence() {
|
|
679
|
+
if (this.sqliteDriver) {
|
|
680
|
+
const latest = this.sqliteDriver.querySql('SELECT seq FROM events ORDER BY seq DESC LIMIT 1');
|
|
681
|
+
return latest.length > 0 ? Number(latest[0].seq) : 0;
|
|
682
|
+
}
|
|
683
|
+
return this.events.length > 0 ? this.events[this.events.length - 1].seq : 0;
|
|
684
|
+
}
|
|
685
|
+
getEventContext() {
|
|
686
|
+
return { ...this.eventContext };
|
|
687
|
+
}
|
|
688
|
+
saveProjectionWatermark(name, eventSeq, projectionVersion) {
|
|
689
|
+
const watermark = { eventSeq, projectionVersion };
|
|
690
|
+
this.projectionWatermarks.set(name, watermark);
|
|
691
|
+
this.sqliteDriver?.saveProjectionWatermark(name, eventSeq, projectionVersion);
|
|
692
|
+
}
|
|
693
|
+
getProjectionWatermark(name) {
|
|
694
|
+
if (this.sqliteDriver) {
|
|
695
|
+
return this.sqliteDriver.getProjectionWatermark(name);
|
|
696
|
+
}
|
|
697
|
+
return this.projectionWatermarks.get(name) || null;
|
|
698
|
+
}
|
|
699
|
+
saveSnapshot(seq, state, context) {
|
|
700
|
+
this.latestSnapshot = { seq, state, context: { ...context } };
|
|
701
|
+
if (this.sqliteDriver) {
|
|
702
|
+
this.sqliteDriver.saveSnapshot(seq, state, context);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
getLatestSnapshot() {
|
|
706
|
+
if (this.sqliteDriver) {
|
|
707
|
+
return this.sqliteDriver.getLatestSnapshot();
|
|
708
|
+
}
|
|
709
|
+
return this.latestSnapshot ? { ...this.latestSnapshot, context: { ...this.latestSnapshot.context } } : null;
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Query recorded events (relational index in SQLite, or in-memory filter)
|
|
713
|
+
*/
|
|
714
|
+
query(filter = {}) {
|
|
715
|
+
if (this.sqliteDriver) {
|
|
716
|
+
return this.sqliteDriver.queryEvents(filter);
|
|
717
|
+
}
|
|
718
|
+
const filtered = this.events.filter(e => {
|
|
719
|
+
if (filter.type && e.type !== filter.type)
|
|
720
|
+
return false;
|
|
721
|
+
if (filter.state && e.state !== filter.state)
|
|
722
|
+
return false;
|
|
723
|
+
if (filter.sinceSeq !== undefined && e.seq <= filter.sinceSeq)
|
|
724
|
+
return false;
|
|
725
|
+
return true;
|
|
726
|
+
});
|
|
727
|
+
return filter.limit ? filtered.slice(0, filter.limit) : filtered;
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Close storage drivers and release file handles
|
|
731
|
+
*/
|
|
732
|
+
close() {
|
|
733
|
+
if (this.sqliteDriver) {
|
|
734
|
+
this.sqliteDriver.close();
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Get SQLite driver instance if enabled
|
|
739
|
+
*/
|
|
740
|
+
getSqliteDriver() {
|
|
741
|
+
return this.sqliteDriver;
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Clear in-memory and file events (for test isolation)
|
|
745
|
+
*/
|
|
746
|
+
clear() {
|
|
747
|
+
this.events = [];
|
|
748
|
+
this.seqCounter = 0;
|
|
749
|
+
this.projectionWatermarks.clear();
|
|
750
|
+
this.latestSnapshot = null;
|
|
751
|
+
if (this.sqliteDriver) {
|
|
752
|
+
this.sqliteDriver.clear();
|
|
753
|
+
}
|
|
754
|
+
if (this.storagePath && fs.existsSync(this.storagePath)) {
|
|
755
|
+
fs.unlinkSync(this.storagePath);
|
|
756
|
+
}
|
|
757
|
+
for (const jsonlPath of this.getJsonlPaths()) {
|
|
758
|
+
if (jsonlPath !== this.storagePath && fs.existsSync(jsonlPath))
|
|
759
|
+
fs.unlinkSync(jsonlPath);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|