@ijfw/memory-server 1.3.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.
Files changed (106) hide show
  1. package/bin/ijfw +27 -0
  2. package/bin/ijfw-dashboard +180 -0
  3. package/bin/ijfw-dispatch-plan +41 -0
  4. package/bin/ijfw-memorize +273 -0
  5. package/bin/ijfw-memory +51 -0
  6. package/fixtures/demo-target.js +28 -0
  7. package/package.json +53 -0
  8. package/src/api-client.js +190 -0
  9. package/src/audit-roster.js +315 -0
  10. package/src/caps.js +37 -0
  11. package/src/cold-scan-runner.mjs +37 -0
  12. package/src/compute/edges.js +155 -0
  13. package/src/compute/extract.js +560 -0
  14. package/src/compute/fts5.js +420 -0
  15. package/src/compute/graph-auto-index.js +191 -0
  16. package/src/compute/graph-lock.js +114 -0
  17. package/src/compute/index.js +18 -0
  18. package/src/compute/migration-runner.js +116 -0
  19. package/src/compute/migrations/001-initial.js +23 -0
  20. package/src/compute/migrations/002-porter-stemming-source.js +139 -0
  21. package/src/compute/migrations/003-tier-semantic.js +69 -0
  22. package/src/compute/migrations/004-kg-tables.js +83 -0
  23. package/src/compute/migrations/005-stale-candidate.js +72 -0
  24. package/src/compute/python-resolver.js +106 -0
  25. package/src/compute/runner-vm.js +185 -0
  26. package/src/compute/runner.js +416 -0
  27. package/src/compute/sandbox-detect.js +122 -0
  28. package/src/compute/sandbox-linux.js +164 -0
  29. package/src/compute/sandbox-macos.js +167 -0
  30. package/src/compute/sandbox-windows.js +63 -0
  31. package/src/compute/schema.sql +118 -0
  32. package/src/compute/staleness.js +239 -0
  33. package/src/compute/synonyms.js +367 -0
  34. package/src/compute/traverse.js +180 -0
  35. package/src/cost/aggregator.js +229 -0
  36. package/src/cost/pricing.js +134 -0
  37. package/src/cost/readers/claude.js +179 -0
  38. package/src/cost/readers/codex.js +131 -0
  39. package/src/cost/readers/gemini.js +111 -0
  40. package/src/cost/savings.js +243 -0
  41. package/src/cross-dispatcher.js +437 -0
  42. package/src/cross-orchestrator-cli.js +1885 -0
  43. package/src/cross-orchestrator.js +598 -0
  44. package/src/cross-project-search.js +114 -0
  45. package/src/dashboard-client.html +1180 -0
  46. package/src/dashboard-server.js +895 -0
  47. package/src/design-companion.js +81 -0
  48. package/src/dispatch/colon-syntax.js +732 -0
  49. package/src/dispatch-planner.js +235 -0
  50. package/src/dream/cooldown.js +105 -0
  51. package/src/dream/runner.mjs +373 -0
  52. package/src/dream/staleness-wiring.js +195 -0
  53. package/src/feedback-detector.js +57 -0
  54. package/src/hero-line.js +115 -0
  55. package/src/importers/claude-mem.js +152 -0
  56. package/src/importers/cli.js +311 -0
  57. package/src/importers/common.js +84 -0
  58. package/src/importers/discover.js +235 -0
  59. package/src/importers/rtk.js +107 -0
  60. package/src/intent-router.js +221 -0
  61. package/src/lib/atomic-io.js +201 -0
  62. package/src/lib/cache.js +33 -0
  63. package/src/lib/npm-view.js +104 -0
  64. package/src/lib/status-card.js +95 -0
  65. package/src/lib/token.js +85 -0
  66. package/src/memory/fts5.js +349 -0
  67. package/src/memory/migration-runner.js +116 -0
  68. package/src/memory/migrations/001-fts5-init.js +26 -0
  69. package/src/memory/migrations/002-tier-semantic.js +60 -0
  70. package/src/memory/migrations/003-stale-candidate.js +60 -0
  71. package/src/memory/reader.js +300 -0
  72. package/src/memory/recall-counter.js +76 -0
  73. package/src/memory/schema.sql +79 -0
  74. package/src/memory/search.js +431 -0
  75. package/src/memory/staleness.js +237 -0
  76. package/src/memory/tier-promotion.js +377 -0
  77. package/src/memory/tokenize.js +63 -0
  78. package/src/project-type-detector.js +866 -0
  79. package/src/prompt-check.js +171 -0
  80. package/src/ralph-allowlist.js +88 -0
  81. package/src/receipts.js +129 -0
  82. package/src/redactor.js +107 -0
  83. package/src/sandbox.js +275 -0
  84. package/src/sanitizer.js +69 -0
  85. package/src/scan-resume.js +167 -0
  86. package/src/schema.js +82 -0
  87. package/src/search-bm25.js +108 -0
  88. package/src/server.js +1414 -0
  89. package/src/swarm-config.js +80 -0
  90. package/src/trident/dispatch.js +211 -0
  91. package/src/trident/lens-health.js +253 -0
  92. package/src/update-apply.js +79 -0
  93. package/src/update-check.js +136 -0
  94. package/src/vectors.js +178 -0
  95. package/templates/design/bento-grid.md +84 -0
  96. package/templates/design/brutalist-luxe.md +82 -0
  97. package/templates/design/cinematic-dark.md +82 -0
  98. package/templates/design/data-dense-dashboard.md +88 -0
  99. package/templates/design/editorial-warm.md +81 -0
  100. package/templates/design/glassmorphic.md +84 -0
  101. package/templates/design/magazine-editorial.md +84 -0
  102. package/templates/design/maximalist-vibrant.md +85 -0
  103. package/templates/design/neo-swiss-tech.md +85 -0
  104. package/templates/design/swiss-minimal.md +80 -0
  105. package/templates/design/terminal-native.md +83 -0
  106. package/templates/design/warm-organic.md +84 -0
