@openparachute/vault 0.7.9-rc.5 → 0.7.9-rc.6
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 +4 -0
- package/core/src/history-import.test.ts +82 -0
- package/core/src/history-import.ts +157 -0
- package/core/src/history.ts +4 -2
- package/core/src/mcp-manifest.ts +6 -0
- package/core/src/mcp.ts +11 -4
- 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 +25 -0
- package/core/src/schema.ts +33 -1
- package/package.json +1 -1
- package/src/cli.ts +10 -1
- package/src/history-import-mcp.test.ts +94 -0
- package/src/history-import-routes.test.ts +91 -0
- package/src/history-import.test.ts +318 -0
- package/src/history-import.ts +482 -0
- package/src/mcp-http.ts +13 -0
- package/src/mcp-tools.ts +7 -1
- package/src/mirror-config.ts +38 -0
- package/src/mirror-manager.ts +6 -0
- package/src/mirror-retirement.test.ts +79 -0
- package/src/mirror-routes.ts +12 -2
- package/src/routes.ts +28 -9
- package/src/server.ts +33 -14
- package/src/vault-store.ts +2 -0
package/README.md
CHANGED
|
@@ -992,3 +992,7 @@ wizard, `parachute init --vault-name <name>`, or `parachute-vault create
|
|
|
992
992
|
## License
|
|
993
993
|
|
|
994
994
|
AGPL-3.0
|
|
995
|
+
|
|
996
|
+
### Importing legacy git history
|
|
997
|
+
|
|
998
|
+
`parachute-vault history-import plan` reports a local mirror archive's recoverable history and retention effects. The offline `prepare`, `apply`, and `retire` commands import it without rewriting live notes, preserve the static archive, and prevent retired mirrors from restarting. See [the cutover and recovery procedure](UPGRADING.md#git-history-import-schema-v30--v31). Audited duplicate trees can use explicit `--selections` on plan/prepare; apply uses the embedded manifest, and unresolved notes stay quarantined. Nothing imports or retires automatically on upgrade.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { SqliteStore } from "./store.js";
|
|
4
|
+
import { resolveHistoryPolicy, eraseHistory, getVersion } from "./history.js";
|
|
5
|
+
import { applyImportedNote, beginImportRun, importTargetDigest, getImportedVersion, importStorageIndex, type ImportedObservation } from "./history-import.js";
|
|
6
|
+
|
|
7
|
+
const run = { run_id: "manifest", source_fingerprint: "source", tip: "tip", options_digest: "options" };
|
|
8
|
+
const rows: ImportedObservation[] = ["older", "newer"].map(content => ({ content, path: "history", metadata: {}, extension: "md", created_at: null, observed_at: "2026-01-01T00:00:00Z", commit: content, blob: content }));
|
|
9
|
+
async function fixture() {
|
|
10
|
+
const db = new Database(":memory:");
|
|
11
|
+
const store = new SqliteStore(db);
|
|
12
|
+
const note = await store.createNote("live", { path: "history" });
|
|
13
|
+
beginImportRun(db, run);
|
|
14
|
+
const opts = { run, noteId: note.id, observations: rows, policy: resolveHistoryPolicy({ min_versions: 20 }), now: Date.parse("2026-01-02T00:00:00Z"), targetDigest: importTargetDigest(db, note.id) };
|
|
15
|
+
return { db, store, note, opts };
|
|
16
|
+
}
|
|
17
|
+
test("import preserves live state and native allocation; refs survive erase without resurrection", async () => {
|
|
18
|
+
const { db, store, note, opts } = await fixture();
|
|
19
|
+
try {
|
|
20
|
+
const result = applyImportedNote(db, opts);
|
|
21
|
+
expect(result.receipt.retained_count).toBe(2);
|
|
22
|
+
expect(getImportedVersion(db, note.id, 0)?.content).toBe("newer");
|
|
23
|
+
expect(getImportedVersion(db, note.id, 1)?.content).toBe("older");
|
|
24
|
+
expect((await store.getNote(note.id))?.content).toBe("live");
|
|
25
|
+
await store.updateNote(note.id, { content: "native" });
|
|
26
|
+
expect(getVersion(db, note.id, 0)?.content).toBe("live");
|
|
27
|
+
eraseHistory(db, note.id);
|
|
28
|
+
expect(applyImportedNote(db, opts).skipped).toBe(true);
|
|
29
|
+
expect(getImportedVersion(db, note.id, 0)).toBeNull();
|
|
30
|
+
expect(db.query("SELECT count(*) n FROM note_versions").get()).toEqual({ n: 0 });
|
|
31
|
+
expect(() => applyImportedNote(db, { ...opts, run: { ...run, run_id: "different" } })).toThrow("already owned");
|
|
32
|
+
} finally { db.close(); }
|
|
33
|
+
});
|
|
34
|
+
test("failed native-drop projection rolls back blobs, references and receipt", async () => {
|
|
35
|
+
const { db, opts } = await fixture();
|
|
36
|
+
try {
|
|
37
|
+
expect(() => applyImportedNote(db, { ...opts, expectedNativeDrops: [123] })).toThrow("deletion set");
|
|
38
|
+
for (const table of ["history_import_receipts", "history_import_refs", "note_versions", "note_blobs"]) expect(db.query(`SELECT count(*) n FROM ${table}`).get()).toEqual({ n: 0 });
|
|
39
|
+
} finally { db.close(); }
|
|
40
|
+
});
|
|
41
|
+
test("stale target and oversized note cannot leave a partial import", async () => {
|
|
42
|
+
const { db, store, note, opts } = await fixture();
|
|
43
|
+
try {
|
|
44
|
+
await store.updateNote(note.id, { content: "changed" });
|
|
45
|
+
expect(() => applyImportedNote(db, opts)).toThrow("Stale");
|
|
46
|
+
expect(() => applyImportedNote(db, { ...opts, targetDigest: importTargetDigest(db, note.id), observations: [...rows, { ...rows[0]!, content: "x".repeat(2_000_001) }] })).toThrow("Oversized");
|
|
47
|
+
expect(db.query("SELECT count(*) n FROM history_import_receipts").get()).toEqual({ n: 0 });
|
|
48
|
+
expect(db.query("SELECT count(*) n FROM note_versions WHERE version_ix<0").get()).toEqual({ n: 0 });
|
|
49
|
+
} finally { db.close(); }
|
|
50
|
+
});
|
|
51
|
+
test("import allocation rejects unsafe and negative public indices", () => {
|
|
52
|
+
expect(importStorageIndex(0)).toBe(-1);
|
|
53
|
+
for (const n of [-1, 0.5, Number.MAX_SAFE_INTEGER, Infinity, NaN]) expect(() => importStorageIndex(n)).toThrow();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("native allocation starts at zero even when the newest import was removed", async () => {
|
|
57
|
+
const { db, store, note, opts } = await fixture();
|
|
58
|
+
try {
|
|
59
|
+
applyImportedNote(db, opts);
|
|
60
|
+
db.prepare("DELETE FROM note_versions WHERE note_id=? AND version_ix=-1").run(note.id);
|
|
61
|
+
await store.updateNote(note.id, { content: "native edit" });
|
|
62
|
+
expect(getVersion(db, note.id, 0)?.content).toBe("live");
|
|
63
|
+
expect(getImportedVersion(db, note.id, 1)?.content).toBe("older");
|
|
64
|
+
} finally { db.close(); }
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("import policy discloses native removals separately and honors disabled retention", async () => {
|
|
68
|
+
const { db, store, note, opts } = await fixture();
|
|
69
|
+
try {
|
|
70
|
+
await store.updateNote(note.id, { content: "first native" });
|
|
71
|
+
await store.updateNote(note.id, { content: "second native" });
|
|
72
|
+
const result = applyImportedNote(db, { ...opts, targetDigest: importTargetDigest(db, note.id), policy: resolveHistoryPolicy({ min_versions: 1, max_versions: 1 }), expectedNativeDrops: [0] });
|
|
73
|
+
expect(result.nativeDrops).toEqual([0]);
|
|
74
|
+
expect(result.receipt.pruned_native).toBe(1);
|
|
75
|
+
expect(result.receipt.pruned_imported).toBe(2);
|
|
76
|
+
expect(getVersion(db, note.id, 1)?.content).toBe("first native");
|
|
77
|
+
expect((await store.getNote(note.id))?.content).toBe("second native");
|
|
78
|
+
const other = await store.createNote("other");
|
|
79
|
+
const disabled = applyImportedNote(db, { ...opts, noteId: other.id, targetDigest: importTargetDigest(db, other.id), policy: resolveHistoryPolicy({ enabled: false, min_versions: 0, max_versions: 1 }) });
|
|
80
|
+
expect(disabled.receipt.retained_count).toBe(2);
|
|
81
|
+
} finally { db.close(); }
|
|
82
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/** Git-independent import primitives. Callers own source parsing and offline access. */
|
|
2
|
+
import type { Database } from "bun:sqlite";
|
|
3
|
+
import { transaction } from "./txn.js";
|
|
4
|
+
import { HistoryUnrecoverableError, hashContent, byteLength, VERSION_MAX_BYTES, pruneVersions, compactNote, getVersion, type HistoryPolicy, type VersionRow } from "./history.js";
|
|
5
|
+
|
|
6
|
+
export interface ImportedObservation {
|
|
7
|
+
content: string;
|
|
8
|
+
path: string | null;
|
|
9
|
+
metadata: Record<string, unknown>;
|
|
10
|
+
extension: string;
|
|
11
|
+
created_at: string | null;
|
|
12
|
+
observed_at: string;
|
|
13
|
+
commit: string;
|
|
14
|
+
blob: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ImportRun {
|
|
17
|
+
run_id: string;
|
|
18
|
+
source_fingerprint: string;
|
|
19
|
+
tip: string;
|
|
20
|
+
options_digest: string;
|
|
21
|
+
}
|
|
22
|
+
export interface ImportReceipt {
|
|
23
|
+
note_id: string;
|
|
24
|
+
run_id: string;
|
|
25
|
+
state_digest: string;
|
|
26
|
+
imported_count: number;
|
|
27
|
+
retained_count: number;
|
|
28
|
+
pruned_imported: number;
|
|
29
|
+
pruned_native: number;
|
|
30
|
+
completed_at: string;
|
|
31
|
+
}
|
|
32
|
+
export function canonicalJson(value: unknown): string {
|
|
33
|
+
function ordered(v: unknown): unknown {
|
|
34
|
+
if (Array.isArray(v)) return v.map(ordered);
|
|
35
|
+
if (v !== null && typeof v === "object") return Object.fromEntries(Object.entries(v).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, x]) => [k, ordered(x)]));
|
|
36
|
+
return v;
|
|
37
|
+
}
|
|
38
|
+
return JSON.stringify(ordered(value));
|
|
39
|
+
}
|
|
40
|
+
export function importStorageIndex(index: number): number {
|
|
41
|
+
const storage = -index - 1;
|
|
42
|
+
if (!Number.isSafeInteger(index) || index < 0 || !Number.isSafeInteger(storage)) throw new Error("Invalid import_ix");
|
|
43
|
+
return storage;
|
|
44
|
+
}
|
|
45
|
+
export function projectHistoryRow<T extends VersionRow>(row: T): T | (Omit<T, "version_ix"> & { origin: "git-import"; import_ix: number }) {
|
|
46
|
+
if (row.version_ix >= 0) return row;
|
|
47
|
+
const { version_ix, ...rest } = row;
|
|
48
|
+
return { ...rest, origin: "git-import", import_ix: -version_ix - 1 };
|
|
49
|
+
}
|
|
50
|
+
export function getImportedVersion(db: Database, id: string, index: number) {
|
|
51
|
+
const ix = importStorageIndex(index);
|
|
52
|
+
if (!db.prepare("SELECT 1 FROM history_import_refs WHERE note_id=? AND import_ix=?").get(id, index)) return null;
|
|
53
|
+
try {
|
|
54
|
+
const row = getVersion(db, id, ix);
|
|
55
|
+
return row?.op === "import" ? row : null;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error instanceof HistoryUnrecoverableError) throw new ImportedHistoryUnrecoverableError(id, index, error.reason);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function beginImportRun(db: Database, run: ImportRun): void {
|
|
62
|
+
transaction(db, () => {
|
|
63
|
+
const existing = db.prepare("SELECT run_id,source_fingerprint,tip,options_digest FROM history_import_runs WHERE run_id=?").get(run.run_id);
|
|
64
|
+
if (existing) {
|
|
65
|
+
if (canonicalJson(existing) !== canonicalJson(run)) throw new Error("Import run conflicts with its manifest");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
db.prepare("INSERT INTO history_import_runs VALUES(?,?,?,?, 'applying', ?)").run(run.run_id, run.source_fingerprint, run.tip, run.options_digest, new Date().toISOString());
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
export function readImportReceipt(db: Database, id: string): ImportReceipt | null {
|
|
72
|
+
return db.prepare("SELECT * FROM history_import_receipts WHERE note_id=?").get(id) as ImportReceipt | null;
|
|
73
|
+
}
|
|
74
|
+
/** Content-addressed global blob changes are excluded from the per-note state fingerprint. */
|
|
75
|
+
export function importTargetDigest(db: Database, id: string): string {
|
|
76
|
+
return hashContent(canonicalJson({
|
|
77
|
+
note: db.prepare("SELECT id,content,path,metadata,extension,created_at,updated_at FROM notes WHERE id=?").get(id),
|
|
78
|
+
versions: db.prepare("SELECT * FROM note_versions WHERE note_id=? ORDER BY version_ix").all(id),
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
export function importObservationDigest(rows: ImportedObservation[]): string {
|
|
82
|
+
return hashContent(canonicalJson(rows));
|
|
83
|
+
}
|
|
84
|
+
export function applyImportedNote(db: Database, opts: {
|
|
85
|
+
run: ImportRun; noteId: string; observations: ImportedObservation[];
|
|
86
|
+
policy: HistoryPolicy; now: number; targetDigest: string;
|
|
87
|
+
expectedNativeDrops?: number[];
|
|
88
|
+
}): { receipt: ImportReceipt; nativeDrops: number[]; skipped: boolean } {
|
|
89
|
+
return transaction(db, () => {
|
|
90
|
+
const prior = readImportReceipt(db, opts.noteId);
|
|
91
|
+
const digest = importObservationDigest(opts.observations);
|
|
92
|
+
if (prior) {
|
|
93
|
+
if (prior.run_id !== opts.run.run_id || prior.state_digest !== digest) throw new Error("Note already owned by another import; resume its original manifest");
|
|
94
|
+
return { receipt: prior, nativeDrops: [], skipped: true };
|
|
95
|
+
}
|
|
96
|
+
if (!db.prepare("SELECT 1 FROM notes WHERE id=?").get(opts.noteId)) throw new Error("Import requires a live note");
|
|
97
|
+
if (importTargetDigest(db, opts.noteId) !== opts.targetDigest) throw new Error("Stale import manifest: target note changed");
|
|
98
|
+
if (!opts.observations.length) throw new Error("Cannot receipt an empty import");
|
|
99
|
+
if (db.prepare("SELECT 1 FROM note_versions WHERE note_id=? AND version_ix<0").get(opts.noteId)) throw new Error("Unreceipted imported history exists");
|
|
100
|
+
const nativeBefore = db.prepare("SELECT version_ix FROM note_versions WHERE note_id=? AND version_ix>=0 ORDER BY version_ix").all(opts.noteId) as { version_ix: number }[];
|
|
101
|
+
const receipt: ImportReceipt = { note_id: opts.noteId, run_id: opts.run.run_id, state_digest: digest, imported_count: opts.observations.length, retained_count: 0, pruned_imported: 0, pruned_native: 0, completed_at: new Date(opts.now).toISOString() };
|
|
102
|
+
db.prepare("INSERT INTO history_import_receipts VALUES(?,?,?,?,?,?,?,?)").run(receipt.note_id, receipt.run_id, receipt.state_digest, receipt.imported_count, 0, 0, 0, receipt.completed_at);
|
|
103
|
+
for (let i = 0; i < opts.observations.length; i++) {
|
|
104
|
+
const row = opts.observations[i]!;
|
|
105
|
+
const size = byteLength(row.content);
|
|
106
|
+
if (size > VERSION_MAX_BYTES) throw new Error("Oversized imported observation: quarantine the whole note");
|
|
107
|
+
const index = opts.observations.length - i - 1;
|
|
108
|
+
const storage = importStorageIndex(index);
|
|
109
|
+
const hash = hashContent(row.content);
|
|
110
|
+
db.prepare("INSERT OR IGNORE INTO note_blobs(hash,content,byte_size) VALUES(?,?,?)").run(hash, row.content, size);
|
|
111
|
+
db.prepare(`INSERT INTO note_versions(note_id,version_ix,content_hash,path,metadata,extension,superseded_at,actor,via,op,content_len,encoding,created_at)
|
|
112
|
+
VALUES(?,?,?,?,?,?,?,NULL,'git-import','import',?,NULL,?)`).run(opts.noteId, storage, hash, row.path, canonicalJson(row.metadata), row.extension, row.observed_at, size, row.created_at);
|
|
113
|
+
db.prepare("INSERT INTO history_import_refs VALUES(?,?,?,?)").run(opts.noteId, index, row.commit, row.blob);
|
|
114
|
+
}
|
|
115
|
+
if (opts.policy.enabled) pruneVersions(db, opts.noteId, opts.policy, opts.now);
|
|
116
|
+
compactNote(db, opts.noteId, opts.policy);
|
|
117
|
+
const after = new Set((db.prepare("SELECT version_ix FROM note_versions WHERE note_id=?").all(opts.noteId) as { version_ix: number }[]).map(r => r.version_ix));
|
|
118
|
+
for (let i = 0; i < opts.observations.length; i++) {
|
|
119
|
+
const index = opts.observations.length - i - 1;
|
|
120
|
+
if (after.has(importStorageIndex(index)) && getImportedVersion(db, opts.noteId, index)?.content !== opts.observations[i]!.content) throw new Error("Imported content failed verification");
|
|
121
|
+
}
|
|
122
|
+
const nativeDrops = nativeBefore.map(r => r.version_ix).filter(ix => !after.has(ix));
|
|
123
|
+
if (opts.expectedNativeDrops && canonicalJson(nativeDrops) !== canonicalJson(opts.expectedNativeDrops)) throw new Error("Native deletion set differs from final manifest");
|
|
124
|
+
receipt.retained_count = [...after].filter(ix => ix < 0).length;
|
|
125
|
+
receipt.pruned_imported = receipt.imported_count - receipt.retained_count;
|
|
126
|
+
receipt.pruned_native = nativeDrops.length;
|
|
127
|
+
db.prepare("UPDATE history_import_receipts SET retained_count=?,pruned_imported=?,pruned_native=? WHERE note_id=?").run(receipt.retained_count, receipt.pruned_imported, receipt.pruned_native, opts.noteId);
|
|
128
|
+
return { receipt, nativeDrops, skipped: false };
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Public import errors must not expose the negative storage key, even in prose. */
|
|
133
|
+
export class ImportedHistoryUnrecoverableError extends Error {
|
|
134
|
+
readonly code = "HISTORY_UNRECOVERABLE";
|
|
135
|
+
readonly error_type = "history_unrecoverable";
|
|
136
|
+
readonly origin = "git-import";
|
|
137
|
+
constructor(readonly note_id: string, readonly import_ix: number, readonly reason: "overflow" | "delta_orphan") {
|
|
138
|
+
super(`Imported version content is unrecoverable: "${note_id}" import ${import_ix}`);
|
|
139
|
+
this.name = "ImportedHistoryUnrecoverableError";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function parseHistorySelector(value: { version_ix?: unknown; origin?: unknown; import_ix?: unknown }):
|
|
143
|
+
{ version_ix: number } | { origin: "git-import"; import_ix: number } | null {
|
|
144
|
+
const imported = value.origin !== undefined || value.import_ix !== undefined;
|
|
145
|
+
if (imported) {
|
|
146
|
+
if (value.version_ix !== undefined || value.origin !== "git-import" || typeof value.import_ix !== "number"
|
|
147
|
+
|| !Number.isSafeInteger(value.import_ix) || value.import_ix < 0) throw invalidHistorySelector();
|
|
148
|
+
importStorageIndex(value.import_ix);
|
|
149
|
+
return { origin: "git-import", import_ix: value.import_ix };
|
|
150
|
+
}
|
|
151
|
+
if (value.version_ix === undefined) return null;
|
|
152
|
+
if (typeof value.version_ix !== "number" || !Number.isSafeInteger(value.version_ix) || value.version_ix < 0) throw invalidHistorySelector();
|
|
153
|
+
return { version_ix: value.version_ix };
|
|
154
|
+
}
|
|
155
|
+
function invalidHistorySelector() {
|
|
156
|
+
return Object.assign(new Error("Specify either a nonnegative version_ix or origin git-import with a nonnegative import_ix"), { error_type: "invalid_request", field: "versions" });
|
|
157
|
+
}
|
package/core/src/history.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { encodeDelta, decodeDelta } from "./delta.js";
|
|
|
16
16
|
import { transaction } from "./txn.js";
|
|
17
17
|
|
|
18
18
|
export type HistoryOp =
|
|
19
|
+
| "import"
|
|
19
20
|
| "update"
|
|
20
21
|
| "append"
|
|
21
22
|
| "prepend"
|
|
@@ -162,7 +163,7 @@ function nextIndex(db: Database, noteId: string): number {
|
|
|
162
163
|
return (
|
|
163
164
|
db
|
|
164
165
|
.prepare(
|
|
165
|
-
"SELECT COALESCE(MAX(version_ix), -1) + 1 AS ix FROM note_versions WHERE note_id = ?",
|
|
166
|
+
"SELECT COALESCE(MAX(version_ix), -1) + 1 AS ix FROM note_versions WHERE note_id = ? AND version_ix >= 0",
|
|
166
167
|
)
|
|
167
168
|
.get(noteId) as { ix: number }
|
|
168
169
|
).ix;
|
|
@@ -258,6 +259,7 @@ export function pruneVersions(
|
|
|
258
259
|
db: Database,
|
|
259
260
|
noteId: string,
|
|
260
261
|
policy: HistoryPolicy,
|
|
262
|
+
now = Date.now(),
|
|
261
263
|
): { versionsDeleted: number; blobsDeleted: number } {
|
|
262
264
|
const rows = db
|
|
263
265
|
.prepare(
|
|
@@ -268,7 +270,7 @@ export function pruneVersions(
|
|
|
268
270
|
"version_ix" | "superseded_at" | "op" | "content_hash"
|
|
269
271
|
>[];
|
|
270
272
|
const cutoff = new Date(
|
|
271
|
-
|
|
273
|
+
now - policy.max_age_days * 86_400_000,
|
|
272
274
|
).toISOString();
|
|
273
275
|
const doomed = rows.filter(
|
|
274
276
|
(r, i) =>
|
package/core/src/mcp-manifest.ts
CHANGED
|
@@ -213,10 +213,16 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
213
213
|
properties: {
|
|
214
214
|
note_id: { type: "string", description: "Note ID or path" },
|
|
215
215
|
version_ix: { type: "integer", minimum: 0 },
|
|
216
|
+
origin: { type: "string", enum: ["git-import"] },
|
|
217
|
+
import_ix: { type: "integer", minimum: 0 },
|
|
216
218
|
limit: { type: "integer", minimum: 0, maximum: 200 },
|
|
217
219
|
offset: { type: "integer", minimum: 0 },
|
|
218
220
|
},
|
|
219
221
|
required: ["note_id"],
|
|
222
|
+
oneOf: [
|
|
223
|
+
{ required: ["origin", "import_ix"], not: { required: ["version_ix"] } },
|
|
224
|
+
{ not: { anyOf: [{ required: ["origin"] }, { required: ["import_ix"] }] } },
|
|
225
|
+
],
|
|
220
226
|
description: "Read note history; mutually exclusive with id, search, near, cursor, aggregate, and semantic. Tag-scoped sessions see the SAME visibility enforcement as every other read — a note outside the token's tag scope answers `not_found`, and a deleted note's history is not readable by a scoped session at all. Deleted-note history is also unavailable to unscoped MCP sessions; restore is REST-only.",
|
|
221
227
|
},
|
|
222
228
|
sort: {
|
package/core/src/mcp.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getImportedVersion, parseHistorySelector, projectHistoryRow } from "./history-import.js";
|
|
1
2
|
import { Database } from "bun:sqlite";
|
|
2
3
|
import type { Store, Note, QueryOpts, Attachment } from "./types.js";
|
|
3
4
|
import { transactionAsync } from "./txn.js";
|
|
@@ -698,16 +699,22 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
698
699
|
error_type: "invalid_query", field: "versions", hint: `drop ${key} when using versions`,
|
|
699
700
|
});
|
|
700
701
|
}
|
|
701
|
-
const v = params.versions as { note_id: string; version_ix?: number; limit?: number; offset?: number };
|
|
702
|
+
const v = params.versions as { note_id: string; version_ix?: number; origin?: unknown; import_ix?: unknown; limit?: number; offset?: number };
|
|
703
|
+
const selector = parseHistorySelector(v);
|
|
702
704
|
const note = requireNote(db, requireNoteReference(v.note_id));
|
|
703
|
-
if (
|
|
704
|
-
const version =
|
|
705
|
+
if (selector && "import_ix" in selector) {
|
|
706
|
+
const version = getImportedVersion(db, note.id, selector.import_ix);
|
|
707
|
+
if (!version) return { error: "Imported version not found", error_type: "not_found", id: v.note_id, ...selector };
|
|
708
|
+
return projectHistoryRow(version);
|
|
709
|
+
}
|
|
710
|
+
if (selector && "version_ix" in selector) {
|
|
711
|
+
const version = await store.getNoteVersion(note.id, selector.version_ix);
|
|
705
712
|
if (!version) return { error: `Version not found: "${v.note_id}"@${v.version_ix}`, error_type: "not_found", id: v.note_id, version_ix: v.version_ix };
|
|
706
713
|
return version;
|
|
707
714
|
}
|
|
708
715
|
const versions = await store.listNoteVersions(note.id, { limit: Math.max(0, Math.min(v.limit ?? 50, 200)), offset: v.offset ?? 0 });
|
|
709
716
|
const total = await store.countNoteVersions(note.id);
|
|
710
|
-
return { versions, total };
|
|
717
|
+
return { versions: versions.map(projectHistoryRow), total };
|
|
711
718
|
}
|
|
712
719
|
|
|
713
720
|
// --- Single note by ID/path ---
|
|
@@ -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: 31 });
|
|
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(31);
|
|
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([]);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { SqliteStore } from "./store.js";
|
|
4
|
+
import { initSchema } from "./schema.js";
|
|
5
|
+
|
|
6
|
+
test("v30 to v31 retains native history and matches fresh import tables; reopening is idempotent", async () => {
|
|
7
|
+
const db = new Database(":memory:"), fresh = new Database(":memory:");
|
|
8
|
+
try {
|
|
9
|
+
const store = new SqliteStore(db), note = await store.createNote("before");
|
|
10
|
+
await store.updateNote(note.id, { content: "after" });
|
|
11
|
+
const tables = ["history_import_refs", "history_import_receipts", "history_import_runs"];
|
|
12
|
+
for (const table of tables) db.exec(`DROP TABLE ${table}`);
|
|
13
|
+
db.exec("UPDATE schema_version SET version=30");
|
|
14
|
+
const before = db.query("SELECT * FROM note_versions").all();
|
|
15
|
+
initSchema(db); initSchema(fresh); initSchema(db);
|
|
16
|
+
expect(db.query("SELECT MAX(version) AS version FROM schema_version").get()).toEqual({ version: 31 });
|
|
17
|
+
expect(db.query("SELECT * FROM note_versions").all()).toEqual(before);
|
|
18
|
+
for (const table of tables) {
|
|
19
|
+
expect(db.query(`PRAGMA table_info(${table})`).all()).toEqual(fresh.query(`PRAGMA table_info(${table})`).all());
|
|
20
|
+
expect(db.query(`SELECT * FROM ${table}`).all()).toEqual([]);
|
|
21
|
+
}
|
|
22
|
+
expect(db.query("PRAGMA foreign_key_check").all()).toEqual([]);
|
|
23
|
+
expect(db.query("PRAGMA integrity_check").get()).toEqual({ integrity_check: "ok" });
|
|
24
|
+
} finally { db.close(); fresh.close(); }
|
|
25
|
+
});
|
package/core/src/schema.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { transaction } from "./txn.js";
|
|
|
6
6
|
import { timestampToMs } from "./cursor.js";
|
|
7
7
|
import { ensureRelationshipColumn } from "./wikilinks.js";
|
|
8
8
|
|
|
9
|
-
export const SCHEMA_VERSION =
|
|
9
|
+
export const SCHEMA_VERSION = 31;
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Deterministic last-resort epoch for a note whose `updated_at` AND
|
|
@@ -741,6 +741,7 @@ export function initSchema(db: Database): void {
|
|
|
741
741
|
migrateToV29(db);
|
|
742
742
|
// v30: depth-one history deltas and captured creation time, no backfill.
|
|
743
743
|
migrateToV30(db);
|
|
744
|
+
migrateToV31(db);
|
|
744
745
|
|
|
745
746
|
// Rebuild any generated columns + indexes declared in indexed_fields.
|
|
746
747
|
// No-op for a fresh vault; idempotent on existing vaults.
|
|
@@ -2032,3 +2033,34 @@ function migrateFromV2(db: Database): void {
|
|
|
2032
2033
|
// Re-enable FK checks
|
|
2033
2034
|
db.exec("PRAGMA foreign_keys = ON");
|
|
2034
2035
|
}
|
|
2036
|
+
|
|
2037
|
+
/** Import receipts deliberately survive version retention and erasure. */
|
|
2038
|
+
function migrateToV31(db: Database): void {
|
|
2039
|
+
transaction(db, () => db.exec(`
|
|
2040
|
+
CREATE TABLE IF NOT EXISTS history_import_runs (
|
|
2041
|
+
run_id TEXT PRIMARY KEY,
|
|
2042
|
+
source_fingerprint TEXT NOT NULL,
|
|
2043
|
+
tip TEXT NOT NULL,
|
|
2044
|
+
options_digest TEXT NOT NULL,
|
|
2045
|
+
status TEXT NOT NULL CHECK(status IN ('applying', 'complete')),
|
|
2046
|
+
created_at TEXT NOT NULL
|
|
2047
|
+
);
|
|
2048
|
+
CREATE TABLE IF NOT EXISTS history_import_receipts (
|
|
2049
|
+
note_id TEXT PRIMARY KEY,
|
|
2050
|
+
run_id TEXT NOT NULL REFERENCES history_import_runs(run_id),
|
|
2051
|
+
state_digest TEXT NOT NULL,
|
|
2052
|
+
imported_count INTEGER NOT NULL,
|
|
2053
|
+
retained_count INTEGER NOT NULL,
|
|
2054
|
+
pruned_imported INTEGER NOT NULL,
|
|
2055
|
+
pruned_native INTEGER NOT NULL,
|
|
2056
|
+
completed_at TEXT NOT NULL
|
|
2057
|
+
);
|
|
2058
|
+
CREATE TABLE IF NOT EXISTS history_import_refs (
|
|
2059
|
+
note_id TEXT NOT NULL REFERENCES history_import_receipts(note_id),
|
|
2060
|
+
import_ix INTEGER NOT NULL CHECK(import_ix >= 0),
|
|
2061
|
+
source_commit TEXT NOT NULL,
|
|
2062
|
+
source_blob TEXT NOT NULL,
|
|
2063
|
+
PRIMARY KEY(note_id, import_ix)
|
|
2064
|
+
);
|
|
2065
|
+
`));
|
|
2066
|
+
}
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -296,6 +296,11 @@ switch (command) {
|
|
|
296
296
|
case "backup":
|
|
297
297
|
await cmdBackup(cmdArgs);
|
|
298
298
|
break;
|
|
299
|
+
case "history-import": {
|
|
300
|
+
const { runHistoryImport } = await import("./history-import.ts");
|
|
301
|
+
console.log(JSON.stringify(await runHistoryImport(cmdArgs), null, 2));
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
299
304
|
case "import":
|
|
300
305
|
await cmdImport(cmdArgs);
|
|
301
306
|
break;
|
|
@@ -4815,7 +4820,7 @@ async function createVault(
|
|
|
4815
4820
|
// git-less box leaves the config written but inactive + logs an actionable
|
|
4816
4821
|
// hint — it must NEVER fail the vault create. Create-time ONLY: existing
|
|
4817
4822
|
// vaults are never retroactively migrated.
|
|
4818
|
-
if (shouldEnableCreateTimeMirror(opts)) {
|
|
4823
|
+
if (shouldEnableCreateTimeMirror(opts) && (await import("./mirror-config.ts")).readHistoryMirrorPhase(name) === "active") {
|
|
4819
4824
|
const mirrorConfig = historyPresetMirrorConfig();
|
|
4820
4825
|
writeMirrorConfigForVault(name, mirrorConfig);
|
|
4821
4826
|
const mirrorPath = resolveMirrorPath(vaultDir(name), mirrorConfig);
|
|
@@ -5213,6 +5218,10 @@ Import/Export:
|
|
|
5213
5218
|
--git-push to push after commit)
|
|
5214
5219
|
|
|
5215
5220
|
History:
|
|
5221
|
+
parachute-vault history-import plan|prepare|apply|retire|cancel --vault <name>
|
|
5222
|
+
Import a local git archive into note history;
|
|
5223
|
+
plan/prepare: --selections <file> for audited duplicates;
|
|
5224
|
+
see UPGRADING.md for offline cutover and recovery.
|
|
5216
5225
|
parachute-vault history [--vault <name>] Show the vault's git write history (the
|
|
5217
5226
|
mirror is git-backed — one file per note,
|
|
5218
5227
|
so git log IS a tamper-evident history)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { handleMcp } from "./mcp-http.ts";
|
|
3
|
+
import type { McpToolDef } from "../core/src/mcp.ts";
|
|
4
|
+
|
|
5
|
+
async function errorEnvelope(fields: Record<string, unknown>) {
|
|
6
|
+
const tool: McpToolDef = {
|
|
7
|
+
name: "query-notes", description: "serializer boundary fixture", requiredVerb: "read",
|
|
8
|
+
inputSchema: { type: "object" },
|
|
9
|
+
execute() { throw Object.assign(new Error("Unrecoverable history"), fields); },
|
|
10
|
+
};
|
|
11
|
+
const request = new Request("http://localhost/vault/import-test/mcp", {
|
|
12
|
+
method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
13
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool.name, arguments: {} } }),
|
|
14
|
+
});
|
|
15
|
+
const response = await handleMcp(request, () => [tool], "import-test", "import-test", {
|
|
16
|
+
permission: "full", scopes: ["vault:import-test:read"], legacyDerived: false,
|
|
17
|
+
scoped_tags: null, vault_name: null, caller_jti: null, actor: "test", via: "api",
|
|
18
|
+
}, "");
|
|
19
|
+
return await response.json() as any;
|
|
20
|
+
}
|
|
21
|
+
test("HTTP MCP forwards valid imported history references only on the dedicated error branch", async () => {
|
|
22
|
+
const fields = { code: "HISTORY_UNRECOVERABLE", error_type: "history_unrecoverable", origin: "git-import", import_ix: 0, version_ix: -1 };
|
|
23
|
+
const rpc = await errorEnvelope(fields);
|
|
24
|
+
expect(rpc.result).toBeUndefined();
|
|
25
|
+
expect(rpc.error.code).toBe(-32602);
|
|
26
|
+
expect(rpc.error.data).toEqual({ error_type: "history_unrecoverable", origin: "git-import", import_ix: 0 });
|
|
27
|
+
for (const invalid of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, "0", null]) {
|
|
28
|
+
const bad = await errorEnvelope({ ...fields, import_ix: invalid });
|
|
29
|
+
expect(bad.result).toBeUndefined();
|
|
30
|
+
expect(bad.error.data).toEqual({ error_type: "history_unrecoverable" });
|
|
31
|
+
}
|
|
32
|
+
const generic = await errorEnvelope({ ...fields, code: "OTHER" });
|
|
33
|
+
expect(generic.error.data).toEqual({ error_type: "history_unrecoverable" });
|
|
34
|
+
const native = await errorEnvelope({ code: "HISTORY_UNRECOVERABLE", error_type: "history_unrecoverable", version_ix: 0, note_id: "note" });
|
|
35
|
+
expect(native.error.data).toEqual({ error_type: "history_unrecoverable" });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
39
|
+
import { tmpdir } from "node:os";
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
import { writeVaultConfig } from "./config.ts";
|
|
42
|
+
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
43
|
+
import { generateScopedMcpTools } from "./mcp-tools.ts";
|
|
44
|
+
import { applyImportedNote, beginImportRun, importTargetDigest } from "../core/src/history-import.ts";
|
|
45
|
+
import { resolveHistoryPolicy } from "../core/src/history.ts";
|
|
46
|
+
|
|
47
|
+
test("HTTP MCP imported get/list/error keeps discriminator and rejects mixed selectors", async () => {
|
|
48
|
+
const saved = process.env.PARACHUTE_HOME;
|
|
49
|
+
const dir = mkdtempSync(join(tmpdir(), "history-import-mcp-"));
|
|
50
|
+
process.env.PARACHUTE_HOME = dir;
|
|
51
|
+
try {
|
|
52
|
+
writeVaultConfig({ name: "import-test", api_keys: [], created_at: new Date().toISOString() });
|
|
53
|
+
const store = getVaultStore("import-test");
|
|
54
|
+
const note = await store.createNote("live");
|
|
55
|
+
const run = { run_id: "test", source_fingerprint: "source", tip: "tip", options_digest: "options" };
|
|
56
|
+
beginImportRun(store.db, run);
|
|
57
|
+
applyImportedNote(store.db, {
|
|
58
|
+
run, noteId: note.id, policy: resolveHistoryPolicy({ min_versions: 20 }), now: Date.now(),
|
|
59
|
+
targetDigest: importTargetDigest(store.db, note.id),
|
|
60
|
+
observations: [{ content: "archive", path: null, metadata: {}, extension: "md", created_at: null, observed_at: new Date().toISOString(), commit: "commit", blob: "blob" }],
|
|
61
|
+
});
|
|
62
|
+
const auth = { permission: "full" as const, scopes: ["vault:import-test:read"], legacyDerived: false, scoped_tags: null, vault_name: null, caller_jti: null, actor: "test", via: "api" };
|
|
63
|
+
async function call(selector: Record<string, unknown>) {
|
|
64
|
+
const req = new Request("http://localhost/vault/import-test/mcp", { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "query-notes", arguments: { versions: { note_id: note.id, ...selector } } } }) });
|
|
65
|
+
return await (await handleMcp(req, () => generateScopedMcpTools("import-test", auth), "test", "import-test", auth, "")).json() as any;
|
|
66
|
+
}
|
|
67
|
+
const got = await call({ origin: "git-import", import_ix: 0 });
|
|
68
|
+
const row = JSON.parse(got.result.content[0].text);
|
|
69
|
+
expect(row.content).toBe("archive");
|
|
70
|
+
expect(row.origin).toBe("git-import");
|
|
71
|
+
expect(row.import_ix).toBe(0);
|
|
72
|
+
expect(row.version_ix).toBeUndefined();
|
|
73
|
+
const listed = JSON.parse((await call({})).result.content[0].text);
|
|
74
|
+
expect(listed.versions[0].origin).toBe("git-import");
|
|
75
|
+
for (const selector of [{ origin: "git-import" }, { import_ix: 0 }, { version_ix: 0, origin: "git-import", import_ix: 0 }, { version_ix: -1 }]) {
|
|
76
|
+
const invalid = await call(selector);
|
|
77
|
+
expect(invalid.error).toBeDefined();
|
|
78
|
+
expect(invalid.result).toBeUndefined();
|
|
79
|
+
}
|
|
80
|
+
store.db.exec("UPDATE note_blobs SET encoding='unknown'");
|
|
81
|
+
const broken = await call({ origin: "git-import", import_ix: 0 });
|
|
82
|
+
expect(broken.result).toBeUndefined();
|
|
83
|
+
expect(broken.error.data).toEqual({ error_type: "history_unrecoverable", origin: "git-import", import_ix: 0 });
|
|
84
|
+
expect(broken.error.message).not.toContain("@-1");
|
|
85
|
+
const scoped = generateScopedMcpTools("import-test", { ...auth, scoped_tags: ["journal"] }).find(t => t.name === "query-notes")!;
|
|
86
|
+
const denied = await scoped.execute({ versions: { note_id: note.id, origin: "git-import", import_ix: 0 } });
|
|
87
|
+
expect(denied).toEqual({ error: "Note not found", error_type: "not_found", id: note.id });
|
|
88
|
+
|
|
89
|
+
} finally {
|
|
90
|
+
clearVaultStoreCache();
|
|
91
|
+
if (saved === undefined) delete process.env.PARACHUTE_HOME; else process.env.PARACHUTE_HOME = saved;
|
|
92
|
+
rmSync(dir, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
});
|