@openparachute/vault 0.7.9-rc.5 → 0.7.9-rc.7

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 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
+ }
@@ -227,3 +227,60 @@ describe("vault#524 history retention and blob integrity", () => {
227
227
  expect(await store.getNote(n.id)).toBeNull();
228
228
  });
229
229
  });
230
+
231
+ describe("vault#743 whole-blob integrity", () => {
232
+ it("reads valid whole blobs including empty and multibyte text", async () => {
233
+ const empty = await store.createNote("placeholder");
234
+ await store.updateNote(empty.id, { content: "" });
235
+ await store.updateNote(empty.id, { content: "final" });
236
+ expect((await store.getNoteVersion(empty.id, 1))!.content).toBe("");
237
+
238
+ const multi = await store.createNote("é".repeat(1000));
239
+ await store.updateNote(multi.id, { content: "next" });
240
+ expect((await store.getNoteVersion(multi.id, 0))!.content).toBe("é".repeat(1000));
241
+
242
+ const plain = await store.createNote("whole body");
243
+ await store.updateNote(plain.id, { content: "updated" });
244
+ expect((await store.getNoteVersion(plain.id, 0))!.content).toBe("whole body");
245
+ });
246
+ it("rejects a corrupted whole blob on direct read", async () => {
247
+ const n = await store.createNote("original");
248
+ await store.updateNote(n.id, { content: "live" });
249
+ const hash = (await store.listNoteVersions(n.id))[0]!.content_hash;
250
+ db.prepare("UPDATE note_blobs SET content = ? WHERE hash = ?").run(
251
+ "tampered",
252
+ hash,
253
+ );
254
+ expect(() => history!.readBlobContent(db, hash!)).toThrow(
255
+ history!.HistoryDeltaOrphanError,
256
+ );
257
+ });
258
+ it("rejects a corrupted whole blob through version read", async () => {
259
+ const n = await store.createNote("original");
260
+ await store.updateNote(n.id, { content: "live" });
261
+ const hash = (await store.listNoteVersions(n.id))[0]!.content_hash;
262
+ db.prepare("UPDATE note_blobs SET content = ? WHERE hash = ?").run(
263
+ "tampered",
264
+ hash,
265
+ );
266
+ await expect(store.getNoteVersion(n.id, 0)).rejects.toMatchObject({
267
+ code: "HISTORY_UNRECOVERABLE",
268
+ reason: "delta_orphan",
269
+ });
270
+ });
271
+ it("rejects restoring a corrupted whole blob and leaves live note and history unchanged", async () => {
272
+ const n = await store.createNote("original");
273
+ await store.updateNote(n.id, { content: "live" });
274
+ const versionsBefore = await store.listNoteVersions(n.id);
275
+ const hash = versionsBefore[0]!.content_hash;
276
+ db.prepare("UPDATE note_blobs SET content = ? WHERE hash = ?").run(
277
+ "tampered",
278
+ hash,
279
+ );
280
+ await expect(store.restoreNoteVersion(n.id, 0, {})).rejects.toMatchObject({
281
+ code: "HISTORY_UNRECOVERABLE",
282
+ });
283
+ expect((await store.getNote(n.id))!.content).toBe("live");
284
+ expect(await store.listNoteVersions(n.id)).toEqual(versionsBefore);
285
+ });
286
+ });
@@ -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
- Date.now() - policy.max_age_days * 86_400_000,
273
+ now - policy.max_age_days * 86_400_000,
272
274
  ).toISOString();
