@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.
@@ -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 result;
595
+ return finish();
565
596
  if (!overBytes && stored <= policy.compact_ratio * Math.max(live, 1))
566
- return result;
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
- result.blobs_deltified += 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;
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 result;
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 candidates = opts?.noteId !== undefined ? [{ note_id: opts.noteId }] : db.prepare(`SELECT v.note_id, SUM(b.byte_size) AS stored,
646
- (SELECT COUNT(*) FROM note_versions n WHERE n.note_id = v.note_id AND n.content_hash IS NOT NULL) AS versions,
647
- COALESCE(LENGTH(CAST(live_note.content AS BLOB)),
648
- (SELECT content_len FROM note_versions newest WHERE newest.note_id = v.note_id ORDER BY version_ix DESC LIMIT 1),0) AS live
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 = candidates.length - result.notes_scanned;
708
+ result.remaining_candidates = total - result.notes_scanned;
682
709
  result.duration_ms = performance.now() - started;
683
710
  return result;
684
711
  }
@@ -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
- return await store.doctor();
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: 31 });
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(31);
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: 31 });
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());
@@ -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 = 31;
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 = noteOps.createNote(this.db, content, opts);
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 noteOps.createNote(this.db, content, opts);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.9-rc.7",
3
+ "version": "0.7.9-rc.9",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
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 restored = await call(`/notes/${note.id}/restore`, "POST", { origin: "git-import", import_ix: 0 });
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;