@ancleto/spec 0.15.3 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/package.json +1 -1
- package/src/cli/index.js +114 -2
- package/src/core/memory/doctor.js +87 -21
- package/src/core/memory/engine.js +91 -3
- package/src/core/memory/snapshot.js +126 -0
package/README.md
CHANGED
|
@@ -401,8 +401,14 @@ ancleto memory list [--type rule|decision] [--scope project|feature|task] [--all
|
|
|
401
401
|
# Lista los nodos de memoria (read-only: no toca la base)
|
|
402
402
|
|
|
403
403
|
ancleto memory doctor [--rebuild]
|
|
404
|
-
# Diagnostica la base de memoria
|
|
405
|
-
#
|
|
404
|
+
# Diagnostica la base de memoria (read-only por defecto);
|
|
405
|
+
# --rebuild es el único camino que escribe y reconstruye el índice FTS5
|
|
406
|
+
|
|
407
|
+
ancleto memory snapshot --out <archivo.db> [--force] [--json]
|
|
408
|
+
# Copia consistente de la base vía VACUUM INTO
|
|
409
|
+
|
|
410
|
+
ancleto memory merge --target <key> --sources <k1,k2,...> [--dry-run] [--json]
|
|
411
|
+
# Fusiona nodos fuente en el target y supersede los sources
|
|
406
412
|
|
|
407
413
|
ancleto mcp # Servidor MCP de memoria propia (stdio): lo consume tu IDE
|
|
408
414
|
|
|
@@ -422,7 +428,7 @@ Funciona en tres piezas que comparten el mismo archivo:
|
|
|
422
428
|
|---|---|
|
|
423
429
|
| **Almacenamiento** | Reglas y decisiones con supersesión atómica por `memory_key` (una sola activa por clave). |
|
|
424
430
|
| **Tools del LLM** (`ancleto mcp`) | Servidor MCP local, sin dependencias, que expone tres herramientas a tu IDE. |
|
|
425
|
-
| **CLI** | `ancleto memory context` materializa el bloque `<ProjectMemoryRules>`, `ancleto memory list` permite inspeccionar los nodos sin escribir, y `ancleto memory doctor` verifica integridad
|
|
431
|
+
| **CLI** | `ancleto memory context` materializa el bloque `<ProjectMemoryRules>`, `ancleto memory list` permite inspeccionar los nodos sin escribir, y `ancleto memory doctor` verifica integridad (read-only por defecto; `--rebuild` reconstruye el índice FTS5). |
|
|
426
432
|
|
|
427
433
|
| Herramienta | Función |
|
|
428
434
|
|---|---|
|
package/package.json
CHANGED
package/src/cli/index.js
CHANGED
|
@@ -9,8 +9,9 @@ import { join, dirname, resolve, basename, relative, isAbsolute } from 'node:pat
|
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
10
10
|
import { homedir, tmpdir } from 'node:os'
|
|
11
11
|
import { createMemoryEngine, createReadonlyMemoryEngine, defaultMemoryDbPath } from '../core/memory/engine.js'
|
|
12
|
+
import { snapshotDatabase } from '../core/memory/snapshot.js'
|
|
12
13
|
import { adaptFrontmatter, parseFrontmatter } from '../core/adapters/frontmatter.js'
|
|
13
|
-
import { writeWorkingContext, writeWorkingContextBlock, renderWorkingContextBlock, resolveMemoryProjectRoot, checkWorkingContextFreshness, refreshWorkingContextOnWrite } from '../core/memory/working-context.js'
|
|
14
|
+
import { writeWorkingContext, writeWorkingContextBlock, workingContextPath, renderWorkingContextBlock, resolveMemoryProjectRoot, checkWorkingContextFreshness, refreshWorkingContextOnWrite } from '../core/memory/working-context.js'
|
|
14
15
|
import { materializeMachineEnv, checkMachineEnvTracked } from '../core/machine-env.js'
|
|
15
16
|
import { memoryDoctor } from '../core/memory/doctor.js'
|
|
16
17
|
import { serveMemoryMcp } from '../core/memory/mcp-server.js'
|
|
@@ -91,6 +92,10 @@ ancleto memory doctor [--rebuild] Diagnostica .ancleto/memory.db (integridad, FT
|
|
|
91
92
|
ancleto memory export [--out <archivo>] Exporta nodos activos a JSON (stdout o archivo)
|
|
92
93
|
ancleto memory import <archivo> Importa nodos desde JSON (upsert por memory_key, idempotente)
|
|
93
94
|
ancleto memory gc [--dry-run] [--days N] Purga superseded con antiguedad > N dias (default 30)
|
|
95
|
+
ancleto memory snapshot --out <archivo.db> [--force] [--json]
|
|
96
|
+
Copia consistente de la memoria (incluye historial; --force sobrescribe)
|
|
97
|
+
ancleto memory merge --target <key> --sources <k1,k2,...> [--dry-run] [--json]
|
|
98
|
+
Supersede cada source activa hacia el target (sin crear nodos)
|
|
94
99
|
ancleto mcp Servidor MCP de memoria propia (stdio) para tu IDE
|
|
95
100
|
expone searchMemory, recordRule y recordDecision
|
|
96
101
|
ancleto check Verifica integridad de archivos instalados vs manifiesto
|
|
@@ -2240,7 +2245,7 @@ async function memoryDoctorCmd(flags) {
|
|
|
2240
2245
|
const { checks, healthy, rebuilt } = memoryDoctor(dbPath, { rebuild })
|
|
2241
2246
|
if (rebuilt) console.log('ancleto: indice FTS5 reconstruido')
|
|
2242
2247
|
for (const c of checks) {
|
|
2243
|
-
console.log(` ${c.ok ? '✔' : '✖'} ${c.name}: ${c.detail}`)
|
|
2248
|
+
console.log(` ${c.skipped ? '−' : c.ok ? '✔' : '✖'} ${c.name}: ${c.detail}`)
|
|
2244
2249
|
}
|
|
2245
2250
|
// Frescura del working-context: advisory, no afecta el exit code (que sigue midiendo
|
|
2246
2251
|
// la integridad de la DB). Read-only: no reescribe el artefacto.
|
|
@@ -2393,6 +2398,103 @@ async function memoryGc(flags) {
|
|
|
2393
2398
|
}
|
|
2394
2399
|
}
|
|
2395
2400
|
|
|
2401
|
+
// `flagValue` devuelve el token siguiente sin mirar si es otro flag. En los
|
|
2402
|
+
// comandos con flags obligatorios, un valor vacio o que empieza con `--` cuenta
|
|
2403
|
+
// como flag faltante (sin cambiar el helper global que usan otros comandos).
|
|
2404
|
+
function requiredMemoryFlag(flags, flag) {
|
|
2405
|
+
const value = flagValue(flags, flag)
|
|
2406
|
+
return value && !value.startsWith('--') ? value : null
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
async function memorySnapshot(flags) {
|
|
2410
|
+
const out = requiredMemoryFlag(flags, '--out')
|
|
2411
|
+
if (!out) {
|
|
2412
|
+
console.error('ancleto: uso: ancleto memory snapshot --out <archivo.db> [--force] [--json]')
|
|
2413
|
+
process.exitCode = 1
|
|
2414
|
+
return
|
|
2415
|
+
}
|
|
2416
|
+
const dbPath = defaultMemoryDbPath()
|
|
2417
|
+
if (!(await exists(dbPath))) {
|
|
2418
|
+
console.error(`ancleto: no hay memoria en este repo (${dbPath})`)
|
|
2419
|
+
process.exitCode = 1
|
|
2420
|
+
return
|
|
2421
|
+
}
|
|
2422
|
+
let result
|
|
2423
|
+
try {
|
|
2424
|
+
result = snapshotDatabase(dbPath, resolve(out), { overwrite: flags.includes('--force') })
|
|
2425
|
+
} catch (err) {
|
|
2426
|
+
console.error(`ancleto: no se pudo crear el snapshot: ${err.message}`)
|
|
2427
|
+
process.exitCode = 1
|
|
2428
|
+
return
|
|
2429
|
+
}
|
|
2430
|
+
if (flags.includes('--json')) {
|
|
2431
|
+
console.log(JSON.stringify({ out: result.out, bytes: result.bytes }))
|
|
2432
|
+
} else {
|
|
2433
|
+
console.log(`ancleto: snapshot escrito en ${result.out} (${result.bytes} bytes)`)
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
async function memoryMerge(flags) {
|
|
2438
|
+
const target = requiredMemoryFlag(flags, '--target')
|
|
2439
|
+
const sourcesRaw = requiredMemoryFlag(flags, '--sources')
|
|
2440
|
+
if (!target || !sourcesRaw) {
|
|
2441
|
+
console.error('ancleto: uso: ancleto memory merge --target <key> --sources <k1,k2,...> [--dry-run] [--json]')
|
|
2442
|
+
process.exitCode = 1
|
|
2443
|
+
return
|
|
2444
|
+
}
|
|
2445
|
+
const sources = sourcesRaw.split(',').map((s) => s.trim()).filter(Boolean)
|
|
2446
|
+
if (sources.length === 0) {
|
|
2447
|
+
console.error('ancleto: uso: ancleto memory merge --target <key> --sources <k1,k2,...> [--dry-run] [--json]')
|
|
2448
|
+
process.exitCode = 1
|
|
2449
|
+
return
|
|
2450
|
+
}
|
|
2451
|
+
const dryRun = flags.includes('--dry-run')
|
|
2452
|
+
const dbPath = defaultMemoryDbPath()
|
|
2453
|
+
if (!(await exists(dbPath))) {
|
|
2454
|
+
console.error(`ancleto: no hay memoria en este repo (${dbPath})`)
|
|
2455
|
+
process.exitCode = 1
|
|
2456
|
+
return
|
|
2457
|
+
}
|
|
2458
|
+
// `createMemoryEngine` aplica `PRAGMA journal_mode=WAL` y migraciones al abrir;
|
|
2459
|
+
// un dry-run no debe escribir ni la base ni sus sidecars, asi que usa la
|
|
2460
|
+
// conexion read-only (su `merge` solo acepta `dryRun: true`).
|
|
2461
|
+
const engine = dryRun ? createReadonlyMemoryEngine(dbPath) : createMemoryEngine(dbPath)
|
|
2462
|
+
try {
|
|
2463
|
+
let result
|
|
2464
|
+
try {
|
|
2465
|
+
result = engine.merge({ target, sources, dryRun })
|
|
2466
|
+
} catch (err) {
|
|
2467
|
+
console.error(`ancleto: merge rechazado: ${err.message}`)
|
|
2468
|
+
process.exitCode = 1
|
|
2469
|
+
return
|
|
2470
|
+
}
|
|
2471
|
+
if (result.dryRun) {
|
|
2472
|
+
if (flags.includes('--json')) console.log(JSON.stringify(result))
|
|
2473
|
+
else console.log(`ancleto: merge --dry-run -> target ${result.target}; sources ${result.sources.join(', ')}; ${result.superseded} nodo(s) a superseder`)
|
|
2474
|
+
return
|
|
2475
|
+
}
|
|
2476
|
+
// Mismo render que el refresh on-write de `import`; la raiz se deriva del
|
|
2477
|
+
// directorio de la DB, no de process.cwd() (aisla fixtures en tmpdir).
|
|
2478
|
+
const projectRoot = resolveMemoryProjectRoot(dbPath)
|
|
2479
|
+
try {
|
|
2480
|
+
refreshWorkingContextOnWrite(engine, projectRoot)
|
|
2481
|
+
} catch (err) {
|
|
2482
|
+
// El merge ya quedo commiteado en la base: no se revierte. Reportar exito
|
|
2483
|
+
// sobre un bloque derivado desactualizado seria mentir; salir != 0 y decir
|
|
2484
|
+
// exactamente que si se aplico, que quedo viejo y como regenerarlo.
|
|
2485
|
+
const ctxPath = workingContextPath(projectRoot)
|
|
2486
|
+
console.error(`ancleto: el merge SI se aplico en la base (${result.superseded} nodo(s) superseded hacia ${result.target}), pero el bloque derivado ${ctxPath} quedo desactualizado: ${err.message}`)
|
|
2487
|
+
console.error(`ancleto: regenera el bloque con: ancleto memory context --out ${ctxPath}`)
|
|
2488
|
+
process.exitCode = 1
|
|
2489
|
+
return
|
|
2490
|
+
}
|
|
2491
|
+
if (flags.includes('--json')) console.log(JSON.stringify(result))
|
|
2492
|
+
else console.log(`ancleto: merge OK -> ${result.superseded} nodo(s) superseded hacia ${result.target}`)
|
|
2493
|
+
} finally {
|
|
2494
|
+
engine.close()
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2396
2498
|
async function memoryCmd(args) {
|
|
2397
2499
|
const [sub, ...flags] = args
|
|
2398
2500
|
if (sub === 'context') {
|
|
@@ -2419,6 +2521,14 @@ async function memoryCmd(args) {
|
|
|
2419
2521
|
await memoryGc(flags)
|
|
2420
2522
|
return
|
|
2421
2523
|
}
|
|
2524
|
+
if (sub === 'snapshot') {
|
|
2525
|
+
await memorySnapshot(flags)
|
|
2526
|
+
return
|
|
2527
|
+
}
|
|
2528
|
+
if (sub === 'merge') {
|
|
2529
|
+
await memoryMerge(flags)
|
|
2530
|
+
return
|
|
2531
|
+
}
|
|
2422
2532
|
console.error(`ancleto: subcomando de memory desconocido: ${sub || '(ninguno)'}`)
|
|
2423
2533
|
console.error('ancleto: uso: ancleto memory context [--scope X] [--out file]')
|
|
2424
2534
|
console.error('ancleto: uso: ancleto memory list [--type X] [--scope X] [--all] [--json]')
|
|
@@ -2426,6 +2536,8 @@ async function memoryCmd(args) {
|
|
|
2426
2536
|
console.error('ancleto: uso: ancleto memory export [--out <archivo>]')
|
|
2427
2537
|
console.error('ancleto: uso: ancleto memory import <archivo>')
|
|
2428
2538
|
console.error('ancleto: uso: ancleto memory gc [--dry-run] [--days <n>]')
|
|
2539
|
+
console.error('ancleto: uso: ancleto memory snapshot --out <archivo.db> [--force] [--json]')
|
|
2540
|
+
console.error('ancleto: uso: ancleto memory merge --target <key> --sources <k1,k2,...> [--dry-run] [--json]')
|
|
2429
2541
|
process.exit(1)
|
|
2430
2542
|
}
|
|
2431
2543
|
|
|
@@ -1,15 +1,47 @@
|
|
|
1
|
+
import { statSync } from 'node:fs'
|
|
1
2
|
import { openDatabase, checkpointDatabase } from './database.js'
|
|
2
3
|
|
|
4
|
+
// Estado observable del WAL sin ejecutar checkpoint ni ningun pragma mutante:
|
|
5
|
+
// solo se mira el tamano del archivo `-wal` que ya existe en disco.
|
|
6
|
+
function observeWal(dbPath) {
|
|
7
|
+
try {
|
|
8
|
+
return `-wal presente (${statSync(`${dbPath}-wal`).size} bytes)`
|
|
9
|
+
} catch {
|
|
10
|
+
return 'sin -wal'
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
3
14
|
export function memoryDoctor(dbPath, { rebuild = false } = {}) {
|
|
4
|
-
|
|
15
|
+
// Default estrictamente read-only: sin migraciones, sin pragmas mutantes y sin
|
|
16
|
+
// checkpoint. `--rebuild` es el unico camino que abre en escritura.
|
|
17
|
+
const db = openDatabase(dbPath, rebuild ? { migrate: false } : { migrate: false, readonly: true })
|
|
18
|
+
let rebuilt = false
|
|
5
19
|
try {
|
|
6
20
|
const checks = []
|
|
7
21
|
|
|
8
22
|
const hasSchema = db.prepare(`SELECT count(*) AS n FROM sqlite_master WHERE name = 'memory_nodes'`).get().n > 0
|
|
9
23
|
if (!hasSchema) {
|
|
10
|
-
|
|
24
|
+
// Error path: los cuatro nombres se conservan y cada check no ejecutable
|
|
25
|
+
// se reporta como falla con su motivo (nunca se omite del array).
|
|
26
|
+
const detail = 'esquema no inicializado'
|
|
27
|
+
return {
|
|
28
|
+
checks: [
|
|
29
|
+
{ name: 'Integridad DB', ok: false, detail },
|
|
30
|
+
{ name: 'Indice FTS5', ok: false, detail: `no verificable: ${detail}` },
|
|
31
|
+
{ name: 'Reglas activas (unicidad)', ok: false, detail: `no verificable: ${detail}` },
|
|
32
|
+
{ name: 'Checkpoint WAL', ok: false, detail: `no verificable: ${detail}` }
|
|
33
|
+
],
|
|
34
|
+
healthy: false,
|
|
35
|
+
rebuilt: false
|
|
36
|
+
}
|
|
11
37
|
}
|
|
12
38
|
|
|
39
|
+
// La existencia del indice se verifica aparte del conteo: sin `memory_fts`
|
|
40
|
+
// la busqueda no funciona (esquema parcial/legacy) y no es un chequeo
|
|
41
|
+
// omitido. La tabla sombra `memory_fts_docsize` solo existe si FTS5 se creo
|
|
42
|
+
// con el default `columnsize=1`.
|
|
43
|
+
const hasFts = db.prepare(`SELECT count(*) AS n FROM sqlite_master WHERE type = 'table' AND name = 'memory_fts'`).get().n > 0
|
|
44
|
+
|
|
13
45
|
const quick = db.prepare('PRAGMA quick_check').get()
|
|
14
46
|
const integrityOk = quick && quick.quick_check === 'ok'
|
|
15
47
|
checks.push({
|
|
@@ -27,19 +59,45 @@ export function memoryDoctor(dbPath, { rebuild = false } = {}) {
|
|
|
27
59
|
}
|
|
28
60
|
}
|
|
29
61
|
|
|
30
|
-
let fts
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
62
|
+
let fts
|
|
63
|
+
if (!hasFts) {
|
|
64
|
+
// Estado 1: el indice no existe. Aunque `memory_nodes` exista, la busqueda
|
|
65
|
+
// esta rota; se reporta como falla (nunca `skipped`) y baja `healthy`.
|
|
66
|
+
fts = { ok: false, detail: 'indice FTS5 ausente; la busqueda no funciona' }
|
|
67
|
+
} else if (rebuild) {
|
|
68
|
+
try {
|
|
69
|
+
db.exec(`INSERT INTO memory_fts(memory_fts) VALUES('rebuild')`)
|
|
70
|
+
rebuilt = true
|
|
71
|
+
const after = runIntegrity()
|
|
72
|
+
fts = { ok: after.ok, detail: after.ok ? 'indice reconstruido y consistente' : `reconstruccion sin exito: ${after.detail}` }
|
|
73
|
+
} catch (err) {
|
|
74
|
+
fts = { ok: false, detail: `reconstruccion fallo: ${err.message}` }
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
// Chequeo no mutante de alineacion. `count(*)` sobre `memory_fts` (external
|
|
78
|
+
// content) NO sirve: FTS5 lo resuelve contra `memory_nodes`, asi que un nodo
|
|
79
|
+
// sin indexar igual se cuenta. La tabla sombra `%_docsize` (default
|
|
80
|
+
// columnsize=1) tiene una fila por documento efectivamente indexado, y su
|
|
81
|
+
// conteo equivale a "filas indexadas". El integrity-check completo (INSERT
|
|
82
|
+
// de control) escribe y queda solo para `--rebuild`.
|
|
83
|
+
try {
|
|
84
|
+
const indexed = db.prepare('SELECT count(*) AS n FROM memory_fts_docsize').get().n
|
|
85
|
+
const total = db.prepare('SELECT count(*) AS n FROM memory_nodes').get().n
|
|
86
|
+
fts = indexed === total
|
|
87
|
+
? { ok: true, detail: `${indexed} filas indexadas, coincide con memory_nodes (${total} nodos); integridad completa no verificable en read-only` }
|
|
88
|
+
: { ok: false, detail: `${indexed} filas indexadas vs ${total} nodos; indice desalineado, usar --rebuild` }
|
|
89
|
+
} catch (err) {
|
|
90
|
+
// No poder verificar no es una falla (D2): p. ej. un FTS5 creado con
|
|
91
|
+
// `columnsize=0` no materializa la tabla sombra `memory_fts_docsize`.
|
|
92
|
+
// El check se reporta omitido y no baja `healthy` ni el exit code.
|
|
93
|
+
fts = {
|
|
94
|
+
ok: true,
|
|
95
|
+
skipped: true,
|
|
96
|
+
detail: `omitido: alineacion de FTS no verificable (${err.message}); integridad completa no verificable en read-only`
|
|
97
|
+
}
|
|
98
|
+
}
|
|
37
99
|
}
|
|
38
|
-
checks.push({
|
|
39
|
-
name: rebuilt ? 'Indice FTS5 (post-rebuild)' : 'Indice FTS5',
|
|
40
|
-
ok: fts.ok,
|
|
41
|
-
detail: fts.detail
|
|
42
|
-
})
|
|
100
|
+
checks.push({ name: 'Indice FTS5', ok: fts.ok, ...(fts.skipped ? { skipped: true } : {}), detail: fts.detail })
|
|
43
101
|
|
|
44
102
|
const dups = db.prepare(`SELECT memory_key, COUNT(*) AS n FROM memory_nodes
|
|
45
103
|
WHERE status = 'active' GROUP BY memory_key HAVING n > 1`).all()
|
|
@@ -49,13 +107,21 @@ export function memoryDoctor(dbPath, { rebuild = false } = {}) {
|
|
|
49
107
|
detail: dups.length === 0 ? 'una sola activa por memory_key' : dups.map((d) => `${d.memory_key} x${d.n}`).join(', ')
|
|
50
108
|
})
|
|
51
109
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
110
|
+
if (rebuild) {
|
|
111
|
+
const checkpointed = checkpointDatabase(db)
|
|
112
|
+
checks.push({
|
|
113
|
+
name: 'Checkpoint WAL',
|
|
114
|
+
ok: checkpointed,
|
|
115
|
+
detail: checkpointed ? 'WAL fusionado en el .db (seguro para copiar)' : 'no se pudo checkpointear (DB en uso por otro proceso)'
|
|
116
|
+
})
|
|
117
|
+
} else {
|
|
118
|
+
checks.push({
|
|
119
|
+
name: 'Checkpoint WAL',
|
|
120
|
+
ok: true,
|
|
121
|
+
skipped: true,
|
|
122
|
+
detail: `omitido: no verificable en modo read-only; ${observeWal(dbPath)}`
|
|
123
|
+
})
|
|
124
|
+
}
|
|
59
125
|
|
|
60
126
|
return { checks, healthy: checks.every((c) => c.ok), rebuilt }
|
|
61
127
|
} finally {
|
|
@@ -91,13 +91,28 @@ export function createMemoryEngine(dbPath = defaultMemoryDbPath()) {
|
|
|
91
91
|
// Apertura read-only: sin migraciones ni escrituras. Pensada para inspeccionar
|
|
92
92
|
// la memoria sin tocar el WAL ni modificar archivos.
|
|
93
93
|
export function createReadonlyMemoryEngine(dbPath = defaultMemoryDbPath()) {
|
|
94
|
-
return
|
|
94
|
+
return buildReadonlyEngine(openDatabase(dbPath, { migrate: false, readonly: true }))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Misma superficie que el engine de escritura. `merge` solo puede planificar:
|
|
98
|
+
// una corrida real se rechaza antes de tocar la conexion read-only, y el plan
|
|
99
|
+
// sale de la validacion compartida (`validateMerge`), identico al de escritura.
|
|
100
|
+
function buildReadonlyEngine(db) {
|
|
101
|
+
const engine = buildEngine(db)
|
|
102
|
+
return {
|
|
103
|
+
...engine,
|
|
104
|
+
merge({ target, sources, dryRun = false } = {}) {
|
|
105
|
+
if (!dryRun) throw new Error('merge: la conexion es read-only')
|
|
106
|
+
return engine.merge({ target, sources, dryRun: true })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
95
109
|
}
|
|
96
110
|
|
|
97
111
|
function buildEngine(db) {
|
|
98
112
|
|
|
99
|
-
const findActive = db.prepare(`SELECT id FROM memory_nodes WHERE memory_key = ? AND status = 'active'`)
|
|
113
|
+
const findActive = db.prepare(`SELECT id, type FROM memory_nodes WHERE memory_key = ? AND status = 'active'`)
|
|
100
114
|
const findActiveRow = db.prepare(`SELECT id, created_at FROM memory_nodes WHERE memory_key = ? AND status = 'active'`)
|
|
115
|
+
const findLatestStatus = db.prepare(`SELECT status FROM memory_nodes WHERE memory_key = ? ORDER BY rowid DESC LIMIT 1`)
|
|
101
116
|
const countSuperseded = db.prepare(`SELECT COUNT(*) AS c FROM memory_nodes WHERE memory_key = ? AND status = 'superseded'`)
|
|
102
117
|
const supersede = db.prepare(`UPDATE memory_nodes SET status = 'superseded', superseded_by = ? WHERE id = ? AND status = 'active'`)
|
|
103
118
|
const insertNode = db.prepare(
|
|
@@ -372,9 +387,82 @@ function buildEngine(db) {
|
|
|
372
387
|
return checkpointDatabase(db)
|
|
373
388
|
}
|
|
374
389
|
|
|
390
|
+
// Validacion compartida por el engine de escritura y el read-only: deja el
|
|
391
|
+
// plan listo y falla cerrado (target activo, sources activas, sin repetidas y
|
|
392
|
+
// con `type` identico) antes de abrir cualquier transaccion.
|
|
393
|
+
function validateMerge({ target, sources } = {}) {
|
|
394
|
+
const targetKey = String(target || '').trim()
|
|
395
|
+
if (!targetKey) throw new Error('merge: target es obligatorio')
|
|
396
|
+
if (!Array.isArray(sources) || sources.length === 0) throw new Error('merge: sources debe ser una lista no vacia')
|
|
397
|
+
|
|
398
|
+
const sourceKeys = sources.map((s) => String(s || '').trim())
|
|
399
|
+
const seen = new Set()
|
|
400
|
+
for (const key of sourceKeys) {
|
|
401
|
+
if (!key) throw new Error('merge: memory_key vacia en sources')
|
|
402
|
+
if (seen.has(key)) throw new Error(`merge: source repetida: ${key}`)
|
|
403
|
+
seen.add(key)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const targetRow = findActive.get(targetKey)
|
|
407
|
+
if (!targetRow) throw new Error(`merge: target "${targetKey}" no es un nodo activo existente`)
|
|
408
|
+
|
|
409
|
+
const sourceIds = []
|
|
410
|
+
for (const key of sourceKeys) {
|
|
411
|
+
if (key === targetKey) throw new Error(`merge: source "${key}" es igual al target`)
|
|
412
|
+
const row = findActive.get(key)
|
|
413
|
+
if (!row) {
|
|
414
|
+
const latest = findLatestStatus.get(key)
|
|
415
|
+
if (latest) throw new Error(`merge: source "${key}" no esta activa (status: ${latest.status})`)
|
|
416
|
+
throw new Error(`merge: source "${key}" no existe`)
|
|
417
|
+
}
|
|
418
|
+
if (row.type !== targetRow.type) {
|
|
419
|
+
throw new Error(`merge: type incompatible entre target "${targetKey}" (${targetRow.type}) y source "${key}" (${row.type})`)
|
|
420
|
+
}
|
|
421
|
+
sourceIds.push(row.id)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return { targetKey, sourceKeys, sourceIds }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Consolida duplicados supersediendo cada source activa hacia un target activo,
|
|
428
|
+
// sin insertar nodos ni aceptar contenido. Valida todo antes de escribir y usa
|
|
429
|
+
// una unica transaccion: cualquier violacion deja la base intacta. `dryRun` no
|
|
430
|
+
// abre transaccion ni escribe.
|
|
431
|
+
function merge({ target, sources, dryRun = false } = {}) {
|
|
432
|
+
const { targetKey, sourceKeys, sourceIds } = validateMerge({ target, sources })
|
|
433
|
+
const plan = { target: targetKey, sources: sourceKeys, superseded: sourceIds.length }
|
|
434
|
+
if (dryRun) return { dryRun: true, ...plan }
|
|
435
|
+
|
|
436
|
+
db.exec('BEGIN IMMEDIATE')
|
|
437
|
+
let superseded = 0
|
|
438
|
+
try {
|
|
439
|
+
// Revalidacion bajo lock: el target pudo quedar superseded entre la
|
|
440
|
+
// validacion previa y la transaccion, y las sources quedarian enlazadas a
|
|
441
|
+
// un nodo inactivo.
|
|
442
|
+
const lockedTarget = findActive.get(targetKey)
|
|
443
|
+
if (!lockedTarget) {
|
|
444
|
+
throw new Error(`merge: el target "${targetKey}" dejo de estar activo durante la transaccion`)
|
|
445
|
+
}
|
|
446
|
+
for (const id of sourceIds) {
|
|
447
|
+
const result = supersede.run(lockedTarget.id, id)
|
|
448
|
+
superseded += Number(result.changes || 0)
|
|
449
|
+
}
|
|
450
|
+
// Si una source dejo de estar activa entre la validacion y el UPDATE,
|
|
451
|
+
// `changes` es 0: se aborta y se revierte todo.
|
|
452
|
+
if (superseded !== sourceIds.length) {
|
|
453
|
+
throw new Error('merge: una source dejo de estar activa durante la transaccion')
|
|
454
|
+
}
|
|
455
|
+
db.exec('COMMIT')
|
|
456
|
+
} catch (err) {
|
|
457
|
+
db.exec('ROLLBACK')
|
|
458
|
+
throw err
|
|
459
|
+
}
|
|
460
|
+
return { dryRun: false, ...plan }
|
|
461
|
+
}
|
|
462
|
+
|
|
375
463
|
function close() {
|
|
376
464
|
db.close()
|
|
377
465
|
}
|
|
378
466
|
|
|
379
|
-
return { buildWorkingContext, searchMemory, recordNode, listNodes, exportActive, importNodes, gcSuperseded, checkpoint, close }
|
|
467
|
+
return { buildWorkingContext, searchMemory, recordNode, listNodes, exportActive, importNodes, gcSuperseded, merge, checkpoint, close }
|
|
380
468
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, realpathSync, renameSync, rmSync, statSync } from 'node:fs'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
5
|
+
|
|
6
|
+
// Copia consistente de la base de memoria con historial (incluye superseded y
|
|
7
|
+
// superseded_by, a diferencia de `memory export`). No usa `openDatabase`: ese
|
|
8
|
+
// helper fija `PRAGMA journal_mode=WAL` y mutaria la fuente. Se abre una conexion
|
|
9
|
+
// directa read-only, sin pragmas ni migraciones: `VACUUM INTO` lee el estado
|
|
10
|
+
// commiteado (incluido el WAL) sin escribir la fuente.
|
|
11
|
+
// Identidad de archivo, no identidad de string: `path.resolve()` no normaliza
|
|
12
|
+
// mayusculas en Windows ni resuelve symlinks/junctions. Para una ruta que todavia
|
|
13
|
+
// no existe se resuelve el ancestro existente mas cercano y se recompone el resto.
|
|
14
|
+
function realpathish(p) {
|
|
15
|
+
const abs = resolve(p)
|
|
16
|
+
if (existsSync(abs)) return realpathSync.native(abs)
|
|
17
|
+
const tail = []
|
|
18
|
+
let cur = abs
|
|
19
|
+
for (;;) {
|
|
20
|
+
const parent = dirname(cur)
|
|
21
|
+
if (parent === cur) return abs
|
|
22
|
+
if (existsSync(parent)) {
|
|
23
|
+
return join(realpathSync.native(parent), basename(cur), ...tail.reverse())
|
|
24
|
+
}
|
|
25
|
+
tail.push(basename(cur))
|
|
26
|
+
cur = parent
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sameFile(a, b) {
|
|
31
|
+
let ca = realpathish(a)
|
|
32
|
+
let cb = realpathish(b)
|
|
33
|
+
if (process.platform === 'win32') {
|
|
34
|
+
ca = ca.toLowerCase()
|
|
35
|
+
cb = cb.toLowerCase()
|
|
36
|
+
}
|
|
37
|
+
return ca === cb
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function vacuumInto(sourcePath, targetPath) {
|
|
41
|
+
const db = new DatabaseSync(sourcePath, { readOnly: true })
|
|
42
|
+
try {
|
|
43
|
+
// `VACUUM INTO` acepta una expresion SQL: el destino viaja como parametro
|
|
44
|
+
// vinculado, sin interpolar la ruta en el texto de la sentencia.
|
|
45
|
+
db.prepare('VACUUM INTO ?').run(targetPath)
|
|
46
|
+
} catch (err) {
|
|
47
|
+
throw new Error(`snapshot: VACUUM INTO fallo sobre la fuente read-only: ${err.message}`)
|
|
48
|
+
} finally {
|
|
49
|
+
db.close()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function verifySnapshot(targetPath) {
|
|
54
|
+
const check = new DatabaseSync(targetPath, { readOnly: true })
|
|
55
|
+
try {
|
|
56
|
+
const row = check.prepare('PRAGMA quick_check').get()
|
|
57
|
+
if (!row || row.quick_check !== 'ok') {
|
|
58
|
+
throw new Error(`snapshot: la verificacion quick_check fallo para ${targetPath}`)
|
|
59
|
+
}
|
|
60
|
+
} finally {
|
|
61
|
+
check.close()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Reemplaza el destino recien despues de tener una copia verificada en el
|
|
66
|
+
// temporal (mismo directorio = mismo volumen). En Windows `rename` no reemplaza
|
|
67
|
+
// un destino existente: se aparta el viejo a un backup, se mueve la copia nueva
|
|
68
|
+
// y solo entonces se borra el backup. Si el movimiento final falla, se restaura
|
|
69
|
+
// el backup: en ningun camino de error se pierden a la vez el destino previo y
|
|
70
|
+
// la copia nueva.
|
|
71
|
+
function replaceDestination(tempPath, targetPath) {
|
|
72
|
+
if (!existsSync(targetPath)) {
|
|
73
|
+
renameSync(tempPath, targetPath)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
const backup = `${targetPath}.bak-${randomUUID()}`
|
|
77
|
+
renameSync(targetPath, backup)
|
|
78
|
+
try {
|
|
79
|
+
renameSync(tempPath, targetPath)
|
|
80
|
+
} catch (err) {
|
|
81
|
+
try {
|
|
82
|
+
renameSync(backup, targetPath)
|
|
83
|
+
} catch {
|
|
84
|
+
// restauracion best-effort: se propaga el error original
|
|
85
|
+
}
|
|
86
|
+
throw err
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
rmSync(backup, { force: true })
|
|
90
|
+
} catch {
|
|
91
|
+
// el snapshot ya quedo en su lugar: un .bak remanente no invalida la copia
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function snapshotDatabase(dbPath, outPath, { overwrite = false } = {}) {
|
|
96
|
+
if (!dbPath || !outPath) throw new Error('snapshot: se requieren la base fuente y la ruta destino')
|
|
97
|
+
const source = resolve(dbPath)
|
|
98
|
+
const out = resolve(outPath)
|
|
99
|
+
if (sameFile(source, out)) {
|
|
100
|
+
throw new Error(`snapshot: el destino no puede ser la fuente (${out})`)
|
|
101
|
+
}
|
|
102
|
+
if (!existsSync(source)) {
|
|
103
|
+
throw new Error(`snapshot: no existe la base fuente (${source})`)
|
|
104
|
+
}
|
|
105
|
+
if (existsSync(out) && !overwrite) {
|
|
106
|
+
throw new Error(`snapshot: el destino ya existe (${out}); usar overwrite/--force para reemplazarlo`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
mkdirSync(dirname(out), { recursive: true })
|
|
110
|
+
const temp = join(dirname(out), `.${basename(out)}.${process.pid}-${randomUUID()}.tmp`)
|
|
111
|
+
try {
|
|
112
|
+
vacuumInto(source, temp)
|
|
113
|
+
verifySnapshot(temp)
|
|
114
|
+
replaceDestination(temp, out)
|
|
115
|
+
return { out, bytes: statSync(out).size }
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// La limpieza del temporal nunca debe reemplazar el error original. SQLite
|
|
118
|
+
// puede haber creado sidecars para el archivo temporal: se borran los tres.
|
|
119
|
+
try {
|
|
120
|
+
for (const file of [temp, `${temp}-wal`, `${temp}-shm`]) rmSync(file, { force: true })
|
|
121
|
+
} catch {
|
|
122
|
+
// best effort: el temporal queda huerfano pero el error original se propaga
|
|
123
|
+
}
|
|
124
|
+
throw err
|
|
125
|
+
}
|
|
126
|
+
}
|