@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.
- package/bin/ijfw +27 -0
- package/bin/ijfw-dashboard +180 -0
- package/bin/ijfw-dispatch-plan +41 -0
- package/bin/ijfw-memorize +273 -0
- package/bin/ijfw-memory +51 -0
- package/fixtures/demo-target.js +28 -0
- package/package.json +53 -0
- package/src/api-client.js +190 -0
- package/src/audit-roster.js +315 -0
- package/src/caps.js +37 -0
- package/src/cold-scan-runner.mjs +37 -0
- package/src/compute/edges.js +155 -0
- package/src/compute/extract.js +560 -0
- package/src/compute/fts5.js +420 -0
- package/src/compute/graph-auto-index.js +191 -0
- package/src/compute/graph-lock.js +114 -0
- package/src/compute/index.js +18 -0
- package/src/compute/migration-runner.js +116 -0
- package/src/compute/migrations/001-initial.js +23 -0
- package/src/compute/migrations/002-porter-stemming-source.js +139 -0
- package/src/compute/migrations/003-tier-semantic.js +69 -0
- package/src/compute/migrations/004-kg-tables.js +83 -0
- package/src/compute/migrations/005-stale-candidate.js +72 -0
- package/src/compute/python-resolver.js +106 -0
- package/src/compute/runner-vm.js +185 -0
- package/src/compute/runner.js +416 -0
- package/src/compute/sandbox-detect.js +122 -0
- package/src/compute/sandbox-linux.js +164 -0
- package/src/compute/sandbox-macos.js +167 -0
- package/src/compute/sandbox-windows.js +63 -0
- package/src/compute/schema.sql +118 -0
- package/src/compute/staleness.js +239 -0
- package/src/compute/synonyms.js +367 -0
- package/src/compute/traverse.js +180 -0
- package/src/cost/aggregator.js +229 -0
- package/src/cost/pricing.js +134 -0
- package/src/cost/readers/claude.js +179 -0
- package/src/cost/readers/codex.js +131 -0
- package/src/cost/readers/gemini.js +111 -0
- package/src/cost/savings.js +243 -0
- package/src/cross-dispatcher.js +437 -0
- package/src/cross-orchestrator-cli.js +1885 -0
- package/src/cross-orchestrator.js +598 -0
- package/src/cross-project-search.js +114 -0
- package/src/dashboard-client.html +1180 -0
- package/src/dashboard-server.js +895 -0
- package/src/design-companion.js +81 -0
- package/src/dispatch/colon-syntax.js +732 -0
- package/src/dispatch-planner.js +235 -0
- package/src/dream/cooldown.js +105 -0
- package/src/dream/runner.mjs +373 -0
- package/src/dream/staleness-wiring.js +195 -0
- package/src/feedback-detector.js +57 -0
- package/src/hero-line.js +115 -0
- package/src/importers/claude-mem.js +152 -0
- package/src/importers/cli.js +311 -0
- package/src/importers/common.js +84 -0
- package/src/importers/discover.js +235 -0
- package/src/importers/rtk.js +107 -0
- package/src/intent-router.js +221 -0
- package/src/lib/atomic-io.js +201 -0
- package/src/lib/cache.js +33 -0
- package/src/lib/npm-view.js +104 -0
- package/src/lib/status-card.js +95 -0
- package/src/lib/token.js +85 -0
- package/src/memory/fts5.js +349 -0
- package/src/memory/migration-runner.js +116 -0
- package/src/memory/migrations/001-fts5-init.js +26 -0
- package/src/memory/migrations/002-tier-semantic.js +60 -0
- package/src/memory/migrations/003-stale-candidate.js +60 -0
- package/src/memory/reader.js +300 -0
- package/src/memory/recall-counter.js +76 -0
- package/src/memory/schema.sql +79 -0
- package/src/memory/search.js +431 -0
- package/src/memory/staleness.js +237 -0
- package/src/memory/tier-promotion.js +377 -0
- package/src/memory/tokenize.js +63 -0
- package/src/project-type-detector.js +866 -0
- package/src/prompt-check.js +171 -0
- package/src/ralph-allowlist.js +88 -0
- package/src/receipts.js +129 -0
- package/src/redactor.js +107 -0
- package/src/sandbox.js +275 -0
- package/src/sanitizer.js +69 -0
- package/src/scan-resume.js +167 -0
- package/src/schema.js +82 -0
- package/src/search-bm25.js +108 -0
- package/src/server.js +1414 -0
- package/src/swarm-config.js +80 -0
- package/src/trident/dispatch.js +211 -0
- package/src/trident/lens-health.js +253 -0
- package/src/update-apply.js +79 -0
- package/src/update-check.js +136 -0
- package/src/vectors.js +178 -0
- package/templates/design/bento-grid.md +84 -0
- package/templates/design/brutalist-luxe.md +82 -0
- package/templates/design/cinematic-dark.md +82 -0
- package/templates/design/data-dense-dashboard.md +88 -0
- package/templates/design/editorial-warm.md +81 -0
- package/templates/design/glassmorphic.md +84 -0
- package/templates/design/magazine-editorial.md +84 -0
- package/templates/design/maximalist-vibrant.md +85 -0
- package/templates/design/neo-swiss-tech.md +85 -0
- package/templates/design/swiss-minimal.md +80 -0
- package/templates/design/terminal-native.md +83 -0
- 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 };
|