@openparachute/vault 0.7.9-rc.4 → 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.
Files changed (40) hide show
  1. package/README.md +4 -0
  2. package/core/src/compact.test.ts +248 -0
  3. package/core/src/delta.test.ts +49 -0
  4. package/core/src/delta.ts +21 -0
  5. package/core/src/doctor.ts +41 -1
  6. package/core/src/history-capture.test.ts +244 -0
  7. package/core/src/history-import.test.ts +82 -0
  8. package/core/src/history-import.ts +157 -0
  9. package/core/src/history.test.ts +229 -0
  10. package/core/src/history.ts +681 -0
  11. package/core/src/mcp-manifest.ts +17 -0
  12. package/core/src/mcp.ts +28 -3
  13. package/core/src/notes.ts +6 -1
  14. package/core/src/portable-md.ts +1 -1
  15. package/core/src/schema-v29-note-versions.test.ts +63 -0
  16. package/core/src/schema-v30-note-deltas.test.ts +51 -0
  17. package/core/src/schema-v31-history-import.test.ts +25 -0
  18. package/core/src/schema.ts +234 -1
  19. package/core/src/store.ts +103 -14
  20. package/core/src/types.ts +35 -2
  21. package/core/src/vendor/fossil-delta.ts +472 -0
  22. package/package.json +1 -1
  23. package/src/cli.ts +10 -1
  24. package/src/config.ts +42 -0
  25. package/src/history-import-mcp.test.ts +94 -0
  26. package/src/history-import-routes.test.ts +91 -0
  27. package/src/history-import.test.ts +318 -0
  28. package/src/history-import.ts +482 -0
  29. package/src/mcp-http.ts +13 -0
  30. package/src/mcp-tools.ts +18 -2
  31. package/src/mirror-config.ts +38 -0
  32. package/src/mirror-manager.ts +6 -0
  33. package/src/mirror-retirement.test.ts +79 -0
  34. package/src/mirror-routes.ts +12 -2
  35. package/src/routes.ts +226 -86
  36. package/src/routing.ts +8 -3
  37. package/src/server.ts +33 -14
  38. package/src/vault-compact-routes.test.ts +257 -0
  39. package/src/vault-history-routes.test.ts +562 -0
  40. package/src/vault-store.ts +13 -0
package/core/src/store.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import type { Store, Note, Link, Attachment, QueryOpts, QueryNotesPage, AggregateRow, SemanticSearchResult } from "./types.js";
3
+ 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";
3
4
  import { initSchema } from "./schema.js";
4
5
  import * as noteOps from "./notes.js";
5
6
  import * as linkOps from "./links.js";
