@openparachute/vault 0.7.9-rc.7 → 0.7.9-rc.9
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/core/src/compact-state-rebuild.test.ts +46 -0
- package/core/src/compact-state.test.ts +129 -0
- package/core/src/compact.test.ts +3 -3
- package/core/src/doctor.ts +74 -2
- package/core/src/history-audit.test.ts +51 -0
- package/core/src/history-capture.test.ts +9 -1
- package/core/src/history-compact-state.ts +52 -0
- package/core/src/history-import.ts +3 -1
- package/core/src/history-visibility.test.ts +11 -0
- package/core/src/history-visibility.ts +9 -0
- package/core/src/history.ts +46 -19
- package/core/src/mcp-manifest.ts +6 -1
- package/core/src/mcp.ts +6 -2
- package/core/src/note-id-shape.test.ts +9 -0
- package/core/src/notes.ts +3 -0
- package/core/src/schema-v29-note-versions.test.ts +1 -1
- package/core/src/schema-v30-note-deltas.test.ts +1 -1
- package/core/src/schema-v31-history-import.test.ts +1 -1
- package/core/src/schema.ts +12 -1
- package/core/src/store.ts +18 -2
- package/core/src/ulid.ts +7 -0
- package/package.json +1 -1
- package/src/cli.ts +29 -0
- package/src/history-import-routes.test.ts +23 -1
- package/src/history-import.test.ts +121 -2
- package/src/history-import.ts +10 -7
- package/src/history-rebuild-cli.test.ts +38 -0
- package/src/mcp-tools.ts +9 -2
- package/src/routes.ts +19 -7
- package/src/tag-integrity-mcp.test.ts +18 -0
- package/src/vault-compact-routes.test.ts +1 -1
- package/src/vault-history-routes.test.ts +65 -1
package/core/src/history.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { Database } from "bun:sqlite";
|
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
15
|
import { encodeDelta, decodeDelta } from "./delta.js";
|
|
16
16
|
import { transaction } from "./txn.js";
|
|
17
|
+
import { refreshCompactState } from "./history-compact-state.js";
|
|
17
18
|
|
|
18
19
|
export type HistoryOp =
|
|
19
20
|
| "import"
|
|
@@ -119,6 +120,14 @@ export class HistoryUnrecoverableError extends Error {
|
|
|
119
120
|
this.name = "HistoryUnrecoverableError";
|
|
120
121
|
}
|
|
121
122
|
}
|
|
123
|
+
export class HistoryPreconditionRequiredError extends Error {
|
|
124
|
+
readonly code = "PRECONDITION_REQUIRED";
|
|
125
|
+
readonly error_type = "precondition_required";
|
|
126
|
+
constructor(readonly note_id: string) {
|
|
127
|
+
super("Restoring an existing note requires if_updated_at from the note you reviewed.");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
122
131
|
export class HistoryNotFoundError extends Error {
|
|
123
132
|
code = "HISTORY_NOT_FOUND" as const;
|
|
124
133
|
note_id: string;
|
|
@@ -204,7 +213,7 @@ export function captureVersion(
|
|
|
204
213
|
size,
|
|
205
214
|
prior.created_at,
|
|
206
215
|
);
|
|
207
|
-
pruneVersions(db, prior.id, opts.policy);
|
|
216
|
+
pruneVersions(db, prior.id, opts.policy, Date.now(), false);
|
|
208
217
|
return;
|
|
209
218
|
}
|
|
210
219
|
const hash = hashContent(text);
|
|
@@ -229,7 +238,7 @@ export function captureVersion(
|
|
|
229
238
|
size,
|
|
230
239
|
prior.created_at,
|
|
231
240
|
);
|
|
232
|
-
pruneVersions(db, prior.id, opts.policy);
|
|
241
|
+
pruneVersions(db, prior.id, opts.policy, Date.now(), false);
|
|
233
242
|
}
|
|
234
243
|
export function appendRestoreMarker(
|
|
235
244
|
db: Database,
|
|
@@ -253,13 +262,14 @@ export function appendRestoreMarker(
|
|
|
253
262
|
noteId,
|
|
254
263
|
tombstone.version_ix,
|
|
255
264
|
);
|
|
256
|
-
pruneVersions(db, noteId, policy);
|
|
265
|
+
pruneVersions(db, noteId, policy, Date.now(), false);
|
|
257
266
|
}
|
|
258
267
|
export function pruneVersions(
|
|
259
268
|
db: Database,
|
|
260
269
|
noteId: string,
|
|
261
270
|
policy: HistoryPolicy,
|
|
262
271
|
now = Date.now(),
|
|
272
|
+
refresh = true,
|
|
263
273
|
): { versionsDeleted: number; blobsDeleted: number } {
|
|
264
274
|
const rows = db
|
|
265
275
|
.prepare(
|
|
@@ -293,6 +303,7 @@ export function pruneVersions(
|
|
|
293
303
|
)
|
|
294
304
|
.run(hash, hash, hash).changes;
|
|
295
305
|
}
|
|
306
|
+
if (refresh) refreshCompactState(db, noteId);
|
|
296
307
|
return { versionsDeleted, blobsDeleted };
|
|
297
308
|
}
|
|
298
309
|
export function gcBlobs(db: Database): { blobsDeleted: number } {
|
|
@@ -323,10 +334,12 @@ export function sweepDeletedHistory(
|
|
|
323
334
|
.all(cutoff) as { note_id: string }[];
|
|
324
335
|
return transaction(db, () => {
|
|
325
336
|
let versionsDeleted = 0;
|
|
326
|
-
for (const v of victims)
|
|
337
|
+
for (const v of victims) {
|
|
327
338
|
versionsDeleted += db
|
|
328
339
|
.prepare("DELETE FROM note_versions WHERE note_id = ?")
|
|
329
340
|
.run(v.note_id).changes;
|
|
341
|
+
db.prepare("DELETE FROM history_compact_state WHERE note_id=?").run(v.note_id);
|
|
342
|
+
}
|
|
330
343
|
return { notesSwept: victims.length, versionsDeleted, ...gcBlobs(db) };
|
|
331
344
|
});
|
|
332
345
|
}
|
|
@@ -398,6 +411,7 @@ export function eraseHistory(
|
|
|
398
411
|
const versionsDeleted = db
|
|
399
412
|
.prepare("DELETE FROM note_versions WHERE note_id = ?")
|
|
400
413
|
.run(noteId).changes;
|
|
414
|
+
db.prepare("DELETE FROM history_compact_state WHERE note_id=?").run(noteId);
|
|
401
415
|
return { versionsDeleted, ...gcBlobs(db) };
|
|
402
416
|
});
|
|
403
417
|
}
|
|
@@ -545,6 +559,23 @@ export function compactNote(db: Database, noteId: string, policy: HistoryPolicy)
|
|
|
545
559
|
const result: CompactResult = { blobs_deltified: 0, blobs_skipped_too_large: 0, versions_dropped: 0, bytes_before: 0, bytes_after: 0 };
|
|
546
560
|
if (!policy.enabled || !policy.compact_enabled || !historyTablesPresent(db))
|
|
547
561
|
return result;
|
|
562
|
+
return transaction(db, () => {
|
|
563
|
+
const changedHashes = new Set<string>();
|
|
564
|
+
const finish = () => {
|
|
565
|
+
// A blob can be referenced by several notes. Refresh each affected hint,
|
|
566
|
+
// without treating another note's compaction as a new history write.
|
|
567
|
+
const affected = new Set<string>();
|
|
568
|
+
for (const hash of changedHashes) {
|
|
569
|
+
for (const row of db.prepare("SELECT DISTINCT note_id FROM note_versions WHERE content_hash=?").all(hash) as { note_id: string }[]) affected.add(row.note_id);
|
|
570
|
+
}
|
|
571
|
+
for (const id of affected) {
|
|
572
|
+
if (id === noteId) continue;
|
|
573
|
+
const prior = db.prepare("SELECT refused FROM history_compact_state WHERE note_id=?").get(id) as { refused: number } | null;
|
|
574
|
+
refreshCompactState(db, id, { refused: prior?.refused === 1 });
|
|
575
|
+
}
|
|
576
|
+
refreshCompactState(db, noteId, { refused: result.blobs_deltified === 0 && result.versions_dropped === 0 });
|
|
577
|
+
return result;
|
|
578
|
+
};
|
|
548
579
|
const rows = db.prepare("SELECT version_ix, content_hash, content_len FROM note_versions WHERE note_id = ? AND content_hash IS NOT NULL ORDER BY version_ix DESC").all(noteId) as {
|
|
549
580
|
version_ix: number;
|
|
550
581
|
content_hash: string;
|
|
@@ -561,10 +592,9 @@ export function compactNote(db: Database, noteId: string, policy: HistoryPolicy)
|
|
|
561
592
|
const live = current?.content == null ? newest?.content_len ?? 0 : byteLength(current.content);
|
|
562
593
|
const overBytes = policy.max_bytes_per_note !== null && stored > policy.max_bytes_per_note;
|
|
563
594
|
if (!overBytes && rows.length < policy.compact_min_versions)
|
|
564
|
-
return
|
|
595
|
+
return finish();
|
|
565
596
|
if (!overBytes && stored <= policy.compact_ratio * Math.max(live, 1))
|
|
566
|
-
return
|
|
567
|
-
return transaction(db, () => {
|
|
597
|
+
return finish();
|
|
568
598
|
for (let start = 0; start < rows.length; start += policy.compact_run_length) {
|
|
569
599
|
const run = rows.slice(start, start + policy.compact_run_length);
|
|
570
600
|
const baseRow = db.prepare("SELECT hash, encoding, delta_of FROM note_blobs WHERE hash = ?").get(run[0]!.content_hash) as {
|
|
@@ -606,13 +636,15 @@ export function compactNote(db: Database, noteId: string, policy: HistoryPolicy)
|
|
|
606
636
|
result.blobs_skipped_too_large++;
|
|
607
637
|
continue;
|
|
608
638
|
}
|
|
609
|
-
|
|
639
|
+
const changed = db.prepare("UPDATE note_blobs SET content = ?, byte_size = ?, encoding = 'fossil-delta', delta_of = ? WHERE hash = ? AND encoding IS NULL").run(payload, payload.length, baseHash, b.hash).changes;
|
|
640
|
+
result.blobs_deltified += changed;
|
|
641
|
+
if (changed) changedHashes.add(b.hash);
|
|
610
642
|
}
|
|
611
643
|
}
|
|
612
644
|
if (policy.max_bytes_per_note !== null)
|
|
613
645
|
result.versions_dropped = enforceByteCeiling(db, noteId, policy);
|
|
614
646
|
result.bytes_after = noteHistoryBytes(db, noteId);
|
|
615
|
-
return
|
|
647
|
+
return finish();
|
|
616
648
|
});
|
|
617
649
|
}
|
|
618
650
|
function enforceByteCeiling(db: Database, noteId: string, policy: HistoryPolicy): number {
|
|
@@ -642,15 +674,10 @@ export function compactVault(db: Database, policy: HistoryPolicy, opts?: {
|
|
|
642
674
|
if (budget !== null && budget <= 0)
|
|
643
675
|
return { ...result, stopped_by: "budget" };
|
|
644
676
|
const started = performance.now();
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
FROM (SELECT DISTINCT note_id, content_hash FROM note_versions WHERE content_hash IS NOT NULL) v
|
|
650
|
-
JOIN note_blobs b ON b.hash = v.content_hash LEFT JOIN notes live_note ON live_note.id = v.note_id
|
|
651
|
-
GROUP BY v.note_id
|
|
652
|
-
HAVING (versions >= ? AND stored > ? * MAX(live,1)) OR (? IS NOT NULL AND stored > ?)
|
|
653
|
-
ORDER BY stored DESC`).all(policy.compact_min_versions, policy.compact_ratio, policy.max_bytes_per_note, policy.max_bytes_per_note) as {
|
|
677
|
+
const predicate = `refused=0 AND ((live>0 AND versions >= ? AND stored > ? * MAX(live,1)) OR (? IS NOT NULL AND stored > ?))`;
|
|
678
|
+
const args = [policy.compact_min_versions, policy.compact_ratio, policy.max_bytes_per_note, policy.max_bytes_per_note];
|
|
679
|
+
const total = opts?.noteId !== undefined ? 1 : (db.prepare(`SELECT COUNT(*) AS n FROM history_compact_state WHERE ${predicate}`).get(...args) as { n: number }).n;
|
|
680
|
+
const candidates = opts?.noteId !== undefined ? [{ note_id: opts.noteId }] : db.prepare(`SELECT note_id FROM history_compact_state WHERE ${predicate} ORDER BY stored DESC LIMIT ?`).all(...args, max === null ? -1 : max * 4) as {
|
|
654
681
|
note_id: string;
|
|
655
682
|
}[];
|
|
656
683
|
for (const candidate of candidates) {
|
|
@@ -678,7 +705,7 @@ export function compactVault(db: Database, policy: HistoryPolicy, opts?: {
|
|
|
678
705
|
console.warn("[history] compaction failed", candidate.note_id, err);
|
|
679
706
|
}
|
|
680
707
|
}
|
|
681
|
-
result.remaining_candidates =
|
|
708
|
+
result.remaining_candidates = total - result.notes_scanned;
|
|
682
709
|
result.duration_ms = performance.now() - started;
|
|
683
710
|
return result;
|
|
684
711
|
}
|
package/core/src/mcp-manifest.ts
CHANGED
|
@@ -705,7 +705,12 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
705
705
|
requiredVerb: "read",
|
|
706
706
|
description:
|
|
707
707
|
"Read-only integrity scan across the tag/metadata taxonomy — run this after any bulk tag reorg (rename/merge/delete/subtree move) to confirm nothing leaked. Returns {findings, summary, scanned_at} — findings is an array, each entry {type, severity, subject, detail, remedy} — NEVER auto-fixes; apply the suggested remedy (usually rename-tag/merge-tags/update-tag/prune-schema) yourself. Finding types: dangling_parent_name (a parent_names entry naming a tag with no identity row), parent_names_cycle (a tag reaching itself through its ancestor chain — traversal tolerates this, but it's dishonest hierarchy state), mixed_type_indexed_field (a note's metadata value for an indexed field has a JSON type disagreeing with the field's declared storage type — the ordering/filtering-goes-silently-wrong precursor), orphaned_indexed_field_declarer (an indexed field naming a dead declarer tag — see prune-schema), and dead_tag_metadata_reference (HEURISTIC, always carries heuristic:true — a metadata value that looks like a stale reference to a renamed/merged/deleted tag, inferred from sibling notes using the same metadata key with values that ARE live tags; can never be certain since vault keeps no tag-rename history).",
|
|
708
|
-
inputSchema: { type: "object", properties: {
|
|
708
|
+
inputSchema: { type: "object", properties: {
|
|
709
|
+
deep: { type: "boolean", description: "Opt-in, unrestricted sessions only: materialize and hash-check a bounded history blob page. Check history_audit.complete; incomplete is not a clean audit." },
|
|
710
|
+
history_after: { type: "string", description: "Resume after history_audit.next_after. Pages are not a snapshot; audit a consistent backup for snapshot assurance." },
|
|
711
|
+
history_max_blobs: { type: "integer", minimum: 1, maximum: 500, description: "Maximum blobs in a deep page; default 100." },
|
|
712
|
+
history_budget_ms: { type: "integer", minimum: 1, maximum: 1000, description: "Between-blob budget; default 250ms. One blob may exceed it." },
|
|
713
|
+
} },
|
|
709
714
|
condition: "core",
|
|
710
715
|
},
|
|
711
716
|
{
|
package/core/src/mcp.ts
CHANGED
|
@@ -2619,8 +2619,12 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
2619
2619
|
// =====================================================================
|
|
2620
2620
|
{
|
|
2621
2621
|
name: "doctor",
|
|
2622
|
-
execute: async () => {
|
|
2623
|
-
|
|
2622
|
+
execute: async (params) => {
|
|
2623
|
+
if (params.deep !== undefined && typeof params.deep !== "boolean") throw new Error("deep must be boolean");
|
|
2624
|
+
if (params.history_after !== undefined && typeof params.history_after !== "string") throw new Error("history_after must be a string");
|
|
2625
|
+
if (params.history_max_blobs !== undefined && typeof params.history_max_blobs !== "number") throw new Error("history_max_blobs must be a number");
|
|
2626
|
+
if (params.history_budget_ms !== undefined && typeof params.history_budget_ms !== "number") throw new Error("history_budget_ms must be a number");
|
|
2627
|
+
return await store.doctor({ deep: params.deep, history_after: params.history_after, history_max_blobs: params.history_max_blobs, history_budget_ms: params.history_budget_ms });
|
|
2624
2628
|
},
|
|
2625
2629
|
},
|
|
2626
2630
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { isNoteIdShape } from "./ulid.js";
|
|
3
|
+
|
|
4
|
+
test("note ID shape accepts exact legacy strings without coercion", () => {
|
|
5
|
+
for (const id of ["a", "A", "legacy:abc123", "2020-01-02-03-04-05", "shortid", "a_b", "a".repeat(64), "01M2KT1VQBHNZ99V5KSGXP6VSE"])
|
|
6
|
+
expect(isNoteIdShape(id)).toBe(true);
|
|
7
|
+
for (const id of ["", "a b", "a/b", "a.b", "a".repeat(65), "-leading", "_leading", ":leading", "a\n", "a\r", "a\0", "é", 123, null, undefined])
|
|
8
|
+
expect(isNoteIdShape(id)).toBe(false);
|
|
9
|
+
});
|
package/core/src/notes.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
} from "./search-query.js";
|
|
33
33
|
import { generateUlid } from "./ulid.js";
|
|
34
34
|
import { captureVersion, readPriorNoteRow, DEFAULT_HISTORY_POLICY, type HistoryPolicy } from "./history.js";
|
|
35
|
+
import { refreshCompactState } from "./history-compact-state.js";
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
38
|
* Write-attribution context (vault#298) — the two axes of provenance threaded
|
|
@@ -2594,6 +2595,7 @@ export function renameTag(db: Database, oldName: string, newName: string, attr?:
|
|
|
2594
2595
|
const prior = readPriorNoteRow(db, row.id);
|
|
2595
2596
|
if (prior) captureVersion(db, prior, { actor: attr?.actor ?? null, via: attr?.via ?? null, op: "tag-rename", policy });
|
|
2596
2597
|
updateStmt.run(next, now, nowMs, row.id);
|
|
2598
|
+
refreshCompactState(db, row.id);
|
|
2597
2599
|
notesRewritten++;
|
|
2598
2600
|
}
|
|
2599
2601
|
}
|
|
@@ -2618,6 +2620,7 @@ export function renameTag(db: Database, oldName: string, newName: string, attr?:
|
|
|
2618
2620
|
const prior = readPriorNoteRow(db, row.id);
|
|
2619
2621
|
if (prior) captureVersion(db, prior, { actor: attr?.actor ?? null, via: attr?.via ?? null, op: "tag-rename", policy });
|
|
2620
2622
|
updateStmt.run(next, now, nowMs, row.id);
|
|
2623
|
+
refreshCompactState(db, row.id);
|
|
2621
2624
|
pathsRenamed++;
|
|
2622
2625
|
}
|
|
2623
2626
|
}
|
|
@@ -19,7 +19,7 @@ it("P14a v28 upgrade creates both tables and three indexes without backfill", ()
|
|
|
19
19
|
initSchema(db);
|
|
20
20
|
expect(
|
|
21
21
|
db.prepare("SELECT MAX(version) AS version FROM schema_version").get(),
|
|
22
|
-
).toEqual({ version:
|
|
22
|
+
).toEqual({ version: 32 });
|
|
23
23
|
const tables = db
|
|
24
24
|
.prepare(
|
|
25
25
|
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('note_versions','note_blobs') ORDER BY name",
|
|
@@ -26,7 +26,7 @@ test("P12 v29 opens losslessly, fresh and upgraded columns/indexes agree", async
|
|
|
26
26
|
const snap = () => ({ blobs: db.prepare("SELECT hash,content,byte_size FROM note_blobs ORDER BY hash").all(), versions: db.prepare("SELECT note_id,version_ix,content_hash,path,metadata,extension,superseded_at,actor,via,op,content_len,encoding FROM note_versions ORDER BY note_id,version_ix").all() });
|
|
27
27
|
const before = snap();
|
|
28
28
|
expect(() => initSchema(db)).not.toThrow();
|
|
29
|
-
expect(SCHEMA_VERSION).toBe(
|
|
29
|
+
expect(SCHEMA_VERSION).toBe(32);
|
|
30
30
|
expect(snap()).toEqual(before);
|
|
31
31
|
expect(db.prepare("SELECT hash FROM note_blobs WHERE delta_of IS NOT NULL OR encoding IS NOT NULL").all()).toEqual([]);
|
|
32
32
|
expect(db.prepare("SELECT note_id FROM note_versions WHERE created_at IS NOT NULL").all()).toEqual([]);
|
|
@@ -13,7 +13,7 @@ test("v30 to v31 retains native history and matches fresh import tables; reopeni
|
|
|
13
13
|
db.exec("UPDATE schema_version SET version=30");
|
|
14
14
|
const before = db.query("SELECT * FROM note_versions").all();
|
|
15
15
|
initSchema(db); initSchema(fresh); initSchema(db);
|
|
16
|
-
expect(db.query("SELECT MAX(version) AS version FROM schema_version").get()).toEqual({ version:
|
|
16
|
+
expect(db.query("SELECT MAX(version) AS version FROM schema_version").get()).toEqual({ version: 32 });
|
|
17
17
|
expect(db.query("SELECT * FROM note_versions").all()).toEqual(before);
|
|
18
18
|
for (const table of tables) {
|
|
19
19
|
expect(db.query(`PRAGMA table_info(${table})`).all()).toEqual(fresh.query(`PRAGMA table_info(${table})`).all());
|
package/core/src/schema.ts
CHANGED
|
@@ -5,8 +5,18 @@ import { findMixedTypeIndexedFieldNotes } from "./doctor.js";
|
|
|
5
5
|
import { transaction } from "./txn.js";
|
|
6
6
|
import { timestampToMs } from "./cursor.js";
|
|
7
7
|
import { ensureRelationshipColumn } from "./wikilinks.js";
|
|
8
|
+
import { rebuildCompactState } from "./history-compact-state.js";
|
|
8
9
|
|
|
9
|
-
export const SCHEMA_VERSION =
|
|
10
|
+
export const SCHEMA_VERSION = 32;
|
|
11
|
+
|
|
12
|
+
function migrateToV32(db: Database): void {
|
|
13
|
+
const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='history_compact_state'").get();
|
|
14
|
+
// Read before initSchema records this open. On a timestamp tie, prefer
|
|
15
|
+
// a harmless rebuild over missing an older writer's stale hints.
|
|
16
|
+
const previous = db.prepare("SELECT version FROM schema_version ORDER BY applied_at DESC, version ASC LIMIT 1").get() as { version: number } | null;
|
|
17
|
+
if (exists && (!previous || previous.version >= 32)) return;
|
|
18
|
+
rebuildCompactState(db);
|
|
19
|
+
}
|
|
10
20
|
|
|
11
21
|
/**
|
|
12
22
|
* Deterministic last-resort epoch for a note whose `updated_at` AND
|
|
@@ -742,6 +752,7 @@ export function initSchema(db: Database): void {
|
|
|
742
752
|
// v30: depth-one history deltas and captured creation time, no backfill.
|
|
743
753
|
migrateToV30(db);
|
|
744
754
|
migrateToV31(db);
|
|
755
|
+
migrateToV32(db);
|
|
745
756
|
|
|
746
757
|
// Rebuild any generated columns + indexes declared in indexed_fields.
|
|
747
758
|
// No-op for a fresh vault; idempotent on existing vaults.
|
package/core/src/store.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
+
import { HistoryPreconditionRequiredError } from "./history.js";
|
|
2
3
|
import type { Store, Note, Link, Attachment, QueryOpts, QueryNotesPage, AggregateRow, SemanticSearchResult } from "./types.js";
|
|
3
4
|
import { compactNote, compactVault, countNoteVersions, type CompactResult, type CompactSummary, captureVersion, readPriorNoteRow, resolveHistoryPolicy, appendRestoreMarker, listVersions, getVersion, latestTombstone, eraseHistory, sweepDeletedHistory, deletedHistoryStats, HistoryNotFoundError, HistoryOverflowError, HistoryUnrecoverableError, DEFAULT_HISTORY_POLICY, VERSION_MAX_BYTES, type VersionRow, type HistoryPolicy, type HistoryOp } from "./history.js";
|
|
4
5
|
import { initSchema } from "./schema.js";
|
|
6
|
+
import { refreshCompactState } from "./history-compact-state.js";
|
|
5
7
|
import * as noteOps from "./notes.js";
|
|
6
8
|
import * as linkOps from "./links.js";
|
|
7
9
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
@@ -529,7 +531,12 @@ export class BunSqliteStore implements Store {
|
|
|
529
531
|
if (normalized !== opts.metadata) opts = { ...opts, metadata: normalized };
|
|
530
532
|
}
|
|
531
533
|
|
|
532
|
-
const note =
|
|
534
|
+
const note = this.transaction(() => {
|
|
535
|
+
const created = noteOps.createNote(this.db, content, opts);
|
|
536
|
+
// Explicit IDs may recreate a deleted note with retained history.
|
|
537
|
+
refreshCompactState(this.db, created.id);
|
|
538
|
+
return created;
|
|
539
|
+
});
|
|
533
540
|
|
|
534
541
|
if (content) {
|
|
535
542
|
syncWikilinks(this.db, note.id, content);
|
|
@@ -691,6 +698,7 @@ export class BunSqliteStore implements Store {
|
|
|
691
698
|
this.syncReferenceFieldLinks(note, priorMetadataForRefs);
|
|
692
699
|
}
|
|
693
700
|
|
|
701
|
+
refreshCompactState(this.db, id);
|
|
694
702
|
return note;
|
|
695
703
|
});
|
|
696
704
|
|
|
@@ -817,6 +825,7 @@ export class BunSqliteStore implements Store {
|
|
|
817
825
|
if (prior) captureVersion(this.db, prior, { ...attr, op: "cascade-rename", policy: this.historyPolicy });
|
|
818
826
|
noteOps.updateNote(this.db, sourceId, { content: updated });
|
|
819
827
|
syncWikilinks(this.db, sourceId, updated);
|
|
828
|
+
refreshCompactState(this.db, sourceId);
|
|
820
829
|
}
|
|
821
830
|
}
|
|
822
831
|
}
|
|
@@ -834,6 +843,7 @@ export class BunSqliteStore implements Store {
|
|
|
834
843
|
const v = getVersion(this.db, id, versionIx);
|
|
835
844
|
if (!v) throw new HistoryNotFoundError(id, versionIx);
|
|
836
845
|
if (v.encoding === "overflow") throw new HistoryUnrecoverableError(id, versionIx);
|
|
846
|
+
if (opts.if_updated_at === undefined) throw new HistoryPreconditionRequiredError(id);
|
|
837
847
|
return await this.updateNote(id, {
|
|
838
848
|
content: v.content!, metadata: v.metadata, extension: v.extension ?? undefined,
|
|
839
849
|
actor: opts.actor ?? null, via: opts.via ?? null,
|
|
@@ -856,6 +866,7 @@ export class BunSqliteStore implements Store {
|
|
|
856
866
|
appendRestoreMarker(this.db, id, tomb, { actor: opts.actor ?? null, via: opts.via ?? null }, this.historyPolicy);
|
|
857
867
|
if (note.content) syncWikilinks(this.db, id, note.content);
|
|
858
868
|
if (note.path) resolveUnresolvedWikilinks(this.db, note.path, id);
|
|
869
|
+
refreshCompactState(this.db, id);
|
|
859
870
|
return note;
|
|
860
871
|
});
|
|
861
872
|
}
|
|
@@ -908,6 +919,7 @@ export class BunSqliteStore implements Store {
|
|
|
908
919
|
}
|
|
909
920
|
noteOps.deleteNote(this.db, id);
|
|
910
921
|
refreshAmbiguousLinks(this.db, ambiguityKeys);
|
|
922
|
+
refreshCompactState(this.db, id);
|
|
911
923
|
});
|
|
912
924
|
if (existing?.path) this.invalidateConfigCachesForPath(existing.path);
|
|
913
925
|
// Dispatch even when `existing` was null — the caller asked for a
|
|
@@ -1601,7 +1613,11 @@ export class BunSqliteStore implements Store {
|
|
|
1601
1613
|
const normalized = normalizeDateFields(this.getSchemaConfig(), { tags: opts.tags, metadata: opts.metadata });
|
|
1602
1614
|
if (normalized !== opts.metadata) opts = { ...opts, metadata: normalized };
|
|
1603
1615
|
}
|
|
1604
|
-
return
|
|
1616
|
+
return this.transaction(() => {
|
|
1617
|
+
const created = noteOps.createNote(this.db, content, opts);
|
|
1618
|
+
refreshCompactState(this.db, created.id);
|
|
1619
|
+
return created;
|
|
1620
|
+
});
|
|
1605
1621
|
}
|
|
1606
1622
|
|
|
1607
1623
|
/**
|
package/core/src/ulid.ts
CHANGED
|
@@ -114,3 +114,10 @@ export function generateUlid(): string {
|
|
|
114
114
|
|
|
115
115
|
/** 26-char Crockford base32 (`0-9A-HJKMNP-TV-Z`, case-insensitive by spec — we always emit uppercase). */
|
|
116
116
|
export const ULID_REGEX = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
117
|
+
|
|
118
|
+
const NOTE_ID_SHAPE = /^[A-Za-z0-9][A-Za-z0-9:_-]{0,63}$/;
|
|
119
|
+
/** Accepted history identity shape, including legacy IDs; never normalizes an ID. */
|
|
120
|
+
export function isNoteIdShape(value: unknown): value is string {
|
|
121
|
+
// Comparing the entire match also rejects a final newline (JS `$` permits one).
|
|
122
|
+
return typeof value === "string" && NOTE_ID_SHAPE.exec(value)?.[0] === value;
|
|
123
|
+
}
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -3432,6 +3432,8 @@ async function cmdExport(args: string[]) {
|
|
|
3432
3432
|
* a past revision (`git show <sha>:<path>.md`).
|
|
3433
3433
|
*/
|
|
3434
3434
|
async function cmdHistory(args: string[]) {
|
|
3435
|
+
const rebuild = args[0] === "rebuild-state";
|
|
3436
|
+
if (rebuild) args = args.slice(1);
|
|
3435
3437
|
let vaultName = "default";
|
|
3436
3438
|
let notePath: string | undefined;
|
|
3437
3439
|
let limit: number | undefined;
|
|
@@ -3485,6 +3487,10 @@ async function cmdHistory(args: string[]) {
|
|
|
3485
3487
|
}
|
|
3486
3488
|
}
|
|
3487
3489
|
|
|
3490
|
+
if (rebuild && (showSha || notePath || limit !== undefined)) {
|
|
3491
|
+
console.error("rebuild-state accepts only --vault and --json.");
|
|
3492
|
+
process.exit(1);
|
|
3493
|
+
}
|
|
3488
3494
|
if (showSha && !notePath) {
|
|
3489
3495
|
console.error("--show <sha> requires --note <path> (which file to read at that revision).");
|
|
3490
3496
|
process.exit(1);
|
|
@@ -3496,6 +3502,25 @@ async function cmdHistory(args: string[]) {
|
|
|
3496
3502
|
process.exit(1);
|
|
3497
3503
|
}
|
|
3498
3504
|
|
|
3505
|
+
if (rebuild) {
|
|
3506
|
+
const path = vaultDbPath(vaultName);
|
|
3507
|
+
if (!existsSync(path)) {
|
|
3508
|
+
console.error(`Vault database not found: ${path}`);
|
|
3509
|
+
process.exit(1);
|
|
3510
|
+
}
|
|
3511
|
+
const { Database } = await import("bun:sqlite");
|
|
3512
|
+
const { initSchema } = await import("../core/src/schema.js");
|
|
3513
|
+
const { rebuildCompactState } = await import("../core/src/history-compact-state.js");
|
|
3514
|
+
const db = new Database(path, { readwrite: true, create: false });
|
|
3515
|
+
try {
|
|
3516
|
+
initSchema(db);
|
|
3517
|
+
const rebuilt_notes = rebuildCompactState(db);
|
|
3518
|
+
console.log(asJson ? JSON.stringify({ vault: vaultName, rebuilt_notes }) :
|
|
3519
|
+
`Rebuilt compaction hints for ${rebuilt_notes} notes in vault "${vaultName}". History unchanged.`);
|
|
3520
|
+
} finally { db.close(); }
|
|
3521
|
+
return;
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3499
3524
|
// Resolve the mirror dir the same way the server does: per-vault mirror
|
|
3500
3525
|
// config (or defaults) → resolveMirrorPath against the vault's data dir.
|
|
3501
3526
|
const mirrorConfig = readMirrorConfigForVault(vaultName) ?? defaultMirrorConfig();
|
|
@@ -3558,6 +3583,10 @@ async function cmdHistory(args: string[]) {
|
|
|
3558
3583
|
}
|
|
3559
3584
|
|
|
3560
3585
|
function printHistoryUsage(): void {
|
|
3586
|
+
console.error("Usage: parachute-vault history rebuild-state [--vault <name>] [--json]");
|
|
3587
|
+
console.error("Rebuild compaction hints and reset refusals; does not compact or change history.");
|
|
3588
|
+
console.error("The current server may stay running: writes serialize with the rebuild transaction.");
|
|
3589
|
+
console.error("Stop the server optionally to avoid write contention; do not run older writers concurrently.\n");
|
|
3561
3590
|
console.error(
|
|
3562
3591
|
"Usage: parachute-vault history [--note <path>] [--limit N] [--vault <name>] [--json]\n" +
|
|
3563
3592
|
" [--note <path> --show <sha>]",
|
|
@@ -45,6 +45,24 @@ async function call(path: string, method = "GET", body?: unknown, verb = "admin"
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
import { applyImportedNote, beginImportRun, importTargetDigest } from "../core/src/history-import.ts";
|
|
48
|
+
test("authenticated history REST redacts scoped provenance for read/write/admin tokens", async () => {
|
|
49
|
+
const note = await store.createNote("before", { tags: ["journal"] });
|
|
50
|
+
await store.updateNote(note.id, { content: "after", actor: "HISTORICAL_EDITOR", via: "HISTORICAL_INTERFACE" });
|
|
51
|
+
for (const verb of ["read", "write", "admin"]) {
|
|
52
|
+
for (const scoped of [false, true]) {
|
|
53
|
+
for (const suffix of ["/versions", "/versions/0"]) {
|
|
54
|
+
const got = await call(`/notes/${note.id}${suffix}`, "GET", undefined, verb, scoped);
|
|
55
|
+
expect(got.status).toBe(200);
|
|
56
|
+
const row = got.body.versions?.[0] ?? got.body;
|
|
57
|
+
expect(Object.hasOwn(row, "actor")).toBe(!scoped);
|
|
58
|
+
expect(Object.hasOwn(row, "via")).toBe(!scoped);
|
|
59
|
+
if (!scoped) expect(row.actor).toBe("HISTORICAL_EDITOR");
|
|
60
|
+
expect(row.version_ix).toBe(0);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
expect((await store.getNoteVersion(note.id, 0))!.actor).toBe("HISTORICAL_EDITOR");
|
|
65
|
+
});
|
|
48
66
|
test("imported REST references restore by ID or encoded path and preserve native keys", async () => {
|
|
49
67
|
const note = await store.createNote("native", { path: "folder/imported", tags: ["journal"] });
|
|
50
68
|
await store.updateNote(note.id, { content: "current" });
|
|
@@ -66,7 +84,11 @@ test("imported REST references restore by ID or encoded path and preserve native
|
|
|
66
84
|
expect(list.body.versions.map((v: any) => v.version_ix ?? v.origin)).toEqual([0, "git-import"]);
|
|
67
85
|
for (const body of [{ origin: "git-import" }, { import_ix: 0 }, { origin: "git-import", import_ix: 0, version_ix: 0 }, { origin: "git-import", import_ix: -1 }]) expect((await call(`/notes/${note.id}/restore`, "POST", body)).status).toBe(400);
|
|
68
86
|
expect((await call(`/notes/${note.id}/versions/-1`)).status).toBe(400);
|
|
69
|
-
const
|
|
87
|
+
const beforeRestore = await store.getNote(note.id);
|
|
88
|
+
const missingStamp = await call(`/notes/${note.id}/restore`, "POST", { origin: "git-import", import_ix: 0 });
|
|
89
|
+
expect(missingStamp.status).toBe(428);
|
|
90
|
+
expect(await store.getNote(note.id)).toEqual(beforeRestore);
|
|
91
|
+
const restored = await call(`/notes/${note.id}/restore`, "POST", { origin: "git-import", import_ix: 0, if_updated_at: beforeRestore!.updatedAt });
|
|
70
92
|
expect(restored.status).toBe(200);
|
|
71
93
|
expect(restored.body.restored_from).toEqual({ origin: "git-import", import_ix: 0 });
|
|
72
94
|
expect((await store.getNote(note.id))!.content).toBe("archive");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { test, expect } from "bun:test";
|
|
1
|
+
import { test, expect, spyOn } from "bun:test";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { Database } from "bun:sqlite";
|
|
3
|
+
import { Database, SQLiteError } from "bun:sqlite";
|
|
4
4
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
@@ -11,6 +11,42 @@ import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
|
11
11
|
import { getImportedVersion } from "../core/src/history-import.ts";
|
|
12
12
|
import { historyMirrorStatePath, readHistoryMirrorPhase, mirrorConfigPath } from "./mirror-config.ts";
|
|
13
13
|
const noteId = "01M2KT1VQBHNZ99V5KSGXP6VSE";
|
|
14
|
+
for (const id of ["legacy:abc123", "2020-01-02-03-04-05", "shortid"]) {
|
|
15
|
+
for (const sidecar of [false, true]) test(`legacy ID staging: ${id}, sidecar=${sidecar}`, () => {
|
|
16
|
+
const dir = mkdtempSync(join(tmpdir(), "legacy-stage-")), db = new Database(":memory:");
|
|
17
|
+
try {
|
|
18
|
+
const repo = repoAt(join(dir, "repo"));
|
|
19
|
+
if (sidecar) {
|
|
20
|
+
mkdirSync(join(repo, ".parachute/notes-meta"), { recursive: true });
|
|
21
|
+
writeFileSync(join(repo, ".parachute/notes-meta/entry.yaml"), `id: ${id}\npath: entry\nextension: json\n`);
|
|
22
|
+
writeFileSync(join(repo, "entry.json"), '{}');
|
|
23
|
+
git(repo, "add", "."); git(repo, "commit", "-m", "sidecar");
|
|
24
|
+
} else commit(repo, "legacy body", id);
|
|
25
|
+
stageArchive(repo, git(repo, "rev-parse", "HEAD"), db);
|
|
26
|
+
expect(db.query("SELECT note_id FROM observations").all()).toEqual([{ note_id: id }]);
|
|
27
|
+
expect(db.query("SELECT * FROM issues").all()).toEqual([]);
|
|
28
|
+
expect(db.query("SELECT * FROM quarantine").all()).toEqual([]);
|
|
29
|
+
} finally { db.close(); rmSync(dir, { recursive: true, force: true }); }
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
for (const id of ["a b", "a/b", "a.b", "a".repeat(65), "", "-leading"]) {
|
|
33
|
+
for (const sidecar of [false, true]) test(`invalid ID staging: ${JSON.stringify(id)}, sidecar=${sidecar}`, () => {
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), "invalid-stage-")), db = new Database(":memory:");
|
|
35
|
+
try {
|
|
36
|
+
const repo = repoAt(join(dir, "repo"));
|
|
37
|
+
const meta = `id: ${JSON.stringify(id)}\npath: entry\n`;
|
|
38
|
+
if (sidecar) {
|
|
39
|
+
mkdirSync(join(repo, ".parachute/notes-meta"), { recursive: true });
|
|
40
|
+
writeFileSync(join(repo, ".parachute/notes-meta/entry.yaml"), meta + "extension: json\n");
|
|
41
|
+
writeFileSync(join(repo, "entry.json"), '{}');
|
|
42
|
+
} else writeFileSync(join(repo, "entry.md"), `---\n${meta}---\nbody`);
|
|
43
|
+
git(repo, "add", "."); git(repo, "commit", "-m", "invalid");
|
|
44
|
+
stageArchive(repo, git(repo, "rev-parse", "HEAD"), db);
|
|
45
|
+
expect(db.query("SELECT * FROM observations").all()).toEqual([]);
|
|
46
|
+
expect(db.query("SELECT reason FROM issues").all()).toContainEqual({ reason: sidecar ? "invalid_sidecar" : "invalid_id" });
|
|
47
|
+
} finally { db.close(); rmSync(dir, { recursive: true, force: true }); }
|
|
48
|
+
});
|
|
49
|
+
}
|
|
14
50
|
function git(dir: string, ...args: string[]) {
|
|
15
51
|
const p = spawnSync("git", ["-C", dir, ...args], { encoding: "utf8", env: { ...process.env, GIT_AUTHOR_NAME: "test", GIT_AUTHOR_EMAIL: "test@example.test", GIT_COMMITTER_NAME: "test", GIT_COMMITTER_EMAIL: "test@example.test" } });
|
|
16
52
|
if (p.status !== 0) throw new Error(p.stderr); return p.stdout.trim();
|
|
@@ -35,6 +71,57 @@ test("git staging preserves exported CRLF bodies and A-B-A while quarantining du
|
|
|
35
71
|
try { stageArchive(repo, duplicate, second); expect(second.query("SELECT reason FROM quarantine").all()).toEqual([{ reason: "duplicate_id" }]); expect(second.query("SELECT * FROM observations").all()).toEqual([]); } finally { second.close(); }
|
|
36
72
|
} finally { db.close(); rmSync(dir, { recursive: true, force: true }); }
|
|
37
73
|
});
|
|
74
|
+
test("legacy duplicate IDs still quarantine and selection entries preserve exact identity", () => {
|
|
75
|
+
const dir = mkdtempSync(join(tmpdir(), "legacy-duplicate-")), db = new Database(":memory:");
|
|
76
|
+
try {
|
|
77
|
+
const repo = repoAt(join(dir, "repo")), id = "legacy:duplicate";
|
|
78
|
+
commit(repo, "first", id); const tip = commit(repo, "second", id, "duplicate.md");
|
|
79
|
+
stageArchive(repo, tip, db);
|
|
80
|
+
expect(db.query("SELECT * FROM quarantine").all()).toEqual([{ note_id: id, reason: "duplicate_id" }]);
|
|
81
|
+
expect(db.query("SELECT * FROM observations").all()).toEqual([]);
|
|
82
|
+
const selections = normalizeHistorySelections({ format: 1, tip, source_fingerprint: "a".repeat(64), selections: [{
|
|
83
|
+
note_id: id, commit: tip, selected: { git_path: "entry.md", blob: git(repo, "rev-parse", `${tip}:entry.md`) },
|
|
84
|
+
rejected: [{ git_path: "duplicate.md", blob: git(repo, "rev-parse", `${tip}:duplicate.md`), reason: "explicit choice" }], reason: "explicit choice",
|
|
85
|
+
}] });
|
|
86
|
+
expect(selections.selections[0]!.note_id).toBe(id);
|
|
87
|
+
} finally { db.close(); rmSync(dir, { recursive: true, force: true }); }
|
|
88
|
+
});
|
|
89
|
+
test("legacy imports preserve exact IDs, receipts and idempotency; case mismatch never merges", async () => {
|
|
90
|
+
const dir = mkdtempSync(join(tmpdir(), "legacy-import-")), saved = process.env.PARACHUTE_HOME;
|
|
91
|
+
process.env.PARACHUTE_HOME = join(dir, "home");
|
|
92
|
+
try {
|
|
93
|
+
const probe = Bun.serve({ port: 0, fetch: () => new Response() }); const port = probe.port!; probe.stop(true);
|
|
94
|
+
writeGlobalConfig({ port });
|
|
95
|
+
writeVaultConfig({ name: "test", api_keys: [], created_at: new Date().toISOString() });
|
|
96
|
+
const ids = ["legacy:abc123", "2020-01-02-03-04-05", "shortid"];
|
|
97
|
+
const store = getVaultStore("test");
|
|
98
|
+
for (const id of [...ids, "CaseId"]) await store.createNote("native", { id });
|
|
99
|
+
clearVaultStoreCache();
|
|
100
|
+
const repo = repoAt(join(dir, "repo"));
|
|
101
|
+
for (const [i, id] of [...ids, "caseid"].entries()) commit(repo, "archive", id, `entry${i}.md`);
|
|
102
|
+
let tip = git(repo, "rev-parse", "HEAD");
|
|
103
|
+
const output = join(dir, "plan.json");
|
|
104
|
+
await runHistoryImport(["plan", "--vault", "test", "--source", repo, "--through", tip, "--output", output]);
|
|
105
|
+
const plan = JSON.parse(readFileSync(output, "utf8"));
|
|
106
|
+
expect(plan.skipped).toEqual(["caseid"]);
|
|
107
|
+
expect(plan.missing_at_tip).toEqual(["CaseId"]);
|
|
108
|
+
expect(plan.notes.map((n: any) => n.id).sort()).toEqual([...ids].sort());
|
|
109
|
+
// Resolve the fixture mismatch explicitly before prepare, never via coercion.
|
|
110
|
+
commit(repo, "archive", "CaseId", "entry3.md"); tip = git(repo, "rev-parse", "HEAD");
|
|
111
|
+
const manifest = join(dir, "final.json"), archive = join(dir, "archive.bundle");
|
|
112
|
+
await runHistoryImport(["prepare", "--vault", "test", "--source", repo, "--through", tip, "--archive", archive, "--output", manifest]);
|
|
113
|
+
await runHistoryImport(["apply", "--vault", "test", "--manifest", manifest, "--archive", archive]);
|
|
114
|
+
const db = new Database(vaultDbPath("test"));
|
|
115
|
+
try {
|
|
116
|
+
const before = db.query("SELECT * FROM history_import_receipts ORDER BY note_id").all();
|
|
117
|
+
expect(before).toHaveLength(4);
|
|
118
|
+
for (const id of ids) expect(getImportedVersion(db, id, 0)?.content).toBe("archive");
|
|
119
|
+
await runHistoryImport(["apply", "--vault", "test", "--manifest", manifest, "--archive", archive]);
|
|
120
|
+
expect(db.query("SELECT * FROM history_import_receipts ORDER BY note_id").all()).toEqual(before);
|
|
121
|
+
expect(db.query("SELECT id FROM notes ORDER BY id").all()).toEqual([...ids, "CaseId"].sort().map(id => ({ id })));
|
|
122
|
+
} finally { db.close(); }
|
|
123
|
+
} finally { clearVaultStoreCache(); if (saved === undefined) delete process.env.PARACHUTE_HOME; else process.env.PARACHUTE_HOME = saved; rmSync(dir, { recursive: true, force: true }); }
|
|
124
|
+
}, 30_000);
|
|
38
125
|
test("offline prepare applies verified bundle, retries without resurrection, then retires", async () => {
|
|
39
126
|
const dir = mkdtempSync(join(tmpdir(), "import-cutover-")), saved = process.env.PARACHUTE_HOME;
|
|
40
127
|
process.env.PARACHUTE_HOME = join(dir, "home");
|
|
@@ -119,6 +206,38 @@ test("ambiguous sidecars quarantine both identities; malformed YAML diagnostics
|
|
|
119
206
|
} finally { stage.close(); rmSync(dir, { recursive: true, force: true }); }
|
|
120
207
|
});
|
|
121
208
|
|
|
209
|
+
for (const format of ["inline", "sidecar"] as const) for (const cache of ["object_cache", "parsed_cache"] as const) {
|
|
210
|
+
test(`staging SQLite failure in ${format} ${cache} aborts without blaming source notes`, () => {
|
|
211
|
+
const dir = mkdtempSync(join(tmpdir(), "import-stage-failure-")), stage = new Database(":memory:");
|
|
212
|
+
const exec = stage.exec.bind(stage);
|
|
213
|
+
const setup = spyOn(stage, "exec").mockImplementation((sql) => {
|
|
214
|
+
const result = exec(sql);
|
|
215
|
+
if (sql.startsWith("CREATE TABLE selection_failures")) {
|
|
216
|
+
exec(`CREATE TRIGGER fail_cache BEFORE INSERT ON ${cache} BEGIN SELECT RAISE(ABORT, 'staging write failed'); END`);
|
|
217
|
+
}
|
|
218
|
+
return result;
|
|
219
|
+
});
|
|
220
|
+
try {
|
|
221
|
+
const repo = repoAt(join(dir, "repo"));
|
|
222
|
+
if (format === "inline") commit(repo, "valid body");
|
|
223
|
+
else {
|
|
224
|
+
mkdirSync(join(repo, ".parachute/notes-meta"), { recursive: true });
|
|
225
|
+
writeFileSync(join(repo, `.parachute/notes-meta/${noteId}.yaml`), `id: ${noteId}\npath: entry\nextension: json\n`);
|
|
226
|
+
writeFileSync(join(repo, "entry.json"), '{"valid":true}');
|
|
227
|
+
git(repo, "add", "."); git(repo, "commit", "-m", "valid sidecar");
|
|
228
|
+
}
|
|
229
|
+
let failure: unknown;
|
|
230
|
+
try { stageArchive(repo, git(repo, "rev-parse", "HEAD"), stage); }
|
|
231
|
+
catch (error) { failure = error; }
|
|
232
|
+
expect(failure).toBeInstanceOf(SQLiteError);
|
|
233
|
+
expect((failure as Error).message).toContain("staging write failed");
|
|
234
|
+
expect(stage.query("SELECT * FROM issues").all()).toEqual([]);
|
|
235
|
+
expect(stage.query("SELECT * FROM quarantine").all()).toEqual([]);
|
|
236
|
+
expect(stage.query("SELECT * FROM observations").all()).toEqual([]);
|
|
237
|
+
} finally { setup.mockRestore(); stage.close(); rmSync(dir, { recursive: true, force: true }); }
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
122
241
|
test("running daemon refuses prepare before creating a pause marker or backup", async () => {
|
|
123
242
|
const dir = mkdtempSync(join(tmpdir(), "import-busy-")), saved = process.env.PARACHUTE_HOME;
|
|
124
243
|
process.env.PARACHUTE_HOME = dir;
|