moflo 4.9.37 → 4.10.1
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 +358 -62
- 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 +144 -108
- package/bin/simplify-classify.cjs +38 -17
- package/dist/src/cli/commands/daemon.js +38 -11
- package/dist/src/cli/commands/doctor-checks-config.js +60 -0
- 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 +87 -0
- package/dist/src/cli/commands/doctor-registry.js +24 -1
- 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-embedder.js +84 -3
- 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 +271 -211
- 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 +21 -16
- package/dist/src/cli/services/learning-service.js +12 -20
- package/dist/src/cli/services/memory-db-integrity-repair.js +119 -0
- 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/lib/db-repair.mjs
CHANGED
|
@@ -1,43 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Memory-DB integrity check +
|
|
2
|
+
* Memory-DB integrity check + tiered repair (#743, #1090-followup).
|
|
3
3
|
*
|
|
4
|
-
* The `.moflo/moflo.db` SQLite file
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* concurrent writes (
|
|
4
|
+
* The `.moflo/moflo.db` SQLite file picks up corruption in two distinct modes:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Index drift** — `row N missing from sqlite_autoindex_memory_entries_1`.
|
|
7
|
+
* Row data is intact; only the unique-key b-tree is wrong. Trigger: sql.js's
|
|
8
|
+
* whole-file dump-on-flush racing with concurrent writes (#714, #743 —
|
|
9
|
+
* fixed for new installs by Phase 5 / #1084 which removed sql.js entirely).
|
|
10
|
+
* **REINDEX** rebuilds the index from canonical row data.
|
|
11
|
+
*
|
|
12
|
+
* 2. **Table b-tree corruption** — `Tree N page M cell K: Rowid X out of
|
|
13
|
+
* order`, where Tree N is a TABLE root page (not just an index). Row data
|
|
14
|
+
* is partly intact, but page ordering is broken. Triggers we've seen:
|
|
15
|
+
* - sql.js → node:sqlite migration: an old 4.9.x sql.js daemon flushes its
|
|
16
|
+
* full-file dump OVER a WAL frame that the new 4.10 backend has already
|
|
17
|
+
* written, leaving WAL referencing pages that no longer exist in main.
|
|
18
|
+
* - Concurrent multi-process writes when the daemon was disabled (#981).
|
|
19
|
+
* **REINDEX cannot fix this** — the table itself is broken. Recovery path:
|
|
20
|
+
* a) `VACUUM INTO` a fresh file (single-shot rebuild; fails fast if
|
|
21
|
+
* iteration hits an unreadable page),
|
|
22
|
+
* b) row-level salvage — chunked `SELECT rowid > ?` per table, catching
|
|
23
|
+
* per-chunk errors and skipping past corrupt page ranges,
|
|
24
|
+
* c) atomic swap with .corrupt.<TS> backup retained for forensics.
|
|
25
|
+
*
|
|
26
|
+
* 3. **Unrecoverable** — header damage, encrypted-by-malware, etc. We can't
|
|
27
|
+
* fix this; surface a clear failure and let the user decide between manual
|
|
28
|
+
* `flo memory rebuild-index` (destructive) and offline recovery tools.
|
|
9
29
|
*
|
|
10
30
|
* Symptoms when uncorrected:
|
|
11
31
|
* - `index-guidance.mjs` and `index-patterns.mjs` fail mid-write with
|
|
12
32
|
* `database disk image is malformed`, leaving partial state.
|
|
13
33
|
* - The ephemeral-namespace purge (#729) fails silently, so hive-mind /
|
|
14
34
|
* tasklist / epic-state / test-bridge-fix rows accumulate.
|
|
15
|
-
* - Vector counts in the statusline stay inflated
|
|
16
|
-
*
|
|
35
|
+
* - Vector counts in the statusline stay inflated.
|
|
36
|
+
* - Healer's deep checks throw with "database disk image is malformed",
|
|
37
|
+
* surfacing as the synthetic 'Check' failure (doctor.ts:214).
|
|
17
38
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* report; manual `flo memory rebuild-index` is the fallback.
|
|
22
|
-
*
|
|
23
|
-
* MUST run BEFORE any long-lived sql.js consumer (MCP server, daemon) opens
|
|
24
|
-
* the DB and BEFORE the embeddings migration / soft-delete purge / ephemeral
|
|
25
|
-
* purge — those all swallow corruption errors and silently no-op.
|
|
39
|
+
* MUST run BEFORE any long-lived consumer (MCP server, daemon) opens the DB
|
|
40
|
+
* and BEFORE the embeddings migration / soft-delete purge / ephemeral purge —
|
|
41
|
+
* those all swallow corruption errors and silently no-op.
|
|
26
42
|
*/
|
|
27
|
-
import { existsSync,
|
|
43
|
+
import { existsSync, renameSync, unlinkSync } from 'node:fs';
|
|
28
44
|
import { memoryDbPath } from './moflo-paths.mjs';
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
// runs from the consumer cwd.
|
|
37
|
-
const mod = await import('sql.js');
|
|
38
|
-
_initSqlJs = mod.default || mod;
|
|
39
|
-
return _initSqlJs;
|
|
40
|
-
}
|
|
45
|
+
import { openBackend } from './get-backend.mjs';
|
|
46
|
+
import './suppress-sqlite-warning.mjs';
|
|
47
|
+
// Resolve node:sqlite once at module load — get-backend.mjs has already
|
|
48
|
+
// loaded it by this point, so the dynamic import is a cache hit. Avoids
|
|
49
|
+
// three independent `await import('node:sqlite')` calls inside the repair
|
|
50
|
+
// functions (style cleanup; was producing no functional difference).
|
|
51
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
41
52
|
|
|
42
53
|
function isOk(execResult) {
|
|
43
54
|
const rows = execResult?.[0]?.values ?? [];
|
|
@@ -49,52 +60,337 @@ function corruptionCount(execResult) {
|
|
|
49
60
|
}
|
|
50
61
|
|
|
51
62
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
63
|
+
* Open `.moflo/moflo.db` raw via node:sqlite in readonly mode and run
|
|
64
|
+
* `PRAGMA integrity_check`. Bypasses {@link openBackend} because that path
|
|
65
|
+
* sets `journal_mode=WAL`, `busy_timeout`, and `synchronous=NORMAL` on every
|
|
66
|
+
* non-readonly open — those PRAGMAs can themselves throw against a corrupt
|
|
67
|
+
* file, and the pre-#1090 code path caught those throws and reported the DB
|
|
68
|
+
* as healthy. Readonly + no PRAGMAs = the probe always reaches the
|
|
69
|
+
* `integrity_check` call regardless of file health.
|
|
58
70
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
71
|
+
* Exported so the TS doctor check (`checkMemoryDbIntegrity` in
|
|
72
|
+
* `src/cli/commands/doctor-checks-config.ts`) can call into the same
|
|
73
|
+
* implementation instead of re-deriving the readonly-no-PRAGMAs probe.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} dbPath
|
|
76
|
+
* @returns {Promise<{ ok: boolean, errors: number, openFailed?: boolean }>}
|
|
61
77
|
*/
|
|
62
|
-
export async function
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
78
|
+
export async function probeIntegrityRaw(dbPath) {
|
|
79
|
+
let db;
|
|
80
|
+
try {
|
|
81
|
+
db = new DatabaseSync(dbPath, { readOnly: true });
|
|
82
|
+
} catch {
|
|
83
|
+
return { ok: false, errors: 0, openFailed: true };
|
|
84
|
+
}
|
|
67
85
|
try {
|
|
68
|
-
|
|
86
|
+
const rows = db.prepare('PRAGMA integrity_check').all();
|
|
87
|
+
if (rows.length === 1 && String(rows[0]?.integrity_check ?? '').toLowerCase() === 'ok') {
|
|
88
|
+
return { ok: true, errors: 0 };
|
|
89
|
+
}
|
|
90
|
+
return { ok: false, errors: rows.length };
|
|
69
91
|
} catch {
|
|
70
|
-
return {
|
|
92
|
+
return { ok: false, errors: 0, openFailed: true };
|
|
93
|
+
} finally {
|
|
94
|
+
try { db.close(); } catch { /* already-dead handle */ }
|
|
71
95
|
}
|
|
96
|
+
}
|
|
72
97
|
|
|
73
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Tier-2 recovery: `VACUUM INTO` a fresh file. Single SQLite call that
|
|
100
|
+
* iterates every row of every table and writes them to a brand-new database
|
|
101
|
+
* with rebuilt indexes. Fails fast if iteration hits an unreadable page —
|
|
102
|
+
* caller falls back to row-level salvage.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} srcPath
|
|
105
|
+
* @param {string} dstPath
|
|
106
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
107
|
+
*/
|
|
108
|
+
async function tryVacuumInto(srcPath, dstPath) {
|
|
109
|
+
try { if (existsSync(dstPath)) unlinkSync(dstPath); } catch { /* best effort */ }
|
|
110
|
+
let db;
|
|
111
|
+
try {
|
|
112
|
+
// Open writable (not readonly) — VACUUM needs to checkpoint WAL first.
|
|
113
|
+
// Skip our standard WAL pragmas (they can throw on corrupt files); SQLite
|
|
114
|
+
// applies its defaults which are sufficient for VACUUM INTO.
|
|
115
|
+
db = new DatabaseSync(srcPath);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
return { ok: false, error: err?.message ?? 'open failed' };
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* corrupt WAL ok */ }
|
|
121
|
+
db.exec(`VACUUM INTO '${dstPath.replace(/'/g, "''")}'`);
|
|
122
|
+
return { ok: true };
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return { ok: false, error: err?.message ?? 'vacuum failed' };
|
|
125
|
+
} finally {
|
|
126
|
+
try { db.close(); } catch { /* */ }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Tier-3 recovery: row-level salvage. Iterate each non-empty table in
|
|
132
|
+
* `rowid > ?` chunks; on any chunk-read failure, skip past that chunk's
|
|
133
|
+
* rowid range and continue. Per-table loss stats returned so the caller can
|
|
134
|
+
* surface what was preserved vs lost.
|
|
135
|
+
*
|
|
136
|
+
* Schema is copied verbatim from `sqlite_master.sql` so triggers/indexes/views
|
|
137
|
+
* are preserved alongside tables. `INSERT OR IGNORE` handles unique-key
|
|
138
|
+
* collisions from any duplicate-rowid corruption mode.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} srcPath
|
|
141
|
+
* @param {string} dstPath
|
|
142
|
+
* @returns {Promise<{
|
|
143
|
+
* ok: boolean,
|
|
144
|
+
* error?: string,
|
|
145
|
+
* lossStats?: Record<string, { read: number, written: number, errors: number }>,
|
|
146
|
+
* }>}
|
|
147
|
+
*/
|
|
148
|
+
async function trySalvageRowByRow(srcPath, dstPath) {
|
|
149
|
+
try { if (existsSync(dstPath)) unlinkSync(dstPath); } catch { /* */ }
|
|
150
|
+
|
|
151
|
+
let src;
|
|
152
|
+
try {
|
|
153
|
+
src = new DatabaseSync(srcPath, { readOnly: true });
|
|
154
|
+
} catch (err) {
|
|
155
|
+
return { ok: false, error: err?.message ?? 'src open failed' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Open dst defensively. If this throws (e.g. permissions, dst path in a
|
|
159
|
+
// dir we can't create, or a concurrent lock on dstPath), keep the
|
|
160
|
+
// "never throws" contract by returning the failure shape — otherwise the
|
|
161
|
+
// open exception would escape past `repairMemoryDbIfCorrupt` and block
|
|
162
|
+
// session start, which is the failure mode this whole module exists to
|
|
163
|
+
// prevent.
|
|
164
|
+
let dst;
|
|
74
165
|
try {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
166
|
+
dst = new DatabaseSync(dstPath);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
try { src.close(); } catch { /* */ }
|
|
169
|
+
return { ok: false, error: err?.message ?? 'dst open failed' };
|
|
170
|
+
}
|
|
78
171
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
172
|
+
const lossStats = {};
|
|
173
|
+
const CHUNK = 500;
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
// Copy schema. Order matters: tables first (else indexes/triggers/views
|
|
177
|
+
// reference nonexistent tables), then everything else. sqlite_* objects
|
|
178
|
+
// (sqlite_sequence, sqlite_autoindex_*) are created implicitly by SQLite.
|
|
179
|
+
const schemaRows = src
|
|
180
|
+
.prepare(
|
|
181
|
+
"SELECT type, name, tbl_name, sql FROM sqlite_master " +
|
|
182
|
+
"WHERE sql IS NOT NULL ORDER BY CASE type " +
|
|
183
|
+
"WHEN 'table' THEN 1 WHEN 'index' THEN 2 WHEN 'view' THEN 3 ELSE 4 END",
|
|
184
|
+
)
|
|
185
|
+
.all();
|
|
186
|
+
for (const s of schemaRows) {
|
|
187
|
+
if (String(s.name).startsWith('sqlite_')) continue;
|
|
188
|
+
try { dst.exec(s.sql + ';'); } catch { /* malformed schema row — skip */ }
|
|
82
189
|
}
|
|
83
190
|
|
|
84
|
-
|
|
85
|
-
|
|
191
|
+
// Salvage rows table-by-table.
|
|
192
|
+
const tables = src
|
|
193
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
|
194
|
+
.all();
|
|
86
195
|
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
196
|
+
for (const t of tables) {
|
|
197
|
+
const name = String(t.name);
|
|
198
|
+
lossStats[name] = { read: 0, written: 0, errors: 0 };
|
|
199
|
+
|
|
200
|
+
const cols = src.prepare(`PRAGMA table_info('${name.replace(/'/g, "''")}')`).all();
|
|
201
|
+
if (cols.length === 0) continue;
|
|
202
|
+
const colList = cols.map((c) => '"' + String(c.name).replace(/"/g, '""') + '"').join(',');
|
|
203
|
+
const placeholders = cols.map(() => '?').join(',');
|
|
204
|
+
const insert = dst.prepare(
|
|
205
|
+
`INSERT OR IGNORE INTO "${name.replace(/"/g, '""')}" (${colList}) VALUES (${placeholders})`,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
let lastRowid = 0;
|
|
209
|
+
let safetyCap = 0;
|
|
210
|
+
const MAX_ITERATIONS = 100_000;
|
|
211
|
+
|
|
212
|
+
while (safetyCap++ < MAX_ITERATIONS) {
|
|
213
|
+
let rows;
|
|
214
|
+
try {
|
|
215
|
+
rows = src
|
|
216
|
+
.prepare(
|
|
217
|
+
`SELECT rowid as __rid, * FROM "${name.replace(/"/g, '""')}" ` +
|
|
218
|
+
`WHERE rowid > ? ORDER BY rowid LIMIT ${CHUNK}`,
|
|
219
|
+
)
|
|
220
|
+
.all(lastRowid);
|
|
221
|
+
} catch {
|
|
222
|
+
lossStats[name].errors++;
|
|
223
|
+
lastRowid += CHUNK;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (!rows || rows.length === 0) break;
|
|
227
|
+
lossStats[name].read += rows.length;
|
|
228
|
+
for (const r of rows) {
|
|
229
|
+
try {
|
|
230
|
+
insert.run(...cols.map((c) => r[c.name]));
|
|
231
|
+
lossStats[name].written++;
|
|
232
|
+
} catch {
|
|
233
|
+
lossStats[name].errors++;
|
|
234
|
+
}
|
|
235
|
+
lastRowid = Number(r.__rid);
|
|
236
|
+
}
|
|
237
|
+
if (rows.length < CHUNK) break;
|
|
238
|
+
}
|
|
90
239
|
}
|
|
91
240
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
241
|
+
// Verify the recovered file. If integrity_check still fails, the
|
|
242
|
+
// salvage didn't actually produce a clean file — surface as failure
|
|
243
|
+
// (caller will keep the corrupted original in place).
|
|
244
|
+
const checkRows = dst.prepare('PRAGMA integrity_check').all();
|
|
245
|
+
const recoveredOk =
|
|
246
|
+
checkRows.length === 1 &&
|
|
247
|
+
String(checkRows[0]?.integrity_check ?? '').toLowerCase() === 'ok';
|
|
248
|
+
if (!recoveredOk) {
|
|
249
|
+
return { ok: false, error: 'recovered file failed integrity_check', lossStats };
|
|
250
|
+
}
|
|
251
|
+
return { ok: true, lossStats };
|
|
252
|
+
} catch (err) {
|
|
253
|
+
return { ok: false, error: err?.message ?? 'salvage failed' };
|
|
97
254
|
} finally {
|
|
98
|
-
|
|
255
|
+
try { src.close(); } catch { /* */ }
|
|
256
|
+
try { dst.close(); } catch { /* */ }
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Atomically swap a freshly recovered DB into the canonical path, keeping the
|
|
262
|
+
* corrupted original (+ its WAL/SHM sidecars if present) under `.corrupt.<TS>`
|
|
263
|
+
* suffixes for forensics. Caller must guarantee no live writer holds the
|
|
264
|
+
* canonical file open before invoking this — see `stopWritersBeforeRepair`
|
|
265
|
+
* for the daemon-coordinated entry point.
|
|
266
|
+
*
|
|
267
|
+
* @param {string} canonicalPath
|
|
268
|
+
* @param {string} recoveredPath
|
|
269
|
+
* @returns {{ ok: boolean, error?: string, corruptSuffix: string }}
|
|
270
|
+
*/
|
|
271
|
+
function atomicSwap(canonicalPath, recoveredPath) {
|
|
272
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').replace(/Z$/, '');
|
|
273
|
+
const corruptSuffix = `.corrupt.${ts}`;
|
|
274
|
+
try {
|
|
275
|
+
if (existsSync(canonicalPath)) {
|
|
276
|
+
renameSync(canonicalPath, canonicalPath + corruptSuffix);
|
|
277
|
+
}
|
|
278
|
+
const walPath = canonicalPath + '-wal';
|
|
279
|
+
const shmPath = canonicalPath + '-shm';
|
|
280
|
+
if (existsSync(walPath)) {
|
|
281
|
+
try { renameSync(walPath, walPath + corruptSuffix); } catch { /* not always present */ }
|
|
282
|
+
}
|
|
283
|
+
if (existsSync(shmPath)) {
|
|
284
|
+
try { renameSync(shmPath, shmPath + corruptSuffix); } catch { /* not always present */ }
|
|
285
|
+
}
|
|
286
|
+
renameSync(recoveredPath, canonicalPath);
|
|
287
|
+
return { ok: true, corruptSuffix };
|
|
288
|
+
} catch (err) {
|
|
289
|
+
return { ok: false, error: err?.message ?? 'swap failed', corruptSuffix };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Probe the memory DB for corruption and run a tiered repair if found:
|
|
295
|
+
*
|
|
296
|
+
* - Tier 1: `REINDEX` in place (index-only corruption — #743).
|
|
297
|
+
* - Tier 2: `VACUUM INTO` fresh file + atomic swap (table b-tree corruption).
|
|
298
|
+
* - Tier 3: row-level salvage + atomic swap (deep corruption with partial
|
|
299
|
+
* row loss).
|
|
300
|
+
*
|
|
301
|
+
* Returns a structured result:
|
|
302
|
+
* - `{ repaired: false, errors: 0 }` — healthy or absent.
|
|
303
|
+
* - `{ repaired: true, errors: N, tier: 'reindex' }` — Tier 1 worked.
|
|
304
|
+
* - `{ repaired: true, errors: N, tier: 'vacuum', corruptBackup }` — Tier 2.
|
|
305
|
+
* - `{ repaired: true, errors: N, tier: 'salvage', corruptBackup, lossStats }`
|
|
306
|
+
* — Tier 3 (partial row loss possible; see `lossStats`).
|
|
307
|
+
* - `{ repaired: false, errors: N, persistent: true }` — nothing worked;
|
|
308
|
+
* manual recovery needed.
|
|
309
|
+
*
|
|
310
|
+
* Never throws; any internal failure becomes `{ repaired: false, errors: 0 }`
|
|
311
|
+
* so a probe failure cannot block session start.
|
|
312
|
+
*
|
|
313
|
+
* @param {string} projectRoot
|
|
314
|
+
* @returns {Promise<{
|
|
315
|
+
* repaired: boolean,
|
|
316
|
+
* errors: number,
|
|
317
|
+
* tier?: 'reindex' | 'vacuum' | 'salvage',
|
|
318
|
+
* persistent?: boolean,
|
|
319
|
+
* corruptBackup?: string,
|
|
320
|
+
* lossStats?: Record<string, { read: number, written: number, errors: number }>,
|
|
321
|
+
* }>}
|
|
322
|
+
*/
|
|
323
|
+
export async function repairMemoryDbIfCorrupt(projectRoot) {
|
|
324
|
+
const dbPath = memoryDbPath(projectRoot);
|
|
325
|
+
if (!existsSync(dbPath)) return { repaired: false, errors: 0 };
|
|
326
|
+
|
|
327
|
+
// Step 1 — defensive readonly probe (cannot throw on WAL-setup errors
|
|
328
|
+
// against corrupt files). If the open itself fails, fall through to the
|
|
329
|
+
// openBackend path which has retry semantics for transient lock issues;
|
|
330
|
+
// truly unopenable files surface as persistent below.
|
|
331
|
+
const probe = await probeIntegrityRaw(dbPath);
|
|
332
|
+
if (probe.ok) return { repaired: false, errors: 0 };
|
|
333
|
+
|
|
334
|
+
const errors = probe.errors;
|
|
335
|
+
|
|
336
|
+
// Step 2 — Tier 1: REINDEX via the existing backend path. Fast for the
|
|
337
|
+
// common index-drift mode and preserves the file in place.
|
|
338
|
+
if (!probe.openFailed) {
|
|
339
|
+
try {
|
|
340
|
+
const db = await openBackend(projectRoot, { create: false });
|
|
341
|
+
try {
|
|
342
|
+
db.run('REINDEX');
|
|
343
|
+
const after = db.exec('PRAGMA integrity_check');
|
|
344
|
+
if (isOk(after)) {
|
|
345
|
+
db.save();
|
|
346
|
+
return { repaired: true, errors, tier: 'reindex' };
|
|
347
|
+
}
|
|
348
|
+
} finally {
|
|
349
|
+
try { db.close(); } catch { /* */ }
|
|
350
|
+
}
|
|
351
|
+
} catch {
|
|
352
|
+
// REINDEX path failed (often because openBackend's WAL pragmas throw
|
|
353
|
+
// on a corrupt file). Fall through to deeper recovery.
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Step 3 — Tier 2: VACUUM INTO a fresh file.
|
|
358
|
+
const recoveredPath = dbPath + '.recovered';
|
|
359
|
+
const vacuum = await tryVacuumInto(dbPath, recoveredPath);
|
|
360
|
+
if (vacuum.ok) {
|
|
361
|
+
const recoveredProbe = await probeIntegrityRaw(recoveredPath);
|
|
362
|
+
if (recoveredProbe.ok) {
|
|
363
|
+
const swap = atomicSwap(dbPath, recoveredPath);
|
|
364
|
+
if (swap.ok) {
|
|
365
|
+
return {
|
|
366
|
+
repaired: true,
|
|
367
|
+
errors: errors || corruptionCount(recoveredProbe),
|
|
368
|
+
tier: 'vacuum',
|
|
369
|
+
corruptBackup: dbPath + swap.corruptSuffix,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
try { unlinkSync(recoveredPath); } catch { /* */ }
|
|
99
374
|
}
|
|
375
|
+
|
|
376
|
+
// Step 4 — Tier 3: row-level salvage.
|
|
377
|
+
const salvage = await trySalvageRowByRow(dbPath, recoveredPath);
|
|
378
|
+
if (salvage.ok) {
|
|
379
|
+
const swap = atomicSwap(dbPath, recoveredPath);
|
|
380
|
+
if (swap.ok) {
|
|
381
|
+
return {
|
|
382
|
+
repaired: true,
|
|
383
|
+
errors,
|
|
384
|
+
tier: 'salvage',
|
|
385
|
+
corruptBackup: dbPath + swap.corruptSuffix,
|
|
386
|
+
lossStats: salvage.lossStats,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
try { unlinkSync(recoveredPath); } catch { /* */ }
|
|
390
|
+
} else {
|
|
391
|
+
try { if (existsSync(recoveredPath)) unlinkSync(recoveredPath); } catch { /* */ }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Step 5 — give up.
|
|
395
|
+
return { repaired: false, errors, persistent: true };
|
|
100
396
|
}
|