@@ -98,6 +99,7 @@ function setsEqual(a: Set<string>, b: Set<string>): boolean {
98
99
  */
99
100
  export class BunSqliteStore implements Store {
100
101
  public readonly hooks: HookRegistry;
102
+ private readonly historyPolicy: HistoryPolicy;
101
103
 
102
104
  // Lazy-built caches over the post-v14 `tags` table — hierarchy via
103
105
  // parent_names, schema validation via `fields`. Null means "not yet
@@ -133,9 +135,10 @@ export class BunSqliteStore implements Store {
133
135
 
134
136
  constructor(
135
137
  public readonly db: Database,
136
- opts?: { hooks?: HookRegistry; embeddingProvider?: EmbeddingProvider; embeddingDisabledReason?: string },
138
+ opts?: { history?: Partial<HistoryPolicy>; hooks?: HookRegistry; embeddingProvider?: EmbeddingProvider; embeddingDisabledReason?: string },
137
139
  ) {
138
140
  initSchema(db);
141
+ this.historyPolicy = resolveHistoryPolicy(opts?.history);
139
142
  this.hooks = opts?.hooks ?? new HookRegistry();
140
143
  this.embeddingProvider = opts?.embeddingProvider;
141
144
  this.embeddingDisabledReason = opts?.embeddingDisabledReason;
@@ -574,6 +577,27 @@ export class BunSqliteStore implements Store {
574
577
  metadata?: Record<string, unknown>;
575
578
  created_at?: string;
576
579
  skipUpdatedAt?: boolean;
580
+ /**
581
+ * State-transition compare-and-set (vault#299 Part B). DECLARED here as
582
+ * of vault#524 — it always arrived at runtime (src/routes.ts:3214 sets
583
+ * it on a `const updates: any = {}` at :3167 and passes it at :3257, and
584
+ * noteOps.updateNote has read it at notes.ts:708 since #299) but it was
585
+ * never on the declared type, so an object LITERAL carrying it did not
586
+ * compile. Declaring it is what lets a core test construct the shape
587
+ * directly (P1, P11b) instead of laundering it through `any`.
588
+ */
589
+ state_transition?: { field: string; from: unknown; to: unknown };
590
+ /**
591
+ * INTERNAL — the `op` stamped on the version row this write captures
592
+ * (vault#524). Set ONLY by Store.restoreNoteVersion, to "restore".
593
+ * noteOps.updateNote ignores it (it reads named fields, never
594
+ * Object.keys), so it never reaches SQL. Do NOT accept it from REST or
595
+ * MCP: src/routes.ts's PATCH handler builds `updates` field by field and
596
+ * must not copy it out of the request body, and core/src/mcp.ts's
597
+ * update-note executor must not either. §10.24 pins that.
598
+ */
599
+ historyOp?: HistoryOp;
600
+
577
601
  // Write-attribution (vault#298) — principal + interface of this edit.
578
602
  actor?: string | null;
579
603
  via?: string | null;
@@ -633,6 +657,10 @@ export class BunSqliteStore implements Store {
633
657
  // atomic unit. Any later failure must leave the note and its indexes at
634
658
  // the pre-update state rather than committing only the first SQL write.
635
659
  const note = this.transaction(() => {
660
+ if (willWriteNoteRow(updates)) {
661
+ const prior = readPriorNoteRow(this.db, id);
662
+ if (prior) captureVersion(this.db, prior, { actor: updates.actor ?? null, via: updates.via ?? null, op: historyOpFor(updates), policy: this.historyPolicy });
663
+ }
636
664
  const note = noteOps.updateNote(this.db, id, updates);
637
665
 
638
666
  // Wikilink sync runs against the *resulting* content. For append/prepend
@@ -644,7 +672,7 @@ export class BunSqliteStore implements Store {
644
672
 
645
673
  if (updates.path !== undefined && note.path) {
646
674
  if (cascadePlan && oldPath && oldPath !== note.path) {
647
- this.cascadeRename(cascadePlan, note, oldPath);
675
+ this.cascadeRename(cascadePlan, note, oldPath, { actor: updates.actor ?? null, via: updates.via ?? null });
648
676
  }
649
677
  resolveUnresolvedWikilinks(this.db, note.path, id);
650
678
  // vault#581 — a rename is one of the two ways an ambiguity stops being
@@ -747,7 +775,7 @@ export class BunSqliteStore implements Store {
747
775
  * `unresolved_wikilinks` and `ambiguous_wikilinks` stay consistent with
748
776
  * the new text.
749
777
  */
750
- private cascadeRename(plan: Map<string, string[]>, note: Note, oldPath: string): void {
778
+ private cascadeRename(plan: Map<string, string[]>, note: Note, oldPath: string, attr: { actor: string | null; via: string | null }): void {
751
779
  if (plan.size === 0 || !note.path) return;
752
780
  const newPath = note.path;
753
781
 
@@ -785,12 +813,53 @@ export class BunSqliteStore implements Store {
785
813
  (target) => mapping.get(target.toLowerCase()) ?? null,
786
814
  );
787
815
  if (updated !== row.content) {
816
+ const prior = readPriorNoteRow(this.db, sourceId);
817
+ if (prior) captureVersion(this.db, prior, { ...attr, op: "cascade-rename", policy: this.historyPolicy });
788
818
  noteOps.updateNote(this.db, sourceId, { content: updated });
789
819
  syncWikilinks(this.db, sourceId, updated);
790
820
  }
791
821
  }
792
822
  }
793
823
 
824
+ async listNoteVersions(id: string, opts?: { limit?: number; offset?: number }) { return listVersions(this.db, id, opts); }
825
+ async getNoteVersion(id: string, versionIx: number) { return getVersion(this.db, id, versionIx); }
826
+ async eraseNoteHistory(id: string) { return eraseHistory(this.db, id); }
827
+ sweepDeletedHistory() { return sweepDeletedHistory(this.db, this.historyPolicy); }
828
+ async countNoteVersions(id: string) { return countNoteVersions(this.db, id); }
829
+ compactNote(id: string) { return compactNote(this.db, id, this.historyPolicy); }
830
+ compactHistory(opts?: { noteId?: string; budgetMs?: number | null; maxNotes?: number | null }) { return compactVault(this.db, this.historyPolicy, opts); }
831
+ async deletedHistoryStats() { return deletedHistoryStats(this.db); }
832
+ async restoreNoteVersion(id: string, versionIx: number, opts: { actor?: string | null; via?: string | null; if_updated_at?: string }): Promise<Note> {
833
+ if (noteOps.getNote(this.db, id)) {
834
+ const v = getVersion(this.db, id, versionIx);
835
+ if (!v) throw new HistoryNotFoundError(id, versionIx);
836
+ if (v.encoding === "overflow") throw new HistoryUnrecoverableError(id, versionIx);
837
+ return await this.updateNote(id, {
838
+ content: v.content!, metadata: v.metadata, extension: v.extension ?? undefined,
839
+ actor: opts.actor ?? null, via: opts.via ?? null,
840
+ ...(opts.if_updated_at !== undefined ? { if_updated_at: opts.if_updated_at } : {}),
841
+ historyOp: "restore",
842
+ });
843
+ }
844
+ const tomb = latestTombstone(this.db, id);
845
+ if (!tomb) throw new HistoryNotFoundError(id, null);
846
+ const v = getVersion(this.db, id, versionIx);
847
+ if (!v) throw new HistoryNotFoundError(id, versionIx);
848
+ if (v.encoding === "overflow") throw new HistoryUnrecoverableError(id, versionIx);
849
+ if (opts.if_updated_at !== undefined) throw new noteOps.ConflictError(id, tomb.path, null, opts.if_updated_at);
850
+ return this.transaction(() => {
851
+ const note = noteOps.createNote(this.db, v.content!, {
852
+ id, path: tomb.path ?? undefined, metadata: v.metadata,
853
+ extension: v.extension ?? undefined, created_at: v.created_at ?? tomb.superseded_at,
854
+ actor: opts.actor ?? null, via: opts.via ?? null,
855
+ });
856
+ appendRestoreMarker(this.db, id, tomb, { actor: opts.actor ?? null, via: opts.via ?? null }, this.historyPolicy);
857
+ if (note.content) syncWikilinks(this.db, id, note.content);
858
+ if (note.path) resolveUnresolvedWikilinks(this.db, note.path, id);
859
+ return note;
860
+ });
861
+ }
862
+
794
863
  async restoreNoteTimestamps(id: string, createdAt: string, updatedAt: string): Promise<void> {
795
864
  // Import-only: direct UPDATE so the importer can restore a note's
796
865
  // historical `created_at`/`updated_at` from the portable-md export
@@ -813,7 +882,7 @@ export class BunSqliteStore implements Store {
813
882
  .run(createdAt, updatedAt, updatedAtMs, id);
814
883
  }
815
884
 
816
- async deleteNote(id: string): Promise<void> {
885
+ async deleteNote(id: string, opts?: { actor?: string | null; via?: string | null; captureHistory?: boolean }): Promise<void> {
817
886
  // Read before delete so we can invalidate config caches on the way out
818
887
  // AND so the post-delete hook dispatch carries the minimum payload
819
888
  // ({ id, path }). The full note can't be reconstructed post-delete —
@@ -826,14 +895,20 @@ export class BunSqliteStore implements Store {
826
895
  // it's individually re-saved. See requeueInboundWikilinksForDelete's doc
827
896
  // comment for why this must run pre-delete and what it deliberately
828
897
  // excludes (typed `links`, not just wikilinks).
829
- requeueInboundWikilinksForDelete(this.db, id);
830
- // vault#581 — the deleted note's resolution keys, captured BEFORE the row
831
- // goes away and swept AFTER, so a `[[Dup]]` that was ambiguous only
832
- // because of THIS note resolves (or, if it was the last candidate,
833
- // demotes to an ordinary broken link).
834
- const ambiguityKeys = noteResolutionKeys(existing);
835
- noteOps.deleteNote(this.db, id);
836
- refreshAmbiguousLinks(this.db, ambiguityKeys);
898
+ this.transaction(() => {
899
+ requeueInboundWikilinksForDelete(this.db, id);
900
+ // vault#581 — the deleted note's resolution keys, captured BEFORE the row
901
+ // goes away and swept AFTER, so a `[[Dup]]` that was ambiguous only
902
+ // because of THIS note resolves (or, if it was the last candidate,
903
+ // demotes to an ordinary broken link).
904
+ const ambiguityKeys = noteResolutionKeys(existing);
905
+ if (opts?.captureHistory !== false) {
906
+ const prior = readPriorNoteRow(this.db, id);
907
+ if (prior) captureVersion(this.db, prior, { actor: opts?.actor ?? null, via: opts?.via ?? null, op: "delete", policy: this.historyPolicy });
908
+ }
909
+ noteOps.deleteNote(this.db, id);
910
+ refreshAmbiguousLinks(this.db, ambiguityKeys);
911
+ });
837
912
  if (existing?.path) this.invalidateConfigCachesForPath(existing.path);
838
913
  // Dispatch even when `existing` was null — the caller asked for a
839
914
  // deletion, and downstream consumers (e.g. the mirror) reconcile via
@@ -1094,8 +1169,8 @@ export class BunSqliteStore implements Store {
1094
1169
  return result;
1095
1170
  }
1096
1171
 
1097
- async renameTag(oldName: string, newName: string): Promise<noteOps.RenameTagResult> {
1098
- const result = noteOps.renameTag(this.db, oldName, newName);
1172
+ async renameTag(oldName: string, newName: string, opts?: { actor?: string; via?: string }): Promise<noteOps.RenameTagResult> {
1173
+ const result = noteOps.renameTag(this.db, oldName, newName, opts, this.historyPolicy);
1099
1174
  // Vault#240: the cascade rewrites parent_names in OTHER tag rows as
1100
1175
  // part of the same transaction, plus tokens.scoped_tags and
1101
1176
  // indexed_fields.declarer_tags. Both caches are tag-keyed, so they
@@ -1728,3 +1803,17 @@ export class BunSqliteStore implements Store {
1728
1803
  export const SqliteStore = BunSqliteStore;
1729
1804
  /** @deprecated Renamed to `BunSqliteStore`. */
1730
1805
  export type SqliteStore = BunSqliteStore;
1806
+
1807
+ const MUTATING_UPDATE_KEYS = ["content", "append", "prepend", "path", "extension", "metadata", "created_at", "state_transition"] as const;
1808
+ function willWriteNoteRow(u: Parameters<Store["updateNote"]>[1]): boolean {
1809
+ for (const key of MUTATING_UPDATE_KEYS) if (u[key] !== undefined) return true;
1810
+ return u.skipUpdatedAt !== true;
1811
+ }
1812
+ function historyOpFor(u: Parameters<Store["updateNote"]>[1]): HistoryOp {
1813
+ if (typeof u.historyOp === "string") return u.historyOp;
1814
+ if (u.append !== undefined) return "append";
1815
+ if (u.prepend !== undefined) return "prepend";
1816
+ return "update";
1817
+ }
1818
+ export { HistoryNotFoundError, HistoryOverflowError, HistoryUnrecoverableError, DEFAULT_HISTORY_POLICY, VERSION_MAX_BYTES };
1819
+ export type { HistoryPolicy, HistoryOp, VersionRow, CompactResult, CompactSummary };
package/core/src/types.ts CHANGED
@@ -7,6 +7,7 @@ import type { ValidationStatus } from "./schema-defaults.js";
7
7
  import type { ConformanceReport } from "./conformance.js";
8
8
  import type { FindPathResult } from "./links.js";
9
9
  import type { DoctorReport, DoctorScanOpts } from "./doctor.js";
10
+ import type { CompactResult, CompactSummary, HistoryOp, VersionRow } from "./history.js";
10
11
 
11
12
  // ---- Re-exports ----
12
13
 
@@ -481,7 +482,28 @@ export interface Store {
481
482
  */
482
483
  getNoteByPath(path: string, extension?: string): Promise<Note | null>;
483
484
  getNotes(ids: string[]): Promise<Note[]>;
484
- updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; extension?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; actor?: string | null; via?: string | null; if_updated_at?: string; tagsForSchemaResolution?: string[] }): Promise<Note>;
485
+ updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; extension?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean;
486
+ /**
487
+ * State-transition compare-and-set (vault#299 Part B). DECLARED here as
488
+ * of vault#524 — it always arrived at runtime (src/routes.ts:3214 sets
489
+ * it on a `const updates: any = {}` at :3167 and passes it at :3257, and
490
+ * noteOps.updateNote has read it at notes.ts:708 since #299) but it was
491
+ * never on the declared type, so an object LITERAL carrying it did not
492
+ * compile. Declaring it is what lets a core test construct the shape
493
+ * directly (P1, P11b) instead of laundering it through `any`.
494
+ */
495
+ state_transition?: { field: string; from: unknown; to: unknown };
496
+ /**
497
+ * INTERNAL — the `op` stamped on the version row this write captures
498
+ * (vault#524). Set ONLY by Store.restoreNoteVersion, to "restore".
499
+ * noteOps.updateNote ignores it (it reads named fields, never
500
+ * Object.keys), so it never reaches SQL. Do NOT accept it from REST or
501
+ * MCP: src/routes.ts's PATCH handler builds `updates` field by field and
502
+ * must not copy it out of the request body, and core/src/mcp.ts's
503
+ * update-note executor must not either. §10.24 pins that.
504
+ */
505
+ historyOp?: HistoryOp;
506
+ actor?: string | null; via?: string | null; if_updated_at?: string; tagsForSchemaResolution?: string[] }): Promise<Note>;
485
507
  /**
486
508
  * Set a note's `created_at` and `updated_at` explicitly. Import-only:
487
509
  * used by the portable-md round-trip path to restore timestamps from
@@ -497,7 +519,17 @@ export interface Store {
497
519
  * content. Returns counts for caller logging.
498
520
  */
499
521
  syncAllWikilinks(): Promise<{ synced: number; totalAdded: number; totalRemoved: number }>;
500
- deleteNote(id: string): Promise<void>;
522
+ /** vault#524: deletion captures a tombstone unless a blow-away import opts out. */
523
+ deleteNote(id: string, opts?: { actor?: string | null; via?: string | null; captureHistory?: boolean }): Promise<void>;
524
+ listNoteVersions(id: string, opts?: { limit?: number; offset?: number }): Promise<VersionRow[]>;
525
+ getNoteVersion(id: string, versionIx: number): Promise<(VersionRow & { content: string | null }) | null>;
526
+ restoreNoteVersion(id: string, versionIx: number, opts: { actor?: string | null; via?: string | null; if_updated_at?: string }): Promise<Note>;
527
+ eraseNoteHistory(id: string): Promise<{ versionsDeleted: number; blobsDeleted: number }>;
528
+ sweepDeletedHistory(): { notesSwept: number; versionsDeleted: number; blobsDeleted: number };
529
+ countNoteVersions(id: string): Promise<number>;
530
+ compactNote(id: string): CompactResult;
531
+ compactHistory(opts?: { noteId?: string; budgetMs?: number | null; maxNotes?: number | null }): CompactSummary;
532
+ deletedHistoryStats(): Promise<{ notes: number; versions: number; bytes: number; overflow_tombstones: number }>;
501
533
  queryNotes(opts: QueryOpts): Promise<Note[]>;
502
534
  /**
503
535
  * Cursor-paginated `queryNotes` (vault#313). Returns the same notes plus
@@ -601,6 +633,7 @@ export interface Store {
601
633
  renameTag(
602
634
  oldName: string,
603
635
  newName: string,
636
+ opts?: { actor?: string; via?: string },
604
637
  ): Promise<
605
638
  | {
606
639
  renamed: number;