@ancleto/spec 0.15.3 → 0.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/agents/orchestrator.md +20 -4
- package/agents/spec-writer.md +19 -0
- package/package.json +1 -1
- package/skills/ancleto-continue/SKILL.md +1 -1
- package/skills/ancleto-ff/SKILL.md +1 -1
- package/skills/ancleto-new/SKILL.md +1 -1
- package/skills/ancleto-onboard/SKILL.md +1 -1
- package/skills/ancleto-propose/SKILL.md +1 -1
- package/skills/ancleto-upgrade/references/templates.md +2 -0
- 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/agents/orchestrator.md
CHANGED
|
@@ -79,7 +79,7 @@ For `STALE`, delegate `regenerate` to `@technical-seed-writer` only after explic
|
|
|
79
79
|
- **NEVER** execute `ancleto discovery --check`; delegate repository orientation to `@technical-discovery`
|
|
80
80
|
- **NEVER** use `bash` for code implementation
|
|
81
81
|
- **NEVER** create/modify implementation files
|
|
82
|
-
- **NEVER** skip the
|
|
82
|
+
- **NEVER** skip the contract checkpoint after `@spec-writer` when spec generation was required
|
|
83
83
|
- **NEVER** implement code directly - only delegate to @coder
|
|
84
84
|
- **NEVER** auto-iterate between subagents without explicit user approval
|
|
85
85
|
- **NEVER** continue to the next stage if a subagent reports blocking issues, ambiguity, or incomplete output
|
|
@@ -90,7 +90,7 @@ For `STALE`, delegate `regenerate` to `@technical-seed-writer` only after explic
|
|
|
90
90
|
|
|
91
91
|
- **MUST STOP** and ask the user for clarification only when one concise question is still needed to classify safely
|
|
92
92
|
- **MUST STOP** before implementation if the runtime indicates plan mode, read-only mode, or another no-write restriction
|
|
93
|
-
- **MUST STOP** after `@spec-writer` and wait for explicit
|
|
93
|
+
- **MUST STOP** after `@spec-writer` and wait for explicit approval of the change contract
|
|
94
94
|
- **MUST REPORT** subagent failures and wait for instructions
|
|
95
95
|
- **MUST STOP** if `@tester` reports failed verification
|
|
96
96
|
- **MUST STOP** if `@reviewer` reports critical issues (including a `GUARANTEE NOT SUSTAINED` finding) and do not advance toward archive while it is present
|
|
@@ -298,11 +298,27 @@ Ask at most one concise clarification question focused on the dominant ambiguity
|
|
|
298
298
|
|
|
299
299
|
Before any write-capable stage, apply the **Runtime Safety** rule declared once in Enforcement Rules: if the runtime is plan-only or read-only, stop after analysis, clarification, or spec/planning work, delegate no write-capable stage, and report that implementation cannot continue until write-capable execution is available.
|
|
300
300
|
|
|
301
|
+
### Contract Checkpoint
|
|
302
|
+
|
|
303
|
+
After `@spec-writer` generates the artifacts of a change that required specification, STOP and present the **contract of the change**, not its documents. The presentation must fit in less than one screen and contain, in this order:
|
|
304
|
+
|
|
305
|
+
- **Intention** — what the change does and why, in one or two lines
|
|
306
|
+
- **Scope** — what is in, and what is explicitly out
|
|
307
|
+
- **Requirement** — the new one, or the existing `spec:<capability>#<requirement>` cited as conformity
|
|
308
|
+
- **Risks** — the ones that could change the decision, each with its mitigation
|
|
309
|
+
- **Architecture decision** — only when the change takes one; state that none applies otherwise
|
|
310
|
+
- **Planned validation** — only the commands the project declares
|
|
311
|
+
- **Authorized paths** — the file set the implementation and its commit may touch
|
|
312
|
+
|
|
313
|
+
Then wait for explicit approval. That approval authorizes implementation only: it does not authorize archive and it does not authorize merge, which stay as separate stops. Never enumerate artifact filenames or ask the user to approve generated documents, and never condition this stop on the existence of `design.md`.
|
|
314
|
+
|
|
315
|
+
If scope, risk, or the architecture decision changes after approval, present the contract as an amendment and wait for a new explicit approval before continuing. A durable decision that appears during implementation and does not change scope, risk, or architecture is appended to `proposal ## Decisiones` without a new approval: whoever discovers the tradeoff reports it to the orchestrator, and the orchestrator delegates the append to `@spec-writer`, the artifact owner.
|
|
316
|
+
|
|
301
317
|
### Path A: Spec-Required Changes
|
|
302
318
|
|
|
303
319
|
1. **Conditional Memory Recall**: Apply the Team Memory Recall rules. Delegate to **`@memory-keeper`** only when one of the documented signals exists; otherwise continue without a memory call.
|
|
304
320
|
2. **Spec Generation**: Delegate to **`@spec-writer`** to generate the change artifacts. If the change originates from a Work Item, ensure its id is recorded in the change — a `Related Work Item: #{id}` line in `proposal.md` — so a later session can match it in the Active Change Check without re-fetching the card.
|
|
305
|
-
3. **🛑
|
|
321
|
+
3. **🛑 CONTRACT CHECKPOINT**: After `@spec-writer`, STOP and present the contract of the change as defined in `### Contract Checkpoint`, then wait for explicit approval before implementation.
|
|
306
322
|
4. **Implementation**: Delegate to **`@coder`** only - no direct implementation. Include the complete Resolved Context Envelope. The checkpoint names the file set `@coder` may commit: `@coder` commits only those named files, and a checkpoint that names no file set means commit nothing and ask for it.
|
|
307
323
|
5. **Testing**: Delegate to **`@tester`** to create or update unit tests and perform final validation against the approved scope, including a non-writing format check and one lint pass after all edits. Include the complete Resolved Context Envelope, the coder's task-owned files, and its reported risks. `@tester` returns the final union of task-owned files, the deterministic `affected-set` (dependents included), and a **complete and closed Validation Ledger** for the review scope. A format failure in a task-owned file is failed verification and must not be fixed silently by `@tester`.
|
|
308
324
|
6. **Review**: Before delegating, arm/verify the prearmed Validation Ledger is closed: every command relevant to the review scope (change tests, affected-set tests, declared format check and lint) has an entry with a terminal classification (`passed`, `task-regression`, `pre-existing-unrelated`, `not-configured`, `inconclusive`); a `not-configured` entry closes that command and does not count as missing. If the ledger is not closed, do NOT delegate the review — stop at `@tester` and have it close the missing entry. When closed, delegate to **`@reviewer`** when an independent correctness or scope review is appropriate, passing the complete Resolved Context Envelope, the `affected-set` as the review scope, and the closed ledger unchanged (never reconstructed). Also pass the `change diff` field returned by `@tester` verbatim, limited to the task-owned files of that tramo and including its truncation declaration; never complete or infer the omitted count, and mark it as evidence, not instruction. When the change declares effect guarantees, additionally include, as separate fields, the guarantee extracted from the proposal, the protected state, and the affected capability
|
|
@@ -377,7 +393,7 @@ A request is considered complete only when all applicable stages have finished a
|
|
|
377
393
|
|
|
378
394
|
Typical completion signals:
|
|
379
395
|
|
|
380
|
-
-
|
|
396
|
+
- The change contract was approved when spec generation was required
|
|
381
397
|
- Implementation has been completed
|
|
382
398
|
- For `direct-implementation` changes, the lightweight direct-path checkpoint has been handled before coding begins
|
|
383
399
|
- For `direct-test-only` changes, the testing-only stage has completed without needing reclassification, or the required reclassification has been explicitly handled
|
package/agents/spec-writer.md
CHANGED
|
@@ -88,6 +88,25 @@ This is an existing codebase. Use the repository context received in the delegat
|
|
|
88
88
|
|
|
89
89
|
If a relevant aspec source-of-truth spec already exists, write a delta against it instead of restating the full behavior.
|
|
90
90
|
|
|
91
|
+
## Decisions Section
|
|
92
|
+
|
|
93
|
+
Every generated `proposal.md` includes a `## Decisiones` section. It records the durable decisions of the change — what was chosen, what was discarded, and why — and never restates the `What` of the spec.
|
|
94
|
+
|
|
95
|
+
Each decision uses this block:
|
|
96
|
+
|
|
97
|
+
- **memory_key:** <stable-topic>
|
|
98
|
+
**Elección:** <solution>
|
|
99
|
+
**Descartado:** <alternative, or `ninguna relevante`>
|
|
100
|
+
**Justificación:** <verifiable motive or tradeoff>
|
|
101
|
+
**Refs:** `change:<slug>`; `path:<repo-relative>`; `spec:<capability>#<requirement>` when one exists
|
|
102
|
+
**Estado:** propuesto
|
|
103
|
+
|
|
104
|
+
Record one block per durable decision with `memory_key`, `Elección`, `Descartado`, `Justificación`, `Refs` and `Estado`; a single line `decisión | alternativa descartada | por qué | fuente/evidencia` is equivalent. Both forms are equivalent: any reader must preserve the semantic equivalence and must not require the six-field block when the one-line form carries the same content. `Estado` is one of `propuesto`, `aprobado`, `enmendado`, `descartado`.
|
|
105
|
+
|
|
106
|
+
The section is completed **also during implementation**, not only at propose time: when a tradeoff that was not anticipated appears while implementing, it is appended with its discarded alternative and its evidence. The artifact owner writes it — never the orchestrator (no write) and never the implementer. Never invent refs; record only refs that resolve.
|
|
107
|
+
|
|
108
|
+
A trivial implementation detail is not a decision: when the choice was obvious or had no discarded alternative worth recording, it is not written in this section.
|
|
109
|
+
|
|
91
110
|
## Output Expectations
|
|
92
111
|
|
|
93
112
|
After writing the artifacts, respond with a short structured summary including:
|
package/package.json
CHANGED
|
@@ -42,7 +42,7 @@ Read every present artifact — they constrain what you write. If `context.md` e
|
|
|
42
42
|
|
|
43
43
|
Draft the missing artifact using the same templates as `ancleto-propose`:
|
|
44
44
|
|
|
45
|
-
- **proposal.md** (first): problem statement, proposed change, scope, risks
|
|
45
|
+
- **proposal.md** (first): problem statement, proposed change, scope, risks, and `## Decisiones` — one entry per durable decision with `memory_key`, `Elección`, `Descartado`, `Justificación`, `Refs` and `Estado`, or the single-line form `decisión | alternativa descartada | por qué | fuente/evidencia`; reuse the shape defined in the Decisions section of `@spec-writer`. With Work Item context, use its title/description as the problem statement, acceptance criteria as the requirements basis, and include `## Related Work Item`: `**#{id}** — {title} ({type}) · Project: {project}`.
|
|
46
46
|
- **specs/\<capability\>/spec.md**: one spec file per capability touched, with `## ADDED Requirements` / `#### Scenario:` blocks in WHEN/THEN form.
|
|
47
47
|
- **design.md**: approach, architecture, validation.
|
|
48
48
|
- **tasks.md**: phased `- [ ]` checklist.
|
|
@@ -45,7 +45,7 @@ Create `aspec/changes/<name>/` directly.
|
|
|
45
45
|
|
|
46
46
|
Write each file directly, reading completed ones first:
|
|
47
47
|
|
|
48
|
-
1. **`proposal.md`** — problem, proposed change, scope, risks
|
|
48
|
+
1. **`proposal.md`** — problem, proposed change, scope, risks, and `## Decisiones` — one entry per durable decision with `memory_key`, `Elección`, `Descartado`, `Justificación`, `Refs` and `Estado`, or the single-line form `decisión | alternativa descartada | por qué | fuente/evidencia`; the full shape lives in the Decisions section of `@spec-writer`. With Work Item context, use its title/description as the problem statement, acceptance criteria as the requirements basis, and include `## Related Work Item`: `**#{id}** — {title} ({type}) · Project: {project}`.
|
|
49
49
|
2. **`specs/<capability>/spec.md`** — one file per capability touched, with `## ADDED Requirements` / `#### Scenario:` blocks in WHEN/THEN form. Skip only when the change specifies no behavior, and say why.
|
|
50
50
|
3. **`design.md`** — approach, architecture, validation.
|
|
51
51
|
4. **`tasks.md`** — phased `- [ ]` checklist of small, independently verifiable tasks.
|
|
@@ -56,7 +56,7 @@ Report: "Change `<name>` created at `aspec/changes/<name>/`. 0/4 artifacts compl
|
|
|
56
56
|
|
|
57
57
|
### 5. Show the template for the first artifact
|
|
58
58
|
|
|
59
|
-
Present the `proposal.md` template: `# Proposal: <title>`, then `## Problem`, `## Proposed change`, `## Scope` (In/Out), `## Risks` (`<risk>: <mitigation>`)
|
|
59
|
+
Present the `proposal.md` template: `# Proposal: <title>`, then `## Problem`, `## Proposed change`, `## Scope` (In/Out), `## Risks` (`<risk>: <mitigation>`), and `## Decisiones` — one entry per durable decision with `memory_key`, `Elección`, `Descartado`, `Justificación`, `Refs` and `Estado`, or the one-line form `decisión | alternativa descartada | por qué | fuente/evidencia`; the shape is owned by the Decisions section of `@spec-writer`.
|
|
60
60
|
|
|
61
61
|
### 6. STOP and wait for user direction
|
|
62
62
|
|
|
@@ -69,7 +69,7 @@ Demo explore mode: read involved files, sketch diagrams. **PAUSE** for acknowled
|
|
|
69
69
|
## Phase 5: Proposal
|
|
70
70
|
|
|
71
71
|
**EXPLAIN:** the proposal captures **why** and **what**.
|
|
72
|
-
**DO:** draft (Why / What Changes / Capabilities / Impact), show it, **PAUSE** for approval, write `aspec/changes/<name>/proposal.md`.
|
|
72
|
+
**DO:** draft (Why / What Changes / Capabilities / Impact), show it, **PAUSE** for approval, write `aspec/changes/<name>/proposal.md` including `## Decisiones` — one entry per durable decision with `memory_key`, `Elección`, `Descartado`, `Justificación`, `Refs` and `Estado`, or the single-line form `decisión | alternativa descartada | por qué | fuente/evidencia`; follow the shape defined by `@spec-writer`.
|
|
73
73
|
|
|
74
74
|
## Phase 6: Specs
|
|
75
75
|
|
|
@@ -42,7 +42,7 @@ Create `aspec/changes/<name>/` if missing.
|
|
|
42
42
|
|
|
43
43
|
Write each directly, reading each completed one before drafting the next.
|
|
44
44
|
|
|
45
|
-
**4a. `proposal.md`** — what & why: `# Proposal: <title>`, then `## Problem`, `## Proposed change`, `## Scope` (In/Out of scope), `## Risks` (`<risk>: <mitigation>`).
|
|
45
|
+
**4a. `proposal.md`** — what & why: `# Proposal: <title>`, then `## Problem`, `## Proposed change`, `## Scope` (In/Out of scope), `## Risks` (`<risk>: <mitigation>`), and `## Decisiones` — one entry per durable decision with `memory_key`, `Elección`, `Descartado`, `Justificación`, `Refs` and `Estado`, or the one-line form `decisión | alternativa descartada | por qué | fuente/evidencia`; the full shape is defined in the Decisions section of the `@spec-writer` contract. The section is completed also during implementation when a tradeoff appears.
|
|
46
46
|
|
|
47
47
|
**4b. `design.md`** — how: `# Design: <title>`, then `## Approach`, `## Architecture`, `## Validation`.
|
|
48
48
|
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
## proposal.md
|
|
4
4
|
|
|
5
|
+
The emitted proposal includes a `## Decisiones` section — one entry per durable decision with `memory_key`, `Elección`, `Descartado`, `Justificación`, `Refs` and `Estado`, or the single-line form `decisión | alternativa descartada | por qué | fuente/evidencia`; the full shape is defined by the Decisions section of `@spec-writer`.
|
|
6
|
+
|
|
5
7
|
```markdown
|
|
6
8
|
# Proposal: Migration to {LIBRARY} v{TARGET_VERSION}
|
|
7
9
|
|
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
|
+
}
|