@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,349 @@
1
+ // IJFW v1.3.0 -- D-Pillar / D0 memory FTS5 layer.
2
+ //
3
+ // Per-project SQLite db at <projectRoot>/.ijfw/index/memory.db with FTS5
4
+ // virtual table mirroring `memory_entries`. This is the warm tier of the
5
+ // memory pipeline (hot = markdown files; warm = FTS5; cold = vectors).
6
+ // Schema is owned by ./schema.sql and applied via ./migration-runner.js.
7
+ //
8
+ // Mirrors src/compute/fts5.js patterns:
9
+ // - WAL journal mode for concurrent readers
10
+ // - PRAGMA busy_timeout = 5000 + BEGIN IMMEDIATE for racing writers
11
+ // - PRAGMA quick_check post-write enforces integrity
12
+ //
13
+ // Security model (D-PILLAR-SPEC §12, real fix-wave C3):
14
+ // indexEntry runs `redactSecrets()` over `entry.body` AND `entry.source`
15
+ // BEFORE the INSERT. The scrub is the security gate that ensures secret-
16
+ // shaped tokens are never persisted in `memory_entries`, the FTS index,
17
+ // or fed forward into the D2 graph layer. Default on; opt out only via
18
+ // IJFW_INGEST_SCRUB=0 for local debugging. Source is scrubbed because
19
+ // while file paths are typical, callers may pass arbitrary text-of-origin
20
+ // strings (chat title, tool result label, etc.) that could include keys.
21
+ //
22
+ // Public surface is intentionally narrow:
23
+ // - openDb(projectRoot) -> handle
24
+ // - indexEntry(db, entry) -> { id }
25
+ // - searchFts5(db, q, k) -> [{id, body, source, session_id, created_at, rank}]
26
+ // - closeDb(db)
27
+ //
28
+ // Driver loader uses better-sqlite3 (already a hard dep in package.json).
29
+
30
+ import { existsSync, mkdirSync } from 'fs';
31
+ import { join, resolve, normalize, isAbsolute, dirname } from 'path';
32
+ import {
33
+ runMigrations,
34
+ highestKnownVersion,
35
+ SchemaVersionError,
36
+ } from './migration-runner.js';
37
+ import { autoIndexGraphFromMemoryBody } from '../compute/graph-auto-index.js';
38
+ import { redactSecrets } from '../redactor.js';
39
+
40
+ // D-PILLAR-SPEC §12 ingest scrub gate. Default-on; the only escape hatch
41
+ // is the IJFW_INGEST_SCRUB=0 env var, used for local debugging only and
42
+ // never a shipping posture. Read on every indexEntry so test harnesses
43
+ // can flip it without re-importing.
44
+ function ingestScrubEnabled() {
45
+ return process.env.IJFW_INGEST_SCRUB !== '0';
46
+ }
47
+
48
+ export { SchemaVersionError };
49
+
50
+ export class MemoryDbError extends Error {
51
+ constructor(message) {
52
+ super(message);
53
+ this.name = 'MemoryDbError';
54
+ }
55
+ }
56
+
57
+ export class MemoryIntegrityError extends Error {
58
+ constructor(message) {
59
+ super(message);
60
+ this.name = 'MemoryIntegrityError';
61
+ }
62
+ }
63
+
64
+ const DB_FILENAME = 'memory.db';
65
+ const INDEX_DIR_NAME = 'index';
66
+ const IJFW_DIR_NAME = '.ijfw';
67
+
68
+ // Thin wrapper around the sqlite driver's multi-statement runner so call
69
+ // sites stay uniform between better-sqlite3 and any future shim.
70
+ function runSql(db, sql) {
71
+ const fn = db.exec.bind(db);
72
+ return fn(sql);
73
+ }
74
+
75
+ // --- Driver loader -----------------------------------------------------------
76
+ //
77
+ // Shipping path: better-sqlite3. Already pinned by package.json so this is
78
+ // a hot path. We do NOT duplicate the node:sqlite escape hatch from
79
+ // src/compute/fts5.js -- the compute tier owns the dev-only fallback; the
80
+ // memory tier is shipping-only.
81
+
82
+ async function loadDriver() {
83
+ const mod = await import('better-sqlite3');
84
+ const Database = mod.default || mod;
85
+ return {
86
+ kind: 'better-sqlite3',
87
+ open(filename) {
88
+ const db = new Database(filename);
89
+ // BEGIN IMMEDIATE invariant: write transactions must acquire RESERVED
90
+ // at txn start, not at first INSERT. Paired with busy_timeout=5000
91
+ // below this converts racing writers from a SQLITE_BUSY explosion
92
+ // into a clean queue. Phase 5 caught this on the compute side; the
93
+ // memory tier ships with it correct from day one.
94
+ db.txn = (fn) => {
95
+ const wrapped = db.transaction(fn);
96
+ if (wrapped && typeof wrapped.immediate === 'function') {
97
+ return wrapped.immediate;
98
+ }
99
+ return (...args) => {
100
+ runSql(db, 'BEGIN IMMEDIATE');
101
+ try {
102
+ const r = fn(...args);
103
+ runSql(db, 'COMMIT');
104
+ return r;
105
+ } catch (err) {
106
+ try { runSql(db, 'ROLLBACK'); } catch { /* ignore */ }
107
+ throw err;
108
+ }
109
+ };
110
+ };
111
+ return db;
112
+ },
113
+ };
114
+ }
115
+
116
+ // --- Path resolution ---------------------------------------------------------
117
+
118
+ function resolveProjectRoot(projectRoot) {
119
+ const raw = projectRoot || process.env.IJFW_PROJECT_DIR || process.cwd();
120
+ if (typeof raw !== 'string' || !raw) {
121
+ throw new MemoryDbError('Project root must be a non-empty string.');
122
+ }
123
+ const abs = resolve(raw);
124
+ const norm = normalize(abs);
125
+ if (!isAbsolute(norm)) {
126
+ throw new MemoryDbError(`Project root resolves to non-absolute path: ${raw}`);
127
+ }
128
+ return norm;
129
+ }
130
+
131
+ export function dbPathFor(projectRoot) {
132
+ const root = resolveProjectRoot(projectRoot);
133
+ return join(root, IJFW_DIR_NAME, INDEX_DIR_NAME, DB_FILENAME);
134
+ }
135
+
136
+ function ensureDbDir(filename) {
137
+ const dir = dirname(filename);
138
+ if (dir && !existsSync(dir)) {
139
+ mkdirSync(dir, { recursive: true });
140
+ }
141
+ }
142
+
143
+ // --- Public API --------------------------------------------------------------
144
+
145
+ // Open or create the per-project memory db. On a fresh file, applies every
146
+ // migration up to highestKnownVersion(). On an existing file, refuses to
147
+ // operate if user_version is higher than this build supports.
148
+ export async function openDb(projectRoot) {
149
+ const filename = dbPathFor(projectRoot);
150
+ ensureDbDir(filename);
151
+
152
+ const driver = await loadDriver();
153
+ const db = driver.open(filename);
154
+ db.__ijfw_driver = driver.kind;
155
+ db.__ijfw_filename = filename;
156
+
157
+ try { runSql(db, 'PRAGMA journal_mode = WAL'); } catch { /* default fine */ }
158
+ try { runSql(db, 'PRAGMA synchronous = NORMAL'); } catch { /* default fine */ }
159
+ try { runSql(db, 'PRAGMA busy_timeout = 5000'); } catch { /* default fine */ }
160
+
161
+ const target = await highestKnownVersion();
162
+ const current = readUserVersion(db);
163
+
164
+ if (current > target) {
165
+ db.close();
166
+ throw new SchemaVersionError(
167
+ `Memory db schema version ${current} at ${filename} is newer than this build supports (max ${target}). ` +
168
+ `Refusing to downgrade.`
169
+ );
170
+ }
171
+ if (current < target) {
172
+ await runMigrations(db, current, target);
173
+ }
174
+ return db;
175
+ }
176
+
177
+ function readUserVersion(db) {
178
+ const row = db.prepare('PRAGMA user_version').get();
179
+ if (!row) return 0;
180
+ return Number(row.user_version ?? row.USER_VERSION ?? 0);
181
+ }
182
+
183
+ // Insert one row into memory_entries inside a BEGIN IMMEDIATE transaction,
184
+ // then run PRAGMA quick_check on the whole db. Throws MemoryIntegrityError
185
+ // on anything other than 'ok'. Returns { id } of the inserted row.
186
+ //
187
+ // Caller passes { body, source?, session_id? }. created_at is set here
188
+ // (unix ms) so callers don't have to remember the convention.
189
+ export function indexEntry(db, entry) {
190
+ if (!db || typeof db.prepare !== 'function') {
191
+ throw new MemoryDbError('indexEntry: db handle is invalid.');
192
+ }
193
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
194
+ throw new MemoryDbError('indexEntry: entry must be a plain object.');
195
+ }
196
+ if (typeof entry.body !== 'string' || entry.body.length === 0) {
197
+ throw new MemoryDbError('indexEntry: entry.body must be a non-empty string.');
198
+ }
199
+ // D-PILLAR-SPEC §12 ingest scrub gate. Replace `body` and `source`
200
+ // with their redacted forms BEFORE the INSERT, so the FTS index, the
201
+ // graph auto-index pass below, and any downstream reader only ever
202
+ // see scrubbed text. After this branch `entry` is unchanged for the
203
+ // caller; we work off a local copy.
204
+ const scrub = ingestScrubEnabled();
205
+ const safeBody = scrub ? redactSecrets(entry.body) : entry.body;
206
+ const safeSource = entry.source != null
207
+ ? (scrub ? redactSecrets(String(entry.source)) : String(entry.source))
208
+ : null;
209
+ const row = {
210
+ body: safeBody,
211
+ source: safeSource,
212
+ session_id: entry.session_id != null ? String(entry.session_id) : null,
213
+ created_at: typeof entry.created_at === 'number' ? entry.created_at : Date.now(),
214
+ };
215
+
216
+ let inserted;
217
+ const tx = db.txn(() => {
218
+ const stmt = db.prepare(
219
+ 'INSERT INTO memory_entries (body, source, session_id, created_at) VALUES (?, ?, ?, ?)'
220
+ );
221
+ const info = stmt.run(row.body, row.source, row.session_id, row.created_at);
222
+ inserted = {
223
+ id: info && info.lastInsertRowid != null ? Number(info.lastInsertRowid) : null,
224
+ };
225
+ const qc = db.prepare('PRAGMA quick_check').get();
226
+ const status = qc && (qc.quick_check ?? qc.QUICK_CHECK);
227
+ if (status !== 'ok') {
228
+ throw new MemoryIntegrityError(
229
+ `PRAGMA quick_check failed after insert into memory_entries: ${status || '(no result)'}.`
230
+ );
231
+ }
232
+ });
233
+ tx();
234
+
235
+ // GA-B3: fire D2 graph auto-population on memory ingest. The graph
236
+ // lives in the compute db at the same projectRoot, so we open compute
237
+ // on demand. Async-but-fire-and-forget: the indexEntry contract stays
238
+ // synchronous (callers don't see a Promise), and a graph failure never
239
+ // affects ingest correctness. Tests that need to await the auto-index
240
+ // promise can read __lastAutoIndexPromise (set below for diagnostic
241
+ // visibility only).
242
+ try {
243
+ const ts = row.created_at;
244
+ const sessionId = row.session_id;
245
+ const body = row.body;
246
+ const p = autoIndexGraphFromMemoryBody({ memoryDb: db, body, sessionId, ts })
247
+ .catch(() => null);
248
+ __lastAutoIndexPromise = p;
249
+ } catch {
250
+ // never throw out of indexEntry due to auto-index
251
+ }
252
+
253
+ return inserted;
254
+ }
255
+
256
+ // Diagnostic hook for tests -- holds the most recent auto-index promise
257
+ // from indexEntry so end-to-end tests can `await __getLastAutoIndexPromise()`
258
+ // before asserting on graph state. Production callers do not read this.
259
+ let __lastAutoIndexPromise = null;
260
+ export function __getLastAutoIndexPromise() {
261
+ return __lastAutoIndexPromise;
262
+ }
263
+
264
+ // FTS5 search against memory_entries_fts. Returns top-k content rows with
265
+ // a `rank` column (bm25; lower is better). Empty/whitespace queries return
266
+ // an empty array. Caller is responsible for sanitising user-supplied
267
+ // queries (FTS5 syntax errors throw -- caught and reported here).
268
+ //
269
+ // opts.include_stale (D4 GA-B2): when false (default), rows with
270
+ // stale_candidate >= 1 are excluded so cascading-staleness flags actually
271
+ // gate retrieval. When true, all rows return (debug + grader path).
272
+ // Pre-v3 dbs (no stale_candidate column) silently drop the WHERE filter
273
+ // so back-compat is preserved.
274
+ export function searchFts5(db, query, k = 10, opts = {}) {
275
+ if (!db || typeof db.prepare !== 'function') {
276
+ throw new MemoryDbError('searchFts5: db handle is invalid.');
277
+ }
278
+ if (typeof query !== 'string' || query.trim().length === 0) {
279
+ return [];
280
+ }
281
+ const limit = Math.min(Math.max(1, parseInt(k, 10) || 10), 1000);
282
+ const includeStale = opts && opts.include_stale === true;
283
+ const hasStaleCol = tableHasColumn(db, 'memory_entries', 'stale_candidate');
284
+ const staleClause = (!includeStale && hasStaleCol)
285
+ ? ' AND COALESCE(t.stale_candidate, 0) = 0'
286
+ : '';
287
+ const sql = `
288
+ SELECT t.id, t.body, t.source, t.session_id, t.created_at,
289
+ bm25(memory_entries_fts) AS rank
290
+ FROM memory_entries_fts f
291
+ JOIN memory_entries t ON t.id = f.rowid
292
+ WHERE memory_entries_fts MATCH ?${staleClause}
293
+ ORDER BY rank ASC
294
+ LIMIT ?`;
295
+ try {
296
+ return db.prepare(sql).all(query, limit);
297
+ } catch (err) {
298
+ throw new MemoryDbError(`searchFts5 failed: ${err.message}`);
299
+ }
300
+ }
301
+
302
+ // Cached PRAGMA table_info lookups per-db so repeated calls don't re-scan.
303
+ const __memTableInfoCache = new WeakMap();
304
+ function tableHasColumn(db, table, column) {
305
+ let perDb = __memTableInfoCache.get(db);
306
+ if (!perDb) {
307
+ perDb = new Map();
308
+ __memTableInfoCache.set(db, perDb);
309
+ }
310
+ const key = `${table}.${column}`;
311
+ if (perDb.has(key)) return perDb.get(key);
312
+ let present = false;
313
+ try {
314
+ const rows = db.prepare(`PRAGMA table_info(${table})`).all();
315
+ present = rows.some(r => String(r.name) === column);
316
+ } catch { /* missing table -> treat column as absent */ }
317
+ perDb.set(key, present);
318
+ return present;
319
+ }
320
+
321
+ // Total row count -- cheap helper used by callers that want to decide
322
+ // whether the index is empty (and thus needs an auto-rebuild from hot tier).
323
+ export function rowCount(db) {
324
+ if (!db || typeof db.prepare !== 'function') return 0;
325
+ try {
326
+ const row = db.prepare('SELECT COUNT(*) AS n FROM memory_entries').get();
327
+ return row ? Number(row.n) : 0;
328
+ } catch {
329
+ return 0;
330
+ }
331
+ }
332
+
333
+ // Clean close. Tolerates double-close.
334
+ export function closeDb(db) {
335
+ if (!db) return;
336
+ try { db.close(); } catch { /* already closed or driver-specific noop */ }
337
+ }
338
+
339
+ export default {
340
+ openDb,
341
+ indexEntry,
342
+ searchFts5,
343
+ rowCount,
344
+ closeDb,
345
+ dbPathFor,
346
+ MemoryDbError,
347
+ MemoryIntegrityError,
348
+ SchemaVersionError,
349
+ };
@@ -0,0 +1,116 @@
1
+ // IJFW v1.3.0 -- migration runner for the memory db.
2
+ //
3
+ // Mirrors src/compute/migration-runner.js shape so future structural
4
+ // alignment between the two tiers stays trivial. The memory db has its
5
+ // OWN user_version sequence -- it does not share state with the compute
6
+ // db at <projectRoot>/.ijfw/index/compute.db.
7
+ //
8
+ // Discovers migrations in ./migrations/ matching NNN-name.js, sorts by
9
+ // numeric prefix, and applies each migration whose VERSION exceeds the
10
+ // db's current PRAGMA user_version. Each migration runs inside a single
11
+ // BEGIN IMMEDIATE transaction (per the Phase 5 fix that landed in the
12
+ // compute migration runner -- racing writers must serialise via the
13
+ // RESERVED lock at txn start instead of upgrading mid-txn).
14
+
15
+ import { readdirSync } from 'fs';
16
+ import { join, dirname } from 'path';
17
+ import { fileURLToPath, pathToFileURL } from 'url';
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const MIGRATIONS_DIR = join(__dirname, 'migrations');
21
+
22
+ export class SchemaVersionError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = 'SchemaVersionError';
26
+ }
27
+ }
28
+
29
+ // Discover and load every migration module under ./migrations/, sorted by
30
+ // numeric prefix ascending. Each module must export VERSION (integer),
31
+ // DESCRIPTION (string), and up(db) (function).
32
+ async function loadMigrations() {
33
+ let files;
34
+ try {
35
+ files = readdirSync(MIGRATIONS_DIR);
36
+ } catch {
37
+ return [];
38
+ }
39
+ const matches = files
40
+ .filter(f => /^\d+-.+\.js$/.test(f))
41
+ .sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
42
+ const out = [];
43
+ for (const f of matches) {
44
+ const url = pathToFileURL(join(MIGRATIONS_DIR, f)).href;
45
+ const mod = await import(url);
46
+ if (typeof mod.VERSION !== 'number' || typeof mod.up !== 'function') {
47
+ throw new Error(`Memory migration ${f} is missing VERSION or up().`);
48
+ }
49
+ out.push({
50
+ file: f,
51
+ version: mod.VERSION,
52
+ description: mod.DESCRIPTION || '',
53
+ up: mod.up,
54
+ });
55
+ }
56
+ out.sort((a, b) => a.version - b.version);
57
+ for (let i = 1; i < out.length; i++) {
58
+ if (out[i].version === out[i - 1].version) {
59
+ throw new Error(
60
+ `Duplicate memory migration VERSION: ${out[i].version} ` +
61
+ `(${out[i - 1].file} and ${out[i].file}).`
62
+ );
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+
68
+ export async function highestKnownVersion() {
69
+ const migrations = await loadMigrations();
70
+ return migrations.length === 0 ? 0 : migrations[migrations.length - 1].version;
71
+ }
72
+
73
+ // Apply every migration whose version is in (currentVersion, targetVersion].
74
+ // Returns the new version. Throws SchemaVersionError on downgrade.
75
+ export async function runMigrations(db, currentVersion, targetVersion) {
76
+ if (typeof currentVersion !== 'number' || typeof targetVersion !== 'number') {
77
+ throw new Error('runMigrations: currentVersion and targetVersion must be numbers.');
78
+ }
79
+ if (currentVersion > targetVersion) {
80
+ throw new SchemaVersionError(
81
+ `Memory db schema version ${currentVersion} is newer than this build supports (max ${targetVersion}). ` +
82
+ `Refusing to downgrade -- upgrade IJFW or open the db with a newer build.`
83
+ );
84
+ }
85
+ if (currentVersion === targetVersion) return currentVersion;
86
+
87
+ const migrations = await loadMigrations();
88
+ const pending = migrations.filter(m => m.version > currentVersion && m.version <= targetVersion);
89
+ if (pending.length === 0) return currentVersion;
90
+
91
+ let lastApplied = currentVersion;
92
+ for (const m of pending) {
93
+ // BEGIN IMMEDIATE acquires the write lock at transaction start. Plain
94
+ // BEGIN (deferred) lets two concurrent writers both enter their migration
95
+ // tx and then the loser gets SQLITE_BUSY when it tries to upgrade. The
96
+ // compute migration runner caught this in Phase 5; the memory runner
97
+ // ships with the fix in place from day one.
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 { /* nothing */ }
110
+ throw new Error(`Memory 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,26 @@
1
+ // IJFW v1.3.0 -- memory migration 001: apply schema.sql on a fresh memory db.
2
+ //
3
+ // Idempotent (CREATE TABLE IF NOT EXISTS + INSERT OR IGNORE). Runs inside
4
+ // the migration runner's BEGIN IMMEDIATE transaction so a crash mid-apply
5
+ // rolls back to user_version 0.
6
+ //
7
+ // Source of truth for DDL: ../schema.sql.
8
+
9
+ import { readFileSync } from 'fs';
10
+ import { join, dirname } from 'path';
11
+ import { fileURLToPath } from 'url';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const SCHEMA_PATH = join(__dirname, '..', 'schema.sql');
15
+
16
+ export const VERSION = 1;
17
+ export const DESCRIPTION = 'memory v1.3.0 -- memory_entries + memory_entries_fts (porter unicode61)';
18
+
19
+ export function up(db) {
20
+ const sql = readFileSync(SCHEMA_PATH, 'utf-8');
21
+ // The sqlite driver runs the multi-statement DDL block from schema.sql
22
+ // atomically when called inside the migration runner's transaction.
23
+ db.exec(sql);
24
+ }
25
+
26
+ export default { version: VERSION, description: DESCRIPTION, up };
@@ -0,0 +1,60 @@
1
+ // IJFW v1.3.0 -- memory migration 002: 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
+ // (tier promotion rules).
5
+ //
6
+ // Adds `tier_semantic` column ORTHOGONAL to existing tier_access (hot/warm/cold
7
+ // in the storage layer). Two axes, both queryable:
8
+ // - tier_access : hot=md files / warm=this FTS5 db / cold=vectors (deferred)
9
+ // - tier_semantic : working / episodic / semantic / procedural
10
+ //
11
+ // ADD-ONLY semantics. Default 'working' protects every legacy row -- existing
12
+ // memory_entries pre-D1 are by definition session-bound observations, which is
13
+ // the exact definition of Working in D-PILLAR-SPEC §1. Promotion to other
14
+ // tiers happens via tier-promotion.js (separate module, not this migration).
15
+ //
16
+ // CREATE INDEX on (tier_semantic, created_at) so the search filter
17
+ // `WHERE tier_semantic = ? ORDER BY created_at DESC` is index-served at
18
+ // any scale.
19
+ //
20
+ // Crash safety: migration runner wraps the whole up() in BEGIN IMMEDIATE.
21
+ // If ALTER TABLE or CREATE INDEX fails the txn rolls back and user_version
22
+ // stays at 1.
23
+
24
+ export const VERSION = 2;
25
+ export const DESCRIPTION = 'memory v1.3.0 -- tier_semantic axis (working/episodic/semantic/procedural)';
26
+
27
+ export function up(db) {
28
+ // ALTER TABLE ADD COLUMN with a TEXT DEFAULT is constant-time in SQLite
29
+ // (writes the default into the schema, not into existing rows). Legacy
30
+ // rows on read return 'working'. Composite index supports tier-filtered
31
+ // scans without a table rewrite.
32
+ if (!hasColumn(db, 'memory_entries', 'tier_semantic')) {
33
+ runDdl(db, `ALTER TABLE memory_entries ADD COLUMN tier_semantic TEXT DEFAULT 'working'`);
34
+ }
35
+
36
+ // Composite index (tier_semantic, created_at) supports the canonical
37
+ // tier-filtered scan: pick a tier, sort by recency. SQLite uses the
38
+ // index for both the equality filter and the ORDER BY.
39
+ runDdl(db,
40
+ `CREATE INDEX IF NOT EXISTS memory_entries_tier_semantic_idx ` +
41
+ `ON memory_entries(tier_semantic, created_at)`
42
+ );
43
+ }
44
+
45
+ // runDdl -- thin wrapper so the migration reads with a single verb that
46
+ // mirrors compute/migrations/002. Runs multi-statement DDL inside the
47
+ // migration runner's BEGIN IMMEDIATE transaction.
48
+ function runDdl(db, sql) {
49
+ return db.exec(sql);
50
+ }
51
+
52
+ // hasColumn -- defensive check so an out-of-band re-application doesn't
53
+ // trip ADD COLUMN's "duplicate column" error. Mirrors the helper in
54
+ // compute/migrations/002-porter-stemming-source.js.
55
+ function hasColumn(db, table, column) {
56
+ const rows = db.prepare(`PRAGMA table_info(${table})`).all();
57
+ return rows.some(r => String(r.name) === column);
58
+ }
59
+
60
+ export default { version: VERSION, description: DESCRIPTION, up };
@@ -0,0 +1,60 @@
1
+ // IJFW v1.3.0 -- memory migration 003: stale_candidate guard column (D4 wiring).
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) + GA fix-wave finding GA-B2.
5
+ //
6
+ // Adds the `stale_candidate` column to memory_entries so the D4 cascading
7
+ // staleness propagation can flag entries whose semantic neighbourhood has
8
+ // been superseded. Memory-side search filters stale rows by default,
9
+ // mirroring the compute-side filter that landed in compute migration 005.
10
+ //
11
+ // Column semantics (identical to compute migration 005):
12
+ // stale_candidate INTEGER DEFAULT 0
13
+ // 0 = fresh (default; never set by D4)
14
+ // 1 = flagged by cascading staleness BFS (excluded from search by
15
+ // default; surface only with include_stale=true override)
16
+ // 2 = confirmed stale (reserved for 1.4.0 contradiction-detection;
17
+ // alpha never writes this value)
18
+ //
19
+ // Defaults to 0 on every existing row -- ADD-ONLY column with explicit
20
+ // DEFAULT clause so the migration is backfill-free.
21
+ //
22
+ // Indexes:
23
+ // memory_entries_stale_candidate_idx -- partial index on stale_candidate > 0
24
+ // so the search() WHERE filter is
25
+ // fast on the common case (most
26
+ // rows fresh).
27
+ //
28
+ // ADD-ONLY: no column drops, no FTS5 touch. The FTS5 mirror is content-
29
+ // table backed and indexes only `body`, so adding a non-FTS column never
30
+ // touches the FTS5 schema.
31
+ //
32
+ // Crash safety: BEGIN IMMEDIATE wrap by the migration runner. If any
33
+ // statement fails, ROLLBACK leaves user_version at 2.
34
+
35
+ export const VERSION = 3;
36
+ export const DESCRIPTION = 'memory v1.3.0 -- stale_candidate guard column (D4 retrieval suppression)';
37
+
38
+ export function up(db) {
39
+ if (!hasColumn(db, 'memory_entries', 'stale_candidate')) {
40
+ runDdl(db, `ALTER TABLE memory_entries ADD COLUMN stale_candidate INTEGER DEFAULT 0`);
41
+ }
42
+ runDdl(db,
43
+ `CREATE INDEX IF NOT EXISTS memory_entries_stale_candidate_idx ` +
44
+ `ON memory_entries(stale_candidate) WHERE stale_candidate > 0`
45
+ );
46
+ }
47
+
48
+ // runDdl -- thin wrapper that runs multi-statement DDL via the sqlite
49
+ // driver's .exec method. Mirrors helpers in earlier migrations.
50
+ function runDdl(db, sql) {
51
+ return db.exec(sql);
52
+ }
53
+
54
+ // hasColumn -- defence against re-application; mirrors helper from 002.
55
+ function hasColumn(db, table, column) {
56
+ const rows = db.prepare(`PRAGMA table_info(${table})`).all();
57
+ return rows.some(r => String(r.name) === column);
58
+ }
59
+
60
+ export default { version: VERSION, description: DESCRIPTION, up };