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.
- package/.claude/guidance/shipped/moflo-memory-protocol.md +5 -1
- package/.claude/guidance/shipped/moflo-memorydb-maintenance.md +22 -11
- package/.claude/guidance/shipped/moflo-root-cause-discipline.md +47 -0
- package/.claude/helpers/statusline.cjs +69 -33
- package/.claude/helpers/subagent-bootstrap.json +1 -1
- package/.claude/helpers/subagent-start.cjs +1 -1
- package/bin/build-embeddings.mjs +6 -20
- package/bin/cli.js +5 -0
- package/bin/generate-code-map.mjs +4 -24
- package/bin/hooks.mjs +3 -12
- package/bin/index-all.mjs +3 -13
- package/bin/index-guidance.mjs +36 -85
- package/bin/index-patterns.mjs +6 -24
- package/bin/index-tests.mjs +4 -23
- package/bin/lib/db-repair.mjs +4 -25
- package/bin/lib/get-backend.mjs +306 -0
- package/bin/lib/incremental-write.mjs +27 -7
- package/bin/lib/moflo-paths.mjs +64 -4
- package/bin/lib/suppress-sqlite-warning.mjs +57 -0
- package/bin/migrations/knowledge-purge.mjs +7 -8
- package/bin/migrations/knowledge-to-learnings.mjs +7 -9
- package/bin/migrations/purge-doc-entries.mjs +7 -8
- package/bin/migrations/strip-context-preambles.mjs +4 -6
- package/bin/run-migrations.mjs +1 -10
- package/bin/semantic-search.mjs +7 -18
- package/bin/session-start-launcher.mjs +102 -102
- package/bin/simplify-classify.cjs +38 -17
- package/dist/src/cli/commands/daemon.js +38 -11
- package/dist/src/cli/commands/doctor-checks-coverage-truth.js +136 -0
- package/dist/src/cli/commands/doctor-checks-memory-access.js +146 -86
- package/dist/src/cli/commands/doctor-checks-memory.js +13 -18
- package/dist/src/cli/commands/doctor-checks-version-skew.js +94 -0
- package/dist/src/cli/commands/doctor-checks-writers-audit.js +170 -0
- package/dist/src/cli/commands/doctor-embedding-hygiene.js +3 -15
- package/dist/src/cli/commands/doctor-fixes.js +30 -0
- package/dist/src/cli/commands/doctor-registry.js +14 -0
- package/dist/src/cli/commands/doctor.js +1 -1
- package/dist/src/cli/commands/embeddings.js +17 -22
- package/dist/src/cli/commands/memory.js +13 -23
- package/dist/src/cli/embeddings/persistent-cache.js +44 -83
- package/dist/src/cli/init/moflo-init.js +40 -0
- package/dist/src/cli/mcp-tools/memory-tools.js +10 -3
- package/dist/src/cli/memory/bridge-core.js +256 -30
- package/dist/src/cli/memory/bridge-entries.js +70 -6
- package/dist/src/cli/memory/controller-registry.js +7 -2
- package/dist/src/cli/memory/controllers/batch-operations.js +5 -1
- package/dist/src/cli/memory/controllers/hierarchical-memory.js +7 -2
- package/dist/src/cli/memory/controllers/mutation-guard.js +22 -2
- package/dist/src/cli/memory/daemon-backend.js +400 -0
- package/dist/src/cli/memory/daemon-write-client.js +192 -15
- package/dist/src/cli/memory/database-provider.js +57 -40
- package/dist/src/cli/memory/hnsw-persistence.js +6 -8
- package/dist/src/cli/memory/index.js +0 -1
- package/dist/src/cli/memory/memory-bridge.js +40 -8
- package/dist/src/cli/memory/memory-initializer.js +269 -209
- package/dist/src/cli/memory/rvf-migration.js +25 -11
- package/dist/src/cli/memory/sqlite-backend.js +573 -0
- package/dist/src/cli/memory/suppress-sqlite-warning.js +49 -0
- package/dist/src/cli/services/cherry-pick-learnings.js +32 -21
- package/dist/src/cli/services/daemon-dashboard.js +13 -1
- package/dist/src/cli/services/daemon-lock.js +58 -1
- package/dist/src/cli/services/daemon-memory-rpc.js +245 -10
- package/dist/src/cli/services/embeddings-migration.js +9 -12
- package/dist/src/cli/services/ephemeral-namespace-purge.js +6 -11
- package/dist/src/cli/services/learning-service.js +12 -20
- package/dist/src/cli/services/project-root.js +69 -9
- package/dist/src/cli/services/soft-delete-purge.js +6 -11
- package/dist/src/cli/services/sqljs-migration-store.js +4 -1
- package/dist/src/cli/services/subagent-bootstrap.js +1 -1
- package/dist/src/cli/shared/events/event-store.js +26 -55
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -4
- package/dist/src/cli/memory/sqljs-backend.js +0 -643
package/bin/index-guidance.mjs
CHANGED
|
@@ -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 {
|
|
29
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
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
|
-
|
|
591
|
-
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
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 };
|
package/bin/index-patterns.mjs
CHANGED
|
@@ -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 {
|
|
32
|
-
import {
|
|
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
|
|
80
|
-
|
|
81
|
-
const
|
|
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
|
-
|
|
118
|
-
writeFileSync(DB_PATH, Buffer.from(data));
|
|
100
|
+
db.save();
|
|
119
101
|
}
|
|
120
102
|
|
|
121
103
|
function countNamespace(db) {
|
package/bin/index-tests.mjs
CHANGED
|
@@ -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 {
|
|
30
|
-
import {
|
|
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
|
|
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
|
-
|
|
128
|
-
writeFileSync(DB_PATH, Buffer.from(data));
|
|
109
|
+
db.save();
|
|
129
110
|
}
|
|
130
111
|
|
|
131
112
|
function countNamespace(db) {
|
package/bin/lib/db-repair.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|