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.
- package/README.md +124 -822
- package/dist/bin/migrate-legacy.d.ts +33 -0
- package/dist/bin/migrate-legacy.js +501 -0
- package/dist/bin/runtime.d.ts +52 -0
- package/dist/bin/runtime.js +381 -0
- package/dist/bin/setup.js +1 -1
- package/dist/db/migrate.d.ts +53 -2
- package/dist/db/migrate.js +106 -37
- package/dist/lib/hook-runtime.js +9 -4
- package/dist/lib/test-faults.d.ts +3 -0
- package/dist/lib/test-faults.js +30 -0
- package/dist/mcp/server.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import Database from 'better-sqlite3';
|
|
3
|
+
export type MigrateLegacyOpts = {
|
|
4
|
+
dbPath: string;
|
|
5
|
+
dry?: boolean;
|
|
6
|
+
now?: Date;
|
|
7
|
+
/** Called before each step with the connection it is about to use; tests inject failures here.
|
|
8
|
+
* Steps: 'vacuum-into' (the recovered copy), 'verify-replica' (the replica), every migrate.ts
|
|
9
|
+
* step (the replica, inside the transaction), 'commit' (the replica, just before COMMIT). */
|
|
10
|
+
onStep?: (step: string, db: Database.Database) => void;
|
|
11
|
+
log?: (line: string) => void;
|
|
12
|
+
};
|
|
13
|
+
export type MigrateLegacyResult = {
|
|
14
|
+
code: 0 | 1;
|
|
15
|
+
outcome: 'missing' | 'not-needed' | 'dry' | 'migrated' | 'recovered' | 'refused' | 'failed';
|
|
16
|
+
backupDir?: string;
|
|
17
|
+
before?: Record<string, number>;
|
|
18
|
+
after?: Record<string, number>;
|
|
19
|
+
from?: number;
|
|
20
|
+
to?: number;
|
|
21
|
+
/** Set when the work folder could not be removed. */
|
|
22
|
+
workLeft?: string;
|
|
23
|
+
};
|
|
24
|
+
/** The four tables the report shows. Every table is checked; these are the ones people know. */
|
|
25
|
+
export declare const REPORTED: readonly ["memories", "entities", "memory_edges", "drift_anchors"];
|
|
26
|
+
export declare function currentSchemaVersion(): number;
|
|
27
|
+
/** Row count of every ordinary table (FTS shadow tables and sqlite_* excluded). */
|
|
28
|
+
export declare function rowCounts(db: Database.Database): Record<string, number>;
|
|
29
|
+
/** Which steps migrate.ts runs from schema `v` (for the report). */
|
|
30
|
+
export declare function planFor(v: number, hasDriftAnchors: boolean): string[];
|
|
31
|
+
/** How to go back to a backup set - always as one set, never mixing generations. */
|
|
32
|
+
export declare function restoreSteps(path: string, backupDir: string): string[];
|
|
33
|
+
export declare function migrateLegacy(o: MigrateLegacyOpts): MigrateLegacyResult;
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// linksee-memory migrate — bring a schema 1–5 memory.db (0.11 and earlier, up to May 2026) to the
|
|
3
|
+
// current schema, by hand, without starting the server (0.17.2, L-1-068 / L-1-122).
|
|
4
|
+
//
|
|
5
|
+
// npx -y linksee-memory@<version> migrate --dry # full rehearsal on a temporary copy
|
|
6
|
+
// npx -y linksee-memory@<version> migrate # migrate a copy, then swap it in
|
|
7
|
+
//
|
|
8
|
+
// EVERY CLIENT MUST BE CLOSED FOR THE WHOLE RUN. Nothing here is a lock: size/mtime checks and the
|
|
9
|
+
// writer probe below only catch a client that is visibly active. A WAL-mode file is never probed,
|
|
10
|
+
// so an idle client that keeps it open is detected only on Windows (the rename is refused).
|
|
11
|
+
//
|
|
12
|
+
// The live files (memory.db and its -wal / -shm / -journal) are not written by this command until
|
|
13
|
+
// the final swap (Codex review of #19: a writable connection on them recovers a hot journal on
|
|
14
|
+
// open and checkpoints a WAL on close). One exception, which writes nothing: step 5's probe.
|
|
15
|
+
//
|
|
16
|
+
// 0. free space where the work happens must be >= 3 x (memory.db + sidecars); unknown -> stop.
|
|
17
|
+
// 1. copy memory.db, -wal, -journal byte for byte into <memory.db>.migrating-<ts>-<pid>/ (plain
|
|
18
|
+
// file copy, no SQLite). If any live file's size or mtime changed during the copy -> stop.
|
|
19
|
+
// 2. open the COPY: SQLite recovers it (rolls back a hot journal / reads the WAL) there.
|
|
20
|
+
// Read its schema; integrity_check must be ok. schema >= 6 -> "not needed" (unless the copy
|
|
21
|
+
// had an unfinished write to roll back - then it is recovered and swapped like a migration).
|
|
22
|
+
// 3. VACUUM INTO a single-file replica; check it: integrity_check ok, same schema, same rows.
|
|
23
|
+
// 4. migrate the replica in one transaction (migrate.ts steps 5->6->...->16). Commit only if no
|
|
24
|
+
// table lost rows, schema = current, quick_check = ok. Any failure -> stop; the live files
|
|
25
|
+
// were not written to.
|
|
26
|
+
// 5. swap: live size + mtime unchanged since step 1; for a rollback-journal file with no -journal
|
|
27
|
+
// / -wal, a writable connection tries BEGIN IMMEDIATE then ROLLBACK (writes nothing; fails if
|
|
28
|
+
// another connection holds a write lock) and is closed again. Then <memory.db>.pre-migrate-<ts>/
|
|
29
|
+
// is created with a README.txt, memory.db and its sidecars are renamed into it (the backup,
|
|
30
|
+
// byte for byte, one set) and the replica is renamed into place. If a rename fails it tries to
|
|
31
|
+
// put back what was already moved and names anything it could not.
|
|
32
|
+
// The work folder is removed at the end; if that fails it says so.
|
|
33
|
+
// It never starts the server, consolidates, merges duplicate entities or deletes rows.
|
|
34
|
+
import Database from 'better-sqlite3';
|
|
35
|
+
import { closeSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, realpathSync, readdirSync, rmdirSync, statSync, statfsSync, writeFileSync, constants as fsc, } from 'node:fs';
|
|
36
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
37
|
+
import { tmpdir } from 'node:os';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
import { getDbPath, readSchemaVersion, runMigrations, MIN_SUPPORTED_SCHEMA, DbRefusedError, migrateCommand } from '../db/migrate.js';
|
|
40
|
+
import { isNativeLoadError, NATIVE_HINT } from '../lib/native-hint.js';
|
|
41
|
+
import { renameGuarded, rmGuarded } from '../lib/test-faults.js';
|
|
42
|
+
/** The four tables the report shows. Every table is checked; these are the ones people know. */
|
|
43
|
+
export const REPORTED = ['memories', 'entities', 'memory_edges', 'drift_anchors'];
|
|
44
|
+
const SIDECARS = ['-wal', '-shm', '-journal'];
|
|
45
|
+
export function currentSchemaVersion() {
|
|
46
|
+
const sql = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'db', 'schema.sql'), 'utf8');
|
|
47
|
+
const m = sql.match(/VALUES \('schema_version', '(\d+)'\)/);
|
|
48
|
+
if (!m)
|
|
49
|
+
throw new Error('schema.sql has no schema_version');
|
|
50
|
+
return Number(m[1]);
|
|
51
|
+
}
|
|
52
|
+
/** Row count of every ordinary table (FTS shadow tables and sqlite_* excluded). */
|
|
53
|
+
export function rowCounts(db) {
|
|
54
|
+
const names = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'memories_fts%' ORDER BY name").all().map((r) => r.name);
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const n of names)
|
|
57
|
+
out[n] = db.prepare(`SELECT COUNT(*) AS c FROM "${n.replace(/"/g, '""')}"`).get().c;
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
const shown = (c, t) => (c && t in c ? String(c[t]) : '(no table)');
|
|
61
|
+
/** Which steps migrate.ts runs from schema `v` (for the report). */
|
|
62
|
+
export function planFor(v, hasDriftAnchors) {
|
|
63
|
+
const s = [];
|
|
64
|
+
if (v < 4)
|
|
65
|
+
s.push('v3→v4: rebuild the full-text index (trigram) from memories');
|
|
66
|
+
if (v < 5)
|
|
67
|
+
s.push('v4→v5: add entities.normalized_name and fill it (duplicate entities are NOT merged)');
|
|
68
|
+
if (v < 6)
|
|
69
|
+
s.push('v5→v6: add the altitude / mem_type / mem_state generated columns');
|
|
70
|
+
if (v < 7)
|
|
71
|
+
s.push('v6→v7: add memories.thread_id (filled from content.session_id) + memory_edges');
|
|
72
|
+
s.push(hasDriftAnchors ? 'v8→v9: add the newer drift_anchors columns' : 'create drift_anchors (absent before v8) with all its columns');
|
|
73
|
+
s.push(`schema.sql: create the newer tables, schema_version → ${currentSchemaVersion()}`);
|
|
74
|
+
return s;
|
|
75
|
+
}
|
|
76
|
+
/** How to go back to a backup set - always as one set, never mixing generations. */
|
|
77
|
+
export function restoreSteps(path, backupDir) {
|
|
78
|
+
const name = basename(path);
|
|
79
|
+
return [
|
|
80
|
+
`To go back to the files in ${backupDir}/:`,
|
|
81
|
+
` 1. Close every Claude Code / Cursor / Codex / other client using linksee-memory.`,
|
|
82
|
+
` 2. Move the CURRENT ${name} and every ${name}-wal / ${name}-shm / ${name}-journal next to it into a new, separate folder, all of them together, as one set.`,
|
|
83
|
+
` 3. Move ALL the files from ${backupDir}/ (${name} and any -wal / -shm / -journal) back to ${dirname(path)}, as one set.`,
|
|
84
|
+
` Never combine a ${name} from one set with a -wal / -shm / -journal from the other: SQLite would read them as one database and can corrupt it.`,
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
function stampOf(d) {
|
|
88
|
+
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
|
89
|
+
}
|
|
90
|
+
/** size + mtime of memory.db and every sidecar (absent = null). Plain stat, no SQLite. */
|
|
91
|
+
function liveState(path) {
|
|
92
|
+
return JSON.stringify(['', ...SIDECARS].map((ext) => {
|
|
93
|
+
try {
|
|
94
|
+
const s = statSync(path + ext);
|
|
95
|
+
return [ext, s.size, s.mtimeMs];
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return [ext, null];
|
|
99
|
+
}
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
/** Header byte 18 = 2 means WAL mode (read with fs, not SQLite). */
|
|
103
|
+
function isWalFile(path) {
|
|
104
|
+
const fd = openSync(path, 'r');
|
|
105
|
+
try {
|
|
106
|
+
const b = Buffer.alloc(20);
|
|
107
|
+
readSync(fd, b, 0, 20, 0);
|
|
108
|
+
return b[18] === 2;
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
closeSync(fd);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export function migrateLegacy(o) {
|
|
115
|
+
const log = o.log ?? ((l) => console.log(l));
|
|
116
|
+
const ctx = {};
|
|
117
|
+
let r;
|
|
118
|
+
try {
|
|
119
|
+
r = migrateInner(o, log, ctx);
|
|
120
|
+
return r;
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
// The work folder goes in every case, also when migrateInner threw (after a swap only the raw
|
|
124
|
+
// copies are left in it). If it cannot be removed, say so.
|
|
125
|
+
if (ctx.work && existsSync(ctx.work)) {
|
|
126
|
+
try {
|
|
127
|
+
rmGuarded(ctx.work, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
log(`Could not remove the work folder ${ctx.work} (${e?.message ?? e}); delete it by hand. It holds copies only.`);
|
|
131
|
+
}
|
|
132
|
+
if (existsSync(ctx.work) && r)
|
|
133
|
+
r.workLeft = ctx.work;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function migrateInner(o, log, ctx) {
|
|
138
|
+
const path = resolve(o.dbPath);
|
|
139
|
+
const name = basename(path);
|
|
140
|
+
const target = currentSchemaVersion();
|
|
141
|
+
const step = (s, db) => o.onStep?.(s, db);
|
|
142
|
+
if (!existsSync(path)) {
|
|
143
|
+
// A run killed during the swap can leave memory.db inside a backup folder: say so, do not say "nothing".
|
|
144
|
+
let left = [];
|
|
145
|
+
try {
|
|
146
|
+
left = readdirSync(dirname(path)).filter((f) => f.startsWith(`${name}.pre-migrate-`));
|
|
147
|
+
}
|
|
148
|
+
catch { /* none visible */ }
|
|
149
|
+
if (left.length) {
|
|
150
|
+
log(`No ${name} at ${path}, but ${left.map((f) => join(dirname(path), f)).join(', ')} exists — an earlier migrate may have stopped half-way. Read README.txt there (if any) and check which original files are in it before doing anything else.`);
|
|
151
|
+
return { code: 1, outcome: 'refused' };
|
|
152
|
+
}
|
|
153
|
+
log(`No memory.db at ${path} — nothing to migrate.`);
|
|
154
|
+
return { code: 0, outcome: 'missing' };
|
|
155
|
+
}
|
|
156
|
+
const stamp = stampOf(o.now ?? new Date());
|
|
157
|
+
// 0. free space: the raw copy and the replica each need about the size of the live set.
|
|
158
|
+
const liveBytes = ['', ...SIDECARS].reduce((n, ext) => { try {
|
|
159
|
+
return n + statSync(path + ext).size;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return n;
|
|
163
|
+
} }, 0);
|
|
164
|
+
const workParent = o.dry ? tmpdir() : dirname(path);
|
|
165
|
+
try {
|
|
166
|
+
const fs = statfsSync(workParent);
|
|
167
|
+
const free = Number(fs.bavail) * Number(fs.bsize);
|
|
168
|
+
if (free < 3 * liveBytes) {
|
|
169
|
+
log(`Not enough free space in ${workParent}: ${Math.round(free / 1048576)} MB free, need at least ${Math.ceil((3 * liveBytes) / 1048576)} MB (3 x ${name} and its sidecars). This command did not write to ${path}.`);
|
|
170
|
+
return { code: 1, outcome: 'refused' };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
log(`Could not check the free space in ${workParent} (${e?.message ?? e}). This command did not write to ${path}.`);
|
|
175
|
+
return { code: 1, outcome: 'refused' };
|
|
176
|
+
}
|
|
177
|
+
const backupDir = `${path}.pre-migrate-${stamp}`;
|
|
178
|
+
// The work folder name carries the pid so two runs never share it; the backup folder name is
|
|
179
|
+
// checked only at the swap (a run that ends "not needed" must not be refused by an earlier backup).
|
|
180
|
+
const work = o.dry ? mkdtempSync(join(tmpdir(), 'linksee-migrate-dry-')) : `${path}.migrating-${stamp}-${process.pid}`;
|
|
181
|
+
if (!o.dry) {
|
|
182
|
+
if (existsSync(work)) {
|
|
183
|
+
log(`${work} already exists; run again in a second. This command did not write to ${path}.`);
|
|
184
|
+
return { code: 1, outcome: 'refused' };
|
|
185
|
+
}
|
|
186
|
+
mkdirSync(work);
|
|
187
|
+
}
|
|
188
|
+
ctx.work = work;
|
|
189
|
+
const conns = [];
|
|
190
|
+
const open = (p) => { const d = new Database(p, { fileMustExist: true, timeout: 0 }); conns.push(d); return d; };
|
|
191
|
+
const closeAll = () => { for (const d of conns.splice(0)) {
|
|
192
|
+
try {
|
|
193
|
+
if (d.open)
|
|
194
|
+
d.close();
|
|
195
|
+
}
|
|
196
|
+
catch { /* ignore */ }
|
|
197
|
+
} };
|
|
198
|
+
const state0 = liveState(path);
|
|
199
|
+
/** Say "unchanged" only when it is checked to be true. */
|
|
200
|
+
const liveNote = () => (liveState(path) === state0
|
|
201
|
+
? `This command did not write to ${path} or its -wal / -shm / -journal (the work happened on a copy in ${work}).`
|
|
202
|
+
: `This command did not write to ${path} or its sidecars, but they changed during the run: another client is using them.`);
|
|
203
|
+
const tag = o.dry ? '[dry] ' : '';
|
|
204
|
+
try {
|
|
205
|
+
// 1. byte copy, no SQLite on the live files
|
|
206
|
+
const raw = join(work, 'raw.db');
|
|
207
|
+
for (const ext of ['', '-wal', '-journal']) {
|
|
208
|
+
if (existsSync(path + ext))
|
|
209
|
+
copyFileSync(path + ext, raw + ext, fsc.COPYFILE_EXCL);
|
|
210
|
+
}
|
|
211
|
+
if (liveState(path) !== state0) {
|
|
212
|
+
log(`${name} changed while it was being copied: another client is writing to it. Close every Claude Code / Cursor / other client using linksee-memory and run again. This command did not write to ${path}.`);
|
|
213
|
+
return { code: 1, outcome: 'refused' };
|
|
214
|
+
}
|
|
215
|
+
// 2. recover and read the copy
|
|
216
|
+
const hadJournal = existsSync(raw + '-journal') && statSync(raw + '-journal').size > 0;
|
|
217
|
+
let rdb;
|
|
218
|
+
let from;
|
|
219
|
+
try {
|
|
220
|
+
rdb = open(raw);
|
|
221
|
+
from = readSchemaVersion(rdb);
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
log(`${path} could not be read as a SQLite database (${e?.code ?? e?.message}). ${liveNote()}`);
|
|
225
|
+
return { code: 1, outcome: 'refused' };
|
|
226
|
+
}
|
|
227
|
+
const recovered = hadJournal && !(existsSync(raw + '-journal') && statSync(raw + '-journal').size > 0);
|
|
228
|
+
if (from === 0) {
|
|
229
|
+
log(`${path} has no schema_version: not a linksee-memory database this command knows. ${liveNote()}`);
|
|
230
|
+
return { code: 1, outcome: 'refused' };
|
|
231
|
+
}
|
|
232
|
+
if (from >= MIN_SUPPORTED_SCHEMA && !recovered) {
|
|
233
|
+
log(`${path} is at schema ${from}: migrate is not needed (the server migrates schema ${MIN_SUPPORTED_SCHEMA} and newer itself). This command did not write to ${path}.`);
|
|
234
|
+
return { code: 0, outcome: 'not-needed', from };
|
|
235
|
+
}
|
|
236
|
+
const ic0 = rdb.pragma('integrity_check', { simple: true });
|
|
237
|
+
if (ic0 !== 'ok') {
|
|
238
|
+
log(`integrity_check on a copy of ${name} returned ${String(ic0)}: the file is damaged, so it is not migrated. ${liveNote()}`);
|
|
239
|
+
return { code: 1, outcome: 'refused', from };
|
|
240
|
+
}
|
|
241
|
+
const before = rowCounts(rdb);
|
|
242
|
+
if (recovered)
|
|
243
|
+
log(`${tag}The copy had an unfinished write (a hot -journal); SQLite rolled it back in the copy.`);
|
|
244
|
+
// 3. single-file replica + check
|
|
245
|
+
const replica = join(work, 'replica.db');
|
|
246
|
+
try {
|
|
247
|
+
step('vacuum-into', rdb);
|
|
248
|
+
rdb.exec(`VACUUM INTO '${replica.replace(/'/g, "''")}'`);
|
|
249
|
+
}
|
|
250
|
+
catch (e) {
|
|
251
|
+
log(`Making a clean copy failed (${e?.message ?? e}). ${liveNote()}`);
|
|
252
|
+
return { code: 1, outcome: 'failed', from };
|
|
253
|
+
}
|
|
254
|
+
rdb.close();
|
|
255
|
+
const rep = open(replica);
|
|
256
|
+
try {
|
|
257
|
+
rep.pragma('journal_mode = DELETE'); // the replica stays one file; the server switches to WAL on start
|
|
258
|
+
rep.pragma('foreign_keys = ON');
|
|
259
|
+
step('verify-replica', rep);
|
|
260
|
+
const ic = rep.pragma('integrity_check', { simple: true });
|
|
261
|
+
if (ic !== 'ok')
|
|
262
|
+
throw new Error(`integrity_check returned ${String(ic)}`);
|
|
263
|
+
if (readSchemaVersion(rep) !== from)
|
|
264
|
+
throw new Error('schema version differs from the original');
|
|
265
|
+
if (JSON.stringify(rowCounts(rep)) !== JSON.stringify(before))
|
|
266
|
+
throw new Error('row counts differ from the original');
|
|
267
|
+
}
|
|
268
|
+
catch (e) {
|
|
269
|
+
log(`The clean copy did not match the original (${e?.message ?? e}). ${liveNote()}`);
|
|
270
|
+
return { code: 1, outcome: 'failed', from };
|
|
271
|
+
}
|
|
272
|
+
// 4. migrate the replica in one transaction
|
|
273
|
+
let after = before;
|
|
274
|
+
if (from < MIN_SUPPORTED_SCHEMA) {
|
|
275
|
+
try {
|
|
276
|
+
rep.exec('BEGIN IMMEDIATE');
|
|
277
|
+
runMigrations(rep, { allowLegacy: true, mergeDuplicateEntities: false, onStep: o.onStep });
|
|
278
|
+
after = rowCounts(rep);
|
|
279
|
+
const lost = Object.keys(before).filter((t) => (after[t] ?? -1) < before[t]);
|
|
280
|
+
const to = readSchemaVersion(rep);
|
|
281
|
+
const qc = rep.pragma('quick_check', { simple: true });
|
|
282
|
+
if (lost.length)
|
|
283
|
+
throw new Error(`rows would be lost in: ${lost.map((t) => `${t} ${before[t]}→${after[t] ?? 'gone'}`).join(', ')}`);
|
|
284
|
+
if (to !== target)
|
|
285
|
+
throw new Error(`schema ended at ${to}, expected ${target}`);
|
|
286
|
+
if (qc !== 'ok')
|
|
287
|
+
throw new Error(`quick_check returned ${String(qc)}`);
|
|
288
|
+
step('commit', rep);
|
|
289
|
+
rep.exec('COMMIT');
|
|
290
|
+
}
|
|
291
|
+
catch (e) {
|
|
292
|
+
let rb = '';
|
|
293
|
+
if (rep.inTransaction) {
|
|
294
|
+
try {
|
|
295
|
+
rep.exec('ROLLBACK');
|
|
296
|
+
rb = ' (rolled back in the copy)';
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
rb = '';
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
log(`Migration failed${rb}: ${e?.message ?? e}`);
|
|
303
|
+
log(liveNote());
|
|
304
|
+
return { code: 1, outcome: 'failed', before, from };
|
|
305
|
+
}
|
|
306
|
+
const fk = rep.pragma('foreign_key_check');
|
|
307
|
+
if (fk.length)
|
|
308
|
+
log(`${tag}note: foreign_key_check lists ${fk.length} row(s) that were already orphaned before; kept as they are.`);
|
|
309
|
+
}
|
|
310
|
+
rep.close();
|
|
311
|
+
const outcome = from < MIN_SUPPORTED_SCHEMA ? 'migrated' : 'recovered';
|
|
312
|
+
const report = () => {
|
|
313
|
+
for (const t of REPORTED)
|
|
314
|
+
log(` ${t.padEnd(14)} ${shown(before, t).padStart(8)} → ${shown(after, t)}`);
|
|
315
|
+
};
|
|
316
|
+
if (o.dry) {
|
|
317
|
+
log(`[dry] ${path}: schema ${from}. Rehearsed on a temporary copy — nothing was written next to ${name}.`);
|
|
318
|
+
if (from < MIN_SUPPORTED_SCHEMA) {
|
|
319
|
+
log(`[dry] steps (one transaction, rolled back on any failure):`);
|
|
320
|
+
for (const s of planFor(from, 'drift_anchors' in before))
|
|
321
|
+
log(` • ${s}`);
|
|
322
|
+
log(`[dry] rehearsal: schema ${from} → ${target}, no rows lost:`);
|
|
323
|
+
}
|
|
324
|
+
report();
|
|
325
|
+
log(`[dry] a real run would move ${name} and its sidecars into ${backupDir}/ and put the migrated copy in its place.`);
|
|
326
|
+
log(`[dry] liveness: ${liveState(path) === state0 ? 'live files unchanged' : 'live files CHANGED during the rehearsal (a client is using them)'}.`);
|
|
327
|
+
return { code: 0, outcome: 'dry', before, after, from, to: from < MIN_SUPPORTED_SCHEMA ? target : from };
|
|
328
|
+
}
|
|
329
|
+
// 5. swap
|
|
330
|
+
if (liveState(path) !== state0) {
|
|
331
|
+
log(`${name} changed while the copy was being migrated: another client is using it. Close every client using linksee-memory and run again. Not swapped; ${name} was not changed by this command.`);
|
|
332
|
+
return { code: 1, outcome: 'refused', from };
|
|
333
|
+
}
|
|
334
|
+
// Writer probe for rollback-journal files without a journal: BEGIN IMMEDIATE takes RESERVED
|
|
335
|
+
// and fails if another connection holds it; nothing is written (no page changes → no journal).
|
|
336
|
+
// Skipped for WAL files and when a -journal exists (opening those for writing changes them).
|
|
337
|
+
if (!existsSync(path + '-wal') && !existsSync(path + '-journal') && !isWalFile(path)) {
|
|
338
|
+
const p = new Database(path, { fileMustExist: true, timeout: 0 });
|
|
339
|
+
try {
|
|
340
|
+
p.exec('BEGIN IMMEDIATE');
|
|
341
|
+
p.exec('ROLLBACK');
|
|
342
|
+
}
|
|
343
|
+
catch (e) {
|
|
344
|
+
p.close();
|
|
345
|
+
log(`Another client holds ${name} (${e?.code ?? e?.message}). Close every client using linksee-memory and run again. Not swapped; ${liveNote()}`);
|
|
346
|
+
return { code: 1, outcome: 'refused', from };
|
|
347
|
+
}
|
|
348
|
+
p.close();
|
|
349
|
+
if (liveState(path) !== state0) {
|
|
350
|
+
log(`${name} changed during the final check. Not swapped. Run again with every client closed.`);
|
|
351
|
+
return { code: 1, outcome: 'refused', from };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// The swap. Where every original file is, is tracked file by file, so a failure can say exactly
|
|
355
|
+
// what to move where (Codex third review of #19: a fixed "move the current set aside, restore
|
|
356
|
+
// the backup set" text would split a set that is half here, half there).
|
|
357
|
+
const readme = join(backupDir, 'README.txt');
|
|
358
|
+
const originals = ['', ...SIDECARS].filter((ext) => existsSync(path + ext)).map((ext) => name + ext);
|
|
359
|
+
const where = Object.fromEntries(originals.map((f) => [f, 'live']));
|
|
360
|
+
let created = false; // only a folder this run created may be removed again
|
|
361
|
+
try {
|
|
362
|
+
mkdirSync(backupDir);
|
|
363
|
+
created = true;
|
|
364
|
+
}
|
|
365
|
+
catch (e) {
|
|
366
|
+
log(`Could not create ${backupDir} (${e?.code ?? e?.message}); run again in a second. Not swapped; ${liveNote()}`);
|
|
367
|
+
return { code: 1, outcome: 'refused', from };
|
|
368
|
+
}
|
|
369
|
+
const writeReadme = (lines) => {
|
|
370
|
+
try {
|
|
371
|
+
writeFileSync(readme, lines.join('\n') + '\n');
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
catch (e) {
|
|
375
|
+
log(` (could not write ${readme}: ${e?.message ?? e})`);
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
try {
|
|
380
|
+
for (const f of originals) {
|
|
381
|
+
renameGuarded(join(dirname(path), f), join(backupDir, f));
|
|
382
|
+
where[f] = 'backup';
|
|
383
|
+
}
|
|
384
|
+
renameGuarded(replica, path);
|
|
385
|
+
}
|
|
386
|
+
catch (e) {
|
|
387
|
+
log(`Could not swap the files (${e?.code ?? e?.message}); on Windows this usually means another process has ${name} open.`);
|
|
388
|
+
// put back, newest first; record each result
|
|
389
|
+
for (const f of originals.slice().reverse()) {
|
|
390
|
+
if (where[f] !== 'backup')
|
|
391
|
+
continue;
|
|
392
|
+
try {
|
|
393
|
+
renameGuarded(join(backupDir, f), join(dirname(path), f));
|
|
394
|
+
where[f] = 'live';
|
|
395
|
+
}
|
|
396
|
+
catch { /* stays in backup */ }
|
|
397
|
+
}
|
|
398
|
+
const stuck = originals.filter((f) => where[f] === 'backup');
|
|
399
|
+
if (stuck.length === 0) {
|
|
400
|
+
try {
|
|
401
|
+
rmdirSync(backupDir);
|
|
402
|
+
}
|
|
403
|
+
catch { /* empty folder left; harmless */ }
|
|
404
|
+
log(` Every original file was moved back: ${originals.join(', ')}. Close every client using linksee-memory and run again.`);
|
|
405
|
+
return { code: 1, outcome: 'failed', from };
|
|
406
|
+
}
|
|
407
|
+
const table = originals.map((f) => ` ${f.padEnd(name.length + 9)} ${where[f] === 'live' ? `in ${dirname(path)} (in place)` : `in ${backupDir}/`}`);
|
|
408
|
+
const moves = stuck.map((f) => ` move ${join(backupDir, f)} → ${join(dirname(path), f)}`);
|
|
409
|
+
const body = [
|
|
410
|
+
`This migration FAILED during the swap. The original files (one set, unchanged) are now split:`,
|
|
411
|
+
...table,
|
|
412
|
+
`The migrated copy was NOT put in place. To put the original set back together, with every client closed:`,
|
|
413
|
+
...moves,
|
|
414
|
+
`Move exactly these and nothing else. Do not move ${originals.filter((f) => where[f] === 'live').join(', ') || 'anything else'} — those are already in place.`,
|
|
415
|
+
`Do not open linksee-memory before this is done: ${name} is missing or incomplete without them.`,
|
|
416
|
+
];
|
|
417
|
+
for (const l of body)
|
|
418
|
+
log(` ${l}`);
|
|
419
|
+
writeReadme([`linksee-memory migrate (${stamp})`, '', ...body]);
|
|
420
|
+
return { code: 1, outcome: 'failed', from };
|
|
421
|
+
}
|
|
422
|
+
log(from < MIN_SUPPORTED_SCHEMA ? `Migrated ${path}: schema ${from} → ${target}.` : `Recovered ${path} (schema ${from}): the unfinished write was rolled back.`);
|
|
423
|
+
report();
|
|
424
|
+
log(`The original files, byte for byte and as one set, are in ${backupDir}/ (see README.txt there).`);
|
|
425
|
+
for (const l of restoreSteps(path, backupDir))
|
|
426
|
+
log(l);
|
|
427
|
+
writeReadme([
|
|
428
|
+
`linksee-memory migrate (${stamp}) moved the original files here, unchanged, as one set:`,
|
|
429
|
+
` ${originals.join(', ')} (from ${dirname(path)})`,
|
|
430
|
+
`They were at schema ${from}; the migrated database (schema ${from < MIN_SUPPORTED_SCHEMA ? target : from}) took their place.`,
|
|
431
|
+
'',
|
|
432
|
+
...restoreSteps(path, backupDir),
|
|
433
|
+
]);
|
|
434
|
+
log(`Nothing was consolidated or deleted. The server switches the file to WAL on its next start.`);
|
|
435
|
+
return { code: 0, outcome, backupDir, before, after, from, to: from < MIN_SUPPORTED_SCHEMA ? target : from };
|
|
436
|
+
}
|
|
437
|
+
finally {
|
|
438
|
+
closeAll(); // the work folder itself is removed by migrateLegacy()
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function usage() {
|
|
442
|
+
return `Usage: ${migrateCommand()} [--dry] [--db <path to memory.db>]
|
|
443
|
+
|
|
444
|
+
Migrates a memory.db at schema 1–5 (linksee-memory 0.11 and earlier) to the current schema.
|
|
445
|
+
--dry rehearse on a temporary copy; nothing is written next to memory.db
|
|
446
|
+
--db a memory.db other than ${getDbPath()}
|
|
447
|
+
Close every Claude Code / Cursor / Codex / other client using linksee-memory first, and keep them
|
|
448
|
+
closed until it finishes: an idle client on a WAL-mode file cannot be detected (except on Windows).
|
|
449
|
+
It migrates a copy, checks it (no table loses rows, integrity ok), and only then moves the
|
|
450
|
+
original files into memory.db.pre-migrate-<time>/ (with a README.txt) and the migrated copy into place.`;
|
|
451
|
+
}
|
|
452
|
+
function main(argv) {
|
|
453
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
454
|
+
console.log(usage());
|
|
455
|
+
return 0;
|
|
456
|
+
}
|
|
457
|
+
for (let i = 0; i < argv.length; i++) {
|
|
458
|
+
if (argv[i] === '--db') {
|
|
459
|
+
i++;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (argv[i] !== '--dry') {
|
|
463
|
+
console.error(`Unknown argument: ${argv[i]}\n\n${usage()}`);
|
|
464
|
+
return 1;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const di = argv.indexOf('--db');
|
|
468
|
+
if (di >= 0 && !argv[di + 1]) {
|
|
469
|
+
console.error(`--db needs a path\n\n${usage()}`);
|
|
470
|
+
return 1;
|
|
471
|
+
}
|
|
472
|
+
const dbPath = di >= 0 ? resolve(argv[di + 1]) : getDbPath();
|
|
473
|
+
try {
|
|
474
|
+
return migrateLegacy({ dbPath, dry: argv.includes('--dry') }).code;
|
|
475
|
+
}
|
|
476
|
+
catch (e) {
|
|
477
|
+
if (e instanceof DbRefusedError) {
|
|
478
|
+
console.error(e.message);
|
|
479
|
+
return 1;
|
|
480
|
+
}
|
|
481
|
+
if (isNativeLoadError(e)) {
|
|
482
|
+
console.error(NATIVE_HINT);
|
|
483
|
+
return 1;
|
|
484
|
+
}
|
|
485
|
+
console.error(`[linksee-memory] migrate stopped: ${e?.message ?? e}`);
|
|
486
|
+
return 1;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const isMain = (() => {
|
|
490
|
+
try {
|
|
491
|
+
const a = process.argv[1] ? realpathSync(process.argv[1]) : '';
|
|
492
|
+
const b = realpathSync(fileURLToPath(import.meta.url));
|
|
493
|
+
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
})();
|
|
499
|
+
if (isMain)
|
|
500
|
+
process.exit(main(process.argv.slice(2)));
|
|
501
|
+
//# sourceMappingURL=migrate-legacy.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export type SettingsSource = {
|
|
3
|
+
path: string;
|
|
4
|
+
explicit: boolean;
|
|
5
|
+
};
|
|
6
|
+
export type RuntimeEntry = {
|
|
7
|
+
version: string;
|
|
8
|
+
dir: string;
|
|
9
|
+
bytes: number;
|
|
10
|
+
referencedBy: string[];
|
|
11
|
+
decision: 'keep-referenced' | 'keep-unconfirmed' | 'keep-running' | 'delete' | 'deleted' | 'delete-failed';
|
|
12
|
+
reason?: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function defaultSources(home: string, cwd: string): SettingsSource[];
|
|
15
|
+
export declare const normPath: (t: string) => string;
|
|
16
|
+
/**
|
|
17
|
+
* Strict, small TOML line reader. Returns every string value it read, or an error naming the first
|
|
18
|
+
* line it does not accept (then the caller treats the file as "could not confirm").
|
|
19
|
+
*/
|
|
20
|
+
export declare function tomlStrings(text: string): {
|
|
21
|
+
strings: string[];
|
|
22
|
+
error?: string;
|
|
23
|
+
};
|
|
24
|
+
/** Read every source into normalized strings, or say why it could not be confirmed. */
|
|
25
|
+
export declare function readSources(sources: SettingsSource[]): {
|
|
26
|
+
texts: Array<{
|
|
27
|
+
path: string;
|
|
28
|
+
strings: string[];
|
|
29
|
+
}>;
|
|
30
|
+
problems: string[];
|
|
31
|
+
};
|
|
32
|
+
export type PruneOpts = {
|
|
33
|
+
memoryDir: string;
|
|
34
|
+
sources: SettingsSource[];
|
|
35
|
+
yes: boolean;
|
|
36
|
+
/** The caller confirmed other repos' project settings were checked or passed with --settings. */
|
|
37
|
+
scopeConfirmed?: boolean;
|
|
38
|
+
/** Package root this command runs from (a runtime in use by this very process is kept). */
|
|
39
|
+
selfRoot?: string;
|
|
40
|
+
log?: (l: string) => void;
|
|
41
|
+
};
|
|
42
|
+
export declare function listRuntimes(memoryDir: string): {
|
|
43
|
+
base: string;
|
|
44
|
+
versions: string[];
|
|
45
|
+
others: string[];
|
|
46
|
+
error?: string;
|
|
47
|
+
};
|
|
48
|
+
export declare function pruneRuntimes(o: PruneOpts): {
|
|
49
|
+
code: 0 | 1;
|
|
50
|
+
entries: RuntimeEntry[];
|
|
51
|
+
problems: string[];
|
|
52
|
+
};
|