moflo 4.9.37 → 4.10.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 (73) hide show
  1. package/.claude/guidance/shipped/moflo-memory-protocol.md +5 -1
  2. package/.claude/guidance/shipped/moflo-memorydb-maintenance.md +22 -11
  3. package/.claude/guidance/shipped/moflo-root-cause-discipline.md +47 -0
  4. package/.claude/helpers/statusline.cjs +69 -33
  5. package/.claude/helpers/subagent-bootstrap.json +1 -1
  6. package/.claude/helpers/subagent-start.cjs +1 -1
  7. package/bin/build-embeddings.mjs +6 -20
  8. package/bin/cli.js +5 -0
  9. package/bin/generate-code-map.mjs +4 -24
  10. package/bin/hooks.mjs +3 -12
  11. package/bin/index-all.mjs +3 -13
  12. package/bin/index-guidance.mjs +36 -85
  13. package/bin/index-patterns.mjs +6 -24
  14. package/bin/index-tests.mjs +4 -23
  15. package/bin/lib/db-repair.mjs +4 -25
  16. package/bin/lib/get-backend.mjs +306 -0
  17. package/bin/lib/incremental-write.mjs +27 -7
  18. package/bin/lib/moflo-paths.mjs +64 -4
  19. package/bin/lib/suppress-sqlite-warning.mjs +57 -0
  20. package/bin/migrations/knowledge-purge.mjs +7 -8
  21. package/bin/migrations/knowledge-to-learnings.mjs +7 -9
  22. package/bin/migrations/purge-doc-entries.mjs +7 -8
  23. package/bin/migrations/strip-context-preambles.mjs +4 -6
  24. package/bin/run-migrations.mjs +1 -10
  25. package/bin/semantic-search.mjs +7 -18
  26. package/bin/session-start-launcher.mjs +102 -102
  27. package/bin/simplify-classify.cjs +38 -17
  28. package/dist/src/cli/commands/daemon.js +38 -11
  29. package/dist/src/cli/commands/doctor-checks-coverage-truth.js +136 -0
  30. package/dist/src/cli/commands/doctor-checks-memory-access.js +146 -86
  31. package/dist/src/cli/commands/doctor-checks-memory.js +13 -18
  32. package/dist/src/cli/commands/doctor-checks-version-skew.js +94 -0
  33. package/dist/src/cli/commands/doctor-checks-writers-audit.js +170 -0
  34. package/dist/src/cli/commands/doctor-embedding-hygiene.js +3 -15
  35. package/dist/src/cli/commands/doctor-fixes.js +30 -0
  36. package/dist/src/cli/commands/doctor-registry.js +14 -0
  37. package/dist/src/cli/commands/doctor.js +1 -1
  38. package/dist/src/cli/commands/embeddings.js +17 -22
  39. package/dist/src/cli/commands/memory.js +13 -23
  40. package/dist/src/cli/embeddings/persistent-cache.js +44 -83
  41. package/dist/src/cli/init/moflo-init.js +40 -0
  42. package/dist/src/cli/mcp-tools/memory-tools.js +10 -3
  43. package/dist/src/cli/memory/bridge-core.js +256 -30
  44. package/dist/src/cli/memory/bridge-entries.js +70 -6
  45. package/dist/src/cli/memory/controller-registry.js +7 -2
  46. package/dist/src/cli/memory/controllers/batch-operations.js +5 -1
  47. package/dist/src/cli/memory/controllers/hierarchical-memory.js +7 -2
  48. package/dist/src/cli/memory/controllers/mutation-guard.js +22 -2
  49. package/dist/src/cli/memory/daemon-backend.js +400 -0
  50. package/dist/src/cli/memory/daemon-write-client.js +192 -15
  51. package/dist/src/cli/memory/database-provider.js +57 -40
  52. package/dist/src/cli/memory/hnsw-persistence.js +6 -8
  53. package/dist/src/cli/memory/index.js +0 -1
  54. package/dist/src/cli/memory/memory-bridge.js +40 -8
  55. package/dist/src/cli/memory/memory-initializer.js +269 -209
  56. package/dist/src/cli/memory/rvf-migration.js +25 -11
  57. package/dist/src/cli/memory/sqlite-backend.js +573 -0
  58. package/dist/src/cli/memory/suppress-sqlite-warning.js +49 -0
  59. package/dist/src/cli/services/cherry-pick-learnings.js +32 -21
  60. package/dist/src/cli/services/daemon-dashboard.js +13 -1
  61. package/dist/src/cli/services/daemon-lock.js +58 -1
  62. package/dist/src/cli/services/daemon-memory-rpc.js +245 -10
  63. package/dist/src/cli/services/embeddings-migration.js +9 -12
  64. package/dist/src/cli/services/ephemeral-namespace-purge.js +6 -11
  65. package/dist/src/cli/services/learning-service.js +12 -20
  66. package/dist/src/cli/services/project-root.js +69 -9
  67. package/dist/src/cli/services/soft-delete-purge.js +6 -11
  68. package/dist/src/cli/services/sqljs-migration-store.js +4 -1
  69. package/dist/src/cli/services/subagent-bootstrap.js +1 -1
  70. package/dist/src/cli/shared/events/event-store.js +26 -55
  71. package/dist/src/cli/version.js +1 -1
  72. package/package.json +2 -4
  73. package/dist/src/cli/memory/sqljs-backend.js +0 -643
