@openparachute/vault 0.7.9-rc.3 → 0.7.9-rc.5
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/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.test.ts +229 -0
- package/core/src/history.ts +679 -0
- package/core/src/link-count.test.ts +29 -0
- package/core/src/links.ts +43 -0
- package/core/src/mcp-manifest.ts +17 -6
- package/core/src/mcp.ts +42 -14
- 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.ts +202 -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/core/src/wikilinks.ts +68 -1
- package/package.json +1 -1
- package/src/config.ts +42 -0
- package/src/mcp-tools.ts +12 -2
- package/src/release-plan.test.ts +90 -1
- package/src/routes.ts +276 -95
- package/src/routing.ts +8 -3
- package/src/test-support/vault-714-find-path.json +58 -0
- package/src/test-support/vault-714-graph.json +27 -0
- package/src/test-support/vault-714-has_links.json +96 -0
- package/src/test-support/vault-714-include_broken_links.json +118 -0
- package/src/test-support/vault-714-include_link_count.json +348 -0
- package/src/test-support/vault-714-include_links.json +86 -0
- package/src/test-support/vault-714-near.json +78 -0
- package/src/test-support/vault-714-unresolved-wikilinks.json +6 -0
- package/src/vault-compact-routes.test.ts +257 -0
- package/src/vault-history-routes.test.ts +562 -0
- package/src/vault-store.ts +11 -0
- package/src/vault.test.ts +243 -1
package/core/src/schema.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { transaction } from "./txn.js";
|
|
|
6
6
|
import { timestampToMs } from "./cursor.js";
|
|
7
7
|
import { ensureRelationshipColumn } from "./wikilinks.js";
|
|
8
8
|
|
|
9
|
-
export const SCHEMA_VERSION =
|
|
9
|
+
export const SCHEMA_VERSION = 30;
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Deterministic last-resort epoch for a note whose `updated_at` AND
|
|
@@ -142,6 +142,114 @@ CREATE TABLE IF NOT EXISTS note_vectors (
|
|
|
142
142
|
-- sweep (a model <> ? scan) the backfill/drain use to find pending work.
|
|
143
143
|
CREATE INDEX IF NOT EXISTS idx_note_vectors_stale ON note_vectors(model, content_hash);
|
|
144
144
|
|
|
145
|
+
-- note_blobs + note_versions (v29, vault#524): note-level version history.
|
|
146
|
+
--
|
|
147
|
+
-- STORAGE. A version row is skinny; the BODY lives once per distinct content,
|
|
148
|
+
-- keyed by its sha256, in note_blobs. A metadata-only edit on a 116 KB note
|
|
149
|
+
-- costs one ~300-byte version row and a no-op \`INSERT OR IGNORE\` on the blob —
|
|
150
|
+
-- the whole answer to "does every little update store an entirely new copy?"
|
|
151
|
+
-- Live \`notes.content\` stays on \`notes\`: no join on the hot read path.
|
|
152
|
+
--
|
|
153
|
+
-- encoding IS NULL FOR EVERY ORDINARY ROW IN v29. NULL means "content_hash
|
|
154
|
+
-- names a WHOLE blob". It exists so the PR-2 compactor can rewrite an old row
|
|
155
|
+
-- to reference a delta (setting a format marker) with no second migration and
|
|
156
|
+
-- no version-id change. The ONE non-NULL value v29 ever writes is the literal
|
|
157
|
+
-- 'overflow', on a delete tombstone for a note whose body exceeded
|
|
158
|
+
-- VERSION_MAX_BYTES: the row records that the note WAS deleted and how big it
|
|
159
|
+
-- was, with content_hash NULL because the bytes were never blobbed. A delete
|
|
160
|
+
-- must never be blocked by history (see history.ts captureVersion step 5).
|
|
161
|
+
-- A restore marker copied from that tombstone also retains overflow encoding
|
|
162
|
+
-- and a NULL hash, including after the note has been recreated.
|
|
163
|
+
--
|
|
164
|
+
-- content_hash IS NULLABLE, and it is nullable ONLY for that overflow
|
|
165
|
+
-- tombstone or its copied restore marker. Every consumer must filter NULL —
|
|
166
|
+
-- in particular gcBlobs's \`NOT IN (SELECT content_hash ...)\` MUST carry
|
|
167
|
+
-- \`WHERE content_hash IS NOT NULL\`, or one NULL in the subquery makes the
|
|
168
|
+
-- whole NOT IN evaluate to NULL and the sweep silently deletes nothing.
|
|
169
|
+
--
|
|
170
|
+
-- NO \`REFERENCES notes(id) ON DELETE CASCADE\` — the one deviation from
|
|
171
|
+
-- note_vectors above, deliberate. deleteNote writes a final op='delete' row and
|
|
172
|
+
-- the \`notes\` row then goes away; a cascade would delete the only surviving
|
|
173
|
+
-- copy at the instant it became the only copy. Deleted-note history is reaped
|
|
174
|
+
-- by the configurable sweep (history.deleted_retention_days) or an explicit
|
|
175
|
+
-- erase, never by the FK.
|
|
176
|
+
--
|
|
177
|
+
-- content_hash DOES have a real FK to note_blobs with the default NO ACTION,
|
|
178
|
+
-- so SQLite itself enforces the one invariant refcount GC protects: deleting a
|
|
179
|
+
-- still-referenced blob raises instead of silently orphaning a version.
|
|
180
|
+
-- foreign_keys is turned ON per connection in applyConnectionPragmas
|
|
181
|
+
-- (schema.ts:486) — but inside a SWALLOWING try/catch, so on a handle where
|
|
182
|
+
-- the pragma throws there is no FK enforcement at all and no signal. The
|
|
183
|
+
-- NOT EXISTS guard on every blob delete is therefore the PRIMARY defence and
|
|
184
|
+
-- the FK is the backstop, not the other way round (§4.6).
|
|
185
|
+
-- note_blobs.encoding IS NULL -> \`content\` is the literal body, \`byte_size\`
|
|
186
|
+
-- is its UTF-8 byte length, and \`hash\` = sha256(content). This is every row
|
|
187
|
+
-- v29 ever wrote.
|
|
188
|
+
-- note_blobs.encoding = 'fossil-delta' -> \`content\` is the BASE64 of a Fossil
|
|
189
|
+
-- delta that reconstructs this row's logical body from the body of the blob
|
|
190
|
+
-- named by \`delta_of\`; \`byte_size\` is the length of that base64 payload, i.e.
|
|
191
|
+
-- the bytes actually stored. \`hash\` is STILL sha256 of the LOGICAL body, so
|
|
192
|
+
-- every note_versions.content_hash reference and its FK stay valid and
|
|
193
|
+
-- content-addressed identity is never a delta hash (ClaudeJi R3).
|
|
194
|
+
-- The LOGICAL byte length is note_versions.content_len, which PR 1
|
|
195
|
+
-- denormalised for exactly this moment (PR 1 §2.1: "it must stay readable
|
|
196
|
+
-- after PR 2 deltifies the blob").
|
|
197
|
+
-- ANY OTHER \`encoding\` VALUE IS CORRUPTION. It is not a future format marker to
|
|
198
|
+
-- be tolerated: a read of such a row answers 409 (materialise's
|
|
199
|
+
-- "unknown_encoding" branch) and the doctor counts it (historyStorageStats's
|
|
200
|
+
-- unknown_encoding_blobs). A later format adds its value here AND to both.
|
|
201
|
+
--
|
|
202
|
+
-- DEPTH IS EXACTLY ONE, AND THAT IS AN INVARIANT, NOT A TUNING CHOICE.
|
|
203
|
+
-- delta_of MUST name a row whose \`encoding IS NULL\`. A delta is never the
|
|
204
|
+
-- base of another delta. Rebuilding any version is therefore ONE applyDelta,
|
|
205
|
+
-- measured p95 3.73 ms / p99 4.68 ms on a 116 KB note, against 38.98 ms /
|
|
206
|
+
-- 49.99 ms for a 24-link chain on the same note -- the chain sits ON the
|
|
207
|
+
-- 50 ms budget with no headroom. Star also confines corruption: a damaged
|
|
208
|
+
-- delta loses one version, not every older one. See §11.1 D1.
|
|
209
|
+
--
|
|
210
|
+
-- delta_of carries a REAL self-referential FK with the default NO ACTION, and
|
|
211
|
+
-- it IS enforced on a bun handle (probe E: deleting a referenced base raises
|
|
212
|
+
-- FOREIGN KEY constraint failed). It is the backstop; the NOT EXISTS guards in
|
|
213
|
+
-- pruneVersions and gcBlobs are the primary defence, exactly as for
|
|
214
|
+
-- content_hash in v29 (PR 1 §4.6).
|
|
215
|
+
--
|
|
216
|
+
-- note_versions.created_at (#735) is the captured note's OWN creation time,
|
|
217
|
+
-- copied from notes.created_at at capture. NULL on every pre-v30 row; a
|
|
218
|
+
-- deleted-note restore falls back to the tombstone's superseded_at when it is
|
|
219
|
+
-- NULL, which is precisely v29's behaviour.
|
|
220
|
+
CREATE TABLE IF NOT EXISTS note_blobs (
|
|
221
|
+
hash TEXT PRIMARY KEY,
|
|
222
|
+
content TEXT NOT NULL,
|
|
223
|
+
byte_size INTEGER NOT NULL,
|
|
224
|
+
encoding TEXT,
|
|
225
|
+
delta_of TEXT REFERENCES note_blobs(hash)
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
CREATE TABLE IF NOT EXISTS note_versions (
|
|
229
|
+
note_id TEXT NOT NULL,
|
|
230
|
+
version_ix INTEGER NOT NULL,
|
|
231
|
+
content_hash TEXT REFERENCES note_blobs(hash),
|
|
232
|
+
path TEXT,
|
|
233
|
+
metadata TEXT,
|
|
234
|
+
extension TEXT,
|
|
235
|
+
superseded_at TEXT NOT NULL,
|
|
236
|
+
actor TEXT,
|
|
237
|
+
via TEXT,
|
|
238
|
+
op TEXT NOT NULL,
|
|
239
|
+
content_len INTEGER NOT NULL,
|
|
240
|
+
encoding TEXT,
|
|
241
|
+
created_at TEXT,
|
|
242
|
+
PRIMARY KEY (note_id, version_ix)
|
|
243
|
+
);
|
|
244
|
+
-- list/get (newest first) + the prune walk.
|
|
245
|
+
CREATE INDEX IF NOT EXISTS idx_note_versions_note ON note_versions(note_id, version_ix DESC);
|
|
246
|
+
-- the age half of retention + the deleted-note sweep.
|
|
247
|
+
CREATE INDEX IF NOT EXISTS idx_note_versions_superseded ON note_versions(superseded_at);
|
|
248
|
+
-- refcount GC: "is any version still pointing at this blob?" This index is
|
|
249
|
+
-- what keeps gcBlobs's per-vault-open anti-join off a full table scan; the
|
|
250
|
+
-- boot sweep runs it on an opted-in vault's open path (§4.6).
|
|
251
|
+
CREATE INDEX IF NOT EXISTS idx_note_versions_hash ON note_versions(content_hash);
|
|
252
|
+
|
|
145
253
|
-- tag_schemas (v6) was retired in v14; description + fields lifted onto the
|
|
146
254
|
-- tags row directly. The CREATE TABLE was removed from SCHEMA_SQL after the
|
|
147
255
|
-- v14 data migration drops the table; existing v6+ vaults pick up the
|
|
@@ -629,6 +737,11 @@ export function initSchema(db: Database): void {
|
|
|
629
737
|
// queued an unresolved link, and does not rewrite notes.
|
|
630
738
|
migrateToV28(db);
|
|
631
739
|
|
|
740
|
+
// v29: note-level history, no backfill.
|
|
741
|
+
migrateToV29(db);
|
|
742
|
+
// v30: depth-one history deltas and captured creation time, no backfill.
|
|
743
|
+
migrateToV30(db);
|
|
744
|
+
|
|
632
745
|
// Rebuild any generated columns + indexes declared in indexed_fields.
|
|
633
746
|
// No-op for a fresh vault; idempotent on existing vaults.
|
|
634
747
|
rebuildIndexes(db);
|
|
@@ -1756,6 +1869,94 @@ function migrateToV28(db: Database): void {
|
|
|
1756
1869
|
ensureRelationshipColumn(db);
|
|
1757
1870
|
}
|
|
1758
1871
|
|
|
1872
|
+
/** Add history tables and indexes without moving existing note data. */
|
|
1873
|
+
function migrateToV29(db: Database): void {
|
|
1874
|
+
transaction(db, () => {
|
|
1875
|
+
db.exec(`
|
|
1876
|
+
-- note_blobs + note_versions (v29, vault#524): note-level version history.
|
|
1877
|
+
--
|
|
1878
|
+
-- STORAGE. A version row is skinny; the BODY lives once per distinct content,
|
|
1879
|
+
-- keyed by its sha256, in note_blobs. A metadata-only edit on a 116 KB note
|
|
1880
|
+
-- costs one ~300-byte version row and a no-op \`INSERT OR IGNORE\` on the blob —
|
|
1881
|
+
-- the whole answer to "does every little update store an entirely new copy?"
|
|
1882
|
+
-- Live \`notes.content\` stays on \`notes\`: no join on the hot read path.
|
|
1883
|
+
--
|
|
1884
|
+
-- encoding IS NULL FOR EVERY ORDINARY ROW IN v29. NULL means "content_hash
|
|
1885
|
+
-- names a WHOLE blob". It exists so the PR-2 compactor can rewrite an old row
|
|
1886
|
+
-- to reference a delta (setting a format marker) with no second migration and
|
|
1887
|
+
-- no version-id change. The ONE non-NULL value v29 ever writes is the literal
|
|
1888
|
+
-- 'overflow', on a delete tombstone for a note whose body exceeded
|
|
1889
|
+
-- VERSION_MAX_BYTES: the row records that the note WAS deleted and how big it
|
|
1890
|
+
-- was, with content_hash NULL because the bytes were never blobbed. A delete
|
|
1891
|
+
-- must never be blocked by history (see history.ts captureVersion step 5).
|
|
1892
|
+
-- A restore marker copied from that tombstone also retains overflow encoding
|
|
1893
|
+
-- and a NULL hash, including after the note has been recreated.
|
|
1894
|
+
--
|
|
1895
|
+
-- content_hash IS NULLABLE, and it is nullable ONLY for that overflow
|
|
1896
|
+
-- tombstone or its copied restore marker. Every consumer must filter NULL —
|
|
1897
|
+
-- in particular gcBlobs's \`NOT IN (SELECT content_hash ...)\` MUST carry
|
|
1898
|
+
-- \`WHERE content_hash IS NOT NULL\`, or one NULL in the subquery makes the
|
|
1899
|
+
-- whole NOT IN evaluate to NULL and the sweep silently deletes nothing.
|
|
1900
|
+
--
|
|
1901
|
+
-- NO \`REFERENCES notes(id) ON DELETE CASCADE\` — the one deviation from
|
|
1902
|
+
-- note_vectors above, deliberate. deleteNote writes a final op='delete' row and
|
|
1903
|
+
-- the \`notes\` row then goes away; a cascade would delete the only surviving
|
|
1904
|
+
-- copy at the instant it became the only copy. Deleted-note history is reaped
|
|
1905
|
+
-- by the configurable sweep (history.deleted_retention_days) or an explicit
|
|
1906
|
+
-- erase, never by the FK.
|
|
1907
|
+
--
|
|
1908
|
+
-- content_hash DOES have a real FK to note_blobs with the default NO ACTION,
|
|
1909
|
+
-- so SQLite itself enforces the one invariant refcount GC protects: deleting a
|
|
1910
|
+
-- still-referenced blob raises instead of silently orphaning a version.
|
|
1911
|
+
-- foreign_keys is turned ON per connection in applyConnectionPragmas
|
|
1912
|
+
-- (schema.ts:486) — but inside a SWALLOWING try/catch, so on a handle where
|
|
1913
|
+
-- the pragma throws there is no FK enforcement at all and no signal. The
|
|
1914
|
+
-- NOT EXISTS guard on every blob delete is therefore the PRIMARY defence and
|
|
1915
|
+
-- the FK is the backstop, not the other way round (§4.6).
|
|
1916
|
+
CREATE TABLE IF NOT EXISTS note_blobs (
|
|
1917
|
+
hash TEXT PRIMARY KEY,
|
|
1918
|
+
content TEXT NOT NULL,
|
|
1919
|
+
byte_size INTEGER NOT NULL
|
|
1920
|
+
);
|
|
1921
|
+
|
|
1922
|
+
CREATE TABLE IF NOT EXISTS note_versions (
|
|
1923
|
+
note_id TEXT NOT NULL,
|
|
1924
|
+
version_ix INTEGER NOT NULL,
|
|
1925
|
+
content_hash TEXT REFERENCES note_blobs(hash),
|
|
1926
|
+
path TEXT,
|
|
1927
|
+
metadata TEXT,
|
|
1928
|
+
extension TEXT,
|
|
1929
|
+
superseded_at TEXT NOT NULL,
|
|
1930
|
+
actor TEXT,
|
|
1931
|
+
via TEXT,
|
|
1932
|
+
op TEXT NOT NULL,
|
|
1933
|
+
content_len INTEGER NOT NULL,
|
|
1934
|
+
encoding TEXT,
|
|
1935
|
+
PRIMARY KEY (note_id, version_ix)
|
|
1936
|
+
);
|
|
1937
|
+
-- list/get (newest first) + the prune walk.
|
|
1938
|
+
CREATE INDEX IF NOT EXISTS idx_note_versions_note ON note_versions(note_id, version_ix DESC);
|
|
1939
|
+
-- the age half of retention + the deleted-note sweep.
|
|
1940
|
+
CREATE INDEX IF NOT EXISTS idx_note_versions_superseded ON note_versions(superseded_at);
|
|
1941
|
+
-- refcount GC: "is any version still pointing at this blob?" This index is
|
|
1942
|
+
-- what keeps gcBlobs's per-vault-open anti-join off a full table scan; the
|
|
1943
|
+
-- boot sweep runs it on an opted-in vault's open path (§4.6).
|
|
1944
|
+
CREATE INDEX IF NOT EXISTS idx_note_versions_hash ON note_versions(content_hash);
|
|
1945
|
+
`);
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
/** Add delta representation and creation time without moving prior rows. */
|
|
1950
|
+
function migrateToV30(db: Database): void {
|
|
1951
|
+
transaction(db, () => {
|
|
1952
|
+
if (hasTable(db, "note_blobs") && !hasColumn(db, "note_blobs", "encoding")) db.exec("ALTER TABLE note_blobs ADD COLUMN encoding TEXT");
|
|
1953
|
+
if (hasTable(db, "note_blobs") && !hasColumn(db, "note_blobs", "delta_of")) db.exec("ALTER TABLE note_blobs ADD COLUMN delta_of TEXT REFERENCES note_blobs(hash)");
|
|
1954
|
+
if (hasTable(db, "note_versions") && !hasColumn(db, "note_versions", "created_at")) db.exec("ALTER TABLE note_versions ADD COLUMN created_at TEXT");
|
|
1955
|
+
// SCHEMA_SQL runs before migrations: the index must be created here.
|
|
1956
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_note_blobs_delta_of ON note_blobs(delta_of)");
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1759
1960
|
function hasTable(db: Database, name: string): boolean {
|
|
1760
1961
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
1761
1962
|
return !!row;
|
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;
|