linksee-memory 0.17.1 → 0.17.2

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.
@@ -11,34 +11,63 @@ const DB_PATH = join(DEFAULT_DB_DIR, 'memory.db');
11
11
  export function getDbPath() {
12
12
  return DB_PATH;
13
13
  }
14
+ /** This package's version (dist/db/migrate.js → ../../package.json), for exact-version guidance. */
15
+ export function packageVersion() {
16
+ try {
17
+ const pkg = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
18
+ return String(JSON.parse(readFileSync(pkg, 'utf8')).version || '');
19
+ }
20
+ catch {
21
+ return '';
22
+ }
23
+ }
14
24
  /**
15
- * Decide, WITHOUT a writable connection, whether this file may be opened for writing at all.
16
- * (0.17.1, Codex re-review of #15.) A writable connection that is the last one to close
17
- * checkpoints a leftover WAL into memory.db and deletes it, and rolls back a hot journal — both
18
- * are writes to a file we are about to refuse. So the schema is read through a read-only
19
- * connection first (SQLITE_OPEN_READONLY: no checkpoint, no rollback, memory.db and -wal untouched).
20
- * • schema 1–5 → LegacySchemaError
21
- * • a hot rollback journal → HotJournalError (it cannot be read without rolling back)
22
- * • not a DB → return; the normal open's corruption path moves it aside
23
- * • native addon failed → rethrown as is
24
- * • anything else (busy, locked, unreadable) → PrecheckError: could not check, so no writing
25
- * When the file is refused, the -wal/-shm that the read-only open itself created are removed
26
- * again, so the folder is left as it was.
25
+ * The command that migrates / recovers this file. Pinned to this exact version: an unpinned
26
+ * `npx linksee-memory` could resolve to a cached older version, and versions before 0.17.2 have
27
+ * no `migrate` subcommand — they would start the MCP server instead (L-1-130).
27
28
  */