@@ -25,25 +25,15 @@
25
25
  import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from 'fs';
26
26
  import { resolve, relative, dirname, basename, extname } from 'path';
27
27
  import { fileURLToPath } from 'url';
28
- import { mofloResolveURL } from './lib/moflo-resolve.mjs';
29
- import { memoryDbPath } from './lib/moflo-paths.mjs';
28
+ import { memoryDbPath, findProjectRoot } from './lib/moflo-paths.mjs';
29
+ import { openBackend } from './lib/get-backend.mjs';
30
+ import { applyIncrementalChunks } from './lib/incremental-write.mjs';
30
31
  import { resolveMofloBin } from './lib/resolve-bin.mjs';
31
32
  import { createProcessManager } from './lib/process-manager.mjs';
32
- const initSqlJs = (await import(mofloResolveURL('sql.js'))).default;
33
33
 
34
34
 
35
35
  const __dirname = dirname(fileURLToPath(import.meta.url));
36
36
 
37
- function findProjectRoot() {
38
- let dir = process.cwd();
39
- const root = resolve(dir, '/');
40
- while (dir !== root) {
41
- if (existsSync(resolve(dir, 'package.json'))) return dir;
42
- dir = dirname(dir);
43
- }
44
- return process.cwd();
45
- }
46
-
47
37
  const projectRoot = findProjectRoot();
48
38
 
49
39
  // Locate the moflo package root (for bundled guidance that ships with moflo)