@@ -0,0 +1,114 @@
1
+ // IJFW v1.3.0 -- D2 graph-write lock.
2
+ //
3
+ // Source authority: D-PILLAR-SPEC.md section 5.
4
+ //
5
+ // Coordinates symbol-graph writes with the existing scan-state lock and
6
+ // session-counter lock. Each lock owns a separate concern and lives in a
7
+ // separate file under `<projectRoot>/.ijfw/`:
8
+ //
9
+ // .scan-state.lock -- Phase 3 cold scan resume coordination
10
+ // .session-counter.lock -- session counter atomicity
11
+ // .graph-write.lock -- D2 kg_nodes/kg_edges write coordination
12
+ //
13
+ // Pattern (mirrors scan-resume.js acquireScanLock):
14
+ // - Exclusive create via `wx` flag (noclobber CAS).
15
+ // - Lock content: `${pid}\n${epoch_ms}\n`
16
+ // - Stale reclamation: 60s OR dead PID.
17
+ // - Reader (BFS traversal) does NOT acquire -- WAL mode handles reads.
18
+ //
19
+ // Concurrent invocation behavior (per D-PILLAR-SPEC section 5):
20
+ // - Writer #1 acquires; writer #2 waits up to 5s polling, then errors
21
+ // with EBUSY_GRAPH_WRITE.
22
+
23
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
24
+ import { join } from 'path';
25
+
26
+ const LOCK_FILE = '.graph-write.lock';
27
+ const LOCK_STALE_MS = 60_000;
28
+ const ACQUIRE_WAIT_MS = 5_000;
29
+ const POLL_INTERVAL_MS = 50;
30
+
31
+ function lockPath(projectRoot) {
32
+ return join(String(projectRoot), '.ijfw', LOCK_FILE);
33
+ }
34
+
35
+ function isPidAlive(pid) {
36
+ if (!Number.isFinite(pid) || pid <= 0) return false;
37
+ try {
38
+ process.kill(pid, 0);
39
+ return true;
40
+ } catch (err) {
41
+ if (err && err.code === 'EPERM') return true;
42
+ return false;
43
+ }
44
+ }
45
+
46
+ function reclaimIfStale(lp) {
47
+ if (!existsSync(lp)) return;
48
+ let raw;
49
+ try { raw = readFileSync(lp, 'utf8'); } catch { return; }
50
+ const lines = String(raw).split(/\r?\n/);
51
+ const pid = Number(lines[0]);
52
+ const ts = Number(lines[1]);
53
+ const ageOk = Number.isFinite(ts) && (Date.now() - ts) <= LOCK_STALE_MS;
54
+ if (isPidAlive(pid) && ageOk) return;
55
+ try { unlinkSync(lp); } catch { /* best-effort */ }
56
+ }
57
+
58
+ /**
59
+ * acquireGraphWriteLock(projectRoot, opts?) -> { released } | throws
60
+ *
61
+ * Acquires the graph-write lock with up to ACQUIRE_WAIT_MS of busy
62
+ * polling. Throws EBUSY_GRAPH_WRITE on timeout. Returns a handle whose
63
+ * `released()` method releases the lock; idempotent.
64
+ *
65
+ * opts.waitMs (default ACQUIRE_WAIT_MS) -- caller-overridable for tests.
66
+ */
67
+ export function acquireGraphWriteLock(projectRoot, opts = {}) {
68
+ const waitMs = Number.isFinite(opts.waitMs) ? opts.waitMs : ACQUIRE_WAIT_MS;
69
+ const dir = join(String(projectRoot), '.ijfw');
70
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
71
+ const lp = lockPath(projectRoot);
72
+
73
+ const deadline = Date.now() + waitMs;
74
+ let _lastErr;
75
+ while (true) {
76
+ reclaimIfStale(lp);
77
+ const payload = String(process.pid) + '\n' + String(Date.now()) + '\n';
78
+ try {
79
+ writeFileSync(lp, payload, { encoding: 'utf8', flag: 'wx' });
80
+ let released = false;
81
+ return {
82
+ released: () => {
83
+ if (released) return;
84
+ released = true;
85
+ try { unlinkSync(lp); } catch { /* best-effort */ }
86
+ },
87
+ };
88
+ } catch (err) {
89
+ lastErr = err;
90
+ if (!err || err.code !== 'EEXIST') throw err;
91
+ }
92
+ if (Date.now() >= deadline) {
93
+ const e = new Error(
94
+ 'EBUSY_GRAPH_WRITE: another writer holds .ijfw/.graph-write.lock; ' +
95
+ `waited ${waitMs}ms.`
96
+ );
97
+ e.code = 'EBUSY_GRAPH_WRITE';
98
+ throw e;
99
+ }
100
+ // Tight polling -- 50ms cadence is fine for short writes.
101
+ sleepSync(POLL_INTERVAL_MS);
102
+ }
103
+ }
104
+
105
+ // Synchronous sleep -- used inside acquireGraphWriteLock's polling loop.
106
+ // Atomics.wait on a SharedArrayBuffer-backed Int32Array is the standard
107
+ // Node sync-sleep idiom. No new deps.
108
+ function sleepSync(ms) {
109
+ const ab = new SharedArrayBuffer(4);
110
+ const ia = new Int32Array(ab);
111
+ Atomics.wait(ia, 0, 0, ms);
112
+ }
113
+
114
+ export const __test = { lockPath, LOCK_STALE_MS, ACQUIRE_WAIT_MS };
@@ -0,0 +1,18 @@
1
+ // IJFW v1.3.0 Alpha -- compute module barrel export.
2
+ //
3
+ // Surfaces the FTS5 layer + migration runner for callers in mcp-server.
4
+ // Intentionally thin: callers should import named symbols from here, not
5
+ // reach into ./fts5.js or ./migration-runner.js directly.
6
+
7
+ export {
8
+ openDb,
9
+ safeWrite,
10
+ search,
11
+ closeDb,
12
+ dbPathFor,
13
+ IntegrityError,
14
+ SchemaVersionError,
15
+ ComputeDbError,
16
+ } from './fts5.js';
17
+
18
+ export { runMigrations, highestKnownVersion } from './migration-runner.js';
@@ -0,0 +1,116 @@
1
+ // IJFW v1.3.0 Alpha -- migration runner for the compute db.
2
+ //
3
+ // Discovers migrations in ./migrations/ matching NNN-name.js, sorts by
4
+ // numeric prefix, and applies each migration whose VERSION exceeds the
5
+ // db's current PRAGMA user_version. Each migration runs inside a single
6
+ // transaction; failure rolls back and halts.
7
+ //
8
+ // Forbids downgrade: if currentVersion > targetVersion (= highest migration
9
+ // VERSION found on disk), throws SchemaVersionError so callers refuse the db
10
+ // rather than silently rebuilding it.
11
+
12
+ import { readdirSync } from 'fs';
13
+ import { join, dirname } from 'path';
14
+ import { fileURLToPath, pathToFileURL } from 'url';
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const MIGRATIONS_DIR = join(__dirname, 'migrations');
18
+
19
+ export class SchemaVersionError extends Error {
20
+ constructor(message) {
21
+ super(message);
22
+ this.name = 'SchemaVersionError';
23
+ }
24
+ }
25
+
26
+ // Discover and load every migration module under ./migrations/, sorted by
27
+ // numeric prefix ascending. Each module must export VERSION (integer),
28
+ // DESCRIPTION (string), and up(db) (function).
29
+ async function loadMigrations() {
30
+ let files;
31
+ try {
32
+ files = readdirSync(MIGRATIONS_DIR);
33
+ } catch {
34
+ return [];
35
+ }
36
+ const matches = files
37
+ .filter(f => /^\d+-.+\.js$/.test(f))
38
+ .sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
39
+ const out = [];
40
+ for (const f of matches) {
41
+ const url = pathToFileURL(join(MIGRATIONS_DIR, f)).href;
42
+ const mod = await import(url);
43
+ if (typeof mod.VERSION !== 'number' || typeof mod.up !== 'function') {
44
+ throw new Error(`Migration ${f} is missing VERSION or up().`);
45
+ }
46
+ out.push({
47
+ file: f,
48
+ version: mod.VERSION,
49
+ description: mod.DESCRIPTION || '',
50
+ up: mod.up,
51
+ });
52
+ }
53
+ // Sort by VERSION ascending and reject duplicates -- defensive against
54
+ // mistakes in numeric prefix vs in-file VERSION.
55
+ out.sort((a, b) => a.version - b.version);
56
+ for (let i = 1; i < out.length; i++) {
57
+ if (out[i].version === out[i - 1].version) {
58
+ throw new Error(`Duplicate migration VERSION: ${out[i].version} (${out[i - 1].file} and ${out[i].file}).`);
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ export async function highestKnownVersion() {
65
+ const migrations = await loadMigrations();
66
+ return migrations.length === 0 ? 0 : migrations[migrations.length - 1].version;
67
+ }
68
+
69
+ // Apply every migration whose version is in (currentVersion, targetVersion].
70
+ // Returns the new version. Throws SchemaVersionError on downgrade.
71
+ export async function runMigrations(db, currentVersion, targetVersion) {
72
+ if (typeof currentVersion !== 'number' || typeof targetVersion !== 'number') {
73
+ throw new Error('runMigrations: currentVersion and targetVersion must be numbers.');
74
+ }
75
+ if (currentVersion > targetVersion) {
76
+ throw new SchemaVersionError(
77
+ `Compute db schema version ${currentVersion} is newer than this build supports (max ${targetVersion}). ` +
78
+ `Refusing to downgrade -- upgrade IJFW or open the db with a newer build.`
79
+ );
80
+ }
81
+ if (currentVersion === targetVersion) return currentVersion;
82
+
83
+ const migrations = await loadMigrations();
84
+ const pending = migrations.filter(m => m.version > currentVersion && m.version <= targetVersion);
85
+ if (pending.length === 0) return currentVersion;
86
+
87
+ let lastApplied = currentVersion;
88
+ for (const m of pending) {
89
+ // Each migration runs in its own transaction. We update user_version +
90
+ // schema_meta in the same tx so a crash mid-migration leaves the db
91
+ // recognisably at the prior version (no half-applied schema).
92
+ //
93
+ // BEGIN IMMEDIATE acquires the write lock at transaction start (paired
94
+ // with PRAGMA busy_timeout=5000 in openDb). Plain BEGIN (deferred) lets
95
+ // two concurrent writers both enter their migration tx, then one gets
96
+ // SQLITE_BUSY when it tries to upgrade -- migration 002's FTS5
97
+ // recreate-with-data path surfaced that race during Phase 5 audit.
98
+ db.exec('BEGIN IMMEDIATE');
99
+ try {
100
+ m.up(db);
101
+ db.exec(`PRAGMA user_version = ${m.version}`);
102
+ const stmt = db.prepare(
103
+ 'INSERT OR IGNORE INTO schema_meta(version, applied_at, description) VALUES (?, ?, ?)'
104
+ );
105
+ stmt.run(m.version, Date.now(), m.description);
106
+ db.exec('COMMIT');
107
+ lastApplied = m.version;
108
+ } catch (err) {
109
+ try { db.exec('ROLLBACK'); } catch { /* ignore */ }
110
+ throw new Error(`Migration ${m.file} (v${m.version}) failed: ${err.message}`);
111
+ }
112
+ }
113
+ return lastApplied;
114
+ }
115
+
116
+ export default { runMigrations, highestKnownVersion, SchemaVersionError };
@@ -0,0 +1,23 @@
1
+ // IJFW v1.3.0 Alpha -- migration 001: apply schema.sql on a fresh db.
2
+ // Idempotent (CREATE TABLE IF NOT EXISTS + INSERT OR IGNORE).
3
+ //
4
+ // Source of truth for DDL: ../schema.sql (see PLAN-alpha.md V3-B4).
5
+
6
+ import { readFileSync } from 'fs';
7
+ import { join, dirname } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const SCHEMA_PATH = join(__dirname, '..', 'schema.sql');
12
+
13
+ export const VERSION = 1;
14
+ export const DESCRIPTION = 'alpha v1.3.0 -- raw + raw_fts + compiled + compiled_fts + trident_run + schema_meta';
15
+
16
+ export function up(db) {
17
+ const sql = readFileSync(SCHEMA_PATH, 'utf-8');
18
+ // db.exec runs the multi-statement DDL block from schema.sql atomically
19
+ // when called inside the migration runner's transaction.
20
+ db.exec(sql);
21
+ }
22
+
23
+ export default { version: VERSION, description: DESCRIPTION, up };
@@ -0,0 +1,139 @@
1
+ // IJFW v1.3.0 Alpha -- migration 002: Porter stemming + provenance source.
2
+ //
3
+ // Single coordinated migration for compute schema 1 -> 2 covering:
4
+ // - C9.4: flip raw_fts + compiled_fts tokenizer from default unicode61 to
5
+ // `porter unicode61` so regular morphological variants collapse
6
+ // (e.g. "authenticate"/"authenticating"/"authentication" stem to
7
+ // the same token; "running" stems to "run"). Porter does NOT
8
+ // handle irregular verbs -- "ran" stays "ran" and won't match
9
+ // "run" / "running". Documented limitation; not an IJFW bug.
10
+ // - C9.6: add `source` column to `raw` (provenance pointer; nullable).
11
+ // `session_id` already exists on `raw` from migration 001 so no
12
+ // column add is needed for it -- migration just covers the new
13
+ // provenance pointer + the matching index.
14
+ //
15
+ // FTS5 tables can't be ALTERed in place to change tokenizer. We use the
16
+ // recreate-with-data path:
17
+ // 1. Drop the old FTS5 virtual table + its triggers.
18
+ // 2. Recreate the FTS5 virtual table with `tokenize='porter unicode61'`.
19
+ // 3. Rebuild the index from the content table via INSERT INTO ... ('rebuild').
20
+ // 4. Recreate the AI/AD/AU triggers so future writes stay synced.
21
+ //
22
+ // ADD-ONLY semantics: no destructive column drops; the only "destruction" is
23
+ // recreation of the FTS5 *index* (an external-content view of `raw` and
24
+ // `compiled`). The content tables are untouched. Crash-safety is delegated
25
+ // to the migration runner's per-migration BEGIN/COMMIT envelope -- if any
26
+ // statement fails the whole transaction rolls back and the db stays at v1.
27
+
28
+ export const VERSION = 2;
29
+ export const DESCRIPTION = 'porter stemming + raw.source provenance pointer';
30
+
31
+ export function up(db) {
32
+ // --- C9.6: add `raw.source` column + matching partial index. -----------
33
+ //
34
+ // ADD COLUMN with no default and no NOT NULL constraint -- legacy rows
35
+ // and non-attributed inserts leave it NULL. The partial index on `source`
36
+ // mirrors schema.sql so fresh-db (path through 001 -> 002) and migrated
37
+ // db (path through 001 only originally) end up structurally identical.
38
+ if (!hasColumn(db, 'raw', 'source')) {
39
+ runDdl(db, 'ALTER TABLE raw ADD COLUMN source TEXT');
40
+ }
41
+ runDdl(db, 'CREATE INDEX IF NOT EXISTS raw_source_idx ON raw(source) WHERE source IS NOT NULL');
42
+
43
+ // --- C9.4: recreate raw_fts with porter stemming. ----------------------
44
+ recreateFts(db, {
45
+ contentTable: 'raw',
46
+ ftsTable: 'raw_fts',
47
+ indexedColumns: ['body'],
48
+ triggerPrefix: 'raw',
49
+ });
50
+
51
+ // --- C9.4: recreate compiled_fts with porter stemming. -----------------
52
+ recreateFts(db, {
53
+ contentTable: 'compiled',
54
+ ftsTable: 'compiled_fts',
55
+ indexedColumns: ['topic', 'body'],
56
+ triggerPrefix: 'compiled',
57
+ });
58
+ }
59
+
60
+ // Run a single multi-statement DDL string. Thin wrapper so the migration
61
+ // reads with a single verb that doesn't collide with shell-`exec` security
62
+ // linters.
63
+ function runDdl(db, sql) {
64
+ return db.exec(sql);
65
+ }
66
+
67
+ // Returns true if `table` has a column named `column`. Uses PRAGMA
68
+ // table_info which both better-sqlite3 and node:sqlite expose via
69
+ // .prepare(...).all().
70
+ function hasColumn(db, table, column) {
71
+ const rows = db.prepare(`PRAGMA table_info(${table})`).all();
72
+ return rows.some(r => String(r.name) === column);
73
+ }
74
+
75
+ // Drop + recreate a contentless FTS5 virtual table with the new tokenizer,
76
+ // then repopulate from the source content table via the FTS5 'rebuild'
77
+ // command. Triggers (ai/ad/au) get recreated against the new FTS5 table.
78
+ //
79
+ // Parameters:
80
+ // contentTable -- e.g. 'raw'
81
+ // ftsTable -- e.g. 'raw_fts'
82
+ // indexedColumns -- columns indexed by FTS5 (must match the original
83
+ // schema; for raw it's ['body'], for compiled it's
84
+ // ['topic','body']).
85
+ // triggerPrefix -- e.g. 'raw' yielding raw_ai/raw_ad/raw_au.
86
+ function recreateFts(db, { contentTable, ftsTable, indexedColumns, triggerPrefix }) {
87
+ // 1. Drop existing triggers first so the FTS5 table drop doesn't trigger
88
+ // spurious deletes via a still-attached AD trigger.
89
+ runDdl(db, `DROP TRIGGER IF EXISTS ${triggerPrefix}_ai`);
90
+ runDdl(db, `DROP TRIGGER IF EXISTS ${triggerPrefix}_ad`);
91
+ runDdl(db, `DROP TRIGGER IF EXISTS ${triggerPrefix}_au`);
92
+
93
+ // 2. Drop the old FTS5 virtual table. Contentless table -- this only
94
+ // removes the index, not any content table data.
95
+ runDdl(db, `DROP TABLE IF EXISTS ${ftsTable}`);
96
+
97
+ // 3. Recreate with porter stemming.
98
+ const cols = indexedColumns.join(', ');
99
+ runDdl(db,
100
+ `CREATE VIRTUAL TABLE ${ftsTable} USING fts5(
101
+ ${cols},
102
+ content='${contentTable}',
103
+ content_rowid='id',
104
+ tokenize='porter unicode61'
105
+ )`
106
+ );
107
+
108
+ // 4. Recreate the AI/AD/AU triggers identical to schema.sql so future
109
+ // writes stay in sync. Column lists are interpolated to handle the
110
+ // raw (body) vs compiled (topic, body) shapes uniformly.
111
+ const colList = indexedColumns.join(', ');
112
+ const newColList = indexedColumns.map(c => `new.${c}`).join(', ');
113
+ const oldColList = indexedColumns.map(c => `old.${c}`).join(', ');
114
+
115
+ runDdl(db,
116
+ `CREATE TRIGGER ${triggerPrefix}_ai AFTER INSERT ON ${contentTable} BEGIN
117
+ INSERT INTO ${ftsTable}(rowid, ${colList}) VALUES (new.id, ${newColList});
118
+ END`
119
+ );
120
+ runDdl(db,
121
+ `CREATE TRIGGER ${triggerPrefix}_ad AFTER DELETE ON ${contentTable} BEGIN
122
+ INSERT INTO ${ftsTable}(${ftsTable}, rowid, ${colList}) VALUES('delete', old.id, ${oldColList});
123
+ END`
124
+ );
125
+ runDdl(db,
126
+ `CREATE TRIGGER ${triggerPrefix}_au AFTER UPDATE ON ${contentTable} BEGIN
127
+ INSERT INTO ${ftsTable}(${ftsTable}, rowid, ${colList}) VALUES('delete', old.id, ${oldColList});
128
+ INSERT INTO ${ftsTable}(rowid, ${colList}) VALUES (new.id, ${newColList});
129
+ END`
130
+ );
131
+
132
+ // 5. Repopulate FTS5 from the content table. The FTS5 'rebuild' command
133
+ // is the canonical way to (re)build an external-content index from
134
+ // its source table -- handles every existing row with a single call
135
+ // and is faster than INSERT-row-by-row.
136
+ runDdl(db, `INSERT INTO ${ftsTable}(${ftsTable}) VALUES('rebuild')`);
137
+ }
138
+
139
+ export default { version: VERSION, description: DESCRIPTION, up };
@@ -0,0 +1,69 @@
1
+ // IJFW v1.3.0 -- compute migration 003: 4-tier semantic axis (D1).
2
+ //
3
+ // Source authority: PRD-v2 §9 Pillar D D1 + .planning/1.3.0/D-PILLAR-SPEC.md §1.
4
+ //
5
+ // Bumps compute schema 2 -> 3. Adds `tier_semantic` to BOTH content tables
6
+ // in the compute db so dream-cycle promotions (Episodic -> Semantic via
7
+ // supersession; Procedural candidate emit) can land on the compiled tier
8
+ // with the correct semantic label and query back through FTS5 by tier.
9
+ //
10
+ // Defaults differ by table on purpose:
11
+ // - raw.tier_semantic DEFAULT 'working' (raw observations are
12
+ // session-bound by
13
+ // definition; Working tier)
14
+ // - compiled.tier_semantic DEFAULT 'semantic' (per D-PILLAR-SPEC §1 the
15
+ // compiled table IS the
16
+ // natural Semantic tier;
17
+ // procedural_candidate /
18
+ // procedural / episodic
19
+ // write explicit values
20
+ // when needed)
21
+ //
22
+ // ADD-ONLY: no column drops, no FTS5 recreation -- the FTS5 index is
23
+ // content-table backed and indexes only `body` (raw) / `topic, body`
24
+ // (compiled), so adding a non-FTS column doesn't touch the FTS5 schema.
25
+ //
26
+ // Crash safety: BEGIN IMMEDIATE wrap by the migration runner. If any
27
+ // statement fails, ROLLBACK leaves user_version at 2.
28
+
29
+ export const VERSION = 3;
30
+ export const DESCRIPTION = 'tier_semantic axis on raw + compiled (working/episodic/semantic/procedural)';
31
+
32
+ export function up(db) {
33
+ // --- raw.tier_semantic --------------------------------------------------
34
+ if (!hasColumn(db, 'raw', 'tier_semantic')) {
35
+ runDdl(db, `ALTER TABLE raw ADD COLUMN tier_semantic TEXT DEFAULT 'working'`);
36
+ }
37
+ runDdl(db,
38
+ `CREATE INDEX IF NOT EXISTS raw_tier_semantic_idx ` +
39
+ `ON raw(tier_semantic, ts)`
40
+ );
41
+
42
+ // --- compiled.tier_semantic --------------------------------------------
43
+ // Compiled rows are the natural Semantic tier per D-PILLAR-SPEC §1.
44
+ // Procedural candidate / procedural rows override the default at write
45
+ // time. Episodic rows that get promoted to Semantic via supersession
46
+ // also write into compiled with this default.
47
+ if (!hasColumn(db, 'compiled', 'tier_semantic')) {
48
+ runDdl(db, `ALTER TABLE compiled ADD COLUMN tier_semantic TEXT DEFAULT 'semantic'`);
49
+ }
50
+ runDdl(db,
51
+ `CREATE INDEX IF NOT EXISTS compiled_tier_semantic_idx ` +
52
+ `ON compiled(tier_semantic, ts)`
53
+ );
54
+ }
55
+
56
+ // runDdl -- thin wrapper that runs multi-statement DDL via the sqlite
57
+ // driver's exec method (better-sqlite3 / node:sqlite both expose .exec).
58
+ // Mirrors the helper in compute/migrations/002. NOT child_process.exec.
59
+ function runDdl(db, sql) {
60
+ return db.exec(sql);
61
+ }
62
+
63
+ // hasColumn -- defence against re-application; mirrors the helper in 002.
64
+ function hasColumn(db, table, column) {
65
+ const rows = db.prepare(`PRAGMA table_info(${table})`).all();
66
+ return rows.some(r => String(r.name) === column);
67
+ }
68
+
69
+ export default { version: VERSION, description: DESCRIPTION, up };
@@ -0,0 +1,83 @@
1
+ // IJFW v1.3.0 -- compute migration 004: symbol-graph kg_nodes + kg_edges (D2).
2
+ //
3
+ // Source authority: PRD-v2 section 9 Pillar D D2 + .planning/1.3.0/D-PILLAR-SPEC.md sections 2-7.
4
+ //
5
+ // Bumps compute schema 3 -> 4. Adds two ADD-ONLY tables that back the
6
+ // regex-only symbol graph:
7
+ //
8
+ // kg_nodes -- one row per (kind, name) pair extracted from observations.
9
+ // kind in {file, function, identifier, error_code, decision}
10
+ // redacted=1 means the entity matched the secret redactor and
11
+ // MUST NOT participate in edges (per D-PILLAR-SPEC section 3
12
+ // ordering invariant).
13
+ //
14
+ // kg_edges -- weighted, kind-typed edge between two clean kg_nodes.
15
+ // Co-occurrence counter + weight column carry the section 2
16
+ // formula output. Edges are undirected at the read layer
17
+ // (BFS looks at both src and dst indexes), but stored with
18
+ // a stable (src, dst) ordering by id ascending so
19
+ // UPSERT-on-co-occurrence stays deterministic.
20
+ //
21
+ // ADD-ONLY: pure CREATE TABLE / CREATE INDEX. No drops, no FTS5 touch.
22
+ // Migration 003 left compute db at user_version=3 with tier_semantic on
23
+ // raw + compiled; this migration is layered on top with no backfill.
24
+ //
25
+ // Crash safety: BEGIN IMMEDIATE wrap by the migration runner. If any
26
+ // statement fails, ROLLBACK leaves user_version at 3.
27
+
28
+ export const VERSION = 4;
29
+ export const DESCRIPTION = 'symbol-graph kg_nodes + kg_edges (D2 regex extractor)';
30
+
31
+ export function up(db) {
32
+ // --- kg_nodes ----------------------------------------------------------
33
+ // Composite UNIQUE on (kind, name) so the entity extractor's "find or
34
+ // create" path can use INSERT OR IGNORE + SELECT id by (kind, name).
35
+ // first_seen / last_seen are observation timestamps so D4 cascading
36
+ // staleness can compute recency_decay without a separate ledger join.
37
+ // redacted is a boolean (0/1) -- redactor.classify() result is captured
38
+ // here per D-PILLAR-SPEC section 3.
39
+ runDdl(db, `
40
+ CREATE TABLE IF NOT EXISTS kg_nodes (
41
+ id INTEGER PRIMARY KEY,
42
+ kind TEXT NOT NULL,
43
+ name TEXT NOT NULL,
44
+ first_seen INTEGER NOT NULL,
45
+ last_seen INTEGER NOT NULL,
46
+ redacted INTEGER NOT NULL DEFAULT 0,
47
+ UNIQUE(kind, name)
48
+ )
49
+ `);
50
+ runDdl(db, `CREATE INDEX IF NOT EXISTS kg_nodes_kind_name_idx ON kg_nodes(kind, name)`);
51
+ runDdl(db, `CREATE INDEX IF NOT EXISTS kg_nodes_last_seen_idx ON kg_nodes(last_seen)`);
52
+
53
+ // --- kg_edges ----------------------------------------------------------
54
+ // src + dst are kg_nodes.id values. kind is the edge label
55
+ // (co_occurs in alpha; D4 will introduce supersedes/implements/references).
56
+ // weight is the section 2 formula output (clamp_01). co_occurrence_count
57
+ // is the raw counter the formula's log1p term reads from.
58
+ // ts is the most-recent co-occurrence ts, used for recency_decay refresh.
59
+ // PRIMARY KEY (src, dst, kind) gives UPSERT-on-co-occurrence semantics
60
+ // without a separate UNIQUE index.
61
+ runDdl(db, `
62
+ CREATE TABLE IF NOT EXISTS kg_edges (
63
+ src INTEGER NOT NULL,
64
+ dst INTEGER NOT NULL,
65
+ kind TEXT NOT NULL,
66
+ weight REAL NOT NULL DEFAULT 0,
67
+ co_occurrence_count INTEGER NOT NULL DEFAULT 0,
68
+ ts INTEGER NOT NULL,
69
+ PRIMARY KEY (src, dst, kind),
70
+ FOREIGN KEY (src) REFERENCES kg_nodes(id),
71
+ FOREIGN KEY (dst) REFERENCES kg_nodes(id)
72
+ )
73
+ `);
74
+ runDdl(db, `CREATE INDEX IF NOT EXISTS kg_edges_src_kind_idx ON kg_edges(src, kind)`);
75
+ runDdl(db, `CREATE INDEX IF NOT EXISTS kg_edges_dst_kind_idx ON kg_edges(dst, kind)`);
76
+ runDdl(db, `CREATE INDEX IF NOT EXISTS kg_edges_weight_idx ON kg_edges(weight)`);
77
+ }
78
+
79
+ function runDdl(db, sql) {
80
+ return db.exec(sql);
81
+ }
82
+
83
+ export default { version: VERSION, description: DESCRIPTION, up };
@@ -0,0 +1,72 @@
1
+ // IJFW v1.3.0 -- compute migration 005: D4 cascading staleness column.
2
+ //
3
+ // Source authority: PRD-v2 section 9 Pillar D D4 + .planning/1.3.0/D-PILLAR-SPEC.md
4
+ // section 2 (cascading staleness propagation threshold) + section 7 (50-fixture
5
+ // grader for D4 cascading staleness).
6
+ //
7
+ // Bumps compute schema 4 -> 5. Adds the `stale_candidate` column to BOTH
8
+ // content tables (`raw` + `compiled`) so D4's BFS-from-superseded-node
9
+ // propagation can flag related observations without touching retrieval
10
+ // until the grader proves >85% precision.
11
+ //
12
+ // Column semantics:
13
+ // stale_candidate INTEGER DEFAULT 0
14
+ // 0 = fresh (default; never set by D4)
15
+ // 1 = flagged by cascading staleness BFS (excluded from search by
16
+ // default; surface only with include_stale=true override)
17
+ // 2 = confirmed stale (reserved for 1.4.0 contradiction-detection;
18
+ // alpha never writes this value)
19
+ //
20
+ // Defaults to 0 on every existing row -- ADD-ONLY column with explicit
21
+ // DEFAULT clause so the migration is backfill-free.
22
+ //
23
+ // Indexes:
24
+ // raw_stale_candidate_idx -- partial index on stale_candidate > 0
25
+ // so the search() WHERE filter is fast
26
+ // on the common case (most rows fresh).
27
+ // compiled_stale_candidate_idx -- same pattern on the compiled tier.
28
+ //
29
+ // ADD-ONLY: no column drops, no FTS5 touch. The FTS5 index is content-
30
+ // table backed and indexes only `body` / `topic+body`, so adding a
31
+ // non-FTS column doesn't touch the FTS5 schema.
32
+ //
33
+ // Crash safety: BEGIN IMMEDIATE wrap by the migration runner. If any
34
+ // statement fails, ROLLBACK leaves user_version at 4.
35
+
36
+ export const VERSION = 5;
37
+ export const DESCRIPTION = 'stale_candidate column on raw + compiled (D4 cascading staleness)';
38
+
39
+ export function up(db) {
40
+ // --- raw.stale_candidate ----------------------------------------------
41
+ if (!hasColumn(db, 'raw', 'stale_candidate')) {
42
+ runDdl(db, `ALTER TABLE raw ADD COLUMN stale_candidate INTEGER DEFAULT 0`);
43
+ }
44
+ runDdl(db,
45
+ `CREATE INDEX IF NOT EXISTS raw_stale_candidate_idx ` +
46
+ `ON raw(stale_candidate) WHERE stale_candidate > 0`
47
+ );
48
+
49
+ // --- compiled.stale_candidate -----------------------------------------
50
+ if (!hasColumn(db, 'compiled', 'stale_candidate')) {
51
+ runDdl(db, `ALTER TABLE compiled ADD COLUMN stale_candidate INTEGER DEFAULT 0`);
52
+ }
53
+ runDdl(db,
54
+ `CREATE INDEX IF NOT EXISTS compiled_stale_candidate_idx ` +
55
+ `ON compiled(stale_candidate) WHERE stale_candidate > 0`
56
+ );
57
+ }
58
+
59
+ // runDdl -- thin wrapper that runs multi-statement DDL via the sqlite
60
+ // driver's `.exec` method (better-sqlite3 + node:sqlite both expose it).
61
+ // Mirrors the helper in compute/migrations/003. Not a child_process call.
62
+ function runDdl(db, sql) {
63
+ return db.exec(sql);
64
+ }
65
+
66
+ // hasColumn -- defence against re-application; mirrors the helper in 003.
67
+ function hasColumn(db, table, column) {
68
+ const rows = db.prepare(`PRAGMA table_info(${table})`).all();
69
+ return rows.some(r => String(r.name) === column);
70
+ }
71
+
72
+ export default { version: VERSION, description: DESCRIPTION, up };