273
275
  const doomed = rows.filter(
274
276
  (r, i) =>
@@ -436,8 +438,11 @@ interface BlobReadRow {
436
438
  function materialise(db: Database, hash: string, row: BlobReadRow): string | null {
437
439
  if (row.content === null)
438
440
  return null;
439
- if (row.blob_encoding === null)
441
+ if (row.blob_encoding === null) {
442
+ if (hashContent(row.content) !== hash)
443
+ throw new HistoryDeltaOrphanError(hash, row.delta_of, "identity_mismatch");
440
444
  return row.content;
445
+ }
441
446
  if (row.blob_encoding !== "fossil-delta")
442
447
  throw new HistoryDeltaOrphanError(hash, row.delta_of, "unknown_encoding");
443
448
  const base = row.delta_of ? db.prepare("SELECT content, encoding FROM note_blobs WHERE hash = ?").get(row.delta_of) as {
@@ -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 (typeof v.version_ix === "number") {
704
- const version = await store.getNoteVersion(note.id, v.version_ix);
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: 30 });
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(30);
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
+ });
@@ -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 = 30;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.9-rc.5",
3
+ "version": "0.7.9-rc.7",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -29,6 +29,7 @@ const { handleScopedMcp } = await import("./mcp-http.ts");
29
29
  const { getServerInstruction } = await import("./mcp-tools.ts");
30
30
  const { writeVaultConfig } = await import("./config.ts");
31
31
  const { getVaultStore } = await import("./vault-store.ts");
32
+ const { clearScribeUrlCache } = await import("./scribe-discovery.ts");
32
33
  const {
33
34
  getSharedAttachmentTicketProvider,
34
35
  InProcessAttachmentTicketProvider,
@@ -275,33 +276,54 @@ describe("attachment tickets — upload lifecycle", () => {
275
276
  // exactly what a new box looks like. The upload must not come back looking
276
277
  // like an ordinary file: it carries a `failed` status and an actionable
277
278
  // reason, so the state is visible in the API and the admin SPA.
278
- test("audio + auto-transcribe on + NO provider → attachment records the failure, not silence", async () => {
279
- const vaultName = freshVault("tickets-no-provider");
280
- const store = getVaultStore(vaultName);
281
- const note = await store.createNote("# Voice memo\n", { path: "memo-noprov" });
282
-
283
- const mint = await callTool(vaultName, "request-attachment-upload", {
284
- note: note.id,
285
- filename: "memo.webm",
286
- size_bytes: 4,
287
- // NO `transcribe: true` — this is the AUTO path, the one that used to
288
- // silently do nothing.
289
- });
290
- const res = await routeReq(
291
- new Request(mint.url, {
292
- method: "PUT",
293
- headers: { "content-type": "audio/webm" },
294
- body: new Uint8Array([1, 2, 3, 4]),
295
- }),
296
- );
297
- expect(res.status).toBe(201);
298
- const attachment = (await res.json()) as any;
299
- expect(attachment.metadata.transcribe_status).toBe("failed");
300
- expect(attachment.metadata.transcribe_error).toMatch(/no transcription provider configured/i);
301
- // The reason has to be actionable — naming both routes out of it.
302
- expect(attachment.metadata.transcribe_error).toMatch(/TRANSCRIPTION_PROVIDER/);
303
- expect(attachment.metadata.transcribe_error).toMatch(/SCRIBE_URL/);
304
- expect(attachment.metadata.transcribe_origin).toBe("auto");
279
+ //
280
+ // Fixture: select the remote provider so the presence/absence of a local
281
+ // whisper-cpp install on the host cannot change the message. With no
282
+ // SCRIBE_URL configured, auto-transcribe is still unavailable and reports
283
+ // the same actionable no-provider error on every box.
284
+ test("audio + auto-transcribe on + scribe-http without URL → attachment records the failure, not silence", async () => {
285
+ const prevProvider = process.env.TRANSCRIPTION_PROVIDER;
286
+ const prevScribeUrl = process.env.SCRIBE_URL;
287
+ process.env.TRANSCRIPTION_PROVIDER = "scribe-http";
288
+ delete process.env.SCRIBE_URL;
289
+ clearScribeUrlCache();
290
+ try {
291
+ const vaultName = freshVault("tickets-no-provider");
292
+ const store = getVaultStore(vaultName);
293
+ const note = await store.createNote("# Voice memo\n", { path: "memo-noprov" });
294
+
295
+ const mint = await callTool(vaultName, "request-attachment-upload", {
296
+ note: note.id,
297
+ filename: "memo.webm",
298
+ size_bytes: 4,
299
+ // NO `transcribe: true` — this is the AUTO path, the one that used to
300
+ // silently do nothing.
301
+ });
302
+ const res = await routeReq(
303
+ new Request(mint.url, {
304
+ method: "PUT",
305
+ headers: { "content-type": "audio/webm" },
306
+ body: new Uint8Array([1, 2, 3, 4]),
307
+ }),
308
+ );
309
+ expect(res.status).toBe(201);
310
+ const attachment = (await res.json()) as any;
311
+ expect(attachment.metadata.transcribe_status).toBe("failed");
312
+ expect(attachment.metadata.transcribe_error).toMatch(/no transcription provider configured/i);
313
+ // The reason has to be actionable — naming both routes out of it.
314
+ expect(attachment.metadata.transcribe_error).toMatch(/TRANSCRIPTION_PROVIDER/);
315
+ expect(attachment.metadata.transcribe_error).toMatch(/SCRIBE_URL/);
316
+ expect(attachment.metadata.transcribe_origin).toBe("auto");
317
+ const unchanged = await store.getNote(note.id);
318
+ expect(unchanged!.content).toBe("# Voice memo\n");
319
+ expect((unchanged!.metadata as any)?.transcribe_stub).toBeUndefined();
320
+ } finally {
321
+ if (prevProvider === undefined) delete process.env.TRANSCRIPTION_PROVIDER;
322
+ else process.env.TRANSCRIPTION_PROVIDER = prevProvider;
323
+ if (prevScribeUrl === undefined) delete process.env.SCRIBE_URL;
324
+ else process.env.SCRIBE_URL = prevScribeUrl;
325
+ clearScribeUrlCache();
326
+ }
305
327
  });
306
328
 
307
329
  // The counter-case: turning auto-transcribe OFF must stay silent. The