@rohirik/openltm-core 2.10.0 → 2.12.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/package.json +6 -3
- package/src/__tests__/cli/memory.test.ts +186 -0
- package/src/__tests__/mcp-server.test.ts +39 -0
- package/src/cli/bin.ts +22 -6
- package/src/cli/memory.ts +305 -0
- package/src/mcp/server.ts +449 -0
- package/src/migrations.ts +370 -8
package/src/migrations.ts
CHANGED
|
@@ -210,6 +210,352 @@ export function parseMigration(content: string): ParsedMigration {
|
|
|
210
210
|
return { up, down };
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
// ── R-2: fail-closed self-heal gate ─────────────────────────────────────────────
|
|
214
|
+
// The old runPendingMigrations() catch treated any `duplicate column name` /
|
|
215
|
+
// `already exists` error as proof the migration could be recorded. That is
|
|
216
|
+
// unsound: db.exec(up) aborts at the FIRST failing statement, so the error only
|
|
217
|
+
// proves the first colliding statement — nothing about later columns, tables,
|
|
218
|
+
// indexes, or backfills. The recorder must never trust the error string; it must
|
|
219
|
+
// independently prove the whole post-migration state against the live schema.
|
|
220
|
+
|
|
221
|
+
function skipWs(s: string, pos: number): number {
|
|
222
|
+
while (pos < s.length && /\s/.test(s.charAt(pos))) pos++;
|
|
223
|
+
return pos;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Match a single SQL keyword at `pos` (whitespace before it is skipped),
|
|
228
|
+
* case-insensitively. Returns the position just past the keyword, or null if
|
|
229
|
+
* `kw` does not appear there as a whole word.
|
|
230
|
+
*/
|
|
231
|
+
function matchKeyword(s: string, pos: number, kw: string): number | null {
|
|
232
|
+
const p = skipWs(s, pos);
|
|
233
|
+
const raw = s.slice(p, p + kw.length);
|
|
234
|
+
if (raw.toUpperCase() !== kw) return null;
|
|
235
|
+
const after = p + kw.length;
|
|
236
|
+
if (after < s.length && /[A-Za-z0-9_]/.test(s.charAt(after))) return null;
|
|
237
|
+
return after;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* If the text at `pos` begins `IF NOT EXISTS`, return the position just past
|
|
242
|
+
* it; otherwise return `pos` unchanged. Only the exact `IF NOT EXISTS` keyword
|
|
243
|
+
* sequence is recognised.
|
|
244
|
+
*/
|
|
245
|
+
function consumeIfNotExists(s: string, pos: number): number {
|
|
246
|
+
const ifEnd = matchKeyword(s, pos, "IF");
|
|
247
|
+
if (ifEnd === null) return pos;
|
|
248
|
+
const notEnd = matchKeyword(s, ifEnd, "NOT");
|
|
249
|
+
if (notEnd === null) return pos;
|
|
250
|
+
const existsEnd = matchKeyword(s, notEnd, "EXISTS");
|
|
251
|
+
return existsEnd === null ? pos : existsEnd;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Match a single SQL identifier starting at `pos`: a bare `[A-Za-z_][A-Za-z0-9_]*`
|
|
256
|
+
* name or one of the quoted forms `"..."`, `` `...` ``, `[...]`. Returns the
|
|
257
|
+
* unquoted name and the position just past the identifier, or null if none starts
|
|
258
|
+
* there.
|
|
259
|
+
*/
|
|
260
|
+
function matchIdent(s: string, pos: number): { name: string; end: number } | null {
|
|
261
|
+
if (pos >= s.length) return null;
|
|
262
|
+
const ch = s.charAt(pos);
|
|
263
|
+
if (ch === '"' || ch === "`") {
|
|
264
|
+
let i = pos + 1;
|
|
265
|
+
let name = "";
|
|
266
|
+
for (; i < s.length; i++) {
|
|
267
|
+
if (s.charAt(i) === ch) {
|
|
268
|
+
if (s.charAt(i + 1) === ch) {
|
|
269
|
+
name += ch;
|
|
270
|
+
i++;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
return { name, end: i + 1 };
|
|
274
|
+
}
|
|
275
|
+
name += s.charAt(i);
|
|
276
|
+
}
|
|
277
|
+
return null; // unterminated quote
|
|
278
|
+
}
|
|
279
|
+
if (ch === "[") {
|
|
280
|
+
const end = s.indexOf("]", pos + 1);
|
|
281
|
+
if (end === -1) return null;
|
|
282
|
+
return { name: s.slice(pos + 1, end), end: end + 1 };
|
|
283
|
+
}
|
|
284
|
+
const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(s.slice(pos));
|
|
285
|
+
return m ? { name: m[0], end: pos + m[0].length } : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* True when `tail` (the column-definition remainder of an `ALTER TABLE ... ADD
|
|
290
|
+
* COLUMN <col>` statement) contains nothing beyond a plain column definition.
|
|
291
|
+
* Rejects a top-level comma (SQLite multi-action ALTER) and any top-level
|
|
292
|
+
* DROP/RENAME keyword, so a statement such as `ALTER TABLE t ADD COLUMN a, DROP
|
|
293
|
+
* COLUMN b` can never slip through. Commas and keywords inside parentheses or
|
|
294
|
+
* string literals are allowed (CHECK constraints, functional defaults).
|
|
295
|
+
*/
|
|
296
|
+
function isColumnDefinitionTail(tail: string): boolean {
|
|
297
|
+
let depth = 0;
|
|
298
|
+
let quote: string | null = null;
|
|
299
|
+
for (let i = 0; i < tail.length; i++) {
|
|
300
|
+
const ch = tail.charAt(i);
|
|
301
|
+
if (quote !== null) {
|
|
302
|
+
if (ch === quote) {
|
|
303
|
+
if (tail.charAt(i + 1) === quote) {
|
|
304
|
+
i++; // doubled quote inside a string literal
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
quote = null;
|
|
308
|
+
}
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (ch === "'" || ch === '"' || ch === "`") {
|
|
312
|
+
quote = ch;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (ch === "(") {
|
|
316
|
+
depth++;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (ch === ")") {
|
|
320
|
+
depth = Math.max(0, depth - 1);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (depth === 0 && ch === ",") return false;
|
|
324
|
+
if (
|
|
325
|
+
depth === 0 &&
|
|
326
|
+
((ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z") || ch === "_")
|
|
327
|
+
) {
|
|
328
|
+
if (/^(DROP|RENAME)\b/i.test(tail.slice(i))) return false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export type DdlTargetKind =
|
|
335
|
+
| "alter-add-column"
|
|
336
|
+
| "create-table"
|
|
337
|
+
| "create-index"
|
|
338
|
+
| "create-trigger";
|
|
339
|
+
|
|
340
|
+
export interface DdlTarget {
|
|
341
|
+
kind: DdlTargetKind;
|
|
342
|
+
/** Table / index / trigger name that must exist in the live schema. */
|
|
343
|
+
name: string;
|
|
344
|
+
/** Column name that must exist on `name` (only for "alter-add-column"). */
|
|
345
|
+
column?: string;
|
|
346
|
+
/** Table an index is built on (informational — the index itself is verified). */
|
|
347
|
+
onTable?: string;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Parse a single SQL statement into one of the four DDL forms the self-heal
|
|
352
|
+
* gate can prove against the live schema:
|
|
353
|
+
*
|
|
354
|
+
* - `ALTER TABLE <t> ADD [COLUMN] <c> <column-definition>`
|
|
355
|
+
* - `CREATE TABLE [IF NOT EXISTS] <t> ( ... )`
|
|
356
|
+
* - `CREATE [UNIQUE] INDEX [IF NOT EXISTS] <i> ON <t> ( ... )`
|
|
357
|
+
* - `CREATE [TEMP|TEMPORARY] TRIGGER [IF NOT EXISTS] <t> ...`
|
|
358
|
+
*
|
|
359
|
+
* Returns null for anything else — UPDATE / INSERT / DELETE, DROP, ALTER ...
|
|
360
|
+
* RENAME, CREATE VIEW / VIRTUAL TABLE, PRAGMA writes, VACUUM, REPLACE,
|
|
361
|
+
* multi-action ALTER — so the gate refuses to self-heal whenever any statement
|
|
362
|
+
* is not one of the supported forms.
|
|
363
|
+
*/
|
|
364
|
+
export function parseStatementDdl(stmt: string): DdlTarget | null {
|
|
365
|
+
const s = stmt.trim();
|
|
366
|
+
if (s.length === 0) return null;
|
|
367
|
+
const start = skipWs(s, 0);
|
|
368
|
+
|
|
369
|
+
// ── CREATE forms ──────────────────────────────────────────────────────────
|
|
370
|
+
const createEnd = matchKeyword(s, start, "CREATE");
|
|
371
|
+
if (createEnd !== null) {
|
|
372
|
+
// CREATE TABLE [IF NOT EXISTS] <t> ( ... )
|
|
373
|
+
const tableEnd = matchKeyword(s, createEnd, "TABLE");
|
|
374
|
+
if (tableEnd !== null) {
|
|
375
|
+
const ident = matchIdent(s, skipWs(s, consumeIfNotExists(s, tableEnd)));
|
|
376
|
+
if (ident === null) return null;
|
|
377
|
+
const after = skipWs(s, ident.end);
|
|
378
|
+
// Require the column-list form — rejects `CREATE TABLE ... AS SELECT`.
|
|
379
|
+
if (after >= s.length || s.charAt(after) !== "(") return null;
|
|
380
|
+
return { kind: "create-table", name: ident.name };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// CREATE [UNIQUE] INDEX [IF NOT EXISTS] <i> ON <t> ( ... )
|
|
384
|
+
const uniqueEnd = matchKeyword(s, createEnd, "UNIQUE");
|
|
385
|
+
const indexStart = uniqueEnd !== null ? uniqueEnd : createEnd;
|
|
386
|
+
const indexEnd = matchKeyword(s, indexStart, "INDEX");
|
|
387
|
+
if (indexEnd !== null) {
|
|
388
|
+
const ident = matchIdent(s, skipWs(s, consumeIfNotExists(s, indexEnd)));
|
|
389
|
+
if (ident === null) return null;
|
|
390
|
+
const onEnd = matchKeyword(s, ident.end, "ON");
|
|
391
|
+
if (onEnd === null) return null;
|
|
392
|
+
const tableIdent = matchIdent(s, skipWs(s, onEnd));
|
|
393
|
+
if (tableIdent === null) return null;
|
|
394
|
+
const after = skipWs(s, tableIdent.end);
|
|
395
|
+
if (after >= s.length || s[after] !== "(") return null;
|
|
396
|
+
return { kind: "create-index", name: ident.name, onTable: tableIdent.name };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// CREATE [TEMP | TEMPORARY] TRIGGER [IF NOT EXISTS] <i> ...
|
|
400
|
+
const tempEnd = matchKeyword(s, createEnd, "TEMP");
|
|
401
|
+
const tmpEnd = tempEnd !== null ? tempEnd : matchKeyword(s, createEnd, "TEMPORARY");
|
|
402
|
+
const triggerEnd = matchKeyword(s, tmpEnd ?? createEnd, "TRIGGER");
|
|
403
|
+
if (triggerEnd !== null) {
|
|
404
|
+
const ident = matchIdent(s, skipWs(s, consumeIfNotExists(s, triggerEnd)));
|
|
405
|
+
if (ident === null) return null;
|
|
406
|
+
return { kind: "create-trigger", name: ident.name };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return null; // CREATE VIEW / CREATE VIRTUAL TABLE / any other CREATE form
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── ALTER TABLE <t> ADD [COLUMN] <c> <column-definition> ──────────────────
|
|
413
|
+
const alterEnd = matchKeyword(s, start, "ALTER");
|
|
414
|
+
if (alterEnd !== null) {
|
|
415
|
+
const tableEnd = matchKeyword(s, alterEnd, "TABLE");
|
|
416
|
+
if (tableEnd === null) return null;
|
|
417
|
+
const tableIdent = matchIdent(s, skipWs(s, tableEnd));
|
|
418
|
+
if (tableIdent === null) return null;
|
|
419
|
+
const addEnd = matchKeyword(s, tableIdent.end, "ADD");
|
|
420
|
+
if (addEnd === null) return null;
|
|
421
|
+
const columnEnd = matchKeyword(s, addEnd, "COLUMN");
|
|
422
|
+
const columnStart = columnEnd !== null ? columnEnd : addEnd;
|
|
423
|
+
const columnIdent = matchIdent(s, skipWs(s, columnStart));
|
|
424
|
+
if (columnIdent === null) return null;
|
|
425
|
+
if (!isColumnDefinitionTail(s.slice(skipWs(s, columnIdent.end)))) return null;
|
|
426
|
+
return { kind: "alter-add-column", name: tableIdent.name, column: columnIdent.name };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function quoteIdent(name: string): string {
|
|
433
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** True when `column` exists on `table` in the live schema (PRAGMA table_info). */
|
|
437
|
+
function columnExists(db: Database, table: string, column: string): boolean {
|
|
438
|
+
const rows = db
|
|
439
|
+
.query<{ name: string }, []>(`PRAGMA table_info(${quoteIdent(table)})`)
|
|
440
|
+
.all();
|
|
441
|
+
return rows.some((r) => r.name === column);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** True when a table / index / trigger of `name` exists in the live schema. */
|
|
445
|
+
function schemaObjectExists(
|
|
446
|
+
db: Database,
|
|
447
|
+
type: "table" | "index" | "trigger",
|
|
448
|
+
name: string,
|
|
449
|
+
): boolean {
|
|
450
|
+
const row = db
|
|
451
|
+
.query<{ cnt: number }, [string, string]>(
|
|
452
|
+
"SELECT COUNT(*) AS cnt FROM sqlite_master WHERE type = ? AND name = ?",
|
|
453
|
+
)
|
|
454
|
+
.get(type, name);
|
|
455
|
+
return (row?.cnt ?? 0) > 0;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Narrow, source-independent recovery gate. Called from the error catch in
|
|
460
|
+
* `runPendingMigrations` instead of trusting the error string.
|
|
461
|
+
*
|
|
462
|
+
* Splits `up` into statements and requires every statement to be one of the
|
|
463
|
+
* four supported DDL forms (`parseStatementDdl`), then verifies every declared
|
|
464
|
+
* schema target (column / table / index / trigger) against the LIVE schema. If
|
|
465
|
+
* any statement is unrecognised, data-changing, or destructive, or any declared
|
|
466
|
+
* target is missing, this throws and the runner fails closed — it refuses to
|
|
467
|
+
* record the version.
|
|
468
|
+
*
|
|
469
|
+
* Empty marker migrations (no DDL) never reach the caller's catch and return
|
|
470
|
+
* here defensively with nothing to prove.
|
|
471
|
+
*/
|
|
472
|
+
export function assertSelfHealEligible(db: Database, up: string): void {
|
|
473
|
+
const statements = up
|
|
474
|
+
.split(";")
|
|
475
|
+
.map((stmt) => stmt.trim())
|
|
476
|
+
.filter((stmt) => stmt.length > 0);
|
|
477
|
+
|
|
478
|
+
if (statements.length === 0) return;
|
|
479
|
+
|
|
480
|
+
const targets: DdlTarget[] = [];
|
|
481
|
+
for (const statement of statements) {
|
|
482
|
+
const target = parseStatementDdl(statement);
|
|
483
|
+
if (target === null) {
|
|
484
|
+
throw new Error(
|
|
485
|
+
`statement is not a supported idempotent DDL form (ALTER TABLE ... ADD COLUMN, ` +
|
|
486
|
+
`CREATE TABLE, CREATE INDEX, CREATE TRIGGER): "${statement}"`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
targets.push(target);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
for (const target of targets) {
|
|
493
|
+
switch (target.kind) {
|
|
494
|
+
case "alter-add-column":
|
|
495
|
+
if (!columnExists(db, target.name, target.column!)) {
|
|
496
|
+
throw new Error(
|
|
497
|
+
`column "${target.column}" is missing on table "${target.name}"`,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
break;
|
|
501
|
+
case "create-table":
|
|
502
|
+
if (!schemaObjectExists(db, "table", target.name)) {
|
|
503
|
+
throw new Error(`table "${target.name}" does not exist`);
|
|
504
|
+
}
|
|
505
|
+
break;
|
|
506
|
+
case "create-index":
|
|
507
|
+
if (!schemaObjectExists(db, "index", target.name)) {
|
|
508
|
+
throw new Error(`index "${target.name}" does not exist`);
|
|
509
|
+
}
|
|
510
|
+
break;
|
|
511
|
+
case "create-trigger":
|
|
512
|
+
if (!schemaObjectExists(db, "trigger", target.name)) {
|
|
513
|
+
throw new Error(`trigger "${target.name}" does not exist`);
|
|
514
|
+
}
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Integrity check that runs at the top of `runPendingMigrations`, before any
|
|
522
|
+
* pending work is selected. For every applied version it recomputes
|
|
523
|
+
* `sha256(file.content)` from the on-disk migration file and requires exact
|
|
524
|
+
* equality with the recorded checksum. An edited or renamed migration file, or
|
|
525
|
+
* a missing file for an applied version, stops the whole run. There is no
|
|
526
|
+
* allowlist to bypass this — recorded state is never trusted.
|
|
527
|
+
*/
|
|
528
|
+
export function verifyRecordedChecksums(
|
|
529
|
+
db: Database,
|
|
530
|
+
files: MigrationFile[],
|
|
531
|
+
applied: Set<number>,
|
|
532
|
+
): void {
|
|
533
|
+
if (applied.size === 0) return;
|
|
534
|
+
|
|
535
|
+
const fileByVersion = new Map(files.map((f) => [f.version, f] as const));
|
|
536
|
+
const rows = db
|
|
537
|
+
.query<{ version: number; checksum: string }, []>(
|
|
538
|
+
"SELECT version, checksum FROM _schema_version",
|
|
539
|
+
)
|
|
540
|
+
.all();
|
|
541
|
+
|
|
542
|
+
for (const row of rows) {
|
|
543
|
+
const file = fileByVersion.get(row.version);
|
|
544
|
+
if (file === undefined) {
|
|
545
|
+
throw new Error(
|
|
546
|
+
`[migrations] fail closed: applied version ${row.version} has no matching migration file`,
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
const current = computeChecksum(file.content);
|
|
550
|
+
if (current !== row.checksum) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
`[migrations] fail closed: checksum mismatch for applied version ${row.version} ` +
|
|
553
|
+
`(${file.name}); recorded ${row.checksum} != current ${current}. Refusing to continue.`,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
213
559
|
// ── Core migration actions ─────────────────────────────────────────────────────
|
|
214
560
|
|
|
215
561
|
export interface MigrationResult {
|
|
@@ -224,6 +570,13 @@ export async function runPendingMigrations(db?: Database): Promise<MigrationResu
|
|
|
224
570
|
|
|
225
571
|
const files = await getMigrationFiles();
|
|
226
572
|
const applied = getAppliedVersions(_db);
|
|
573
|
+
|
|
574
|
+
// R-2: before any pending work is selected, prove every already-recorded
|
|
575
|
+
// version still matches its on-disk migration file. An edited or renamed
|
|
576
|
+
// migration file, or a missing file, stops the whole run — recorded state
|
|
577
|
+
// is never trusted.
|
|
578
|
+
verifyRecordedChecksums(_db, files, applied);
|
|
579
|
+
|
|
227
580
|
const pending = files.filter((f) => !applied.has(f.version));
|
|
228
581
|
|
|
229
582
|
if (pending.length === 0) return [];
|
|
@@ -245,16 +598,25 @@ export async function runPendingMigrations(db?: Database): Promise<MigrationResu
|
|
|
245
598
|
);
|
|
246
599
|
})();
|
|
247
600
|
} catch (err: unknown) {
|
|
248
|
-
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
601
|
+
// R-2: a duplicate-column / already-exists error only proves the FIRST
|
|
602
|
+
// colliding statement failed — db.exec(up) aborts there, so later
|
|
603
|
+
// columns, tables, indexes, and backfills may still be missing. Never
|
|
604
|
+
// trust the error string: self-heal only when every statement is a
|
|
605
|
+
// supported idempotent DDL form AND every declared schema target is
|
|
606
|
+
// verifiably present in the live schema. Otherwise fail closed.
|
|
607
|
+
try {
|
|
608
|
+
assertSelfHealEligible(_db, up);
|
|
609
|
+
} catch (selfHealErr: unknown) {
|
|
610
|
+
throw new Error(
|
|
611
|
+
`Migration ${file.version} (${file.name}) failed and is not self-heal eligible; ` +
|
|
612
|
+
`refusing to record it. Original error: ${(err as Error).message}. ` +
|
|
613
|
+
`Self-heal refusal: ${(selfHealErr as Error).message}`,
|
|
254
614
|
);
|
|
255
|
-
} else {
|
|
256
|
-
throw err;
|
|
257
615
|
}
|
|
616
|
+
_db.run(
|
|
617
|
+
`INSERT OR IGNORE INTO _schema_version (version, name, checksum) VALUES (?, ?, ?)`,
|
|
618
|
+
[file.version, file.name, checksum],
|
|
619
|
+
);
|
|
258
620
|
}
|
|
259
621
|
|
|
260
622
|
results.push({ version: file.version, name: file.name, action: "applied" });
|