28
- function precheckBeforeWrite(path) {
29
+ export function migrateCommand() {
30
+ const v = packageVersion();
31
+ // Never fall back to an unpinned command (Codex review of #19): if this version cannot be read,
32
+ // name the first version that has `migrate`, as a fixed string.
33
+ return /^\d+\.\d+\.\d+/.test(v) ? `npx -y linksee-memory@${v} migrate` : `npx -y linksee-memory@${MIGRATE_SINCE} migrate`;
34
+ }
35
+ /** First version with the `migrate` subcommand. */
36
+ export const MIGRATE_SINCE = '0.17.2';
37
+ /**
38
+ * Read the schema through a read-only connection (no checkpoint, no rollback). Returns what it
39
+ * found plus `tidy`, which removes the -wal/-shm that the read-only open itself added.
40
+ * Throws the native load error as is, and PrecheckError when the file could not be checked at all.
41
+ */
42
+ export function probeReadonly(path) {
29
43
  if (!existsSync(path))
30
- return; // new DB: nothing to protect, create it
44
+ return { probe: { kind: 'missing' }, tidy: () => { } };
31
45
  const had = { wal: existsSync(path + '-wal'), shm: existsSync(path + '-shm') };
46
+ const walStat = had.wal ? statSync(path + '-wal') : null;
32
47
  // Remove only what the read-only open itself added: a -wal that was absent and is still empty,
33
48
  // and a -shm that was absent (e.g. a leftover -wal with no -shm — the reader builds one).
34
- // Known limit (0.17.2): if another process opens the file in the same instant, the -shm deleted
35
- // here may already be in use by it. The tests check a single process only, not concurrency.
49
+ // 0.17.2 (Codex fifth review of #15, P2): another process may open the file in the same instant
50
+ // and start using the -shm. Two guards: (1) if the -wal changed while we looked (size or mtime),
51
+ // someone else is active → leave both; (2) the -shm is renamed aside first — on Windows a file
52
+ // another process has open cannot be renamed, so an in-use -shm stays. On POSIX an open -shm can
53
+ // still be renamed, so a writer that starts in the exact window remains a known limit.
36
54
  const tidy = () => {
37
55
  try {
38
- if (!had.wal && existsSync(path + '-wal') && statSync(path + '-wal').size === 0)
56
+ if (had.wal) {
57
+ const now = existsSync(path + '-wal') ? statSync(path + '-wal') : null;
58
+ if (!now || !walStat || now.size !== walStat.size || now.mtimeMs !== walStat.mtimeMs)
59
+ return;
60
+ }
61
+ else if (existsSync(path + '-wal')) {
62
+ if (statSync(path + '-wal').size !== 0)
63
+ return; // someone wrote to it: not ours
39
64
  unlinkSync(path + '-wal');
40
- if (!had.shm && existsSync(path + '-shm'))
41
- unlinkSync(path + '-shm');
65
+ }
66
+ if (!had.shm && existsSync(path + '-shm')) {
67
+ const aside = `${path}-shm.tidy-${process.pid}`;
68
+ renameSync(path + '-shm', aside);
69
+ unlinkSync(aside);
70
+ }
42
71
  }
43
72
  catch { /* leave them; memory.db itself is untouched either way */ }
44
73
  };
@@ -56,24 +85,46 @@ function precheckBeforeWrite(path) {
56
85
  const code = e?.code ?? '';
57
86
  const msg = e?.message ?? String(e);
58
87
  // The file vanished between the check and the open: a new DB, as above.
59
- if (!existsSync(path) && (code === 'SQLITE_CANTOPEN' || code === 'ENOENT' || /unable to open|does not exist/i.test(msg)))
60
- return;
88
+ if (!existsSync(path) && (code === 'SQLITE_CANTOPEN' || code === 'ENOENT' || /unable to open|does not exist/i.test(msg))) {
89
+ return { probe: { kind: 'missing' }, tidy: () => { } };
90
+ }
61
91
  // The native addon itself failed: let it surface as such (the hooks turn it into one line).
62
92
  if (isNativeLoadError(e))
63
93
  throw e;
64
- // Not a database at all: the normal open's corruption path moves it aside (nothing lost).
65
- if (code === 'SQLITE_NOTADB' || code === 'SQLITE_CORRUPT' || /not a database|malformed|disk image/i.test(msg))
66
- return;
94
+ // Not a database at all.
95
+ if (code === 'SQLITE_NOTADB' || code === 'SQLITE_CORRUPT' || /not a database|malformed|disk image/i.test(msg)) {
96
+ return { probe: { kind: 'notadb' }, tidy };
97
+ }
67
98
  tidy();
68
99
  if (code === 'SQLITE_READONLY_ROLLBACK')
69
- throw new HotJournalError(path);
100
+ return { probe: { kind: 'hot' }, tidy: () => { } };
70
101
  // Anything else (SQLITE_BUSY, locked, permission, I/O): we could not check it, so we do not
71
102
  // write to it either — fail closed (0.17.1, Codex third review of #15).
72
103
  throw new PrecheckError(path, code || msg.split('\n')[0]);
73
104
  }
74
- if (v > 0 && v < MIN_SUPPORTED_SCHEMA) {
105
+ return { probe: { kind: 'version', version: v }, tidy };
106
+ }
107
+ /**
108
+ * Decide, WITHOUT a writable connection, whether this file may be opened for writing at all.
109
+ * (0.17.1, Codex re-review of #15.) A writable connection that is the last one to close
110
+ * checkpoints a leftover WAL into memory.db and deletes it, and rolls back a hot journal — both
111
+ * are writes to a file we are about to refuse. So the schema is read through a read-only
112
+ * connection first (SQLITE_OPEN_READONLY: no checkpoint, no rollback, memory.db and -wal untouched).
113
+ * • schema 1–5 → LegacySchemaError
114
+ * • a hot rollback journal → HotJournalError (it cannot be read without rolling back)
115
+ * • not a DB → return; the normal open's corruption path moves it aside
116
+ * • native addon failed → rethrown as is
117
+ * • anything else (busy, locked, unreadable) → PrecheckError: could not check, so no writing
118
+ * When the file is refused, the -wal/-shm that the read-only open itself created are removed
119
+ * again, so the folder is left as it was.
120
+ */
121
+ function precheckBeforeWrite(path) {
122
+ const { probe, tidy } = probeReadonly(path);
123
+ if (probe.kind === 'hot')
124
+ throw new HotJournalError(path);
125
+ if (probe.kind === 'version' && probe.version > 0 && probe.version < MIN_SUPPORTED_SCHEMA) {
75
126
  tidy();
76
- throw new LegacySchemaError(v, path);
127
+ throw new LegacySchemaError(probe.version, path);
77
128
  }
78
129
  }
79
130
  function openAt(path) {
@@ -139,7 +190,7 @@ export class DbRefusedError extends Error {
139
190
  * No old version is recommended as a stepping stone: 0.8.0–0.17.0 stop on `no such table:
140
191
  * drift_anchors` after partly altering the file, and 0.7.2 has no subcommands — `npx
141
192
  * linksee-memory@0.7.2 <anything>` starts its MCP server, whose start-up consolidate can delete
142
- * original rows (Codex review of PR #15). A migration command for these files is 0.17.2 work.
193
+ * original rows (Codex review of PR #15). 0.17.2: `linksee-memory migrate` (src/bin/migrate-legacy.ts).
143
194
  */
144
195
  export class LegacySchemaError extends DbRefusedError {
145
196
  schemaVersion;
@@ -153,7 +204,9 @@ export class LegacySchemaError extends DbRefusedError {
153
204
  \n` +
154
205
  ` 3. Do not open this file with another linksee-memory version in the meantime — older versions can delete or half-migrate rows.
155
206
  \n` +
156
- `A migration command for this schema is planned for 0.17.2. To start fresh instead, point LINKSEE_MEMORY_DIR at an empty folder.`);
207
+ ` 4. Migrate it (it backs the file up first; --dry only reports): ${migrateCommand()} --dry, then the same without --dry.
208
+ \n` +
209
+ `To start fresh instead, point LINKSEE_MEMORY_DIR at an empty folder.`);
157
210
  this.name = 'LegacySchemaError';
158
211
  this.schemaVersion = schemaVersion;
159
212
  }
@@ -175,8 +228,8 @@ export class HotJournalError extends DbRefusedError {
175
228
  super(`[linksee-memory] ${dbPath} has an unfinished write from an interrupted process (memory.db-journal), ` +
176
229
  `so it cannot be checked without changing it. Stopped before writing anything.
177
230
  \n` +
178
- ` Back up the folder that holds memory.db (all memory.db* files) before anything else opens it. ` +
179
- `A recovery command is planned for 0.17.2.`);
231
+ ` Back up the folder that holds memory.db (all memory.db* files) before anything else opens it, ` +
232
+ `then run: ${migrateCommand()} (it copies the files first, then lets SQLite roll the unfinished write back).`);
180
233
  this.name = 'HotJournalError';
181
234
  }
182
235
  }
@@ -188,7 +241,8 @@ export function readSchemaVersion(db) {
188
241
  const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
189
242
  return row ? Number(row.value) || 0 : 0;
190
243
  }
191
- export function runMigrations(db) {
244
+ export function runMigrations(db, opts = {}) {
245
+ const step = (name) => opts.onStep?.(name, db);
192
246
  const __filename = fileURLToPath(import.meta.url);
193
247
  const schemaPath = join(dirname(__filename), 'schema.sql');
194
248
  const sql = readFileSync(schemaPath, 'utf8');
@@ -198,14 +252,16 @@ export function runMigrations(db) {
198
252
  // 0.17.1 (L-1-106 / audit F3): refuse schema 1–5 BEFORE any write. Their path through here
199
253
  // (v5 → v6 generated columns, then the v5 entity merge running after the version has already
200
254
  // been bumped to 16, with no transaction around it) can leave a half-migrated file. A safe,
201
- // tested migration is 0.17.2 work. openDb() already refused these before any write; this is
202
- // the second line for callers that hand in their own connection.
203
- if (currentVersion > 0 && currentVersion < MIN_SUPPORTED_SCHEMA) {
255
+ // tested path is the `migrate` command (0.17.2): it backs up, sets allowLegacy and runs all of
256
+ // this inside one transaction. openDb() already refused these before any write; this is the
257
+ // second line for callers that hand in their own connection.
258
+ if (currentVersion > 0 && currentVersion < MIN_SUPPORTED_SCHEMA && !opts.allowLegacy) {
204
259
  throw new LegacySchemaError(currentVersion, getDbPath());
205
260
  }
206
261
  // v3 → v4: rebuild memories_fts with trigram tokenizer for JP/CJK support.
207
262
  // Only runs when upgrading an existing DB from schema v1-3.
208
263
  if (currentVersion > 0 && currentVersion < 4) {
264
+ step('v3-v4');
209
265
  db.exec(`
210
266
  DROP TRIGGER IF EXISTS trg_memories_fts_ai;
211
267
  DROP TRIGGER IF EXISTS trg_memories_fts_ad;
@@ -216,6 +272,7 @@ export function runMigrations(db) {
216
272
  // v4 → v5: add normalized_name column BEFORE schema.sql runs,
217
273
  // so the CREATE INDEX IF NOT EXISTS on (kind, normalized_name) succeeds.
218
274
  if (currentVersion > 0 && currentVersion < 5) {
275
+ step('v4-v5');
219
276
  const cols = db.prepare("PRAGMA table_info(entities)").all();
220
277
  if (!cols.some(c => c.name === 'normalized_name')) {
221
278
  db.exec('ALTER TABLE entities ADD COLUMN normalized_name TEXT');
@@ -229,6 +286,7 @@ export function runMigrations(db) {
229
286
  // NOTE: VIRTUAL generated columns are hidden from PRAGMA table_info.
230
287
  // Use PRAGMA table_xinfo (hidden=2 = VIRTUAL generated column) to detect them.
231
288
  if (currentVersion > 0 && currentVersion < 6) {
289
+ step('v5-v6');
232
290
  const xcols = db.prepare("PRAGMA table_xinfo(memories)").all();
233
291
  const hasAltitude = xcols.some(c => c.name === 'altitude' && c.hidden === 2);
234
292
  if (hasAltitude) {
@@ -249,6 +307,7 @@ export function runMigrations(db) {
249
307
  // thread_id groups related memories (session-level or decision chains).
250
308
  // memory_edges creates directed relationships between individual memories.
251
309
  if (currentVersion > 0 && currentVersion < 7) {
310
+ step('v6-v7');
252
311
  const cols = db.prepare("PRAGMA table_info(memories)").all();
253
312
  if (!cols.some(c => c.name === 'thread_id')) {
254
313
  db.exec('ALTER TABLE memories ADD COLUMN thread_id TEXT');
@@ -270,6 +329,7 @@ export function runMigrations(db) {
270
329
  // with all of these columns.
271
330
  const hasDriftAnchors = !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='drift_anchors'").get();
272
331
  if (currentVersion > 0 && currentVersion < 9 && hasDriftAnchors) {
332
+ step('v8-v9');
273
333
  const have = new Set(db.prepare('PRAGMA table_info(drift_anchors)').all().map((c) => c.name));
274
334
  const addCol = (name, ddl) => {
275
335
  if (!have.has(name))
@@ -292,6 +352,7 @@ export function runMigrations(db) {
292
352
  // without them). Only ALTER if map_nodes already exists; a v10→v12 jump creates
293
353
  // it fresh (with the columns) via db.exec(sql) below.
294
354
  if (currentVersion > 0 && currentVersion < 12) {
355
+ step('v11-v12');
295
356
  const hasMapNodes = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='map_nodes'").get();
296
357
  if (hasMapNodes) {
297
358
  const have = new Set(db.prepare('PRAGMA table_info(map_nodes)').all().map((c) => c.name));
@@ -305,6 +366,7 @@ export function runMigrations(db) {
305
366
  }
306
367
  // v12 → v13: edge strength + accounted-for expiry (anti-noise / anti-graveyard).
307
368
  if (currentVersion > 0 && currentVersion < 13) {
369
+ step('v12-v13');
308
370
  const has = (table, col) => db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === col);
309
371
  if (db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='map_edges'").get()) {
310
372
  if (!has('map_edges', 'strength'))
@@ -322,8 +384,10 @@ export function runMigrations(db) {
322
384
  // node or a `readme→docs-site` edge. SQLite can't alter a PK/UNIQUE, so drop + recreate;
323
385
  // safe because importMap rebuilds both from map.yaml on every run.
324
386
  if (currentVersion > 0 && currentVersion < 14) {
387
+ step('v13-v14');
325
388
  db.exec('DROP TABLE IF EXISTS map_nodes; DROP TABLE IF EXISTS map_edges;');
326
389
  }
390
+ step('schema.sql');
327
391
  db.exec(sql);
328
392
  if (currentVersion > 0 && currentVersion < 4) {
329
393
  db.exec(`INSERT INTO memories_fts(rowid, content) SELECT id, content FROM memories;`);
@@ -345,13 +409,14 @@ export function runMigrations(db) {
345
409
  // v4 → v5: entity name normalization for dedup prevention.
346
410
  // Adds normalized_name column + backfills from existing entity names.
347
411
  if (currentVersion > 0 && currentVersion < 5) {
348
- migrateV5EntityNormalization(db);
412
+ step('v5-backfill');
413
+ migrateV5EntityNormalization(db, opts.mergeDuplicateEntities !== false);
349
414
  }
350
415
  }
351
416
  /**
352
417
  * v5 migration: add normalized_name column and backfill + merge duplicates.
353
418
  */
354
- function migrateV5EntityNormalization(db) {
419
+ function migrateV5EntityNormalization(db, merge) {
355
420
  // Column + index already added before db.exec(sql) above.
356
421
  // Now backfill normalized_name for all entities.
357
422
  const entities = db.prepare('SELECT id, name FROM entities').all();
@@ -369,6 +434,10 @@ function migrateV5EntityNormalization(db) {
369
434
  GROUP BY kind, normalized_name
370
435
  HAVING COUNT(*) > 1
371
436
  `).all();
437
+ if (dupes.length > 0 && !merge) {
438
+ console.log(`[linksee-memory] v5 migration: ${dupes.length} duplicate entity clusters left as they are (not merged)`);
439
+ return;
440
+ }
372
441
  if (dupes.length > 0) {
373
442
  console.log(`[linksee-memory] v5 migration: merging ${dupes.length} duplicate entity clusters`);
374
443
  db.transaction(() => {
@@ -11,7 +11,8 @@
11
11
  // a git clone is already stable and is used where it is.
12
12
  //
13
13
  // Codex review of #15: copying npx's node_modules tree was replaced by this install — a real
14
- // install has no links back into the cache, its size is known, and old versions are removed.
14
+ // install has no links back into the cache and its size is known. Old versions are never removed
15
+ // here; `linksee-memory runtime prune` (src/bin/runtime.ts, 0.17.2) removes unreferenced ones.
15
16
  // • the new runtime must load better-sqlite3 before anything else happens;
16
17
  // • older runtime/<version> folders are NOT deleted (0.17.1); setup says where they are;
17
18
  // • on any failure the existing runtime is left as it was and the hooks keep the npx form.
@@ -143,12 +144,16 @@ export function resolveHookRuntime(o) {
143
144
  const spec = o.spec ?? `linksee-memory@${o.version}`;
144
145
  const staging = join(base, `.tmp-${o.version}-${process.pid}`);
145
146
  const stagingRoot = join(staging, 'node_modules', 'linksee-memory');
146
- const fail = (why) => {
147
+ // `previousAt`: the same-version swap moved the old runtime aside and could not put it back —
148
+ // then "left as it was" would be false, so the note says where it is instead (Codex 5th review, P2).
149
+ const fail = (why, previousAt) => {
147
150
  try {
148
151
  rmSync(staging, { recursive: true, force: true });
149
152
  }
150
153
  catch { /* ignore */ }
151
- return npx(`${why}; kept the npx form, existing runtime left as it was`);
154
+ return npx(previousAt
155
+ ? `${why}; kept the npx form. The previous runtime could not be moved back: it is at ${previousAt} — rename it to ${dest} to restore it`
156
+ : `${why}; kept the npx form, existing runtime left as it was`);
152
157
  };
153
158
  try {
154
159
  rmSync(staging, { recursive: true, force: true });
@@ -183,7 +188,7 @@ export function resolveHookRuntime(o) {
183
188
  catch { /* reported below */ }
184
189
  }
185
190
  const restored = !movedAside || existsSync(dest);
186
- return fail(`could not move the runtime into ${dest} (${e?.message ?? e})${restored ? '' : `; the previous one is at ${aside}`}`);
191
+ return fail(`could not move the runtime into ${dest} (${e?.message ?? e})`, restored ? undefined : aside);
187
192
  }
188
193
  if (movedAside) {
189
194
  try {
@@ -0,0 +1,3 @@
1
+ import { type RmOptions } from 'node:fs';
2
+ export declare function renameGuarded(from: string, to: string): void;
3
+ export declare function rmGuarded(path: string, opts: RmOptions): void;
@@ -0,0 +1,30 @@
1
+ // test-faults — explicit failure injection for the rename / remove steps of `migrate` and
2
+ // `runtime prune` (0.17.2, Codex second review of #19). Windows file locking cannot be relied on
3
+ // to make these fail in a test (libuv opens files with FILE_SHARE_DELETE), and CI runs on Linux.
4
+ //
5
+ // LINKSEE_TEST_FAIL_RENAME=2,3 the 2nd and 3rd guarded rename in this process throw
6
+ // LINKSEE_TEST_FAIL_RM=1 the 1st guarded recursive remove throws
7
+ //
8
+ // Unset (the normal case) these are plain renameSync / rmSync.
9
+ import { renameSync, rmSync } from 'node:fs';
10
+ const counters = {};
11
+ function inject(kind) {
12
+ const spec = process.env[`LINKSEE_TEST_FAIL_${kind}`];
13
+ if (!spec)
14
+ return;
15
+ counters[kind] = (counters[kind] ?? 0) + 1;
16
+ if (spec.split(',').map((s) => Number(s.trim())).includes(counters[kind])) {
17
+ const e = new Error(`injected ${kind.toLowerCase()} failure #${counters[kind]}`);
18
+ e.code = 'EINJECTED';
19
+ throw e;
20
+ }
21
+ }
22
+ export function renameGuarded(from, to) {
23
+ inject('RENAME');
24
+ renameSync(from, to);
25
+ }
26
+ export function rmGuarded(path, opts) {
27
+ inject('RM');
28
+ rmSync(path, opts);
29
+ }
30
+ //# sourceMappingURL=test-faults.js.map
@@ -64,6 +64,8 @@ const SUBCOMMANDS = {
64
64
  detect: 'detect-drift.js',
65
65
  export: 'export-report.js',
66
66
  'share-draft': 'share-draft.js',
67
+ migrate: 'migrate-legacy.js', // 0.17.2: schema 1–5 → current, without starting the server
68
+ runtime: 'runtime.js', // 0.17.2: runtime list | prune
67
69
  };
68
70
  const subcommand = process.argv[2];
69
71
  if (subcommand && Object.prototype.hasOwnProperty.call(SUBCOMMANDS, subcommand)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linksee-memory",
3
- "version": "0.17.1",
3
+ "version": "0.17.2",
4
4
  "mcpName": "io.github.michielinksee/linksee-memory",
5
5
  "description": "Hand a project over — to your next session, to Cursor/Codex/Gemini, to your successor — with the reasons attached. Local-first memory MCP for coding agents: decisions re-injected before the agent acts, drift detection across every project you run. One SQLite file, nothing leaves your machine.",
6
6
  "type": "module",