@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
|
@@ -299,3 +299,32 @@ describe("query-notes MCP surface: include_link_count", () => {
|
|
|
299
299
|
for (const n of ordered) expect(typeof n.linkCount).toBe("number");
|
|
300
300
|
});
|
|
301
301
|
});
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
describe("vault#714 scoped link degree", () => {
|
|
305
|
+
async function seedScope() {
|
|
306
|
+
for (const id of ["a", "b", "hidden"]) await store.createNote(id, { id });
|
|
307
|
+
await store.createLink("a", "b", "visible");
|
|
308
|
+
await store.createLink("a", "hidden", "hidden-out");
|
|
309
|
+
await store.createLink("hidden", "a", "hidden-in");
|
|
310
|
+
}
|
|
311
|
+
for (const [direction, expected] of [["both", 1], ["outbound", 1], ["inbound", 0]] as const) {
|
|
312
|
+
it(`vault#714 scoped ${direction}`, async () => {
|
|
313
|
+
await seedScope();
|
|
314
|
+
expect(getLinkCounts(db, ["a"], direction, (id) => id !== "hidden").get("a")).toBe(expected);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
it("vault#714 visible self-loop remains degree two", async () => {
|
|
318
|
+
await seedScope(); await store.createLink("b", "b", "self");
|
|
319
|
+
expect(getLinkCounts(db, ["b"], "both", (id) => id === "b").get("b")).toBe(2);
|
|
320
|
+
});
|
|
321
|
+
it("vault#714 absent predicate preserves global degree", async () => {
|
|
322
|
+
await seedScope();
|
|
323
|
+
expect(getLinkCounts(db, ["a"], "both")).toEqual(new Map([["a", 3]]));
|
|
324
|
+
expect(getLinkCounts(db, ["a"], "both", undefined)).toEqual(getLinkCounts(db, ["a"], "both"));
|
|
325
|
+
});
|
|
326
|
+
it("vault#714 repeated ids and typed rows retain row-count semantics", async () => {
|
|
327
|
+
await seedScope(); await store.createLink("a", "b", "second");
|
|
328
|
+
expect(getLinkCounts(db, ["a", "a"], "both", (id) => id !== "hidden").get("a")).toBe(2);
|
|
329
|
+
});
|
|
330
|
+
});
|
package/core/src/links.ts
CHANGED
|
@@ -267,12 +267,16 @@ export function getLinksHydratedForNotes(
|
|
|
267
267
|
* most two index scans regardless of page size. The IN-list is chunked to
|
|
268
268
|
* stay under SQLite's bound-variable limit on very large pages.
|
|
269
269
|
*
|
|
270
|
+
* With a visibility predicate, callers supply visible note ids and only rows
|
|
271
|
+
* whose other endpoint is visible count. The indexed selects return neighbour
|
|
272
|
+
* ids instead of grouped counts; absent the predicate, SQL is unchanged.
|
|
270
273
|
* Returns 0 for ids with no links (every requested id is present in the map).
|
|
271
274
|
*/
|
|
272
275
|
export function getLinkCounts(
|
|
273
276
|
db: Database,
|
|
274
277
|
noteIds: string[],
|
|
275
278
|
direction: "both" | "outbound" | "inbound" = "both",
|
|
279
|
+
visible?: (noteId: string) => boolean,
|
|
276
280
|
): Map<string, number> {
|
|
277
281
|
const counts = new Map<string, number>();
|
|
278
282
|
if (noteIds.length === 0) return counts;
|
|
@@ -290,6 +294,28 @@ export function getLinkCounts(
|
|
|
290
294
|
for (const chunk of chunkForInClause(ids)) {
|
|
291
295
|
const placeholders = chunk.map(() => "?").join(", ");
|
|
292
296
|
|
|
297
|
+
// Scoped callers supply visible page-note ids. Count a row only when
|
|
298
|
+
// its other endpoint is visible too; a visible self-loop still adds 2.
|
|
299
|
+
if (visible) {
|
|
300
|
+
if (wantOutbound) {
|
|
301
|
+
const rows = db.prepare(
|
|
302
|
+
`SELECT source_id AS id, target_id AS other FROM links WHERE source_id IN (${placeholders})`,
|
|
303
|
+
).all(...chunk) as { id: string; other: string }[];
|
|
304
|
+
for (const row of rows) {
|
|
305
|
+
if (visible(row.other)) counts.set(row.id, counts.get(row.id)! + 1);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (wantInbound) {
|
|
309
|
+
const rows = db.prepare(
|
|
310
|
+
`SELECT target_id AS id, source_id AS other FROM links WHERE target_id IN (${placeholders})`,
|
|
311
|
+
).all(...chunk) as { id: string; other: string }[];
|
|
312
|
+
for (const row of rows) {
|
|
313
|
+
if (visible(row.other)) counts.set(row.id, counts.get(row.id)! + 1);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
293
319
|
if (wantOutbound) {
|
|
294
320
|
const rows = db.prepare(
|
|
295
321
|
`SELECT source_id AS id, COUNT(*) AS c FROM links
|
|
@@ -314,6 +340,23 @@ export function getLinkCounts(
|
|
|
314
340
|
return counts;
|
|
315
341
|
}
|
|
316
342
|
|
|
343
|
+
/** Lift both has_links polarities from SQL under scope (vault#714). */
|
|
344
|
+
export function sqlHasLinks(wanted: boolean | undefined, scoped: boolean): boolean | undefined {
|
|
345
|
+
return scoped ? undefined : wanted;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Presence and degree share one oracle; narrowing can shorten a scoped page. */
|
|
349
|
+
export function narrowByVisibleLinks<T extends { id: string }>(
|
|
350
|
+
db: Database,
|
|
351
|
+
notes: T[],
|
|
352
|
+
wanted: boolean | undefined,
|
|
353
|
+
visible: ((noteId: string) => boolean) | undefined,
|
|
354
|
+
): T[] {
|
|
355
|
+
if (wanted === undefined || !visible || notes.length === 0) return notes;
|
|
356
|
+
const counts = getLinkCounts(db, notes.map((n) => n.id), "both", visible);
|
|
357
|
+
return notes.filter((n) => (counts.get(n.id)! > 0) === wanted);
|
|
358
|
+
}
|
|
359
|
+
|
|
317
360
|
// ---- Deeper Link Queries ----
|
|
318
361
|
|
|
319
362
|
export interface TraversalNode {
|
package/core/src/mcp-manifest.ts
CHANGED
|
@@ -121,8 +121,8 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
121
121
|
description: "Alias for `exclude_tags` (singular). Same shape and semantics — accepts a single tag or an array.",
|
|
122
122
|
},
|
|
123
123
|
has_tags: { type: "boolean", description: "Presence filter: true = only notes with at least one tag; false = only untagged notes. Ignored when `tag` is set." },
|
|
124
|
-
has_links: { type: "boolean", description: "Presence filter: true = only notes with at least one inbound or outbound link; false = only orphaned notes (no links in either direction)." },
|
|
125
|
-
has_broken_links: { type: "boolean", description: "Presence filter (vault#555): true = only notes with at least one dangling outbound link — a [[wikilink]] or structured `links` target that never resolved to a note; false = only notes with none. Backed by the unresolved_wikilinks table (same data `doctor`/list-unresolved surfaces); safe on a vault where no link has ever gone unresolved (true matches nothing, false is a no-op). For a TAG-SCOPED session both polarities are answered on the notes the session can see (vault#239): a target whose candidates are ALL out of scope matches nothing in that session's sub-vault, so it counts as broken there even though the vault-wide record calls it ambiguous — otherwise the note would only become broken once the last invisible candidate was deleted. Decided after the page is drawn, so a scoped page may come back shorter than `limit` while more results remain." },
|
|
124
|
+
has_links: { type: "boolean", description: "Presence filter: true = only notes with at least one inbound or outbound link; false = only orphaned notes (no links in either direction). For a TAG-SCOPED session both endpoints must be visible. Both polarities are decided after the page is drawn, so a scoped page may be shorter than `limit` while more results remain; this filter does not participate in a scoped cursor query hash." },
|
|
125
|
+
has_broken_links: { type: "boolean", description: "Presence filter (vault#555): true = only notes with at least one dangling outbound link — a [[wikilink]] or structured `links` target that never resolved to a note; false = only notes with none. Backed by the unresolved_wikilinks table (same data `doctor`/list-unresolved surfaces); safe on a vault where no link has ever gone unresolved (true matches nothing, false is a no-op). For a TAG-SCOPED session both polarities are answered on the notes the session can see (vault#239): a target whose candidates are ALL out of scope matches nothing in that session's sub-vault, so it counts as broken there even though the vault-wide record calls it ambiguous or a content wikilink resolves to an invisible note. Resolved structured links cannot recover the original caller string and are not named as broken — otherwise the note would only become broken once the last invisible candidate was deleted. Decided after the page is drawn, so a scoped page may come back shorter than `limit` while more results remain." },
|
|
126
126
|
has_ambiguous_links: { type: "boolean", description: "Presence filter (vault#581): true = only notes with at least one AMBIGUOUS outbound link — a [[wikilink]] or structured `links` target that matched TWO OR MORE notes, so no link was created and none was guessed at; false = only notes with none. Disjoint from `has_broken_links` (dangling = matched nothing; ambiguous = matched too much). Backed by the ambiguous_wikilinks table — the same source `create-note`/`update-note`'s `ambiguous_link` warning draws from; safe on a vault where no link has ever been ambiguous (true matches nothing, false is a no-op). For a TAG-SCOPED session both polarities are answered on the notes the session can see — a collision between a visible and an invisible note is neither reported by `true` nor excluded by `false` — so a scoped page may come back shorter than `limit` while more results remain." },
|
|
127
127
|
path: { type: "string", description: "Exact path match (case-insensitive)" },
|
|
128
128
|
path_prefix: { type: "string", description: "Path prefix match (e.g., 'Projects/')" },
|
|
@@ -176,7 +176,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
176
176
|
last_updated_by: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write was attributed to this principal. Exact match; indexed." },
|
|
177
177
|
created_via: { type: "string", description: "Write-attribution filter (vault#298): only notes FIRST written through this interface/channel — e.g. `mcp`, `surface:<name>`, `agent:<id>`, `nostr:<64-hex-pubkey>`, `operator`, `api`. `nostr:<pubkey>` (vault#698) is the Nostr key that SIGNED the request, and is the axis that tells two agents apart when they share one hub user (`created_by`). Emitted by BOTH doors — self-hosted hub (parachute-hub#937) and cloud (parachute-cloud#277). Exact match; indexed." },
|
|
178
178
|
last_updated_via: { type: "string", description: "Write-attribution filter (vault#298): only notes whose MOST RECENT write came through this interface/channel — same vocabulary as `created_via`, including `nostr:<64-hex-pubkey>` for the signing key. Exact match; indexed." },
|
|
179
|
-
order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. Two special values need no declaration: `link_count` sorts by link DEGREE (both-directions raw row count), matching the `include_link_count` field for
|
|
179
|
+
order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. Two special values need no declaration: `link_count` sorts by link DEGREE (both-directions raw row count), matching the `include_link_count` field for unscoped both-direction counts. Under tag scope ordering still uses GLOBAL degree and may disagree with `linkCount`; `updated_at` (vault#585) sorts on the integer `updated_at_ms` mirror column — correct on non-canonical/imported timestamps — with `id` as the tiebreaker. Direction is taken from `sort` (default 'asc'); for other fields `created_at` is appended as a stable tiebreaker." },
|
|
180
180
|
date_from: { type: "string", description: "Start date (ISO, inclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', from }`." },
|
|
181
181
|
date_to: { type: "string", description: "End date (ISO, exclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', to }`." },
|
|
182
182
|
date_filter: {
|
|
@@ -208,6 +208,17 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
208
208
|
required: ["note_id"],
|
|
209
209
|
description: "Scope results to notes within N hops of an anchor note",
|
|
210
210
|
},
|
|
211
|
+
versions: {
|
|
212
|
+
type: "object",
|
|
213
|
+
properties: {
|
|
214
|
+
note_id: { type: "string", description: "Note ID or path" },
|
|
215
|
+
version_ix: { type: "integer", minimum: 0 },
|
|
216
|
+
limit: { type: "integer", minimum: 0, maximum: 200 },
|
|
217
|
+
offset: { type: "integer", minimum: 0 },
|
|
218
|
+
},
|
|
219
|
+
required: ["note_id"],
|
|
220
|
+
description: "Read note history; mutually exclusive with id, search, near, cursor, aggregate, and semantic. Tag-scoped sessions see the SAME visibility enforcement as every other read — a note outside the token's tag scope answers `not_found`, and a deleted note's history is not readable by a scoped session at all. Deleted-note history is also unavailable to unscoped MCP sessions; restore is REST-only.",
|
|
221
|
+
},
|
|
211
222
|
sort: {
|
|
212
223
|
type: "string",
|
|
213
224
|
enum: ["asc", "desc"],
|
|
@@ -240,18 +251,18 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
240
251
|
description: "Control metadata in response: true (all, default), false (none), or array of field names to include",
|
|
241
252
|
},
|
|
242
253
|
include_links: { type: "boolean", description: "Include inbound + outbound links per note (default: false)" },
|
|
243
|
-
include_broken_links: { type: "boolean", description: "Include each note's dangling outbound links as `broken_links: [{target, relationship}]` (default: false; vault#555). `target` is the unresolved path/title the [[wikilink]] or structured `links` entry named; `relationship` is \"wikilink\" for content-parsed links or the caller's own relationship string for a structured link. Empty array when the note has none. One batched query per request regardless of page size — mirrors `has_broken_links` (same backing table) and `include_links`. For a TAG-SCOPED session this also lists a target whose candidates are all out of scope,
|
|
254
|
+
include_broken_links: { type: "boolean", description: "Include each note's dangling outbound links as `broken_links: [{target, relationship}]` (default: false; vault#555). `target` is the unresolved path/title the [[wikilink]] or structured `links` entry named; `relationship` is \"wikilink\" for content-parsed links or the caller's own relationship string for a structured link. Empty array when the note has none. One batched query per request regardless of page size — mirrors `has_broken_links` (same backing table) and `include_links`. For a TAG-SCOPED session this also lists a target whose candidates are all out of scope, including a content wikilink resolved to an invisible note (vault#239, vault#714). Resolved structured links to invisible targets are suppressed but not named as broken: their original caller string is no longer stored, and the hidden target path must not be disclosed." },
|
|
244
255
|
include_ambiguous_links: { type: "boolean", description: "Include each note's ambiguous outbound links as `ambiguous_links: [{target, relationship, candidate_count}]` (default: false; vault#581). `target` is the path/title the [[wikilink]] or structured `links` entry named; `relationship` is \"wikilink\" for content-parsed links or the caller's own relationship string; `candidate_count` is how many notes it matched. For a TAG-SCOPED session the candidates are re-counted within the session's scope, and a target with fewer than two visible candidates is omitted — a scoped reader gets exactly what an unscoped one would get on a vault holding only the notes it can see. Empty array when the note has none. One batched query per request regardless of page size — mirrors `has_ambiguous_links` (same backing table) and `include_broken_links`." },
|
|
245
256
|
include_link_count: {
|
|
246
257
|
type: "boolean",
|
|
247
258
|
description:
|
|
248
|
-
"Include the note's link DEGREE as a `linkCount` field, without hauling the link objects (default: false). Degree is a raw row count: outbound (source) + inbound (target). A self-loop counts as 2. Cheap COUNT over indexes; batched once per request. For a tag-scoped token,
|
|
259
|
+
"Include the note's link DEGREE as a `linkCount` field, without hauling the link objects (default: false). Degree is a raw row count: outbound (source) + inbound (target). A self-loop counts as 2. Cheap COUNT over indexes; batched once per request. For a tag-scoped token, only edges with BOTH endpoints visible count, in every direction; a visible self-loop still counts as 2. Scoped counts select neighbour ids over the same indexes; unscoped counts are unchanged.",
|
|
249
260
|
},
|
|
250
261
|
link_count_direction: {
|
|
251
262
|
type: "string",
|
|
252
263
|
enum: ["both", "outbound", "inbound"],
|
|
253
264
|
description:
|
|
254
|
-
"Which edges `include_link_count` counts: both (default), outbound only (source_id), or inbound only (target_id). order_by=link_count always uses
|
|
265
|
+
"Which edges `include_link_count` counts: both (default), outbound only (source_id), or inbound only (target_id). For tag-scoped tokens only edges with both endpoints visible count in all three directions. order_by=link_count always uses GLOBAL both-directions degree, even under scope, and may disagree with linkCount.",
|
|
255
266
|
},
|
|
256
267
|
include_attachments: { type: "boolean", description: "Include attachment records (default: false)" },
|
|
257
268
|
expand_links: { type: "boolean", description: "Inline [[wikilinks]] in returned content (default: false). Has no effect if content is not included (e.g., default list mode with include_content=false); wikilinks inside fenced or inline code are not expanded." },
|
package/core/src/mcp.ts
CHANGED
|
@@ -184,7 +184,7 @@ function requireNoteReference(value: unknown): string {
|
|
|
184
184
|
* match required. Same [[wikilink]] semantics as `resolveWikilink`; exact
|
|
185
185
|
* id/path always wins first.
|
|
186
186
|
*/
|
|
187
|
-
function resolveNote(db: Database, idOrPath: string): Note | null {
|
|
187
|
+
export function resolveNote(db: Database, idOrPath: string): Note | null {
|
|
188
188
|
// Try ID match first (fast, indexed)
|
|
189
189
|
const byId = noteOps.getNote(db, idOrPath);
|
|
190
190
|
if (byId) return byId;
|
|
@@ -659,6 +659,10 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
659
659
|
const requestedHasBroken = params.has_broken_links as boolean | undefined;
|
|
660
660
|
const sqlHasBroken = sqlHasBrokenLinks(requestedHasBroken, Boolean(ambiguityVisible));
|
|
661
661
|
|
|
662
|
+
// vault#714: degree and presence use the same visible-edge count.
|
|
663
|
+
const requestedHasLinks = params.has_links as boolean | undefined;
|
|
664
|
+
const sqlLinks = linkOps.sqlHasLinks(requestedHasLinks, Boolean(ambiguityVisible));
|
|
665
|
+
|
|
662
666
|
// --- Link expansion config (shared across single + list paths) ---
|
|
663
667
|
const expandLinks = params.expand_links === true;
|
|
664
668
|
const expandMode = (params.expand_mode as ExpandMode) ?? "full";
|
|
@@ -688,6 +692,24 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
688
692
|
// stays byte-identical to the no-pagination behavior.
|
|
689
693
|
const contentRange = parseContentRange(params.content_offset, params.content_length);
|
|
690
694
|
|
|
695
|
+
if (params.versions) {
|
|
696
|
+
for (const key of ["search", "near", "cursor", "aggregate", "semantic", "id"] as const) {
|
|
697
|
+
if (params[key] !== undefined) throw new QueryError(`versions is incompatible with ${key}`, "INVALID_QUERY", {
|
|
698
|
+
error_type: "invalid_query", field: "versions", hint: `drop ${key} when using versions`,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
const v = params.versions as { note_id: string; version_ix?: number; limit?: number; offset?: number };
|
|
702
|
+
const note = requireNote(db, requireNoteReference(v.note_id));
|
|
703
|
+
if (typeof v.version_ix === "number") {
|
|
704
|
+
const version = await store.getNoteVersion(note.id, v.version_ix);
|
|
705
|
+
if (!version) return { error: `Version not found: "${v.note_id}"@${v.version_ix}`, error_type: "not_found", id: v.note_id, version_ix: v.version_ix };
|
|
706
|
+
return version;
|
|
707
|
+
}
|
|
708
|
+
const versions = await store.listNoteVersions(note.id, { limit: Math.max(0, Math.min(v.limit ?? 50, 200)), offset: v.offset ?? 0 });
|
|
709
|
+
const total = await store.countNoteVersions(note.id);
|
|
710
|
+
return { versions, total };
|
|
711
|
+
}
|
|
712
|
+
|
|
691
713
|
// --- Single note by ID/path ---
|
|
692
714
|
if (params.id) {
|
|
693
715
|
const note = resolveNote(db, params.id as string);
|
|
@@ -742,7 +764,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
742
764
|
// links/attachments above; filterMetadata only touches `metadata`.
|
|
743
765
|
if (params.include_link_count) {
|
|
744
766
|
const dir = normalizeLinkCountDirection(params.link_count_direction);
|
|
745
|
-
result.linkCount = linkOps.getLinkCounts(db, [note.id], dir).get(note.id) ?? 0;
|
|
767
|
+
result.linkCount = linkOps.getLinkCounts(db, [note.id], dir, ambiguityVisible).get(note.id) ?? 0;
|
|
746
768
|
}
|
|
747
769
|
return result;
|
|
748
770
|
}
|
|
@@ -877,7 +899,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
877
899
|
expand,
|
|
878
900
|
excludeTags: aggExcludeTags,
|
|
879
901
|
hasTags: params.has_tags as boolean | undefined,
|
|
880
|
-
hasLinks:
|
|
902
|
+
hasLinks: sqlLinks,
|
|
881
903
|
hasBrokenLinks: sqlHasBroken,
|
|
882
904
|
hasAmbiguousLinks: sqlHasAmbiguous,
|
|
883
905
|
path: params.path as string | undefined,
|
|
@@ -911,15 +933,20 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
911
933
|
// reader's own view of each row before aggregating.
|
|
912
934
|
// vault#239: the `has_broken_links` rollup is the same oracle for
|
|
913
935
|
// the same reason — re-decide brokenness on the sub-vault too.
|
|
914
|
-
const aggVisibleIds =
|
|
936
|
+
const aggVisibleIds = linkOps.narrowByVisibleLinks(
|
|
915
937
|
db,
|
|
916
|
-
|
|
938
|
+
narrowByVisibleBrokenness(
|
|
917
939
|
db,
|
|
918
|
-
|
|
919
|
-
|
|
940
|
+
narrowByVisibleAmbiguity(
|
|
941
|
+
db,
|
|
942
|
+
aggAllMatches.filter(aggregateVisibility),
|
|
943
|
+
requestedHasAmbiguous,
|
|
944
|
+
ambiguityVisible,
|
|
945
|
+
),
|
|
946
|
+
requestedHasBroken,
|
|
920
947
|
ambiguityVisible,
|
|
921
948
|
),
|
|
922
|
-
|
|
949
|
+
requestedHasLinks,
|
|
923
950
|
ambiguityVisible,
|
|
924
951
|
).map((n) => n.id);
|
|
925
952
|
// Always run the rollup, even on an empty visible set: ungrouped
|
|
@@ -1017,7 +1044,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1017
1044
|
expand,
|
|
1018
1045
|
excludeTags,
|
|
1019
1046
|
hasTags: params.has_tags as boolean | undefined,
|
|
1020
|
-
hasLinks:
|
|
1047
|
+
hasLinks: sqlLinks,
|
|
1021
1048
|
hasBrokenLinks: sqlHasBroken,
|
|
1022
1049
|
hasAmbiguousLinks: sqlHasAmbiguous,
|
|
1023
1050
|
path: params.path as string | undefined,
|
|
@@ -1101,7 +1128,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1101
1128
|
expand,
|
|
1102
1129
|
excludeTags,
|
|
1103
1130
|
hasTags: params.has_tags as boolean | undefined,
|
|
1104
|
-
hasLinks:
|
|
1131
|
+
hasLinks: sqlLinks,
|
|
1105
1132
|
hasBrokenLinks: sqlHasBroken,
|
|
1106
1133
|
hasAmbiguousLinks: sqlHasAmbiguous,
|
|
1107
1134
|
path: params.path as string | undefined,
|
|
@@ -1171,7 +1198,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1171
1198
|
expand,
|
|
1172
1199
|
excludeTags,
|
|
1173
1200
|
hasTags: params.has_tags as boolean | undefined,
|
|
1174
|
-
hasLinks:
|
|
1201
|
+
hasLinks: sqlLinks,
|
|
1175
1202
|
hasBrokenLinks: sqlHasBroken,
|
|
1176
1203
|
hasAmbiguousLinks: sqlHasAmbiguous,
|
|
1177
1204
|
path: params.path as string | undefined,
|
|
@@ -1240,6 +1267,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1240
1267
|
// lifted for a scoped reader, so the real predicate is applied here
|
|
1241
1268
|
// on that reader's own sub-vault. No-op unscoped.
|
|
1242
1269
|
results = narrowByVisibleBrokenness(db, results, requestedHasBroken, ambiguityVisible);
|
|
1270
|
+
results = linkOps.narrowByVisibleLinks(db, results, requestedHasLinks, ambiguityVisible);
|
|
1243
1271
|
|
|
1244
1272
|
// --- Format output ---
|
|
1245
1273
|
const includeContent = params.include_content === true; // default false for list
|
|
@@ -1307,7 +1335,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1307
1335
|
// survives. Don't casually swap the order.
|
|
1308
1336
|
if (params.include_link_count) {
|
|
1309
1337
|
const dir = normalizeLinkCountDirection(params.link_count_direction);
|
|
1310
|
-
const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), dir);
|
|
1338
|
+
const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), dir, ambiguityVisible);
|
|
1311
1339
|
for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
|
|
1312
1340
|
}
|
|
1313
1341
|
|
|
@@ -2270,7 +2298,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
2270
2298
|
name: "delete-note",
|
|
2271
2299
|
execute: async (params) => {
|
|
2272
2300
|
const note = requireNote(db, requireNoteReference(params.id));
|
|
2273
|
-
await store.deleteNote(note.id);
|
|
2301
|
+
await store.deleteNote(note.id, { actor: writeActor, via: writeVia });
|
|
2274
2302
|
return { deleted: true, id: note.id };
|
|
2275
2303
|
},
|
|
2276
2304
|
},
|
|
@@ -2484,7 +2512,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
2484
2512
|
field: "new_name",
|
|
2485
2513
|
});
|
|
2486
2514
|
}
|
|
2487
|
-
const result = await store.renameTag(oldName, newName);
|
|
2515
|
+
const result = await store.renameTag(oldName, newName, { actor: writeActor ?? undefined, via: writeVia ?? undefined });
|
|
2488
2516
|
if ("error" in result) {
|
|
2489
2517
|
if (result.error === "not_found") {
|
|
2490
2518
|
throw structuredError(`rename-tag: tag "${oldName}" not found`, {
|
package/core/src/notes.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type SearchMode,
|
|
32
32
|
} from "./search-query.js";
|
|
33
33
|
import { generateUlid } from "./ulid.js";
|
|
34
|
+
import { captureVersion, readPriorNoteRow, DEFAULT_HISTORY_POLICY, type HistoryPolicy } from "./history.js";
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* Write-attribution context (vault#298) — the two axes of provenance threaded
|
|
@@ -2390,7 +2391,7 @@ export type RenameTagResult =
|
|
|
2390
2391
|
* (the store wrapper) bust both `_tagHierarchy` and `_schemaConfig`
|
|
2391
2392
|
* after the cascade returns.
|
|
2392
2393
|
*/
|
|
2393
|
-
export function renameTag(db: Database, oldName: string, newName: string): RenameTagResult {
|
|
2394
|
+
export function renameTag(db: Database, oldName: string, newName: string, attr?: { actor?: string | null; via?: string | null }, policy: HistoryPolicy = DEFAULT_HISTORY_POLICY): RenameTagResult {
|
|
2394
2395
|
// Normalize the TARGET so a rename can never create a `#`-prefixed tag. The
|
|
2395
2396
|
// SOURCE (`oldName`) is left LITERAL on purpose — it's the transitional escape
|
|
2396
2397
|
// hatch that lets the `#legacy/*` → `legacy/*` data migration find the
|
|
@@ -2590,6 +2591,8 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
2590
2591
|
for (const row of candidates) {
|
|
2591
2592
|
const next = rewriteNoteBody(row.content, renames);
|
|
2592
2593
|
if (next === row.content) continue;
|
|
2594
|
+
const prior = readPriorNoteRow(db, row.id);
|
|
2595
|
+
if (prior) captureVersion(db, prior, { actor: attr?.actor ?? null, via: attr?.via ?? null, op: "tag-rename", policy });
|
|
2593
2596
|
updateStmt.run(next, now, nowMs, row.id);
|
|
2594
2597
|
notesRewritten++;
|
|
2595
2598
|
}
|
|
@@ -2612,6 +2615,8 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
2612
2615
|
for (const row of candidates) {
|
|
2613
2616
|
const next = rewriteTagConfigPath(row.path, renames);
|
|
2614
2617
|
if (next === row.path) continue;
|
|
2618
|
+
const prior = readPriorNoteRow(db, row.id);
|
|
2619
|
+
if (prior) captureVersion(db, prior, { actor: attr?.actor ?? null, via: attr?.via ?? null, op: "tag-rename", policy });
|
|
2615
2620
|
updateStmt.run(next, now, nowMs, row.id);
|
|
2616
2621
|
pathsRenamed++;
|
|
2617
2622
|
}
|
package/core/src/portable-md.ts
CHANGED
|
@@ -1961,7 +1961,7 @@ async function importVaultReplay(
|
|
|
1961
1961
|
const batch = await store.queryNotes({ sort: "asc", limit: EXPORT_BATCH_SIZE });
|
|
1962
1962
|
if (batch.length === 0) break;
|
|
1963
1963
|
for (const note of batch) {
|
|
1964
|
-
await store.deleteNote(note.id);
|
|
1964
|
+
await store.deleteNote(note.id, { captureHistory: false });
|
|
1965
1965
|
stats.notes_wiped++;
|
|
1966
1966
|
}
|
|
1967
1967
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { initSchema } from "./schema.js";
|
|
4
|
+
import { BunSqliteStore } from "./store.js";
|
|
5
|
+
let db: Database;
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
db = new Database(":memory:");
|
|
8
|
+
initSchema(db);
|
|
9
|
+
});
|
|
10
|
+
afterEach(() => db.close());
|
|
11
|
+
function dropHistory() {
|
|
12
|
+
db.exec(
|
|
13
|
+
"DROP TABLE IF EXISTS note_versions; DROP TABLE IF EXISTS note_blobs;",
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
it("P14a v28 upgrade creates both tables and three indexes without backfill", () => {
|
|
17
|
+
dropHistory();
|
|
18
|
+
db.prepare("UPDATE schema_version SET version=28").run();
|
|
19
|
+
initSchema(db);
|
|
20
|
+
expect(
|
|
21
|
+
db.prepare("SELECT MAX(version) AS version FROM schema_version").get(),
|
|
22
|
+
).toEqual({ version: 30 });
|
|
23
|
+
const tables = db
|
|
24
|
+
.prepare(
|
|
25
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('note_versions','note_blobs') ORDER BY name",
|
|
26
|
+
)
|
|
27
|
+
.all();
|
|
28
|
+
expect(tables).toEqual([{ name: "note_blobs" }, { name: "note_versions" }]);
|
|
29
|
+
expect(
|
|
30
|
+
db
|
|
31
|
+
.prepare(
|
|
32
|
+
"SELECT name FROM sqlite_master WHERE type='index' AND name IN ('idx_note_versions_note','idx_note_versions_hash','idx_note_versions_superseded')",
|
|
33
|
+
)
|
|
34
|
+
.all(),
|
|
35
|
+
).toHaveLength(3);
|
|
36
|
+
expect(db.prepare("SELECT COUNT(*) AS n FROM note_versions").get()).toEqual({
|
|
37
|
+
n: 0,
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
it("P14b repeated init is idempotent", () => {
|
|
41
|
+
initSchema(db);
|
|
42
|
+
initSchema(db);
|
|
43
|
+
expect(db.prepare("SELECT COUNT(*) AS n FROM note_versions").get()).toEqual({
|
|
44
|
+
n: 0,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
it("P14c rerunning the v29 migration preserves captured rows", async () => {
|
|
48
|
+
const store = new BunSqliteStore(db);
|
|
49
|
+
const n = await store.createNote("a");
|
|
50
|
+
await store.updateNote(n.id, { content: "b" });
|
|
51
|
+
db.prepare("UPDATE schema_version SET version=28").run();
|
|
52
|
+
initSchema(db);
|
|
53
|
+
expect((await store.getNoteVersion(n.id, 0))!.content).toBe("a");
|
|
54
|
+
});
|
|
55
|
+
it("P14d missing tables before the first capture degrade to no history", async () => {
|
|
56
|
+
const store = new BunSqliteStore(db);
|
|
57
|
+
const n = await store.createNote("a");
|
|
58
|
+
// Drop before this handle has probed true. A drop after capture is an
|
|
59
|
+
// accepted throw because historyTablesPresent memoises true per handle.
|
|
60
|
+
dropHistory();
|
|
61
|
+
await store.updateNote(n.id, { content: "b" });
|
|
62
|
+
expect((await store.getNote(n.id))!.content).toBe("b");
|
|
63
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { BunSqliteStore } from "./store.js";
|
|
4
|
+
import { initSchema, SCHEMA_VERSION } from "./schema.js";
|
|
5
|
+
test("P12 v29 opens losslessly, fresh and upgraded columns/indexes agree", async () => {
|
|
6
|
+
const db = new Database(":memory:"), fresh = new Database(":memory:");
|
|
7
|
+
try {
|
|
8
|
+
const store = new BunSqliteStore(db);
|
|
9
|
+
const a = await store.createNote("one"), b = await store.createNote("other");
|
|
10
|
+
await store.updateNote(a.id, { content: "two" });
|
|
11
|
+
await store.updateNote(a.id, { content: "three" });
|
|
12
|
+
await store.deleteNote(a.id);
|
|
13
|
+
await store.updateNote(b.id, { content: "changed" });
|
|
14
|
+
// Remove only v30 additions to recreate the actual v29 table shape.
|
|
15
|
+
db.exec("DROP INDEX IF EXISTS idx_note_blobs_delta_of");
|
|
16
|
+
const cols = (table: string) => db.prepare(`PRAGMA table_info(${table})`).all() as {
|
|
17
|
+
name: string;
|
|
18
|
+
}[];
|
|
19
|
+
if (cols("note_blobs").some(c => c.name === "delta_of"))
|
|
20
|
+
db.exec("ALTER TABLE note_blobs DROP COLUMN delta_of");
|
|
21
|
+
if (cols("note_blobs").some(c => c.name === "encoding"))
|
|
22
|
+
db.exec("ALTER TABLE note_blobs DROP COLUMN encoding");
|
|
23
|
+
if (cols("note_versions").some(c => c.name === "created_at"))
|
|
24
|
+
db.exec("ALTER TABLE note_versions DROP COLUMN created_at");
|
|
25
|
+
db.exec("UPDATE schema_version SET version=29");
|
|
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
|
+
const before = snap();
|
|
28
|
+
expect(() => initSchema(db)).not.toThrow();
|
|
29
|
+
expect(SCHEMA_VERSION).toBe(30);
|
|
30
|
+
expect(snap()).toEqual(before);
|
|
31
|
+
expect(db.prepare("SELECT hash FROM note_blobs WHERE delta_of IS NOT NULL OR encoding IS NOT NULL").all()).toEqual([]);
|
|
32
|
+
expect(db.prepare("SELECT note_id FROM note_versions WHERE created_at IS NOT NULL").all()).toEqual([]);
|
|
33
|
+
initSchema(fresh);
|
|
34
|
+
for (const table of ["note_blobs", "note_versions"]) {
|
|
35
|
+
expect(cols(table).map(c => c.name).sort()).toEqual((fresh.prepare(`PRAGMA table_info(${table})`).all() as {
|
|
36
|
+
name: string;
|
|
37
|
+
}[]).map(c => c.name).sort());
|
|
38
|
+
}
|
|
39
|
+
for (const handle of [db, fresh])
|
|
40
|
+
expect(handle.prepare("SELECT name FROM sqlite_master WHERE name='idx_note_blobs_delta_of'").get()).not.toBeNull();
|
|
41
|
+
expect(db.prepare("PRAGMA integrity_check").get()).toEqual({ integrity_check: "ok" });
|
|
42
|
+
expect(db.prepare("PRAGMA foreign_key_check").all()).toEqual([]);
|
|
43
|
+
initSchema(db);
|
|
44
|
+
expect(snap()).toEqual(before);
|
|
45
|
+
expect(() => db.exec("UPDATE note_blobs SET delta_of='nosuchhash'")).toThrow("FOREIGN KEY");
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
db.close();
|
|
49
|
+
fresh.close();
|
|
50
|
+
}
|
|
51
|
+
});
|