@@ -181,14 +171,7 @@ function ensureDbDir() {
181
171
 
182
172
  async function getDb() {
183
173
  ensureDbDir();
184
- const SQL = await initSqlJs();
185
- let db;
186
- if (existsSync(DB_PATH)) {
187
- const buffer = readFileSync(DB_PATH);
188
- db = new SQL.Database(buffer);
189
- } else {
190
- db = new SQL.Database();
191
- }
174
+ const db = await openBackend(projectRoot, { create: true });
192
175
 
193
176
  // Ensure table exists with unique constraint
194
177
  db.run(`
@@ -221,12 +204,7 @@ async function getDb() {
221
204
  }
222
205
 
223
206
  function saveDb(db) {
224
- const data = db.export();
225
- writeFileSync(DB_PATH, Buffer.from(data));
226
- }
227
-
228
- function generateId() {
229
- return `mem_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
207
+ db.save();
230
208
  }
231
209
 
232
210
  function hashContent(content) {
@@ -239,39 +217,6 @@ function hashContent(content) {
239
217
  return hash.toString(16);
240
218
  }
241
219
 
242
- function storeEntry(db, key, content, metadata = {}, tags = []) {
243
- const now = Date.now();
244
- const id = generateId();
245
- const metaJson = JSON.stringify(metadata);
246
- const tagsJson = JSON.stringify(tags);
247
-
248
- db.run(`
249
- INSERT OR REPLACE INTO memory_entries
250
- (id, key, namespace, content, metadata, tags, created_at, updated_at, status)
251
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')
252
- `, [id, key, NAMESPACE, content, metaJson, tagsJson, now, now]);
253
-
254
- return true;
255
- }
256
-
257
- function deleteByPrefix(db, prefix) {
258
- db.run(`DELETE FROM memory_entries WHERE namespace = ? AND key LIKE ?`, [NAMESPACE, `${prefix}%`]);
259
- }
260
-
261
- function getEntryHash(db, key) {
262
- const stmt = db.prepare('SELECT metadata FROM memory_entries WHERE key = ? AND namespace = ?');
263
- stmt.bind([key, NAMESPACE]);
264
- const entry = stmt.step() ? stmt.getAsObject() : null;
265
- stmt.free();
266
- if (entry?.metadata) {
267
- try {
268
- const meta = JSON.parse(entry.metadata);
269
- return meta.contentHash;
270
- } catch { /* ignore */ }
271
- }
272
- return null;
273
- }
274
-
275
220
  // #1053 S4: doc-* entries retired. Doc-level skip check now reads
276
221
  // docContentHash off chunk-0 (every chunk carries it).
277
222
  function getDocHashFromChunkZero(db, chunkPrefix) {
@@ -564,10 +509,9 @@ function indexFile(db, filePath, keyPrefix, options = {}) {
564
509
  const stats = statSync(filePath);
565
510
  const relativePath = '/' + relative(projectRoot, filePath).replace(/\\/g, '/');
566
511
 
567
- // Delete old chunks for this file before re-indexing.
568
- // #1053 S4: also delete any legacy doc-* row (one-time cleanup if a
569
- // pre-S4 install left one behind for this prefix).
570
- deleteByPrefix(db, chunkPrefix);
512
+ // #1053 S4: drop any legacy doc-* row left over from a pre-S4 install.
513
+ // The chunker stopped emitting these and the audit found zero production
514
+ // readers; safe one-time cleanup as part of the per-doc re-index.
571
515
  db.run(`DELETE FROM memory_entries WHERE namespace = ? AND key = ?`, [NAMESPACE, docKey]);
572
516
 
573
517
  // #1053 S4: Chunker no longer writes doc-* entries. Audit found zero
@@ -580,6 +524,8 @@ function indexFile(db, filePath, keyPrefix, options = {}) {
580
524
  const chunks = chunkMarkdown(content, fileName);
581
525
 
582
526
  if (chunks.length === 0) {
527
+ // No chunks generated → sweep any stragglers from a prior run and exit.
528
+ db.run(`DELETE FROM memory_entries WHERE namespace = ? AND key LIKE ?`, [NAMESPACE, `${chunkPrefix}-%`]);
583
529
  return { docKey, status: 'indexed', chunks: 0 };
584
530
  }
585
531
 
@@ -587,21 +533,17 @@ function indexFile(db, filePath, keyPrefix, options = {}) {
587
533
  const hierarchy = buildHierarchy(chunks, chunkPrefix);
588
534
  const siblings = chunks.map((_, i) => `${chunkPrefix}-${i}`);
589
535
 
590
- for (let i = 0; i < chunks.length; i++) {
591
- const chunk = chunks[i];
536
+ // #1057: route chunk writes through applyIncrementalChunks so an
537
+ // unchanged chunk (~95% of the time when only one section of a doc
538
+ // edited) keeps its embedding column. Pre-#1057 this loop used raw
539
+ // INSERT OR REPLACE after a blanket deleteByPrefix — every re-index
540
+ // wiped every chunk's embedding and forced build-embeddings to
541
+ // re-vectorise the whole doc on every save. See
542
+ // feedback_indexer_preserve_embeddings.md.
543
+ const chunkRows = chunks.map((chunk, i) => {
592
544
  const chunkKey = `${chunkPrefix}-${i}`;
593
-
594
- // Build prev/next links
595
545
  const prevChunk = i > 0 ? `${chunkPrefix}-${i - 1}` : null;
596
546
  const nextChunk = i < chunks.length - 1 ? `${chunkPrefix}-${i + 1}` : null;
597
-
598
- // #1053 S5: dropped extractOverlapContext + preamble wrapping. The
599
- // preambles were a workaround for missing traversal — once memory_get_neighbors
600
- // is wired (S2), prevChunk/nextChunk metadata + a real call is the
601
- // alternative path. Saved ~25-30% bloat per chunk on disk and in
602
- // embeddings.
603
-
604
- // Get hierarchical relationships
605
547
  const hierInfo = hierarchy[chunkKey];
606
548
 
607
549
  const chunkMetadata = {
@@ -646,16 +588,25 @@ function indexFile(db, filePath, keyPrefix, options = {}) {
646
588
  // #1053 S5: title heading + chunk body. No prev/next preamble.
647
589
  const searchableContent = `# ${chunk.title}\n\n${chunk.content}`;
648
590
 
649
- // Store chunk with full metadata
650
- storeEntry(
651
- db,
652
- chunkKey,
653
- searchableContent,
654
- chunkMetadata,
655
- [keyPrefix, 'chunk', `level-${chunk.level}`, chunk.title.toLowerCase().replace(/[^a-z0-9]+/g, '-'), ...extraTags]
656
- );
591
+ return {
592
+ key: chunkKey,
593
+ content: searchableContent,
594
+ metadata: chunkMetadata,
595
+ tags: [
596
+ keyPrefix,
597
+ 'chunk',
598
+ `level-${chunk.level}`,
599
+ chunk.title.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
600
+ ...extraTags,
601
+ ],
602
+ };
603
+ });
657
604
 
658
- debug(` Stored chunk ${i}: ${chunk.title} (${chunk.content.length} chars, prev=${!!prevChunk}, next=${!!nextChunk})`);
605
+ const counts = applyIncrementalChunks(db, NAMESPACE, chunkRows, {
606
+ keyPrefix: `${chunkPrefix}-`,
607
+ });
608
+ if (verbose) {
609
+ debug(` Doc ${docKey}: inserted=${counts.inserted} updated=${counts.updated} unchanged=${counts.unchanged} removed=${counts.removed}`);
659
610
  }
660
611
 
661
612
  return { docKey, status: 'indexed', chunks: chunks.length };
@@ -28,23 +28,13 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from
28
28
  import { resolve, dirname, relative, basename, extname } from 'path';
29
29
  import { fileURLToPath } from 'url';
30
30
  import { resolveMofloBin } from './lib/resolve-bin.mjs';
31
- import { mofloResolveURL } from './lib/moflo-resolve.mjs';
32
- import { memoryDbPath, MOFLO_DIR } from './lib/moflo-paths.mjs';
31
+ import { memoryDbPath, MOFLO_DIR, findProjectRoot } from './lib/moflo-paths.mjs';
32
+ import { openBackend } from './lib/get-backend.mjs';
33
33
  import { applyIncrementalChunks, computeContentListHash } from './lib/incremental-write.mjs';
34
34
  import { createProcessManager } from './lib/process-manager.mjs';
35
35
 
36
36
  const __dirname = dirname(fileURLToPath(import.meta.url));
37
37
 
38
- function findProjectRoot() {
39
- let dir = process.cwd();
40
- const root = resolve(dir, '/');
41
- while (dir !== root) {
42
- if (existsSync(resolve(dir, 'package.json'))) return dir;
43
- dir = dirname(dir);
44
- }
45
- return process.cwd();
46
- }
47
-
48
38
  const projectRoot = findProjectRoot();
49
39
  const NAMESPACE = 'patterns';
50
40
  const DB_PATH = memoryDbPath(projectRoot);
@@ -76,16 +66,9 @@ function ensureDbDir() {
76
66
  async function getDb() {
77
67
  ensureDbDir();
78
68
  // Lazy: hash-cache-match and no-source-files early-exits in main() never
79
- // reach this, and the sql.js wasm cold-load is ~400ms otherwise wasted.
80
- const initSqlJs = (await import(mofloResolveURL('sql.js'))).default;
81
- const SQL = await initSqlJs();
82
- let db;
83
- if (existsSync(DB_PATH)) {
84
- const buffer = readFileSync(DB_PATH);
85
- db = new SQL.Database(buffer);
86
- } else {
87
- db = new SQL.Database();
88
- }
69
+ // reach this, and the backend cold-load (sql.js wasm ~400ms, node:sqlite
70
+ // WAL init ~20ms) is otherwise wasted on the no-op path.
71
+ const db = await openBackend(projectRoot, { create: true });
89
72
  db.run(`
90
73
  CREATE TABLE IF NOT EXISTS memory_entries (
91
74
  id TEXT PRIMARY KEY,
@@ -114,8 +97,7 @@ async function getDb() {
114
97
  }
115
98
 
116
99
  function saveDb(db) {
117
- const data = db.export();
118
- writeFileSync(DB_PATH, Buffer.from(data));
100
+ db.save();
119
101
  }
120
102
 
121
103
  function countNamespace(db) {
@@ -26,24 +26,13 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from
26
26
  import { resolve, dirname, relative, basename, extname, join } from 'path';
27
27
  import { fileURLToPath } from 'url';
28
28
  import { execSync, execFileSync, spawn } from 'child_process';
29
- import { mofloResolveURL } from './lib/moflo-resolve.mjs';
30
- import { memoryDbPath, MOFLO_DIR } from './lib/moflo-paths.mjs';
29
+ import { memoryDbPath, MOFLO_DIR, findProjectRoot } from './lib/moflo-paths.mjs';
30
+ import { openBackend } from './lib/get-backend.mjs';
31
31
  import { resolveMofloBin } from './lib/resolve-bin.mjs';
32
32
  import { applyIncrementalChunks, computeContentListHash } from './lib/incremental-write.mjs';
33
- const initSqlJs = (await import(mofloResolveURL('sql.js'))).default;
34
33
 
35
34
  const __dirname = dirname(fileURLToPath(import.meta.url));
36
35
 
37
- function findProjectRoot() {
38
- let dir = process.cwd();
39
- const root = resolve(dir, '/');
40
- while (dir !== root) {
41
- if (existsSync(resolve(dir, 'package.json'))) return dir;
42
- dir = dirname(dir);
43
- }
44
- return process.cwd();
45
- }
46
-
47
36
  const projectRoot = findProjectRoot();
48
37
  const NAMESPACE = 'tests';
49
38
  const DB_PATH = memoryDbPath(projectRoot);
@@ -87,14 +76,7 @@ function ensureDbDir() {
87
76
 
88
77
  async function getDb() {
89
78
  ensureDbDir();
90
- const SQL = await initSqlJs();
91
- let db;
92
- if (existsSync(DB_PATH)) {
93
- const buffer = readFileSync(DB_PATH);
94
- db = new SQL.Database(buffer);
95
- } else {
96
- db = new SQL.Database();
97
- }
79
+ const db = await openBackend(projectRoot, { create: true });
98
80
 
99
81
  db.run(`
100
82
  CREATE TABLE IF NOT EXISTS memory_entries (
@@ -124,8 +106,7 @@ async function getDb() {
124
106
  }
125
107
 
126
108
  function saveDb(db) {
127
- const data = db.export();
128
- writeFileSync(DB_PATH, Buffer.from(data));
109
+ db.save();
129
110
  }
130
111
 
131
112
  function countNamespace(db) {
@@ -24,20 +24,9 @@
24
24
  * the DB and BEFORE the embeddings migration / soft-delete purge / ephemeral
25
25
  * purge — those all swallow corruption errors and silently no-op.
26
26
  */
27
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
27
+ import { existsSync } from 'node:fs';
28
28
  import { memoryDbPath } from './moflo-paths.mjs';
29
-
30
- let _initSqlJs = null;
31
-
32
- async function loadSqlJs() {
33
- if (_initSqlJs) return _initSqlJs;
34
- // sql.js is a hard dependency of moflo (see top-level package.json);
35
- // resolving it from the consumer's node_modules works because the launcher
36
- // runs from the consumer cwd.
37
- const mod = await import('sql.js');
38
- _initSqlJs = mod.default || mod;
39
- return _initSqlJs;
40
- }
29
+ import { openBackend } from './get-backend.mjs';
41
30
 
42
31
  function isOk(execResult) {
43
32
  const rows = execResult?.[0]?.values ?? [];
@@ -63,18 +52,9 @@ export async function repairMemoryDbIfCorrupt(projectRoot) {
63
52
  const dbPath = memoryDbPath(projectRoot);
64
53
  if (!existsSync(dbPath)) return { repaired: false, errors: 0 };
65
54
 
66
- let initSql;
67
- try {
68
- initSql = await loadSqlJs();
69
- } catch {
70
- return { repaired: false, errors: 0 };
71
- }
72
-
73
55
  let db = null;
74
56
  try {
75
- const SQL = await initSql();
76
- const data = readFileSync(dbPath);
77
- db = new SQL.Database(data);
57
+ db = await openBackend(projectRoot, { create: false });
78
58
 
79
59
  const before = db.exec('PRAGMA integrity_check');
80
60
  if (isOk(before)) {
@@ -89,8 +69,7 @@ export async function repairMemoryDbIfCorrupt(projectRoot) {
89
69
  return { repaired: false, errors, persistent: true };
90
70
  }
91
71
 
92
- const out = Buffer.from(db.export());
93
- writeFileSync(dbPath, out);
72
+ db.save();
94
73
  return { repaired: true, errors };
95
74
  } catch {
96
75
  return { repaired: false, errors: 0 };
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Pure-JS factory for moflo.db low-level SQL handles — JS twin of the
3
+ * `openDaemonDatabase` factory in `src/cli/memory/daemon-backend.ts`. Every
4
+ * `bin/` script that opens `.moflo/moflo.db` MUST go through {@link openBackend}
5
+ * so the engine choice stays consistent with the rest of the runtime.
6
+ *
7
+ * Backend selection: always `node:sqlite` (Phase 5 / #1084 — sql.js has been
8
+ * deleted from the package). The `resolveBackend()` shim is retained because
9
+ * a handful of tests still pass an explicit `backend` option; it now validates
10
+ * the value but only honours `'node-sqlite'`.
11
+ *
12
+ * Engine surface — the handle exposes the **sql.js low-level Statement API**
13
+ * because every existing bin/ caller was written against it (db.prepare/
14
+ * stmt.bind/step/getAsObject/free/run, db.run/exec, db.export-via-save,
15
+ * db.close). For `node:sqlite`, the adapter emulates `stmt.bind()/step()/
16
+ * getAsObject()` via `StatementSync.iterate()` so callers don't refactor
17
+ * their loops.
18
+ *
19
+ * Persistence semantics:
20
+ * - node:sqlite — writes through the OS file handle under WAL; `save()` is
21
+ * a no-op kept for API parity. WAL pragmas (`journal_mode=WAL`,
22
+ * `synchronous=NORMAL`, `busy_timeout=15000`) are set on first open per
23
+ * Phase 0 spike (#1079) and Phase 1 backend (#1080).
24
+ *
25
+ * @module bin/lib/get-backend
26
+ */
27
+
28
+ // MUST come before any direct/transitive `node:sqlite` import below — the
29
+ // node:sqlite module fires ExperimentalWarning exactly once per process on
30
+ // first load, and once it fires there's no way to scrub it from stderr.
31
+ import './suppress-sqlite-warning.mjs';
32
+
33
+ import { existsSync, mkdirSync } from 'node:fs';
34
+ import { dirname } from 'node:path';
35
+ import { memoryDbPath } from './moflo-paths.mjs';
36
+
37
+ export const BACKEND_NODE_SQLITE = 'node-sqlite';
38
+
39
+ /**
40
+ * Resolve the configured backend. Phase 5 (#1084) deleted the sql.js path,
41
+ * so this always returns `node-sqlite`. The `opts.backend` parameter is kept
42
+ * for API compatibility — anything else throws so a stale caller asking for
43
+ * sql.js surfaces a clear error rather than silently dropping to the wrong
44
+ * engine.
45
+ *
46
+ * @param {{ backend?: string }} [opts]
47
+ * @returns {'node-sqlite'}
48
+ */
49
+ export function resolveBackend(opts = {}) {
50
+ if (opts.backend && opts.backend !== BACKEND_NODE_SQLITE) {
51
+ throw new Error(
52
+ `Unknown backend "${opts.backend}". moflo only supports "node-sqlite"; ` +
53
+ `sql.js was retired in Phase 5 (#1084).`,
54
+ );
55
+ }
56
+ return BACKEND_NODE_SQLITE;
57
+ }
58
+
59
+ function ensureDir(filePath) {
60
+ const dir = dirname(filePath);
61
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
62
+ }
63
+
64
+ /**
65
+ * Open a low-level SQL backend handle. Defaults to `.moflo/moflo.db` under
66
+ * `projectRoot`; pass `opts.dbPath` to point at a different file (used by
67
+ * migrations that touch sibling DBs).
68
+ *
69
+ * @param {string} projectRoot
70
+ * @param {{
71
+ * backend?: 'node-sqlite',
72
+ * create?: boolean,
73
+ * readOnly?: boolean,
74
+ * dbPath?: string,
75
+ * }} [opts]
76
+ * @returns {Promise<object>} backend handle (see module doc)
77
+ */
78
+ export async function openBackend(projectRoot, opts = {}) {
79
+ const dbPath = opts.dbPath || memoryDbPath(projectRoot);
80
+ resolveBackend(opts); // throws on stale sql.js callers
81
+ ensureDir(dbPath);
82
+ return openNodeSqlite(dbPath, opts);
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // node:sqlite adapter — the only backend as of Phase 5 (#1084)
87
+ // ---------------------------------------------------------------------------
88
+
89
+ // Module-scope guard so we only fire the network-FS warning once per path
90
+ // per process — the indexer + daemon + bin/ scripts all open the same DB and
91
+ // we don't want N copies of the same message in one session.
92
+ const _networkFsWarnedPaths = new Set();
93
+
94
+ async function openNodeSqlite(dbPath, opts) {
95
+ const { DatabaseSync } = await import('node:sqlite');
96
+ const readOnly = opts.readOnly === true;
97
+ const db = new DatabaseSync(dbPath, { readOnly });
98
+ if (!readOnly) {
99
+ // Close the handle on any PRAGMA failure — node:sqlite opens forgivingly
100
+ // (even non-SQLite files succeed in the constructor) and a PRAGMA that
101
+ // throws later would otherwise leak the file handle across processes
102
+ // (visible on Windows as EPERM on subsequent rmdir of the parent).
103
+ try {
104
+ // WAL trinity validated by Phase 0 spike (#1079) and Phase 1 backend.
105
+ // busy_timeout MUST be set BEFORE journal_mode=WAL — the WAL pragma
106
+ // briefly takes an EXCLUSIVE lock, and concurrent openers (parallel
107
+ // doctor probes, indexer subprocess, daemon bridge init) otherwise hit
108
+ // "database is locked" with no retry budget. See #1097.
109
+ // 15000ms — sized for the consumer-smoke worst case where a
110
+ // background indexer holds a write lock for 5–8s during its first
111
+ // full-tree pass after `npm install`. See daemon-backend.ts twin for
112
+ // the full rationale (#1098).
113
+ db.exec('PRAGMA busy_timeout = 15000');
114
+ db.exec('PRAGMA journal_mode = WAL');
115
+ db.exec('PRAGMA synchronous = NORMAL');
116
+ // Phase 4 / #1083 — network-FS detection. SQLite's POSIX advisory locks
117
+ // and WAL shared-memory both fail silently on NFS/SMB; the engine falls
118
+ // back to a non-WAL journal mode rather than erroring. Read journal_mode
119
+ // back and warn if it isn't `wal`.
120
+ if (dbPath !== ':memory:') warnIfNotWal(db, dbPath);
121
+ } catch (err) {
122
+ try { db.close(); } catch { /* already-dead handle */ }
123
+ throw err;
124
+ }
125
+ }
126
+ return wrapNodeSqlite(db, dbPath);
127
+ }
128
+
129
+ /**
130
+ * Read `journal_mode` back after we requested WAL. If the engine returned a
131
+ * different mode (`delete`, `truncate`, `persist`, `memory`, `off`), the
132
+ * underlying filesystem doesn't support WAL's shared-memory sidecar — a
133
+ * strong signal that POSIX advisory locks are also unreliable. Surface a
134
+ * one-line stderr warning naming the path so the user knows to move the
135
+ * project off the network mount. Deduped per (path, process).
136
+ *
137
+ * Exported so the test in `tests/bin/get-backend.test.ts` can drive a real
138
+ * non-WAL handle through the same probe (a local-disk DB will always come
139
+ * back as WAL, so we can't trigger the warning by simply opening a DB).
140
+ *
141
+ * @param {object} db node:sqlite DatabaseSync handle
142
+ * @param {string} dbPath
143
+ */
144
+ export function warnIfNotWal(db, dbPath) {
145
+ if (_networkFsWarnedPaths.has(dbPath)) return;
146
+ let mode;
147
+ try {
148
+ const stmt = db.prepare('PRAGMA journal_mode');
149
+ const row = stmt.get();
150
+ mode = String(row?.journal_mode ?? '').toLowerCase();
151
+ } catch {
152
+ // Probe must never break the open path — silent failure is acceptable
153
+ // because the WAL pragma above already either took effect or didn't.
154
+ return;
155
+ }
156
+ if (mode && mode !== 'wal') {
157
+ _networkFsWarnedPaths.add(dbPath);
158
+ process.stderr.write(
159
+ `[moflo] WARNING: SQLite journal_mode=${mode} on ${dbPath} (WAL not active). ` +
160
+ `If this directory is on NFS/SMB or another network filesystem, POSIX ` +
161
+ `advisory locks are unreliable and concurrent moflo processes can corrupt ` +
162
+ `the database. Move the project to a local disk to restore multi-process safety.\n`
163
+ );
164
+ }
165
+ }
166
+
167
+ /** @internal — test hook only (resets the dedupe set). */
168
+ export function _resetNetworkFsWarnings() {
169
+ _networkFsWarnedPaths.clear();
170
+ }
171
+
172
+ function wrapNodeSqlite(db, dbPath) {
173
+ // node:sqlite has no `db.changes` field, so the rowsModified probe is a
174
+ // tiny prepared statement reused across calls — preparing on every probe
175
+ // would dominate the indexer's tight write loops.
176
+ let changesStmt = null;
177
+ const getChanges = () => {
178
+ if (!changesStmt) changesStmt = db.prepare('SELECT changes() AS c');
179
+ const row = changesStmt.get();
180
+ return Number(row?.c ?? 0);
181
+ };
182
+
183
+ // Per-connection prepare cache for `db.run(sql, params)` calls — without
184
+ // this the indexer's bulk-DELETE loop (index-guidance:698,699,717) allocates
185
+ // a fresh StatementSync per row, churning the engine's compile cache.
186
+ const runStmtCache = new Map();
187
+ const runWithParams = (sql, params) => {
188
+ let s = runStmtCache.get(sql);
189
+ if (!s) {
190
+ s = db.prepare(sql);
191
+ runStmtCache.set(sql, s);
192
+ }
193
+ s.run(...params);
194
+ };
195
+
196
+ return {
197
+ kind: BACKEND_NODE_SQLITE,
198
+ prepare: (sql) => wrapNodeSqliteStmt(db.prepare(sql)),
199
+ run: (sql, params) => {
200
+ if (params && params.length > 0) runWithParams(sql, params);
201
+ else db.exec(sql);
202
+ },
203
+ exec: (sql) => execAsRowsNodeSqlite(db, sql),
204
+ getRowsModified: getChanges,
205
+ save: () => {
206
+ // node:sqlite persists incrementally via WAL — explicit save is a no-op.
207
+ // Callers can still invoke `save()` unconditionally; the API parity
208
+ // matters more than micro-optimising one call away.
209
+ },
210
+ close: () => {
211
+ changesStmt = null;
212
+ runStmtCache.clear();
213
+ db.close();
214
+ },
215
+ _raw: db,
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Heuristic match for multi-statement SQL: `;` followed by anything
221
+ * substantive. node:sqlite's `db.prepare()` does NOT throw on multi-statement
222
+ * input — it silently parses the first statement only and drops the rest,
223
+ * which is the worst possible failure mode for DDL batches like
224
+ * `CREATE TABLE a; CREATE INDEX i; CREATE TABLE b;`. Detect and route
225
+ * multi-stmt SQL to `db.exec()` (which runs every statement).
226
+ */
227
+ function isMultiStatement(sql) {
228
+ const trimmed = sql.trimEnd();
229
+ const semi = trimmed.indexOf(';');
230
+ if (semi === -1) return false;
231
+ return /\S/.test(trimmed.slice(semi + 1));
232
+ }
233
+
234
+ /**
235
+ * Adapt node:sqlite to sql.js's `db.exec(sql)` return shape:
236
+ * `[{ columns: string[], values: any[][] }]`. The bin scripts use this for
237
+ * single-statement queries that return rows (`PRAGMA integrity_check` in
238
+ * `db-repair.mjs`, `SELECT COUNT(*)` in `index-guidance.mjs`) and for
239
+ * multi-statement DDL batches (controller `ensureSchema`).
240
+ */
241
+ function execAsRowsNodeSqlite(db, sql) {
242
+ // Multi-statement DDL: db.exec() runs every statement. Matches sql.js's
243
+ // exec() contract — DDL batches return `[]` (no row results to surface).
244
+ if (isMultiStatement(sql)) {
245
+ db.exec(sql);
246
+ return [];
247
+ }
248
+ let stmt;
249
+ try {
250
+ stmt = db.prepare(sql);
251
+ } catch {
252
+ db.exec(sql);
253
+ return [];
254
+ }
255
+ const rows = stmt.all();
256
+ if (rows.length === 0) return [];
257
+ const columns = Object.keys(rows[0]);
258
+ const values = rows.map((r) => columns.map((c) => r[c]));
259
+ return [{ columns, values }];
260
+ }
261
+
262
+ function wrapNodeSqliteStmt(stmt) {
263
+ // sql.js statements are stateful (bind → step* → free); node:sqlite's
264
+ // StatementSync is stateless (each call takes its own params). The shim
265
+ // captures the pending params and lazily opens an iterator on first
266
+ // `step()`, releasing the iterator on `free()` so the next `bind()`+
267
+ // `step()` cycle starts cleanly.
268
+ let pendingParams = null;
269
+ let iter = null;
270
+ let currentRow = null;
271
+ return {
272
+ bind: (params) => {
273
+ pendingParams = params && params.length > 0 ? params : null;
274
+ iter = null;
275
+ currentRow = null;
276
+ },
277
+ step: () => {
278
+ if (!iter) {
279
+ iter = pendingParams ? stmt.iterate(...pendingParams) : stmt.iterate();
280
+ }
281
+ const next = iter.next();
282
+ if (next.done) {
283
+ currentRow = null;
284
+ return false;
285
+ }
286
+ currentRow = next.value;
287
+ return true;
288
+ },
289
+ getAsObject: () => currentRow || {},
290
+ run: (params) => {
291
+ if (params && params.length > 0) stmt.run(...params);
292
+ else stmt.run();
293
+ },
294
+ free: () => {
295
+ // sql.js's `Statement.free()` finalises the underlying statement;
296
+ // node:sqlite has no per-statement finalize (StatementSync is GC'd
297
+ // when the Database closes). The wrapper's `free()` instead resets
298
+ // the iteration state so the next `bind()`+`step()` cycle starts
299
+ // cleanly. Functional parity with sql.js callers despite the
300
+ // different underlying lifecycle.
301
+ iter = null;
302
+ currentRow = null;
303
+ pendingParams = null;
304
+ },
305
+ };
306
+ }