@vaur94/agz-memory 0.4.0-beta.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/ARCHITECTURE.md +173 -0
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +237 -0
- package/README.tr.md +240 -0
- package/dist/admin.js +1494 -0
- package/dist/core.js +2434 -0
- package/dist/server.js +1790 -0
- package/dist/types/admin/doctor.d.ts +10 -0
- package/dist/types/admin/index.d.ts +2 -0
- package/dist/types/capture/contract.d.ts +102 -0
- package/dist/types/capture/identity.d.ts +25 -0
- package/dist/types/capture/policy.d.ts +9 -0
- package/dist/types/capture/projection.d.ts +17 -0
- package/dist/types/capture/redact.d.ts +12 -0
- package/dist/types/config.d.ts +4 -0
- package/dist/types/context.d.ts +1 -0
- package/dist/types/core.d.ts +30 -0
- package/dist/types/db/backup.d.ts +25 -0
- package/dist/types/db/health.d.ts +11 -0
- package/dist/types/db/migration-lock.d.ts +17 -0
- package/dist/types/db/migrations/v009.d.ts +3 -0
- package/dist/types/db/schema.d.ts +5 -0
- package/dist/types/db.d.ts +6 -0
- package/dist/types/identity.d.ts +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/project.d.ts +4 -0
- package/dist/types/retrieval/backends/none.d.ts +9 -0
- package/dist/types/retrieval/contract.d.ts +42 -0
- package/dist/types/retrieval/derived.d.ts +11 -0
- package/dist/types/retrieval/formatter.d.ts +5 -0
- package/dist/types/retrieval/fusion.d.ts +8 -0
- package/dist/types/server.d.ts +5 -0
- package/dist/types/store/capture.d.ts +46 -0
- package/dist/types/store/outbox.d.ts +14 -0
- package/dist/types/store/retrieval.d.ts +18 -0
- package/dist/types/store.d.ts +95 -0
- package/dist/types/tools.d.ts +3 -0
- package/dist/types/types.d.ts +54 -0
- package/docs/backup-restore-runbook.md +73 -0
- package/package.json +63 -0
package/dist/admin.js
ADDED
|
@@ -0,0 +1,1494 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// src/admin/index.ts
|
|
5
|
+
import { createHash as createHash5 } from "crypto";
|
|
6
|
+
import { Database as Database3 } from "bun:sqlite";
|
|
7
|
+
import {
|
|
8
|
+
existsSync as existsSync4,
|
|
9
|
+
lstatSync as lstatSync2,
|
|
10
|
+
readdirSync,
|
|
11
|
+
readFileSync as readFileSync3,
|
|
12
|
+
rmSync as rmSync3
|
|
13
|
+
} from "fs";
|
|
14
|
+
import { basename as basename2, dirname as dirname2, resolve as resolve2 } from "path";
|
|
15
|
+
|
|
16
|
+
// src/config.ts
|
|
17
|
+
import { homedir } from "os";
|
|
18
|
+
import { join } from "path";
|
|
19
|
+
function resolveConfig(environment = process.env) {
|
|
20
|
+
const databasePath = environment.OPENCODE_MEMORY_DATABASE_PATH?.trim() || join(environment.HOME ?? homedir(), ".local", "share", "opencode-memory", "memory.sqlite");
|
|
21
|
+
return { databasePath };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/db.ts
|
|
25
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
26
|
+
import { Database as Database2 } from "bun:sqlite";
|
|
27
|
+
import { chmodSync as chmodSync2, copyFileSync as copyFileSync2, existsSync as existsSync3 } from "fs";
|
|
28
|
+
|
|
29
|
+
// src/identity.ts
|
|
30
|
+
import { createHash } from "crypto";
|
|
31
|
+
function hashRoot(directory) {
|
|
32
|
+
return createHash("sha256").update(directory).digest("hex");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/project.ts
|
|
36
|
+
function cleanProjectName(value) {
|
|
37
|
+
return value.trim().replace(/\s+/g, " ");
|
|
38
|
+
}
|
|
39
|
+
function normalizeProjectName(value) {
|
|
40
|
+
return cleanProjectName(value).normalize("NFKC").toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/types.ts
|
|
44
|
+
var SCHEMA_VERSION = 9;
|
|
45
|
+
var PREDICATES = ["SUPPORTS", "DERIVED_FROM", "PART_OF", "ABOUT", "PRECEDES", "SUPERSEDES"];
|
|
46
|
+
|
|
47
|
+
// src/db/backup.ts
|
|
48
|
+
import { createHash as createHash2, randomUUID } from "crypto";
|
|
49
|
+
import { Database } from "bun:sqlite";
|
|
50
|
+
import {
|
|
51
|
+
chmodSync,
|
|
52
|
+
closeSync,
|
|
53
|
+
copyFileSync,
|
|
54
|
+
existsSync,
|
|
55
|
+
fsyncSync,
|
|
56
|
+
lstatSync,
|
|
57
|
+
mkdirSync,
|
|
58
|
+
openSync,
|
|
59
|
+
readFileSync,
|
|
60
|
+
renameSync,
|
|
61
|
+
rmSync,
|
|
62
|
+
writeFileSync
|
|
63
|
+
} from "fs";
|
|
64
|
+
import { basename, dirname, join as join2, resolve } from "path";
|
|
65
|
+
|
|
66
|
+
// src/db/health.ts
|
|
67
|
+
function inspectDatabase(db) {
|
|
68
|
+
const integrity = db.query("PRAGMA integrity_check").get().integrity_check;
|
|
69
|
+
const foreignKeyViolations = db.query("PRAGMA foreign_key_check").all();
|
|
70
|
+
const schemaVersion = hasTable(db, "schema_state") ? db.query("SELECT MAX(version) AS version FROM schema_state").get().version ?? undefined : undefined;
|
|
71
|
+
const counts = {};
|
|
72
|
+
for (const table of [
|
|
73
|
+
"projects",
|
|
74
|
+
"notes",
|
|
75
|
+
"note_edges",
|
|
76
|
+
"notes_fts",
|
|
77
|
+
"project_bindings",
|
|
78
|
+
"capture_events",
|
|
79
|
+
"capture_checkpoints",
|
|
80
|
+
"note_provenance",
|
|
81
|
+
"note_revisions",
|
|
82
|
+
"index_outbox"
|
|
83
|
+
]) {
|
|
84
|
+
if (!hasTable(db, table))
|
|
85
|
+
continue;
|
|
86
|
+
counts[table] = db.query(`SELECT COUNT(*) AS count FROM ${table}`).get().count;
|
|
87
|
+
}
|
|
88
|
+
return { integrity, foreignKeyViolations, schemaVersion, counts };
|
|
89
|
+
}
|
|
90
|
+
function assertHealthyDatabase(db) {
|
|
91
|
+
const health = inspectDatabase(db);
|
|
92
|
+
if (health.integrity !== "ok") {
|
|
93
|
+
throw new Error(`database integrity check failed: ${health.integrity}`);
|
|
94
|
+
}
|
|
95
|
+
if (health.foreignKeyViolations.length > 0) {
|
|
96
|
+
throw new Error(`database foreign key check failed: ${health.foreignKeyViolations.length} violation(s)`);
|
|
97
|
+
}
|
|
98
|
+
return health;
|
|
99
|
+
}
|
|
100
|
+
function hasTable(db, table) {
|
|
101
|
+
const row = db.query("SELECT COUNT(*) AS count FROM sqlite_master WHERE type IN ('table','view') AND name = ?").get(table);
|
|
102
|
+
return row.count > 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/db/backup.ts
|
|
106
|
+
var BACKUP_FORMAT = "opencode2-memory-backup/1";
|
|
107
|
+
function createVerifiedBackup(db, databasePath, sourceSchema, targetSchema, productVersion) {
|
|
108
|
+
const sourceHealth = assertHealthyDatabase(db);
|
|
109
|
+
const checkpoint = db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
110
|
+
if (checkpoint.busy !== 0)
|
|
111
|
+
throw new Error("database WAL checkpoint is busy");
|
|
112
|
+
const backupDirectory = `${databasePath}.backup`;
|
|
113
|
+
mkdirSync(backupDirectory, { recursive: true, mode: 448 });
|
|
114
|
+
chmodSync(backupDirectory, 448);
|
|
115
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
116
|
+
const stem = `schema-v${sourceSchema}-${stamp}-${randomUUID()}`;
|
|
117
|
+
const finalDatabasePath = join2(backupDirectory, `${stem}.sqlite`);
|
|
118
|
+
const finalManifestPath = join2(backupDirectory, `${stem}.manifest.json`);
|
|
119
|
+
const temporaryDatabasePath = `${finalDatabasePath}.tmp`;
|
|
120
|
+
const temporaryManifestPath = `${finalManifestPath}.tmp`;
|
|
121
|
+
try {
|
|
122
|
+
db.exec(`VACUUM INTO '${escapeSql(temporaryDatabasePath)}'`);
|
|
123
|
+
chmodSync(temporaryDatabasePath, 384);
|
|
124
|
+
const verification = new Database(temporaryDatabasePath, { readonly: true });
|
|
125
|
+
let backupHealth;
|
|
126
|
+
let sqliteVersion;
|
|
127
|
+
try {
|
|
128
|
+
backupHealth = assertHealthyDatabase(verification);
|
|
129
|
+
sqliteVersion = verification.query("SELECT sqlite_version() AS version").get().version;
|
|
130
|
+
} finally {
|
|
131
|
+
verification.close();
|
|
132
|
+
}
|
|
133
|
+
if (JSON.stringify(backupHealth.counts) !== JSON.stringify(sourceHealth.counts)) {
|
|
134
|
+
throw new Error("backup row counts differ from source database");
|
|
135
|
+
}
|
|
136
|
+
const bytes = readFileSync(temporaryDatabasePath);
|
|
137
|
+
const manifest = {
|
|
138
|
+
format: BACKUP_FORMAT,
|
|
139
|
+
productVersion,
|
|
140
|
+
sourceSchema,
|
|
141
|
+
targetSchema,
|
|
142
|
+
createdAt: new Date().toISOString(),
|
|
143
|
+
sqliteVersion,
|
|
144
|
+
databaseFile: basename(finalDatabasePath),
|
|
145
|
+
sha256: createHash2("sha256").update(bytes).digest("hex"),
|
|
146
|
+
size: bytes.byteLength,
|
|
147
|
+
counts: backupHealth.counts,
|
|
148
|
+
integrity: "ok",
|
|
149
|
+
foreignKeyViolations: 0
|
|
150
|
+
};
|
|
151
|
+
writeFileSync(temporaryManifestPath, `${JSON.stringify(manifest, null, 2)}
|
|
152
|
+
`, {
|
|
153
|
+
mode: 384
|
|
154
|
+
});
|
|
155
|
+
fsyncPath(temporaryDatabasePath);
|
|
156
|
+
fsyncPath(temporaryManifestPath);
|
|
157
|
+
renameSync(temporaryDatabasePath, finalDatabasePath);
|
|
158
|
+
renameSync(temporaryManifestPath, finalManifestPath);
|
|
159
|
+
fsyncPath(backupDirectory);
|
|
160
|
+
return { databasePath: finalDatabasePath, manifestPath: finalManifestPath, manifest };
|
|
161
|
+
} catch (error) {
|
|
162
|
+
rmSync(temporaryDatabasePath, { force: true });
|
|
163
|
+
rmSync(temporaryManifestPath, { force: true });
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function verifyBackupManifest(manifestPath) {
|
|
168
|
+
const resolvedManifestPath = resolve(manifestPath);
|
|
169
|
+
const manifestStat = lstatSync(resolvedManifestPath);
|
|
170
|
+
if (!manifestStat.isFile() || manifestStat.isSymbolicLink()) {
|
|
171
|
+
throw new Error("backup manifest must be a regular file");
|
|
172
|
+
}
|
|
173
|
+
const manifest = JSON.parse(readFileSync(resolvedManifestPath, "utf8"));
|
|
174
|
+
if (manifest.format !== BACKUP_FORMAT) {
|
|
175
|
+
throw new Error("unsupported backup manifest format");
|
|
176
|
+
}
|
|
177
|
+
if (typeof manifest.databaseFile !== "string" || !manifest.databaseFile || basename(manifest.databaseFile) !== manifest.databaseFile) {
|
|
178
|
+
throw new Error("backup databaseFile must be a basename");
|
|
179
|
+
}
|
|
180
|
+
if (typeof manifest.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(manifest.sha256)) {
|
|
181
|
+
throw new Error("backup manifest sha256 is invalid");
|
|
182
|
+
}
|
|
183
|
+
if (!Number.isSafeInteger(manifest.size) || manifest.size < 0) {
|
|
184
|
+
throw new Error("backup manifest size is invalid");
|
|
185
|
+
}
|
|
186
|
+
const manifestDirectory = dirname(resolvedManifestPath);
|
|
187
|
+
const databasePath = resolve(manifestDirectory, manifest.databaseFile);
|
|
188
|
+
if (dirname(databasePath) !== manifestDirectory) {
|
|
189
|
+
throw new Error("backup database file must stay inside the manifest directory");
|
|
190
|
+
}
|
|
191
|
+
if (!existsSync(databasePath))
|
|
192
|
+
throw new Error("backup database file is missing");
|
|
193
|
+
const databaseStat = lstatSync(databasePath);
|
|
194
|
+
if (!databaseStat.isFile() || databaseStat.isSymbolicLink()) {
|
|
195
|
+
throw new Error("backup database must be a regular file");
|
|
196
|
+
}
|
|
197
|
+
const bytes = readFileSync(databasePath);
|
|
198
|
+
const hash = createHash2("sha256").update(bytes).digest("hex");
|
|
199
|
+
if (hash !== manifest.sha256 || databaseStat.size !== manifest.size) {
|
|
200
|
+
throw new Error("backup hash or size mismatch");
|
|
201
|
+
}
|
|
202
|
+
const db = new Database(databasePath, { readonly: true });
|
|
203
|
+
try {
|
|
204
|
+
const health = assertHealthyDatabase(db);
|
|
205
|
+
if (JSON.stringify(health.counts) !== JSON.stringify(manifest.counts)) {
|
|
206
|
+
throw new Error("backup manifest row counts do not match");
|
|
207
|
+
}
|
|
208
|
+
} finally {
|
|
209
|
+
db.close();
|
|
210
|
+
}
|
|
211
|
+
return { databasePath, manifestPath: resolvedManifestPath, manifest };
|
|
212
|
+
}
|
|
213
|
+
function restoreVerifiedBackup(manifestPath, targetPath, confirmation) {
|
|
214
|
+
if (confirmation !== "RESTORE_DATABASE_FROM_VERIFIED_BACKUP") {
|
|
215
|
+
throw new Error("invalid restore confirmation");
|
|
216
|
+
}
|
|
217
|
+
const verified = verifyBackupManifest(manifestPath);
|
|
218
|
+
mkdirSync(dirname(targetPath), { recursive: true, mode: 448 });
|
|
219
|
+
const temporary = `${targetPath}.restore-${randomUUID()}.tmp`;
|
|
220
|
+
const preserved = `${targetPath}.failed-restore-source-${Date.now()}-${randomUUID()}`;
|
|
221
|
+
const movedSidecars = [];
|
|
222
|
+
let hasPreservedSource = false;
|
|
223
|
+
let preservedSourceHealthy = false;
|
|
224
|
+
let targetInstalled = false;
|
|
225
|
+
try {
|
|
226
|
+
copyFileSync(verified.databasePath, temporary);
|
|
227
|
+
chmodSync(temporary, 384);
|
|
228
|
+
fsyncPath(temporary);
|
|
229
|
+
if (existsSync(targetPath)) {
|
|
230
|
+
preservedSourceHealthy = checkpointSource(targetPath);
|
|
231
|
+
copyFileSync(targetPath, preserved);
|
|
232
|
+
chmodSync(preserved, 384);
|
|
233
|
+
fsyncPath(preserved);
|
|
234
|
+
if (preservedSourceHealthy)
|
|
235
|
+
verifyDatabaseFile(preserved);
|
|
236
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
237
|
+
const source = `${targetPath}${suffix}`;
|
|
238
|
+
if (!existsSync(source))
|
|
239
|
+
continue;
|
|
240
|
+
const preservedSidecar = `${preserved}${suffix}`;
|
|
241
|
+
copyFileSync(source, preservedSidecar);
|
|
242
|
+
chmodSync(preservedSidecar, 384);
|
|
243
|
+
fsyncPath(preservedSidecar);
|
|
244
|
+
}
|
|
245
|
+
fsyncPath(dirname(targetPath));
|
|
246
|
+
hasPreservedSource = true;
|
|
247
|
+
}
|
|
248
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
249
|
+
const source = `${targetPath}${suffix}`;
|
|
250
|
+
if (!existsSync(source))
|
|
251
|
+
continue;
|
|
252
|
+
const quarantine = `${source}.quarantine-${randomUUID()}`;
|
|
253
|
+
renameSync(source, quarantine);
|
|
254
|
+
movedSidecars.push({ source, quarantine });
|
|
255
|
+
}
|
|
256
|
+
renameSync(temporary, targetPath);
|
|
257
|
+
targetInstalled = true;
|
|
258
|
+
fsyncPath(dirname(targetPath));
|
|
259
|
+
verifyDatabaseFile(targetPath);
|
|
260
|
+
for (const { quarantine } of movedSidecars) {
|
|
261
|
+
rmSync(quarantine, { recursive: true, force: true });
|
|
262
|
+
}
|
|
263
|
+
} catch (error) {
|
|
264
|
+
const rollbackErrors = [];
|
|
265
|
+
rmSync(temporary, { force: true });
|
|
266
|
+
if (targetInstalled) {
|
|
267
|
+
try {
|
|
268
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
269
|
+
rmSync(`${targetPath}${suffix}`, { recursive: true, force: true });
|
|
270
|
+
}
|
|
271
|
+
if (hasPreservedSource) {
|
|
272
|
+
const rollback = `${targetPath}.rollback-${randomUUID()}.tmp`;
|
|
273
|
+
copyFileSync(preserved, rollback);
|
|
274
|
+
chmodSync(rollback, 384);
|
|
275
|
+
fsyncPath(rollback);
|
|
276
|
+
rmSync(targetPath, { force: true });
|
|
277
|
+
renameSync(rollback, targetPath);
|
|
278
|
+
} else {
|
|
279
|
+
rmSync(targetPath, { force: true });
|
|
280
|
+
}
|
|
281
|
+
} catch (rollbackError) {
|
|
282
|
+
rollbackErrors.push(rollbackError);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
for (const { source, quarantine } of movedSidecars.reverse()) {
|
|
286
|
+
if (!existsSync(quarantine))
|
|
287
|
+
continue;
|
|
288
|
+
try {
|
|
289
|
+
rmSync(source, { recursive: true, force: true });
|
|
290
|
+
renameSync(quarantine, source);
|
|
291
|
+
} catch (rollbackError) {
|
|
292
|
+
rollbackErrors.push(rollbackError);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (targetInstalled && hasPreservedSource && preservedSourceHealthy && rollbackErrors.length === 0) {
|
|
296
|
+
try {
|
|
297
|
+
fsyncPath(dirname(targetPath));
|
|
298
|
+
verifyDatabaseFile(targetPath);
|
|
299
|
+
} catch (rollbackError) {
|
|
300
|
+
rollbackErrors.push(rollbackError);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (rollbackErrors.length > 0) {
|
|
304
|
+
throw new AggregateError([error, ...rollbackErrors], "restore failed and rollback was incomplete");
|
|
305
|
+
}
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
return preserved;
|
|
309
|
+
}
|
|
310
|
+
function escapeSql(value) {
|
|
311
|
+
return value.replaceAll("'", "''");
|
|
312
|
+
}
|
|
313
|
+
function fsyncPath(path) {
|
|
314
|
+
const descriptor = openSync(path, "r");
|
|
315
|
+
try {
|
|
316
|
+
fsyncSync(descriptor);
|
|
317
|
+
} finally {
|
|
318
|
+
closeSync(descriptor);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function checkpointSource(path) {
|
|
322
|
+
let db;
|
|
323
|
+
try {
|
|
324
|
+
db = new Database(path);
|
|
325
|
+
const checkpoint = db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
326
|
+
if (checkpoint.busy !== 0)
|
|
327
|
+
throw new Error("source database WAL checkpoint is busy");
|
|
328
|
+
try {
|
|
329
|
+
assertHealthyDatabase(db);
|
|
330
|
+
return true;
|
|
331
|
+
} catch {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if (isBusyError(error))
|
|
336
|
+
throw error;
|
|
337
|
+
return false;
|
|
338
|
+
} finally {
|
|
339
|
+
db?.close();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function verifyDatabaseFile(path) {
|
|
343
|
+
const db = new Database(path, { readonly: true });
|
|
344
|
+
try {
|
|
345
|
+
assertHealthyDatabase(db);
|
|
346
|
+
} finally {
|
|
347
|
+
db.close();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function isBusyError(error) {
|
|
351
|
+
if (error && typeof error === "object" && "code" in error) {
|
|
352
|
+
const code = String(error.code);
|
|
353
|
+
if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED")
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
return error instanceof Error && /\b(?:busy|locked)\b/i.test(error.message);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// src/db/migration-lock.ts
|
|
360
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
361
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
362
|
+
import { hostname } from "os";
|
|
363
|
+
function migrationLockPath(databasePath) {
|
|
364
|
+
return `${databasePath}.migration.lock`;
|
|
365
|
+
}
|
|
366
|
+
function acquireMigrationLock(databasePath, targetSchema, timeoutMs = 30000) {
|
|
367
|
+
const path = migrationLockPath(databasePath);
|
|
368
|
+
const owner = {
|
|
369
|
+
ownerID: randomUUID2(),
|
|
370
|
+
pid: process.pid,
|
|
371
|
+
processStartMarker: processStartMarker(process.pid) ?? "unavailable",
|
|
372
|
+
hostname: hostname(),
|
|
373
|
+
startedAt: Date.now(),
|
|
374
|
+
targetSchema
|
|
375
|
+
};
|
|
376
|
+
const deadline = Date.now() + timeoutMs;
|
|
377
|
+
const stagedOwner = `${path}.owner-${owner.ownerID}.tmp`;
|
|
378
|
+
writeFileSync2(stagedOwner, `${JSON.stringify(owner, null, 2)}
|
|
379
|
+
`, { mode: 384 });
|
|
380
|
+
try {
|
|
381
|
+
while (true) {
|
|
382
|
+
let created = false;
|
|
383
|
+
try {
|
|
384
|
+
mkdirSync2(path, { mode: 448 });
|
|
385
|
+
created = true;
|
|
386
|
+
renameSync2(stagedOwner, `${path}/owner.json`);
|
|
387
|
+
break;
|
|
388
|
+
} catch (error) {
|
|
389
|
+
if (created) {
|
|
390
|
+
rmSync2(path, { recursive: true, force: true });
|
|
391
|
+
throw error;
|
|
392
|
+
}
|
|
393
|
+
if (!existsSync2(path))
|
|
394
|
+
throw error;
|
|
395
|
+
if (Date.now() >= deadline) {
|
|
396
|
+
const current = readMigrationLockOwner(path);
|
|
397
|
+
throw new Error(`migration lock is held${current ? ` by ${current.ownerID} (pid ${current.pid})` : ""}`);
|
|
398
|
+
}
|
|
399
|
+
Bun.sleepSync(Math.min(250, Math.max(25, deadline - Date.now())));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
} finally {
|
|
403
|
+
rmSync2(stagedOwner, { force: true });
|
|
404
|
+
}
|
|
405
|
+
let released = false;
|
|
406
|
+
return {
|
|
407
|
+
path,
|
|
408
|
+
owner,
|
|
409
|
+
release() {
|
|
410
|
+
if (released)
|
|
411
|
+
return;
|
|
412
|
+
const current = readMigrationLockOwner(path);
|
|
413
|
+
if (current?.ownerID !== owner.ownerID) {
|
|
414
|
+
throw new Error("migration lock ownership changed before release");
|
|
415
|
+
}
|
|
416
|
+
rmSync2(path, { recursive: true, force: true });
|
|
417
|
+
released = true;
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
function readMigrationLockOwner(path) {
|
|
422
|
+
try {
|
|
423
|
+
return JSON.parse(readFileSync2(`${path}/owner.json`, "utf8"));
|
|
424
|
+
} catch {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
function breakMigrationLock(databasePath, ownerID, confirmation) {
|
|
429
|
+
if (confirmation !== "BREAK_STALE_MIGRATION_LOCK") {
|
|
430
|
+
throw new Error("invalid migration lock confirmation");
|
|
431
|
+
}
|
|
432
|
+
const path = migrationLockPath(databasePath);
|
|
433
|
+
const owner = readMigrationLockOwner(path);
|
|
434
|
+
if (!owner) {
|
|
435
|
+
if (ownerID !== "ORPHANED" || !existsSync2(path))
|
|
436
|
+
throw new Error("migration lock owner mismatch");
|
|
437
|
+
rmSync2(path, { recursive: true, force: true });
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (owner.ownerID !== ownerID)
|
|
441
|
+
throw new Error("migration lock owner mismatch");
|
|
442
|
+
if (owner.hostname === hostname() && processIsAlive(owner.pid)) {
|
|
443
|
+
const currentMarker = processStartMarker(owner.pid);
|
|
444
|
+
if (!currentMarker || currentMarker === owner.processStartMarker) {
|
|
445
|
+
throw new Error(`migration lock process ${owner.pid} is still alive`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
rmSync2(path, { recursive: true, force: true });
|
|
449
|
+
}
|
|
450
|
+
function processStartMarker(pid) {
|
|
451
|
+
try {
|
|
452
|
+
const fields = readFileSync2(`/proc/${pid}/stat`, "utf8").trim().split(/\s+/);
|
|
453
|
+
return fields[21];
|
|
454
|
+
} catch {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
function processIsAlive(pid) {
|
|
459
|
+
try {
|
|
460
|
+
process.kill(pid, 0);
|
|
461
|
+
return true;
|
|
462
|
+
} catch {
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// src/db/migrations/v009.ts
|
|
468
|
+
import { createHash as createHash3, randomUUID as randomUUID3 } from "crypto";
|
|
469
|
+
|
|
470
|
+
// src/db/schema.ts
|
|
471
|
+
var SCHEMA_V9_TABLES = `
|
|
472
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
473
|
+
id TEXT PRIMARY KEY,
|
|
474
|
+
name TEXT NOT NULL,
|
|
475
|
+
normalized_name TEXT NOT NULL UNIQUE,
|
|
476
|
+
created_at INTEGER NOT NULL,
|
|
477
|
+
updated_at INTEGER NOT NULL
|
|
478
|
+
);
|
|
479
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
480
|
+
id TEXT PRIMARY KEY,
|
|
481
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
482
|
+
kind TEXT NOT NULL CHECK (kind IN ('decision','fact','procedure','context','research','preference','task')),
|
|
483
|
+
title TEXT NOT NULL,
|
|
484
|
+
summary TEXT NOT NULL,
|
|
485
|
+
content TEXT NOT NULL,
|
|
486
|
+
size_class TEXT NOT NULL CHECK (size_class IN ('inline','indexed')),
|
|
487
|
+
pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0,1)),
|
|
488
|
+
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','superseded','archived')),
|
|
489
|
+
supersedes_id TEXT,
|
|
490
|
+
current_revision INTEGER NOT NULL DEFAULT 1 CHECK (current_revision >= 1),
|
|
491
|
+
subject_key TEXT,
|
|
492
|
+
content_hash TEXT NOT NULL CHECK (length(content_hash) = 64),
|
|
493
|
+
created_at INTEGER NOT NULL,
|
|
494
|
+
updated_at INTEGER NOT NULL,
|
|
495
|
+
UNIQUE(project_id, id)
|
|
496
|
+
);
|
|
497
|
+
CREATE INDEX IF NOT EXISTS notes_project_idx ON notes(project_id, status);
|
|
498
|
+
CREATE UNIQUE INDEX IF NOT EXISTS notes_active_subject_idx
|
|
499
|
+
ON notes(project_id, kind, subject_key)
|
|
500
|
+
WHERE status = 'active' AND subject_key IS NOT NULL;
|
|
501
|
+
CREATE TABLE IF NOT EXISTS note_edges (
|
|
502
|
+
id TEXT PRIMARY KEY,
|
|
503
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
504
|
+
source_id TEXT NOT NULL,
|
|
505
|
+
target_id TEXT NOT NULL,
|
|
506
|
+
predicate TEXT NOT NULL CHECK (predicate IN ('SUPPORTS','DERIVED_FROM','PART_OF','ABOUT','PRECEDES','SUPERSEDES')),
|
|
507
|
+
created_at INTEGER NOT NULL,
|
|
508
|
+
UNIQUE(project_id, source_id, target_id, predicate),
|
|
509
|
+
FOREIGN KEY (project_id, source_id) REFERENCES notes(project_id, id) ON DELETE CASCADE,
|
|
510
|
+
FOREIGN KEY (project_id, target_id) REFERENCES notes(project_id, id) ON DELETE CASCADE
|
|
511
|
+
);
|
|
512
|
+
CREATE INDEX IF NOT EXISTS note_edges_source_idx ON note_edges(project_id, source_id);
|
|
513
|
+
CREATE INDEX IF NOT EXISTS note_edges_target_idx ON note_edges(project_id, target_id);
|
|
514
|
+
CREATE TABLE IF NOT EXISTS project_bindings (
|
|
515
|
+
binding_key TEXT PRIMARY KEY,
|
|
516
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
517
|
+
source TEXT NOT NULL CHECK (source = 'opencode-v2'),
|
|
518
|
+
source_project_id TEXT NOT NULL,
|
|
519
|
+
workspace_id TEXT NOT NULL,
|
|
520
|
+
canonical_path_hash TEXT NOT NULL CHECK (length(canonical_path_hash) = 64),
|
|
521
|
+
created_at INTEGER NOT NULL,
|
|
522
|
+
updated_at INTEGER NOT NULL,
|
|
523
|
+
UNIQUE(source, source_project_id, workspace_id)
|
|
524
|
+
);
|
|
525
|
+
CREATE TABLE IF NOT EXISTS capture_checkpoints (
|
|
526
|
+
session_id TEXT PRIMARY KEY,
|
|
527
|
+
binding_key TEXT NOT NULL REFERENCES project_bindings(binding_key) ON DELETE CASCADE,
|
|
528
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
529
|
+
state TEXT NOT NULL CHECK (state IN ('active','idle','unavailable','closed')),
|
|
530
|
+
last_message_id TEXT,
|
|
531
|
+
last_reconciled_at INTEGER,
|
|
532
|
+
next_reconcile_at INTEGER NOT NULL,
|
|
533
|
+
failure_count INTEGER NOT NULL DEFAULT 0 CHECK (failure_count >= 0),
|
|
534
|
+
lease_owner TEXT,
|
|
535
|
+
lease_expires_at INTEGER,
|
|
536
|
+
created_at INTEGER NOT NULL,
|
|
537
|
+
updated_at INTEGER NOT NULL
|
|
538
|
+
);
|
|
539
|
+
CREATE INDEX IF NOT EXISTS capture_checkpoints_due_idx
|
|
540
|
+
ON capture_checkpoints(state, next_reconcile_at);
|
|
541
|
+
CREATE TABLE IF NOT EXISTS capture_events (
|
|
542
|
+
idempotency_key TEXT PRIMARY KEY,
|
|
543
|
+
contract TEXT NOT NULL CHECK (contract = 'opencode2-memory.capture/1'),
|
|
544
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
545
|
+
binding_key TEXT NOT NULL REFERENCES project_bindings(binding_key) ON DELETE CASCADE,
|
|
546
|
+
event_kind TEXT NOT NULL CHECK (event_kind IN ('user-candidate','assistant-candidate','session-summary','tool-signal')),
|
|
547
|
+
source_session_id TEXT NOT NULL,
|
|
548
|
+
source_message_id TEXT,
|
|
549
|
+
source_ordinal INTEGER CHECK (source_ordinal IS NULL OR source_ordinal >= 0),
|
|
550
|
+
source_tool_call_id TEXT,
|
|
551
|
+
payload_json TEXT CHECK (payload_json IS NULL OR json_valid(payload_json)),
|
|
552
|
+
payload_hash TEXT,
|
|
553
|
+
redaction_version TEXT NOT NULL,
|
|
554
|
+
state TEXT NOT NULL CHECK (state IN ('pending','shadowed','review','materialized','duplicate','ignored','rejected','quarantined','failed','dead')),
|
|
555
|
+
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
|
556
|
+
note_id TEXT,
|
|
557
|
+
last_error_code TEXT,
|
|
558
|
+
generation INTEGER NOT NULL DEFAULT 0 CHECK (generation >= 0),
|
|
559
|
+
created_at INTEGER NOT NULL,
|
|
560
|
+
updated_at INTEGER NOT NULL,
|
|
561
|
+
processed_at INTEGER
|
|
562
|
+
);
|
|
563
|
+
CREATE INDEX IF NOT EXISTS capture_events_state_idx ON capture_events(state, updated_at);
|
|
564
|
+
CREATE INDEX IF NOT EXISTS capture_events_session_idx ON capture_events(project_id, source_session_id);
|
|
565
|
+
CREATE INDEX IF NOT EXISTS capture_events_note_idx ON capture_events(project_id, note_id);
|
|
566
|
+
CREATE TABLE IF NOT EXISTS note_provenance (
|
|
567
|
+
id TEXT PRIMARY KEY,
|
|
568
|
+
project_id TEXT NOT NULL,
|
|
569
|
+
note_id TEXT NOT NULL,
|
|
570
|
+
source_type TEXT NOT NULL CHECK (source_type IN ('mcp-manual','opencode-capture','migration','legacy-import','admin')),
|
|
571
|
+
capture_event_id TEXT,
|
|
572
|
+
source_session_id TEXT,
|
|
573
|
+
source_message_id TEXT,
|
|
574
|
+
source_ordinal INTEGER,
|
|
575
|
+
source_tool_call_id TEXT,
|
|
576
|
+
redaction_version TEXT,
|
|
577
|
+
extractor_version TEXT,
|
|
578
|
+
confidence REAL CHECK (confidence IS NULL OR (confidence >= 0 AND confidence <= 1)),
|
|
579
|
+
created_at INTEGER NOT NULL,
|
|
580
|
+
UNIQUE(project_id, id),
|
|
581
|
+
FOREIGN KEY (project_id, note_id) REFERENCES notes(project_id, id) ON DELETE CASCADE
|
|
582
|
+
);
|
|
583
|
+
CREATE TABLE IF NOT EXISTS note_revisions (
|
|
584
|
+
project_id TEXT NOT NULL,
|
|
585
|
+
note_id TEXT NOT NULL,
|
|
586
|
+
revision INTEGER NOT NULL CHECK (revision >= 1),
|
|
587
|
+
kind TEXT NOT NULL,
|
|
588
|
+
title TEXT NOT NULL,
|
|
589
|
+
summary TEXT NOT NULL,
|
|
590
|
+
content TEXT NOT NULL,
|
|
591
|
+
size_class TEXT NOT NULL,
|
|
592
|
+
pinned INTEGER NOT NULL CHECK (pinned IN (0,1)),
|
|
593
|
+
status TEXT NOT NULL CHECK (status IN ('active','superseded','archived')),
|
|
594
|
+
supersedes_id TEXT,
|
|
595
|
+
subject_key TEXT,
|
|
596
|
+
content_hash TEXT NOT NULL,
|
|
597
|
+
provenance_id TEXT NOT NULL,
|
|
598
|
+
created_at INTEGER NOT NULL,
|
|
599
|
+
PRIMARY KEY(project_id, note_id, revision),
|
|
600
|
+
FOREIGN KEY (project_id, note_id) REFERENCES notes(project_id, id) ON DELETE CASCADE,
|
|
601
|
+
FOREIGN KEY (project_id, provenance_id) REFERENCES note_provenance(project_id, id)
|
|
602
|
+
);
|
|
603
|
+
CREATE TABLE IF NOT EXISTS index_outbox (
|
|
604
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
605
|
+
backend TEXT NOT NULL,
|
|
606
|
+
operation TEXT NOT NULL CHECK (operation IN ('upsert-note','delete-note','purge-project')),
|
|
607
|
+
project_id TEXT NOT NULL,
|
|
608
|
+
note_id TEXT,
|
|
609
|
+
revision INTEGER,
|
|
610
|
+
content_hash TEXT,
|
|
611
|
+
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','leased','succeeded','dead')),
|
|
612
|
+
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
|
613
|
+
available_at INTEGER NOT NULL,
|
|
614
|
+
lease_owner TEXT,
|
|
615
|
+
lease_expires_at INTEGER,
|
|
616
|
+
last_error_code TEXT,
|
|
617
|
+
created_at INTEGER NOT NULL,
|
|
618
|
+
completed_at INTEGER,
|
|
619
|
+
UNIQUE(backend, operation, project_id, note_id, revision)
|
|
620
|
+
);
|
|
621
|
+
CREATE INDEX IF NOT EXISTS index_outbox_due_idx
|
|
622
|
+
ON index_outbox(backend, project_id, state, available_at, id);
|
|
623
|
+
CREATE TABLE IF NOT EXISTS schema_state (version INTEGER PRIMARY KEY);
|
|
624
|
+
`;
|
|
625
|
+
var FTS_V9 = `
|
|
626
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
|
|
627
|
+
title, summary, content,
|
|
628
|
+
content='notes', content_rowid='rowid',
|
|
629
|
+
tokenize='unicode61'
|
|
630
|
+
);
|
|
631
|
+
CREATE TRIGGER IF NOT EXISTS notes_fts_ai AFTER INSERT ON notes BEGIN
|
|
632
|
+
INSERT INTO notes_fts(rowid, title, summary, content)
|
|
633
|
+
VALUES (new.rowid, new.title, new.summary, new.content);
|
|
634
|
+
END;
|
|
635
|
+
CREATE TRIGGER IF NOT EXISTS notes_fts_ad AFTER DELETE ON notes BEGIN
|
|
636
|
+
INSERT INTO notes_fts(notes_fts, rowid, title, summary, content)
|
|
637
|
+
VALUES ('delete', old.rowid, old.title, old.summary, old.content);
|
|
638
|
+
END;
|
|
639
|
+
CREATE TRIGGER IF NOT EXISTS notes_fts_au AFTER UPDATE OF title, summary, content ON notes BEGIN
|
|
640
|
+
INSERT INTO notes_fts(notes_fts, rowid, title, summary, content)
|
|
641
|
+
VALUES ('delete', old.rowid, old.title, old.summary, old.content);
|
|
642
|
+
INSERT INTO notes_fts(rowid, title, summary, content)
|
|
643
|
+
VALUES (new.rowid, new.title, new.summary, new.content);
|
|
644
|
+
END;
|
|
645
|
+
`;
|
|
646
|
+
function createSchemaV9(db) {
|
|
647
|
+
db.exec(SCHEMA_V9_TABLES);
|
|
648
|
+
db.exec(FTS_V9);
|
|
649
|
+
db.query("DELETE FROM schema_state").run();
|
|
650
|
+
db.query("INSERT INTO schema_state(version) VALUES (9)").run();
|
|
651
|
+
}
|
|
652
|
+
function rebuildFts(db) {
|
|
653
|
+
db.query("INSERT INTO notes_fts(notes_fts) VALUES ('rebuild')").run();
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// src/db/migrations/v009.ts
|
|
657
|
+
function migrateV8ToV9(db) {
|
|
658
|
+
const notes = db.query("SELECT * FROM notes ORDER BY rowid").all();
|
|
659
|
+
db.exec(`
|
|
660
|
+
DROP TABLE IF EXISTS capture_checkpoints;
|
|
661
|
+
DROP TABLE IF EXISTS capture_events;
|
|
662
|
+
DROP TABLE IF EXISTS project_bindings;
|
|
663
|
+
DROP TABLE IF EXISTS note_revisions;
|
|
664
|
+
DROP TABLE IF EXISTS note_provenance;
|
|
665
|
+
DROP TABLE IF EXISTS index_outbox;
|
|
666
|
+
DROP TABLE IF EXISTS note_edges_v9;
|
|
667
|
+
DROP TABLE IF EXISTS notes_v9;
|
|
668
|
+
`);
|
|
669
|
+
db.exec(`
|
|
670
|
+
CREATE TABLE notes_v9 (
|
|
671
|
+
id TEXT PRIMARY KEY,
|
|
672
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
673
|
+
kind TEXT NOT NULL CHECK (kind IN ('decision','fact','procedure','context','research','preference','task')),
|
|
674
|
+
title TEXT NOT NULL,
|
|
675
|
+
summary TEXT NOT NULL,
|
|
676
|
+
content TEXT NOT NULL,
|
|
677
|
+
size_class TEXT NOT NULL CHECK (size_class IN ('inline','indexed')),
|
|
678
|
+
pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0,1)),
|
|
679
|
+
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','superseded','archived')),
|
|
680
|
+
supersedes_id TEXT,
|
|
681
|
+
current_revision INTEGER NOT NULL DEFAULT 1 CHECK (current_revision >= 1),
|
|
682
|
+
subject_key TEXT,
|
|
683
|
+
content_hash TEXT NOT NULL CHECK (length(content_hash) = 64),
|
|
684
|
+
created_at INTEGER NOT NULL,
|
|
685
|
+
updated_at INTEGER NOT NULL,
|
|
686
|
+
UNIQUE(project_id, id)
|
|
687
|
+
);
|
|
688
|
+
CREATE TABLE note_edges_v9 (
|
|
689
|
+
id TEXT PRIMARY KEY,
|
|
690
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
691
|
+
source_id TEXT NOT NULL,
|
|
692
|
+
target_id TEXT NOT NULL,
|
|
693
|
+
predicate TEXT NOT NULL CHECK (predicate IN ('SUPPORTS','DERIVED_FROM','PART_OF','ABOUT','PRECEDES','SUPERSEDES')),
|
|
694
|
+
created_at INTEGER NOT NULL,
|
|
695
|
+
UNIQUE(project_id, source_id, target_id, predicate),
|
|
696
|
+
FOREIGN KEY (project_id, source_id) REFERENCES notes_v9(project_id, id) ON DELETE CASCADE,
|
|
697
|
+
FOREIGN KEY (project_id, target_id) REFERENCES notes_v9(project_id, id) ON DELETE CASCADE
|
|
698
|
+
);
|
|
699
|
+
INSERT INTO note_edges_v9 SELECT * FROM note_edges;
|
|
700
|
+
`);
|
|
701
|
+
const insert = db.query(`
|
|
702
|
+
INSERT INTO notes_v9
|
|
703
|
+
(id, project_id, kind, title, summary, content, size_class, pinned, status,
|
|
704
|
+
supersedes_id, current_revision, subject_key, content_hash, created_at, updated_at)
|
|
705
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, NULL, ?, ?, ?)
|
|
706
|
+
`);
|
|
707
|
+
const hashes = new Map;
|
|
708
|
+
for (const note of notes) {
|
|
709
|
+
const hash = noteContentHash(note.kind, note.title, note.summary, note.content);
|
|
710
|
+
hashes.set(note.id, hash);
|
|
711
|
+
insert.run(note.id, note.project_id, note.kind, note.title, note.summary, note.content, note.size_class, note.pinned, note.status, note.supersedes_id, hash, note.created_at, note.updated_at);
|
|
712
|
+
}
|
|
713
|
+
db.exec(`
|
|
714
|
+
DROP TRIGGER IF EXISTS notes_fts_ai;
|
|
715
|
+
DROP TRIGGER IF EXISTS notes_fts_ad;
|
|
716
|
+
DROP TRIGGER IF EXISTS notes_fts_au;
|
|
717
|
+
DROP TABLE IF EXISTS notes_fts;
|
|
718
|
+
DROP TABLE note_edges;
|
|
719
|
+
DROP TABLE notes;
|
|
720
|
+
ALTER TABLE notes_v9 RENAME TO notes;
|
|
721
|
+
ALTER TABLE note_edges_v9 RENAME TO note_edges;
|
|
722
|
+
`);
|
|
723
|
+
db.exec(SCHEMA_V9_TABLES);
|
|
724
|
+
for (const note of notes) {
|
|
725
|
+
const provenanceID = randomUUID3();
|
|
726
|
+
db.query(`
|
|
727
|
+
INSERT INTO note_provenance
|
|
728
|
+
(id, project_id, note_id, source_type, created_at)
|
|
729
|
+
VALUES (?, ?, ?, 'migration', ?)
|
|
730
|
+
`).run(provenanceID, note.project_id, note.id, note.updated_at);
|
|
731
|
+
db.query(`
|
|
732
|
+
INSERT INTO note_revisions
|
|
733
|
+
(project_id, note_id, revision, kind, title, summary, content, size_class,
|
|
734
|
+
pinned, status, supersedes_id, subject_key, content_hash, provenance_id, created_at)
|
|
735
|
+
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)
|
|
736
|
+
`).run(note.project_id, note.id, note.kind, note.title, note.summary, note.content, note.size_class, note.pinned, note.status, note.supersedes_id, hashes.get(note.id), provenanceID, note.updated_at);
|
|
737
|
+
}
|
|
738
|
+
db.exec(FTS_V9);
|
|
739
|
+
rebuildFts(db);
|
|
740
|
+
db.query("DELETE FROM schema_state").run();
|
|
741
|
+
db.query("INSERT INTO schema_state(version) VALUES (9)").run();
|
|
742
|
+
}
|
|
743
|
+
function noteContentHash(kind, title, summary, content) {
|
|
744
|
+
return createHash3("sha256").update(`${kind}\x00${title}\x00${summary}\x00${content}`, "utf8").digest("hex");
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// src/db.ts
|
|
748
|
+
var DDL = `
|
|
749
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
750
|
+
id TEXT PRIMARY KEY,
|
|
751
|
+
name TEXT NOT NULL,
|
|
752
|
+
normalized_name TEXT NOT NULL UNIQUE,
|
|
753
|
+
created_at INTEGER NOT NULL,
|
|
754
|
+
updated_at INTEGER NOT NULL
|
|
755
|
+
);
|
|
756
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
757
|
+
id TEXT PRIMARY KEY,
|
|
758
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
759
|
+
kind TEXT NOT NULL CHECK (kind IN ('decision','fact','procedure','context','research','preference','task')),
|
|
760
|
+
title TEXT NOT NULL,
|
|
761
|
+
summary TEXT NOT NULL,
|
|
762
|
+
content TEXT NOT NULL,
|
|
763
|
+
size_class TEXT NOT NULL CHECK (size_class IN ('inline','indexed')),
|
|
764
|
+
pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0,1)),
|
|
765
|
+
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','superseded','archived')),
|
|
766
|
+
supersedes_id TEXT,
|
|
767
|
+
created_at INTEGER NOT NULL,
|
|
768
|
+
updated_at INTEGER NOT NULL,
|
|
769
|
+
UNIQUE(project_id, id)
|
|
770
|
+
);
|
|
771
|
+
CREATE INDEX IF NOT EXISTS notes_project_idx ON notes(project_id, status);
|
|
772
|
+
CREATE TABLE IF NOT EXISTS note_edges (
|
|
773
|
+
id TEXT PRIMARY KEY,
|
|
774
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
775
|
+
source_id TEXT NOT NULL,
|
|
776
|
+
target_id TEXT NOT NULL,
|
|
777
|
+
predicate TEXT NOT NULL CHECK (predicate IN ('SUPPORTS','DERIVED_FROM','PART_OF','ABOUT','PRECEDES','SUPERSEDES')),
|
|
778
|
+
created_at INTEGER NOT NULL,
|
|
779
|
+
UNIQUE(project_id, source_id, target_id, predicate),
|
|
780
|
+
FOREIGN KEY (project_id, source_id) REFERENCES notes(project_id, id) ON DELETE CASCADE,
|
|
781
|
+
FOREIGN KEY (project_id, target_id) REFERENCES notes(project_id, id) ON DELETE CASCADE
|
|
782
|
+
);
|
|
783
|
+
CREATE INDEX IF NOT EXISTS note_edges_source_idx ON note_edges(project_id, source_id);
|
|
784
|
+
CREATE INDEX IF NOT EXISTS note_edges_target_idx ON note_edges(project_id, target_id);
|
|
785
|
+
CREATE TABLE IF NOT EXISTS schema_state (version INTEGER PRIMARY KEY);
|
|
786
|
+
`;
|
|
787
|
+
function openMemoryDatabase(path) {
|
|
788
|
+
const db = new Database2(path, { create: true });
|
|
789
|
+
chmodSync2(path, 384);
|
|
790
|
+
let lock;
|
|
791
|
+
let backup;
|
|
792
|
+
try {
|
|
793
|
+
db.exec("PRAGMA busy_timeout=5000");
|
|
794
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
795
|
+
const existingVersion = getSchemaVersion(db);
|
|
796
|
+
if (existingVersion && existingVersion.version > SCHEMA_VERSION) {
|
|
797
|
+
throw new Error(`database schema v${existingVersion.version} is newer than supported v${SCHEMA_VERSION}`);
|
|
798
|
+
}
|
|
799
|
+
const hasExistingData = hasLegacyV2(db) || hasTable2(db, "notes") || Boolean(existingVersion);
|
|
800
|
+
if (!hasExistingData) {
|
|
801
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
802
|
+
db.transaction(() => createSchemaV9(db))();
|
|
803
|
+
assertHealthyDatabase(db);
|
|
804
|
+
return { db, close: () => db.close() };
|
|
805
|
+
}
|
|
806
|
+
if ((existingVersion?.version ?? 0) < SCHEMA_VERSION) {
|
|
807
|
+
lock = acquireMigrationLock(path, SCHEMA_VERSION);
|
|
808
|
+
backup = createVerifiedBackup(db, path, existingVersion?.version ?? 2, SCHEMA_VERSION, "0.4.0-beta.1");
|
|
809
|
+
db.exec("PRAGMA foreign_keys=OFF");
|
|
810
|
+
if (!existingVersion && hasLegacyV2(db)) {
|
|
811
|
+
db.exec(DDL);
|
|
812
|
+
db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(id UNINDEXED, title, summary, content, tokenize='unicode61')");
|
|
813
|
+
migrateFromV2(db, path);
|
|
814
|
+
} else if (!existingVersion) {
|
|
815
|
+
db.exec(DDL);
|
|
816
|
+
db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(id UNINDEXED, title, summary, content, tokenize='unicode61')");
|
|
817
|
+
db.transaction(() => {
|
|
818
|
+
adoptLegacyProjectIDs(db);
|
|
819
|
+
db.query("DELETE FROM schema_state").run();
|
|
820
|
+
db.query("INSERT INTO schema_state (version) VALUES (8)").run();
|
|
821
|
+
})();
|
|
822
|
+
} else if (existingVersion.version < 8) {
|
|
823
|
+
db.exec(DDL);
|
|
824
|
+
db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(id UNINDEXED, title, summary, content, tokenize='unicode61')");
|
|
825
|
+
migrateToV8(db);
|
|
826
|
+
}
|
|
827
|
+
const version = getSchemaVersion(db)?.version ?? 8;
|
|
828
|
+
if (version < 9)
|
|
829
|
+
db.transaction(() => migrateV8ToV9(db))();
|
|
830
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
831
|
+
if (db.query("PRAGMA foreign_keys").get().foreign_keys !== 1) {
|
|
832
|
+
throw new Error("failed to enable database foreign keys");
|
|
833
|
+
}
|
|
834
|
+
assertHealthyDatabase(db);
|
|
835
|
+
console.warn(`[agz-memory] migrated to v${SCHEMA_VERSION} (backup: ${backup.manifestPath})`);
|
|
836
|
+
lock.release();
|
|
837
|
+
lock = undefined;
|
|
838
|
+
return { db, close: () => db.close() };
|
|
839
|
+
}
|
|
840
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
841
|
+
db.exec(SCHEMA_V9_TABLES);
|
|
842
|
+
db.exec(FTS_V9);
|
|
843
|
+
assertHealthyDatabase(db);
|
|
844
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
845
|
+
return { db, close: () => db.close() };
|
|
846
|
+
} catch (error) {
|
|
847
|
+
db.close();
|
|
848
|
+
if (backup) {
|
|
849
|
+
try {
|
|
850
|
+
restoreVerifiedBackup(backup.manifestPath, path, "RESTORE_DATABASE_FROM_VERIFIED_BACKUP");
|
|
851
|
+
} catch (restoreError) {
|
|
852
|
+
throw new AggregateError([error, restoreError], "migration and automatic restore failed");
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
throw error;
|
|
856
|
+
} finally {
|
|
857
|
+
lock?.release();
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
function getSchemaVersion(db) {
|
|
861
|
+
if (!hasTable2(db, "schema_state"))
|
|
862
|
+
return;
|
|
863
|
+
return db.query("SELECT version FROM schema_state ORDER BY version DESC LIMIT 1").get();
|
|
864
|
+
}
|
|
865
|
+
function migrateToV8(db) {
|
|
866
|
+
db.transaction(() => {
|
|
867
|
+
adoptLegacyProjectIDs(db);
|
|
868
|
+
const pinned = hasColumn(db, "notes", "pinned") ? "pinned" : "0";
|
|
869
|
+
db.exec(`
|
|
870
|
+
CREATE TABLE notes_v7 (
|
|
871
|
+
id TEXT PRIMARY KEY,
|
|
872
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
873
|
+
kind TEXT NOT NULL CHECK (kind IN ('decision','fact','procedure','context','research','preference','task')),
|
|
874
|
+
title TEXT NOT NULL,
|
|
875
|
+
summary TEXT NOT NULL,
|
|
876
|
+
content TEXT NOT NULL,
|
|
877
|
+
size_class TEXT NOT NULL CHECK (size_class IN ('inline','indexed')),
|
|
878
|
+
pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0,1)),
|
|
879
|
+
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','superseded','archived')),
|
|
880
|
+
supersedes_id TEXT,
|
|
881
|
+
created_at INTEGER NOT NULL,
|
|
882
|
+
updated_at INTEGER NOT NULL,
|
|
883
|
+
UNIQUE(project_id, id)
|
|
884
|
+
);
|
|
885
|
+
INSERT INTO notes_v7
|
|
886
|
+
(id, project_id, kind, title, summary, content, size_class, pinned, status, supersedes_id, created_at, updated_at)
|
|
887
|
+
SELECT id, project_id, kind, title, summary, content, size_class, ${pinned}, status, supersedes_id, created_at, updated_at
|
|
888
|
+
FROM notes;
|
|
889
|
+
CREATE TABLE note_edges_v7 (
|
|
890
|
+
id TEXT PRIMARY KEY,
|
|
891
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
892
|
+
source_id TEXT NOT NULL,
|
|
893
|
+
target_id TEXT NOT NULL,
|
|
894
|
+
predicate TEXT NOT NULL CHECK (predicate IN ('SUPPORTS','DERIVED_FROM','PART_OF','ABOUT','PRECEDES','SUPERSEDES')),
|
|
895
|
+
created_at INTEGER NOT NULL,
|
|
896
|
+
UNIQUE(project_id, source_id, target_id, predicate),
|
|
897
|
+
FOREIGN KEY (project_id, source_id) REFERENCES notes_v7(project_id, id) ON DELETE CASCADE,
|
|
898
|
+
FOREIGN KEY (project_id, target_id) REFERENCES notes_v7(project_id, id) ON DELETE CASCADE
|
|
899
|
+
);
|
|
900
|
+
INSERT OR IGNORE INTO note_edges_v7
|
|
901
|
+
(id, project_id, source_id, target_id, predicate, created_at)
|
|
902
|
+
SELECT e.id, source.project_id, e.source_id, e.target_id, e.predicate, e.created_at
|
|
903
|
+
FROM note_edges e
|
|
904
|
+
JOIN notes source ON source.id = e.source_id
|
|
905
|
+
JOIN notes target ON target.id = e.target_id
|
|
906
|
+
WHERE source.project_id = target.project_id;
|
|
907
|
+
DROP TABLE note_edges;
|
|
908
|
+
DROP TABLE notes;
|
|
909
|
+
ALTER TABLE notes_v7 RENAME TO notes;
|
|
910
|
+
ALTER TABLE note_edges_v7 RENAME TO note_edges;
|
|
911
|
+
`);
|
|
912
|
+
importLegacyAssociations(db);
|
|
913
|
+
db.query("DELETE FROM schema_state").run();
|
|
914
|
+
db.query("INSERT INTO schema_state (version) VALUES (8)").run();
|
|
915
|
+
})();
|
|
916
|
+
}
|
|
917
|
+
function adoptLegacyProjectIDs(db) {
|
|
918
|
+
const existingProjects = db.query("SELECT id FROM projects").all();
|
|
919
|
+
for (const { id: legacyID } of existingProjects) {
|
|
920
|
+
if (isUUID(legacyID))
|
|
921
|
+
continue;
|
|
922
|
+
const id = randomUUID4();
|
|
923
|
+
db.query("UPDATE projects SET id = ? WHERE id = ?").run(id, legacyID);
|
|
924
|
+
db.query("UPDATE notes SET project_id = ? WHERE project_id = ?").run(id, legacyID);
|
|
925
|
+
db.query("UPDATE note_edges SET project_id = ? WHERE project_id = ?").run(id, legacyID);
|
|
926
|
+
}
|
|
927
|
+
const rows = db.query("SELECT DISTINCT project_id FROM notes").all();
|
|
928
|
+
for (const { project_id: legacyID } of rows) {
|
|
929
|
+
if (db.query("SELECT id FROM projects WHERE id = ?").get(legacyID))
|
|
930
|
+
continue;
|
|
931
|
+
const id = randomUUID4();
|
|
932
|
+
const name = uniqueLegacyProjectName(db, legacyID);
|
|
933
|
+
const now = Date.now();
|
|
934
|
+
db.query("INSERT INTO projects (id, name, normalized_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)").run(id, name, normalizeProjectName(name), now, now);
|
|
935
|
+
db.query("UPDATE notes SET project_id = ? WHERE project_id = ?").run(id, legacyID);
|
|
936
|
+
db.query("UPDATE note_edges SET project_id = ? WHERE project_id = ?").run(id, legacyID);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
function isUUID(value) {
|
|
940
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
941
|
+
}
|
|
942
|
+
function uniqueLegacyProjectName(db, legacyID) {
|
|
943
|
+
const base = legacyID === "global" ? "Legacy Global" : legacyID === "legacy" ? "Legacy" : `Legacy ${legacyID.slice(0, 12)}`;
|
|
944
|
+
let name = base;
|
|
945
|
+
let suffix = 2;
|
|
946
|
+
while (db.query("SELECT id FROM projects WHERE normalized_name = ?").get(normalizeProjectName(name))) {
|
|
947
|
+
name = `${base} ${suffix++}`;
|
|
948
|
+
}
|
|
949
|
+
return name;
|
|
950
|
+
}
|
|
951
|
+
function hasColumn(db, table, column) {
|
|
952
|
+
const rows = db.query(`PRAGMA table_info(${table})`).all();
|
|
953
|
+
return rows.some((row) => row.name === column);
|
|
954
|
+
}
|
|
955
|
+
function hasLegacyV2(db) {
|
|
956
|
+
return hasTable2(db, "memory_items");
|
|
957
|
+
}
|
|
958
|
+
function hasTable2(db, table) {
|
|
959
|
+
const row = db.query("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name = ?").get(table);
|
|
960
|
+
return (row?.n ?? 0) > 0;
|
|
961
|
+
}
|
|
962
|
+
var KIND_MAP = {
|
|
963
|
+
decision: "decision",
|
|
964
|
+
fact: "fact",
|
|
965
|
+
observation: "fact",
|
|
966
|
+
experiment: "fact",
|
|
967
|
+
hypothesis: "fact",
|
|
968
|
+
open_question: "fact",
|
|
969
|
+
rule: "fact",
|
|
970
|
+
direction: "fact",
|
|
971
|
+
constraint: "fact",
|
|
972
|
+
procedure: "procedure",
|
|
973
|
+
failure_remedy: "procedure",
|
|
974
|
+
agent_behavior: "procedure",
|
|
975
|
+
context: "context",
|
|
976
|
+
preference: "preference"
|
|
977
|
+
};
|
|
978
|
+
function migrateFromV2(db, path) {
|
|
979
|
+
const requiredTables = ["memory_items", "memory_versions", "memory_identities"];
|
|
980
|
+
const missingTables = requiredTables.filter((table) => !hasTable2(db, table));
|
|
981
|
+
if (missingTables.length > 0) {
|
|
982
|
+
throw new Error(`unsupported legacy schema; missing tables: ${missingTables.join(", ")}`);
|
|
983
|
+
}
|
|
984
|
+
const backup = `${path}.v2-backup`;
|
|
985
|
+
if (!existsSync3(backup)) {
|
|
986
|
+
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
987
|
+
copyFileSync2(path, backup);
|
|
988
|
+
}
|
|
989
|
+
db.transaction(() => {
|
|
990
|
+
migrateFromV2Data(db, backup, {
|
|
991
|
+
documents: ["document_sources", "document_chunks", "memories"].every((table) => hasTable2(db, table)),
|
|
992
|
+
links: hasTable2(db, "memory_links"),
|
|
993
|
+
edges: hasTable2(db, "memory_edges")
|
|
994
|
+
});
|
|
995
|
+
adoptLegacyProjectIDs(db);
|
|
996
|
+
db.query("DELETE FROM schema_state").run();
|
|
997
|
+
db.query("INSERT INTO schema_state (version) VALUES (8)").run();
|
|
998
|
+
})();
|
|
999
|
+
}
|
|
1000
|
+
function migrateFromV2Data(db, backup, options) {
|
|
1001
|
+
const now = Date.now();
|
|
1002
|
+
db.query("DELETE FROM notes_fts").run();
|
|
1003
|
+
db.query("DELETE FROM note_edges").run();
|
|
1004
|
+
db.query("DELETE FROM notes").run();
|
|
1005
|
+
db.query("DELETE FROM projects").run();
|
|
1006
|
+
const items = db.query(`SELECT i.id AS item_id, i.subject_key, i.kind, i.created_at, i.updated_at,
|
|
1007
|
+
i.identity_id, v.summary, v.content
|
|
1008
|
+
FROM memory_items i
|
|
1009
|
+
LEFT JOIN memory_versions v ON v.id = i.current_version_id
|
|
1010
|
+
WHERE i.lifecycle_state = 'active'`).all();
|
|
1011
|
+
const identities = new Map;
|
|
1012
|
+
for (const row of db.query("SELECT id, project_id FROM memory_identities").all()) {
|
|
1013
|
+
if (row.project_id)
|
|
1014
|
+
identities.set(row.id, row.project_id);
|
|
1015
|
+
}
|
|
1016
|
+
let migratedNotes = 0;
|
|
1017
|
+
for (const item of items) {
|
|
1018
|
+
const projectID = identities.get(item.identity_id) ?? "legacy";
|
|
1019
|
+
const content = item.content ?? item.summary ?? "";
|
|
1020
|
+
const summary = item.summary ?? content.slice(0, 200);
|
|
1021
|
+
const title = item.subject_key;
|
|
1022
|
+
const kind = KIND_MAP[item.kind] ?? "fact";
|
|
1023
|
+
const sizeClass = content.length <= 1200 ? "inline" : "indexed";
|
|
1024
|
+
db.query(`INSERT INTO notes (id, project_id, kind, title, summary, content, size_class, status, supersedes_id, created_at, updated_at)
|
|
1025
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', NULL, ?, ?)`).run(item.item_id, projectID, kind, title, summary, content, sizeClass, item.created_at, item.updated_at);
|
|
1026
|
+
db.query("INSERT INTO notes_fts (id, title, summary, content) VALUES (?, ?, ?, ?)").run(item.item_id, title, summary, content);
|
|
1027
|
+
migratedNotes++;
|
|
1028
|
+
}
|
|
1029
|
+
const sources = options.documents ? db.query(`SELECT s.id, s.project_root, s.title, s.created_at, s.updated_at,
|
|
1030
|
+
GROUP_CONCAT(m.content, '
|
|
1031
|
+
|
|
1032
|
+
') AS body
|
|
1033
|
+
FROM document_sources s
|
|
1034
|
+
JOIN document_chunks c ON c.source_id = s.id
|
|
1035
|
+
JOIN memories m ON m.id = c.memory_id
|
|
1036
|
+
WHERE s.status = 'active'
|
|
1037
|
+
GROUP BY s.id
|
|
1038
|
+
ORDER BY s.created_at`).all() : [];
|
|
1039
|
+
for (const source of sources) {
|
|
1040
|
+
const content = source.body ?? "";
|
|
1041
|
+
if (!content.trim())
|
|
1042
|
+
continue;
|
|
1043
|
+
const id = randomUUID4();
|
|
1044
|
+
db.query(`INSERT INTO notes (id, project_id, kind, title, summary, content, size_class, status, supersedes_id, created_at, updated_at)
|
|
1045
|
+
VALUES (?, ?, 'research', ?, ?, ?, 'indexed', 'active', NULL, ?, ?)`).run(id, source.project_root ? hashRoot(source.project_root) : "legacy", source.title, content.slice(0, 200), content, source.created_at, source.updated_at);
|
|
1046
|
+
db.query("INSERT INTO notes_fts (id, title, summary, content) VALUES (?, ?, ?, ?)").run(id, source.title, content.slice(0, 200), content);
|
|
1047
|
+
migratedNotes++;
|
|
1048
|
+
}
|
|
1049
|
+
const noteIDs = new Set(db.query("SELECT id FROM notes").all().map((r) => r.id));
|
|
1050
|
+
let migratedEdges = 0;
|
|
1051
|
+
const edges = options.edges ? db.query(`SELECT id, source_item_id, target_item_id, predicate, recorded_at
|
|
1052
|
+
FROM memory_edges
|
|
1053
|
+
WHERE lifecycle_state = 'active'`).all() : [];
|
|
1054
|
+
for (const edge of edges) {
|
|
1055
|
+
if (!noteIDs.has(edge.source_item_id) || !noteIDs.has(edge.target_item_id))
|
|
1056
|
+
continue;
|
|
1057
|
+
if (edge.source_item_id === edge.target_item_id)
|
|
1058
|
+
continue;
|
|
1059
|
+
const sourceProject = noteProjectID(db, edge.source_item_id);
|
|
1060
|
+
if (sourceProject !== noteProjectID(db, edge.target_item_id))
|
|
1061
|
+
continue;
|
|
1062
|
+
const predicate = PREDICATES.includes(edge.predicate) ? edge.predicate : "ABOUT";
|
|
1063
|
+
const result = db.query("INSERT OR IGNORE INTO note_edges (id, project_id, source_id, target_id, predicate, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(`edge-${edge.id}`, sourceProject, edge.source_item_id, edge.target_item_id, predicate, edge.recorded_at);
|
|
1064
|
+
migratedEdges += result.changes;
|
|
1065
|
+
}
|
|
1066
|
+
const links = options.links ? db.query("SELECT source_memory_id, target_memory_id FROM memory_links WHERE status = 'active'").all() : [];
|
|
1067
|
+
for (const link of links) {
|
|
1068
|
+
if (!noteIDs.has(link.source_memory_id) || !noteIDs.has(link.target_memory_id))
|
|
1069
|
+
continue;
|
|
1070
|
+
if (link.source_memory_id === link.target_memory_id)
|
|
1071
|
+
continue;
|
|
1072
|
+
const sourceProject = noteProjectID(db, link.source_memory_id);
|
|
1073
|
+
if (sourceProject !== noteProjectID(db, link.target_memory_id))
|
|
1074
|
+
continue;
|
|
1075
|
+
const result = db.query("INSERT OR IGNORE INTO note_edges (id, project_id, source_id, target_id, predicate, created_at) VALUES (?, ?, ?, ?, 'ABOUT', ?)").run(`edge-${link.source_memory_id}-${link.target_memory_id}`, sourceProject, link.source_memory_id, link.target_memory_id, now);
|
|
1076
|
+
migratedEdges += result.changes;
|
|
1077
|
+
}
|
|
1078
|
+
migratedEdges += importLegacyAssociations(db);
|
|
1079
|
+
console.warn(`[agz-memory] v2\u2192v3 migration complete: ${migratedNotes} notes, ${migratedEdges} edges (backup: ${backup})`);
|
|
1080
|
+
}
|
|
1081
|
+
function importLegacyAssociations(db) {
|
|
1082
|
+
if (!hasTable2(db, "memory_associations"))
|
|
1083
|
+
return 0;
|
|
1084
|
+
const associations = db.query(`SELECT id, left_item_id, right_item_id, kind, created_at
|
|
1085
|
+
FROM memory_associations
|
|
1086
|
+
WHERE lifecycle_state = 'active'`).all();
|
|
1087
|
+
let imported = 0;
|
|
1088
|
+
for (const association of associations) {
|
|
1089
|
+
const source = db.query("SELECT project_id FROM notes WHERE id = ?").get(association.left_item_id);
|
|
1090
|
+
const target = db.query("SELECT project_id FROM notes WHERE id = ?").get(association.right_item_id);
|
|
1091
|
+
if (!source || !target || source.project_id !== target.project_id)
|
|
1092
|
+
continue;
|
|
1093
|
+
if (association.left_item_id === association.right_item_id)
|
|
1094
|
+
continue;
|
|
1095
|
+
const candidate = association.kind.toUpperCase();
|
|
1096
|
+
const predicate = PREDICATES.includes(candidate) ? candidate : "ABOUT";
|
|
1097
|
+
const result = db.query("INSERT OR IGNORE INTO note_edges (id, project_id, source_id, target_id, predicate, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(`association-${association.id}`, source.project_id, association.left_item_id, association.right_item_id, predicate, association.created_at);
|
|
1098
|
+
imported += result.changes;
|
|
1099
|
+
}
|
|
1100
|
+
return imported;
|
|
1101
|
+
}
|
|
1102
|
+
function noteProjectID(db, noteID) {
|
|
1103
|
+
return db.query("SELECT project_id FROM notes WHERE id = ?").get(noteID).project_id;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// src/admin/doctor.ts
|
|
1107
|
+
function doctorDatabase(db) {
|
|
1108
|
+
const health = inspectDatabase(db);
|
|
1109
|
+
const warnings = [];
|
|
1110
|
+
const failures = [];
|
|
1111
|
+
const invariants = {};
|
|
1112
|
+
if (health.integrity !== "ok")
|
|
1113
|
+
failures.push("integrity_check_failed");
|
|
1114
|
+
if (health.foreignKeyViolations.length > 0)
|
|
1115
|
+
failures.push("foreign_key_violation");
|
|
1116
|
+
if (health.schemaVersion === undefined)
|
|
1117
|
+
failures.push("schema_state_missing");
|
|
1118
|
+
else if (health.schemaVersion > SCHEMA_VERSION)
|
|
1119
|
+
failures.push("schema_newer_than_runtime");
|
|
1120
|
+
else if (health.schemaVersion < SCHEMA_VERSION)
|
|
1121
|
+
warnings.push("schema_upgrade_required");
|
|
1122
|
+
if (hasTable(db, "notes_fts") && hasTable(db, "notes")) {
|
|
1123
|
+
invariants.notes = count(db, "SELECT COUNT(*) AS count FROM notes");
|
|
1124
|
+
invariants.fts = count(db, "SELECT COUNT(*) AS count FROM notes_fts");
|
|
1125
|
+
if (invariants.notes !== invariants.fts)
|
|
1126
|
+
failures.push("fts_count_mismatch");
|
|
1127
|
+
}
|
|
1128
|
+
if (health.schemaVersion === SCHEMA_VERSION) {
|
|
1129
|
+
invariants.missingCurrentRevisions = count(db, `SELECT COUNT(*) AS count
|
|
1130
|
+
FROM notes n
|
|
1131
|
+
LEFT JOIN note_revisions r
|
|
1132
|
+
ON r.project_id = n.project_id
|
|
1133
|
+
AND r.note_id = n.id
|
|
1134
|
+
AND r.revision = n.current_revision
|
|
1135
|
+
WHERE r.note_id IS NULL`);
|
|
1136
|
+
invariants.missingProvenance = count(db, `SELECT COUNT(*) AS count
|
|
1137
|
+
FROM note_revisions r
|
|
1138
|
+
LEFT JOIN note_provenance p
|
|
1139
|
+
ON p.project_id = r.project_id AND p.id = r.provenance_id
|
|
1140
|
+
WHERE p.id IS NULL`);
|
|
1141
|
+
invariants.bindingConflicts = count(db, `SELECT COUNT(*) AS count FROM (
|
|
1142
|
+
SELECT source, source_project_id, workspace_id
|
|
1143
|
+
FROM project_bindings
|
|
1144
|
+
GROUP BY source, source_project_id, workspace_id
|
|
1145
|
+
HAVING COUNT(DISTINCT project_id) > 1
|
|
1146
|
+
)`);
|
|
1147
|
+
invariants.deadOutbox = count(db, "SELECT COUNT(*) AS count FROM index_outbox WHERE state = 'dead'");
|
|
1148
|
+
invariants.dueCheckpoints = count(db, "SELECT COUNT(*) AS count FROM capture_checkpoints WHERE next_reconcile_at <= ? AND state = 'active'", Date.now());
|
|
1149
|
+
for (const [name, value] of Object.entries(invariants)) {
|
|
1150
|
+
if (["missingCurrentRevisions", "missingProvenance", "bindingConflicts"].includes(name) && value > 0) {
|
|
1151
|
+
failures.push(name);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
return { ok: failures.length === 0, health, warnings, failures, invariants };
|
|
1156
|
+
}
|
|
1157
|
+
function count(db, sql, ...bindings) {
|
|
1158
|
+
return db.query(sql).get(...bindings).count;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// src/retrieval/derived.ts
|
|
1162
|
+
import { createHash as createHash4 } from "crypto";
|
|
1163
|
+
|
|
1164
|
+
// src/capture/redact.ts
|
|
1165
|
+
var RULES = [
|
|
1166
|
+
{
|
|
1167
|
+
name: "private-key",
|
|
1168
|
+
pattern: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/gi,
|
|
1169
|
+
highRisk: true
|
|
1170
|
+
},
|
|
1171
|
+
{
|
|
1172
|
+
name: "credential-uri",
|
|
1173
|
+
pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]+@[^\s]+/gi,
|
|
1174
|
+
highRisk: true
|
|
1175
|
+
},
|
|
1176
|
+
{ name: "bearer", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi },
|
|
1177
|
+
{ name: "basic-auth", pattern: /\bBasic\s+[A-Za-z0-9+/=]{12,}/gi },
|
|
1178
|
+
{ name: "github-token", pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g },
|
|
1179
|
+
{ name: "gitlab-token", pattern: /\bglpat-[A-Za-z0-9_-]{16,}\b/g },
|
|
1180
|
+
{ name: "aws-access-key", pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g },
|
|
1181
|
+
{ name: "jwt", pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
|
|
1182
|
+
{
|
|
1183
|
+
name: "secret-assignment",
|
|
1184
|
+
pattern: /\b(?:PASSWORD|PASSWD|SECRET|TOKEN|API_KEY|PRIVATE_KEY)\s*[:=]\s*["']?[^\s,"']{8,}["']?/gi
|
|
1185
|
+
}
|
|
1186
|
+
];
|
|
1187
|
+
function redactText(value, options = {}) {
|
|
1188
|
+
const maxCharacters = options.maxCharacters ?? Number.MAX_SAFE_INTEGER;
|
|
1189
|
+
let text = value;
|
|
1190
|
+
let replacements = 0;
|
|
1191
|
+
let highRisk = 0;
|
|
1192
|
+
const classes = {};
|
|
1193
|
+
for (const literal of options.denylist ?? []) {
|
|
1194
|
+
if (!literal)
|
|
1195
|
+
continue;
|
|
1196
|
+
const count2 = text.split(literal).length - 1;
|
|
1197
|
+
if (count2 === 0)
|
|
1198
|
+
continue;
|
|
1199
|
+
replacements += count2;
|
|
1200
|
+
classes.denylist = (classes.denylist ?? 0) + count2;
|
|
1201
|
+
text = text.replaceAll(literal, "[REDACTED:denylist]");
|
|
1202
|
+
}
|
|
1203
|
+
for (const rule of RULES) {
|
|
1204
|
+
text = text.replace(rule.pattern, () => {
|
|
1205
|
+
replacements++;
|
|
1206
|
+
classes[rule.name] = (classes[rule.name] ?? 0) + 1;
|
|
1207
|
+
if (rule.highRisk)
|
|
1208
|
+
highRisk++;
|
|
1209
|
+
return `[REDACTED:${rule.name}]`;
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
text = text.replace(/\b[A-Za-z0-9+/=_-]{32,}\b/g, (candidate) => {
|
|
1213
|
+
if (!looksHighEntropy(candidate))
|
|
1214
|
+
return candidate;
|
|
1215
|
+
replacements++;
|
|
1216
|
+
classes.entropy = (classes.entropy ?? 0) + 1;
|
|
1217
|
+
return "[REDACTED:entropy]";
|
|
1218
|
+
});
|
|
1219
|
+
const truncated = text.length > maxCharacters;
|
|
1220
|
+
if (truncated)
|
|
1221
|
+
text = text.slice(0, maxCharacters);
|
|
1222
|
+
return {
|
|
1223
|
+
text,
|
|
1224
|
+
replacements,
|
|
1225
|
+
classes,
|
|
1226
|
+
truncated,
|
|
1227
|
+
quarantined: highRisk > 0 || replacements >= 3
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
function looksHighEntropy(value) {
|
|
1231
|
+
if (!/[A-Za-z]/.test(value) || !/\d/.test(value))
|
|
1232
|
+
return false;
|
|
1233
|
+
const counts = new Map;
|
|
1234
|
+
for (const character of value)
|
|
1235
|
+
counts.set(character, (counts.get(character) ?? 0) + 1);
|
|
1236
|
+
let entropy = 0;
|
|
1237
|
+
for (const count2 of counts.values()) {
|
|
1238
|
+
const probability = count2 / value.length;
|
|
1239
|
+
entropy -= probability * Math.log2(probability);
|
|
1240
|
+
}
|
|
1241
|
+
return entropy >= 4.1;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// src/retrieval/derived.ts
|
|
1245
|
+
function deriveDocument(source) {
|
|
1246
|
+
const title = redactText(source.title);
|
|
1247
|
+
const summary = redactText(source.summary);
|
|
1248
|
+
const content = redactText(source.content);
|
|
1249
|
+
if (title.quarantined || summary.quarantined || content.quarantined)
|
|
1250
|
+
return;
|
|
1251
|
+
const contentHash = createHash4("sha256").update(`${source.kind}\x00${title.text}\x00${summary.text}\x00${content.text}`, "utf8").digest("hex");
|
|
1252
|
+
return {
|
|
1253
|
+
projectID: source.projectID,
|
|
1254
|
+
noteID: source.noteID,
|
|
1255
|
+
revision: source.revision,
|
|
1256
|
+
kind: source.kind,
|
|
1257
|
+
title: title.text,
|
|
1258
|
+
summary: summary.text,
|
|
1259
|
+
content: content.text,
|
|
1260
|
+
contentHash
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// src/admin/index.ts
|
|
1265
|
+
async function runAdmin(argv = process.argv.slice(2)) {
|
|
1266
|
+
const databasePath = resolveConfig().databasePath;
|
|
1267
|
+
const [command, subcommand] = argv;
|
|
1268
|
+
if (!command)
|
|
1269
|
+
throw new Error("admin command is required");
|
|
1270
|
+
if (command === "doctor") {
|
|
1271
|
+
requireExistingDatabase(databasePath);
|
|
1272
|
+
const db = new Database3(databasePath, { readonly: true });
|
|
1273
|
+
try {
|
|
1274
|
+
return doctorDatabase(db);
|
|
1275
|
+
} finally {
|
|
1276
|
+
db.close();
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
if (command === "backup" && subcommand !== "prune") {
|
|
1280
|
+
requireExistingDatabase(databasePath);
|
|
1281
|
+
const lock = acquireMigrationLock(databasePath, readSchemaVersion(databasePath));
|
|
1282
|
+
const db = new Database3(databasePath);
|
|
1283
|
+
try {
|
|
1284
|
+
const version = schemaVersion(db);
|
|
1285
|
+
return createVerifiedBackup(db, databasePath, version, version, "0.4.0-beta.1");
|
|
1286
|
+
} finally {
|
|
1287
|
+
db.close();
|
|
1288
|
+
lock.release();
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
if (command === "upgrade") {
|
|
1292
|
+
if (option(argv, "--to") !== String(SCHEMA_VERSION)) {
|
|
1293
|
+
throw new Error(`only --to ${SCHEMA_VERSION} is supported`);
|
|
1294
|
+
}
|
|
1295
|
+
const opened = openMemoryDatabase(databasePath);
|
|
1296
|
+
try {
|
|
1297
|
+
return doctorDatabase(opened.db);
|
|
1298
|
+
} finally {
|
|
1299
|
+
opened.close();
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
if (command === "restore") {
|
|
1303
|
+
const manifestPath = subcommand;
|
|
1304
|
+
if (!manifestPath)
|
|
1305
|
+
throw new Error("restore manifest path is required");
|
|
1306
|
+
assertInsideBackupRoot(databasePath, manifestPath);
|
|
1307
|
+
const verified = verifyBackupManifest(manifestPath);
|
|
1308
|
+
const confirmation = option(argv, "--confirm");
|
|
1309
|
+
const expectedHash = option(argv, "--sha256");
|
|
1310
|
+
if (!confirmation || !expectedHash) {
|
|
1311
|
+
return { dryRun: true, manifest: verified.manifest, targetPath: databasePath };
|
|
1312
|
+
}
|
|
1313
|
+
if (expectedHash !== verified.manifest.sha256)
|
|
1314
|
+
throw new Error("restore manifest hash mismatch");
|
|
1315
|
+
const lock = acquireMigrationLock(databasePath, verified.manifest.sourceSchema);
|
|
1316
|
+
try {
|
|
1317
|
+
const preservedPath = restoreVerifiedBackup(manifestPath, databasePath, confirmation);
|
|
1318
|
+
return { restored: true, preservedPath, manifest: verified.manifest };
|
|
1319
|
+
} finally {
|
|
1320
|
+
lock.release();
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
if (command === "unlock") {
|
|
1324
|
+
const ownerID = option(argv, "--owner");
|
|
1325
|
+
const confirmation = option(argv, "--confirm");
|
|
1326
|
+
if (!ownerID || !confirmation)
|
|
1327
|
+
throw new Error("unlock requires --owner and --confirm");
|
|
1328
|
+
breakMigrationLock(databasePath, ownerID, confirmation);
|
|
1329
|
+
return { unlocked: true, ownerID };
|
|
1330
|
+
}
|
|
1331
|
+
if (command === "reindex") {
|
|
1332
|
+
const backend = option(argv, "--backend");
|
|
1333
|
+
if (!backend || !/^[a-z0-9][a-z0-9._-]{0,79}$/i.test(backend)) {
|
|
1334
|
+
throw new Error("reindex requires a valid --backend");
|
|
1335
|
+
}
|
|
1336
|
+
const opened = openMemoryDatabase(databasePath);
|
|
1337
|
+
try {
|
|
1338
|
+
const now = Date.now();
|
|
1339
|
+
let queued = 0;
|
|
1340
|
+
const notes = opened.db.query("SELECT * FROM notes WHERE status = 'active' ORDER BY project_id, id").all();
|
|
1341
|
+
const insert = opened.db.query(`
|
|
1342
|
+
INSERT OR IGNORE INTO index_outbox
|
|
1343
|
+
(backend, operation, project_id, note_id, revision, content_hash,
|
|
1344
|
+
state, attempt_count, available_at, created_at)
|
|
1345
|
+
VALUES (?, 'upsert-note', ?, ?, ?, ?, 'pending', 0, ?, ?)
|
|
1346
|
+
`);
|
|
1347
|
+
opened.db.transaction(() => {
|
|
1348
|
+
for (const note of notes) {
|
|
1349
|
+
const document = deriveDocument({
|
|
1350
|
+
projectID: note.project_id,
|
|
1351
|
+
noteID: note.id,
|
|
1352
|
+
revision: note.current_revision,
|
|
1353
|
+
kind: note.kind,
|
|
1354
|
+
title: note.title,
|
|
1355
|
+
summary: note.summary,
|
|
1356
|
+
content: note.content
|
|
1357
|
+
});
|
|
1358
|
+
queued += insert.run(backend, note.project_id, note.id, note.current_revision, document?.contentHash ?? null, now, now).changes;
|
|
1359
|
+
}
|
|
1360
|
+
})();
|
|
1361
|
+
return { backend, queued };
|
|
1362
|
+
} finally {
|
|
1363
|
+
opened.close();
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
if (command === "outbox" && subcommand === "status") {
|
|
1367
|
+
return withDatabase(databasePath, (db) => ({
|
|
1368
|
+
states: db.query("SELECT state, COUNT(*) AS count FROM index_outbox GROUP BY state ORDER BY state").all(),
|
|
1369
|
+
oldestPendingAt: db.query("SELECT MIN(created_at) AS value FROM index_outbox WHERE state IN ('pending','leased')").get().value
|
|
1370
|
+
}));
|
|
1371
|
+
}
|
|
1372
|
+
if (command === "outbox" && subcommand === "retry") {
|
|
1373
|
+
const id = Number(argv[2]);
|
|
1374
|
+
if (!Number.isSafeInteger(id) || id <= 0)
|
|
1375
|
+
throw new Error("outbox retry requires a positive id");
|
|
1376
|
+
return withDatabase(databasePath, (db) => {
|
|
1377
|
+
const result = db.query(`UPDATE index_outbox
|
|
1378
|
+
SET state = 'pending', available_at = ?, lease_owner = NULL,
|
|
1379
|
+
lease_expires_at = NULL, last_error_code = NULL
|
|
1380
|
+
WHERE id = ? AND state = 'dead'`).run(Date.now(), id);
|
|
1381
|
+
return { id, retried: result.changes === 1 };
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
if (command === "capture" && subcommand === "status") {
|
|
1385
|
+
return withDatabase(databasePath, (db) => ({
|
|
1386
|
+
events: db.query("SELECT state, COUNT(*) AS count FROM capture_events GROUP BY state ORDER BY state").all(),
|
|
1387
|
+
checkpoints: db.query("SELECT state, COUNT(*) AS count FROM capture_checkpoints GROUP BY state ORDER BY state").all()
|
|
1388
|
+
}));
|
|
1389
|
+
}
|
|
1390
|
+
if (command === "backup" && subcommand === "prune") {
|
|
1391
|
+
const entries = backupEntries(databasePath);
|
|
1392
|
+
const root = resolve2(`${databasePath}.backup`);
|
|
1393
|
+
const digest = createHash5("sha256").update(`${resolve2(databasePath)}\x00${root}
|
|
1394
|
+
${entries.map((entry) => `${basename2(entry.manifest)}\x00${basename2(entry.database)}\x00${entry.sha256}\x00${entry.size}\x00${entry.manifestHash}`).join(`
|
|
1395
|
+
`)}`).digest("hex");
|
|
1396
|
+
if (option(argv, "--confirm") !== "DELETE_VERIFIED_BACKUPS") {
|
|
1397
|
+
return { dryRun: true, digest, backups: entries };
|
|
1398
|
+
}
|
|
1399
|
+
if (option(argv, "--digest") !== digest)
|
|
1400
|
+
throw new Error("backup prune digest mismatch");
|
|
1401
|
+
const currentEntries = entries.map((entry) => {
|
|
1402
|
+
const current = verifiedBackupEntry(root, entry.manifest);
|
|
1403
|
+
if (current.database !== entry.database || current.sha256 !== entry.sha256 || current.size !== entry.size || current.manifestHash !== entry.manifestHash) {
|
|
1404
|
+
throw new Error(`backup changed after confirmation: ${basename2(entry.manifest)}`);
|
|
1405
|
+
}
|
|
1406
|
+
return current;
|
|
1407
|
+
});
|
|
1408
|
+
for (const current of currentEntries) {
|
|
1409
|
+
rmSync3(current.database, { force: true });
|
|
1410
|
+
rmSync3(current.manifest, { force: true });
|
|
1411
|
+
}
|
|
1412
|
+
return { deleted: entries.length, digest };
|
|
1413
|
+
}
|
|
1414
|
+
throw new Error(`unknown admin command: ${argv.join(" ")}`);
|
|
1415
|
+
}
|
|
1416
|
+
function withDatabase(databasePath, action) {
|
|
1417
|
+
const opened = openMemoryDatabase(databasePath);
|
|
1418
|
+
try {
|
|
1419
|
+
return action(opened.db);
|
|
1420
|
+
} finally {
|
|
1421
|
+
opened.close();
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
function option(argv, name) {
|
|
1425
|
+
const index = argv.indexOf(name);
|
|
1426
|
+
return index >= 0 ? argv[index + 1] : undefined;
|
|
1427
|
+
}
|
|
1428
|
+
function requireExistingDatabase(databasePath) {
|
|
1429
|
+
if (!existsSync4(databasePath))
|
|
1430
|
+
throw new Error(`database does not exist: ${databasePath}`);
|
|
1431
|
+
}
|
|
1432
|
+
function readSchemaVersion(databasePath) {
|
|
1433
|
+
const db = new Database3(databasePath, { readonly: true });
|
|
1434
|
+
try {
|
|
1435
|
+
return schemaVersion(db);
|
|
1436
|
+
} finally {
|
|
1437
|
+
db.close();
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
function schemaVersion(db) {
|
|
1441
|
+
const row = db.query("SELECT MAX(version) AS version FROM schema_state").get();
|
|
1442
|
+
if (row.version === null)
|
|
1443
|
+
throw new Error("schema version is missing");
|
|
1444
|
+
return row.version;
|
|
1445
|
+
}
|
|
1446
|
+
function assertInsideBackupRoot(databasePath, manifestPath) {
|
|
1447
|
+
const root = resolve2(`${databasePath}.backup`);
|
|
1448
|
+
const candidate = resolve2(manifestPath);
|
|
1449
|
+
if (dirname2(candidate) !== root)
|
|
1450
|
+
throw new Error("manifest must be inside the database backup directory");
|
|
1451
|
+
}
|
|
1452
|
+
function backupEntries(databasePath) {
|
|
1453
|
+
const root = resolve2(`${databasePath}.backup`);
|
|
1454
|
+
if (!existsSync4(root))
|
|
1455
|
+
return [];
|
|
1456
|
+
return readdirSync(root).filter((name) => name.endsWith(".manifest.json")).sort().map((name) => verifiedBackupEntry(root, resolve2(root, name)));
|
|
1457
|
+
}
|
|
1458
|
+
function verifiedBackupEntry(root, manifest) {
|
|
1459
|
+
const candidate = resolve2(manifest);
|
|
1460
|
+
if (dirname2(candidate) !== root)
|
|
1461
|
+
throw new Error("backup manifest escaped the backup directory");
|
|
1462
|
+
const stat = lstatSync2(candidate);
|
|
1463
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
1464
|
+
throw new Error("backup manifest must be a regular file");
|
|
1465
|
+
const bytes = readFileSync3(candidate);
|
|
1466
|
+
const verified = verifyBackupManifest(candidate);
|
|
1467
|
+
if (dirname2(verified.databasePath) !== root) {
|
|
1468
|
+
throw new Error("backup database escaped the backup directory");
|
|
1469
|
+
}
|
|
1470
|
+
return {
|
|
1471
|
+
manifest: candidate,
|
|
1472
|
+
database: verified.databasePath,
|
|
1473
|
+
sha256: verified.manifest.sha256,
|
|
1474
|
+
size: verified.manifest.size,
|
|
1475
|
+
manifestHash: createHash5("sha256").update(bytes).digest("hex")
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
if (import.meta.main) {
|
|
1479
|
+
try {
|
|
1480
|
+
const result = await runAdmin();
|
|
1481
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
1482
|
+
`);
|
|
1483
|
+
if (typeof result === "object" && result && "ok" in result && result.ok === false) {
|
|
1484
|
+
process.exitCode = 2;
|
|
1485
|
+
}
|
|
1486
|
+
} catch (error) {
|
|
1487
|
+
process.stderr.write(`[agz-memory-admin] ${error instanceof Error ? error.message : String(error)}
|
|
1488
|
+
`);
|
|
1489
|
+
process.exitCode = 1;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
export {
|
|
1493
|
+
runAdmin
|
|
1494
|
+
};
|