@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.
- package/README.md +4 -0
- package/core/src/compact.test.ts +248 -0
- package/core/src/delta.test.ts +49 -0
- package/core/src/delta.ts +21 -0
- package/core/src/doctor.ts +41 -1
- package/core/src/history-capture.test.ts +244 -0
- package/core/src/history-import.test.ts +82 -0
- package/core/src/history-import.ts +157 -0
- package/core/src/history.test.ts +229 -0
- package/core/src/history.ts +681 -0
- package/core/src/mcp-manifest.ts +17 -0
- package/core/src/mcp.ts +28 -3
- package/core/src/notes.ts +6 -1
- package/core/src/portable-md.ts +1 -1
- package/core/src/schema-v29-note-versions.test.ts +63 -0
- package/core/src/schema-v30-note-deltas.test.ts +51 -0
- package/core/src/schema-v31-history-import.test.ts +25 -0
- package/core/src/schema.ts +234 -1
- package/core/src/store.ts +103 -14
- package/core/src/types.ts +35 -2
- package/core/src/vendor/fossil-delta.ts +472 -0
- package/package.json +1 -1
- package/src/cli.ts +10 -1
- package/src/config.ts +42 -0
- package/src/history-import-mcp.test.ts +94 -0
- package/src/history-import-routes.test.ts +91 -0
- package/src/history-import.test.ts +318 -0
- package/src/history-import.ts +482 -0
- package/src/mcp-http.ts +13 -0
- package/src/mcp-tools.ts +18 -2
- package/src/mirror-config.ts +38 -0
- package/src/mirror-manager.ts +6 -0
- package/src/mirror-retirement.test.ts +79 -0
- package/src/mirror-routes.ts +12 -2
- package/src/routes.ts +226 -86
- package/src/routing.ts +8 -3
- package/src/server.ts +33 -14
- package/src/vault-compact-routes.test.ts +257 -0
- package/src/vault-history-routes.test.ts +562 -0
- 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
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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;
|
|
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
|
-
|
|
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;
|