@openparachute/vault 0.7.9-rc.3 → 0.7.9-rc.4
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/link-count.test.ts +29 -0
- package/core/src/links.ts +43 -0
- package/core/src/mcp-manifest.ts +6 -6
- package/core/src/mcp.ts +21 -11
- package/core/src/wikilinks.ts +68 -1
- package/package.json +1 -1
- package/src/release-plan.test.ts +90 -1
- package/src/routes.ts +69 -9
- 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.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: {
|
|
@@ -240,18 +240,18 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
240
240
|
description: "Control metadata in response: true (all, default), false (none), or array of field names to include",
|
|
241
241
|
},
|
|
242
242
|
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,
|
|
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, 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
244
|
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
245
|
include_link_count: {
|
|
246
246
|
type: "boolean",
|
|
247
247
|
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,
|
|
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, 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
249
|
},
|
|
250
250
|
link_count_direction: {
|
|
251
251
|
type: "string",
|
|
252
252
|
enum: ["both", "outbound", "inbound"],
|
|
253
253
|
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
|
|
254
|
+
"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
255
|
},
|
|
256
256
|
include_attachments: { type: "boolean", description: "Include attachment records (default: false)" },
|
|
257
257
|
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
|
@@ -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";
|
|
@@ -742,7 +746,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
742
746
|
// links/attachments above; filterMetadata only touches `metadata`.
|
|
743
747
|
if (params.include_link_count) {
|
|
744
748
|
const dir = normalizeLinkCountDirection(params.link_count_direction);
|
|
745
|
-
result.linkCount = linkOps.getLinkCounts(db, [note.id], dir).get(note.id) ?? 0;
|
|
749
|
+
result.linkCount = linkOps.getLinkCounts(db, [note.id], dir, ambiguityVisible).get(note.id) ?? 0;
|
|
746
750
|
}
|
|
747
751
|
return result;
|
|
748
752
|
}
|
|
@@ -877,7 +881,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
877
881
|
expand,
|
|
878
882
|
excludeTags: aggExcludeTags,
|
|
879
883
|
hasTags: params.has_tags as boolean | undefined,
|
|
880
|
-
hasLinks:
|
|
884
|
+
hasLinks: sqlLinks,
|
|
881
885
|
hasBrokenLinks: sqlHasBroken,
|
|
882
886
|
hasAmbiguousLinks: sqlHasAmbiguous,
|
|
883
887
|
path: params.path as string | undefined,
|
|
@@ -911,15 +915,20 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
911
915
|
// reader's own view of each row before aggregating.
|
|
912
916
|
// vault#239: the `has_broken_links` rollup is the same oracle for
|
|
913
917
|
// the same reason — re-decide brokenness on the sub-vault too.
|
|
914
|
-
const aggVisibleIds =
|
|
918
|
+
const aggVisibleIds = linkOps.narrowByVisibleLinks(
|
|
915
919
|
db,
|
|
916
|
-
|
|
920
|
+
narrowByVisibleBrokenness(
|
|
917
921
|
db,
|
|
918
|
-
|
|
919
|
-
|
|
922
|
+
narrowByVisibleAmbiguity(
|
|
923
|
+
db,
|
|
924
|
+
aggAllMatches.filter(aggregateVisibility),
|
|
925
|
+
requestedHasAmbiguous,
|
|
926
|
+
ambiguityVisible,
|
|
927
|
+
),
|
|
928
|
+
requestedHasBroken,
|
|
920
929
|
ambiguityVisible,
|
|
921
930
|
),
|
|
922
|
-
|
|
931
|
+
requestedHasLinks,
|
|
923
932
|
ambiguityVisible,
|
|
924
933
|
).map((n) => n.id);
|
|
925
934
|
// Always run the rollup, even on an empty visible set: ungrouped
|
|
@@ -1017,7 +1026,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1017
1026
|
expand,
|
|
1018
1027
|
excludeTags,
|
|
1019
1028
|
hasTags: params.has_tags as boolean | undefined,
|
|
1020
|
-
hasLinks:
|
|
1029
|
+
hasLinks: sqlLinks,
|
|
1021
1030
|
hasBrokenLinks: sqlHasBroken,
|
|
1022
1031
|
hasAmbiguousLinks: sqlHasAmbiguous,
|
|
1023
1032
|
path: params.path as string | undefined,
|
|
@@ -1101,7 +1110,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1101
1110
|
expand,
|
|
1102
1111
|
excludeTags,
|
|
1103
1112
|
hasTags: params.has_tags as boolean | undefined,
|
|
1104
|
-
hasLinks:
|
|
1113
|
+
hasLinks: sqlLinks,
|
|
1105
1114
|
hasBrokenLinks: sqlHasBroken,
|
|
1106
1115
|
hasAmbiguousLinks: sqlHasAmbiguous,
|
|
1107
1116
|
path: params.path as string | undefined,
|
|
@@ -1171,7 +1180,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1171
1180
|
expand,
|
|
1172
1181
|
excludeTags,
|
|
1173
1182
|
hasTags: params.has_tags as boolean | undefined,
|
|
1174
|
-
hasLinks:
|
|
1183
|
+
hasLinks: sqlLinks,
|
|
1175
1184
|
hasBrokenLinks: sqlHasBroken,
|
|
1176
1185
|
hasAmbiguousLinks: sqlHasAmbiguous,
|
|
1177
1186
|
path: params.path as string | undefined,
|
|
@@ -1240,6 +1249,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1240
1249
|
// lifted for a scoped reader, so the real predicate is applied here
|
|
1241
1250
|
// on that reader's own sub-vault. No-op unscoped.
|
|
1242
1251
|
results = narrowByVisibleBrokenness(db, results, requestedHasBroken, ambiguityVisible);
|
|
1252
|
+
results = linkOps.narrowByVisibleLinks(db, results, requestedHasLinks, ambiguityVisible);
|
|
1243
1253
|
|
|
1244
1254
|
// --- Format output ---
|
|
1245
1255
|
const includeContent = params.include_content === true; // default false for list
|
|
@@ -1307,7 +1317,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1307
1317
|
// survives. Don't casually swap the order.
|
|
1308
1318
|
if (params.include_link_count) {
|
|
1309
1319
|
const dir = normalizeLinkCountDirection(params.link_count_direction);
|
|
1310
|
-
const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), dir);
|
|
1320
|
+
const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), dir, ambiguityVisible);
|
|
1311
1321
|
for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
|
|
1312
1322
|
}
|
|
1313
1323
|
|
package/core/src/wikilinks.ts
CHANGED
|
@@ -492,6 +492,42 @@ export function listZeroVisibleAmbiguousAsUnresolved(
|
|
|
492
492
|
return out;
|
|
493
493
|
}
|
|
494
494
|
|
|
495
|
+
/**
|
|
496
|
+
* Resolved-to-invisible content wikilinks, shaped for the scoped unresolved
|
|
497
|
+
* listing (vault#714). Bounded and non-exhaustive: scan at most 2000 edges,
|
|
498
|
+
* fetch content only for sources with a hidden target, and stop at limit.
|
|
499
|
+
* Structured links cannot recover their original caller string from links;
|
|
500
|
+
* never disclose the hidden target's stored path as a replacement.
|
|
501
|
+
*/
|
|
502
|
+
export function listResolvedToInvisibleAsUnresolved(
|
|
503
|
+
db: Database,
|
|
504
|
+
visible: (noteId: string) => boolean,
|
|
505
|
+
limit = 50,
|
|
506
|
+
): UnresolvedWikilink[] {
|
|
507
|
+
if (limit <= 0) return [];
|
|
508
|
+
const rows = db.prepare(
|
|
509
|
+
"SELECT source_id, target_id, relationship FROM links ORDER BY source_id, target_id LIMIT ?",
|
|
510
|
+
).all(Math.min(2000, Math.max(200, limit * 20))) as { source_id: string; target_id: string; relationship: string }[];
|
|
511
|
+
const sources = new Set<string>();
|
|
512
|
+
const out: UnresolvedWikilink[] = [];
|
|
513
|
+
for (const row of rows) {
|
|
514
|
+
if (visible(row.target_id) || sources.has(row.source_id)) continue;
|
|
515
|
+
sources.add(row.source_id);
|
|
516
|
+
const note = db.prepare("SELECT path, content FROM notes WHERE id = ?").get(row.source_id) as { path: string | null; content: string } | null;
|
|
517
|
+
if (!note) continue;
|
|
518
|
+
const seen = new Set<string>();
|
|
519
|
+
for (const { target } of parseWikilinks(note.content)) {
|
|
520
|
+
if (visibleResolutionCount(db, target, WIKILINK_REL, visible) > 0) continue;
|
|
521
|
+
const key = target.toLowerCase();
|
|
522
|
+
if (seen.has(key)) continue;
|
|
523
|
+
seen.add(key);
|
|
524
|
+
out.push({ source_id: row.source_id, source_path: note.path ?? undefined, target_path: target, relationship: WIKILINK_REL });
|
|
525
|
+
if (out.length >= limit) return out;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return out;
|
|
529
|
+
}
|
|
530
|
+
|
|
495
531
|
/** One note's dangling outbound link, as surfaced on a note read (vault#555). */
|
|
496
532
|
export interface BrokenLink {
|
|
497
533
|
target: string;
|
|
@@ -522,7 +558,10 @@ export interface BrokenLink {
|
|
|
522
558
|
*
|
|
523
559
|
* Cost: one extra query plus one re-resolution per ambiguous row, and only
|
|
524
560
|
* for scoped readers — the same profile the ambiguity surface accepts, on a
|
|
525
|
-
* table that is rare by nature.
|
|
561
|
+
* table that is rare by nature. Scoped reads also scan outbound edges for
|
|
562
|
+
* the requested page, fetching content only for sources with hidden targets
|
|
563
|
+
* and re-resolving their own bracket text (vault#714). An unscoped reader
|
|
564
|
+
* does no extra work.
|
|
526
565
|
*/
|
|
527
566
|
export function getUnresolvedLinksForNotes(
|
|
528
567
|
db: Database,
|
|
@@ -573,6 +612,34 @@ export function getUnresolvedLinksForNotes(
|
|
|
573
612
|
seen.add(key);
|
|
574
613
|
result.get(row.source_id)?.push({ target: row.target_path, relationship });
|
|
575
614
|
}
|
|
615
|
+
|
|
616
|
+
// vault#714: the persisted edge may resolve only to a hidden note.
|
|
617
|
+
// Reparse the source's own brackets, never the hidden target's path.
|
|
618
|
+
const hiddenTargetSources = new Set<string>();
|
|
619
|
+
for (const chunk of chunkForInClause(noteIds)) {
|
|
620
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
621
|
+
const links = db.prepare(
|
|
622
|
+
`SELECT source_id, target_id FROM links WHERE source_id IN (${placeholders})`,
|
|
623
|
+
).all(...chunk) as { source_id: string; target_id: string }[];
|
|
624
|
+
for (const link of links) {
|
|
625
|
+
if (!visible(link.target_id)) hiddenTargetSources.add(link.source_id);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
for (const chunk of chunkForInClause([...hiddenTargetSources])) {
|
|
629
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
630
|
+
const notes = db.prepare(
|
|
631
|
+
`SELECT id, content FROM notes WHERE id IN (${placeholders})`,
|
|
632
|
+
).all(...chunk) as { id: string; content: string }[];
|
|
633
|
+
for (const note of notes) {
|
|
634
|
+
for (const { target } of parseWikilinks(note.content)) {
|
|
635
|
+
if (visibleResolutionCount(db, target, WIKILINK_REL, visible) > 0) continue;
|
|
636
|
+
const key = `${note.id}\u0000${WIKILINK_REL}\u0000${target.toLowerCase()}`;
|
|
637
|
+
if (seen.has(key)) continue;
|
|
638
|
+
seen.add(key);
|
|
639
|
+
result.get(note.id)?.push({ target, relationship: WIKILINK_REL });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
576
643
|
}
|
|
577
644
|
return result;
|
|
578
645
|
}
|
package/package.json
CHANGED
package/src/release-plan.test.ts
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
import { describe, expect, test } from "bun:test";
|
|
12
12
|
import {
|
|
13
13
|
compareVersions,
|
|
14
|
+
coreVersion,
|
|
14
15
|
decidePublish,
|
|
15
16
|
distTagFor,
|
|
17
|
+
matchingRcVersions,
|
|
16
18
|
readRegistry,
|
|
17
19
|
unpublishedDrift,
|
|
18
20
|
} from "../scripts/release-plan.ts";
|
|
@@ -193,13 +195,81 @@ describe("decidePublish", () => {
|
|
|
193
195
|
{
|
|
194
196
|
versionExists: false,
|
|
195
197
|
currentDistTagVersion: "0.7.8",
|
|
196
|
-
publishedVersions: ["0.7.8"],
|
|
198
|
+
publishedVersions: ["0.7.8", "0.7.9-rc.1"],
|
|
197
199
|
},
|
|
198
200
|
{ branch: "main" },
|
|
199
201
|
);
|
|
200
202
|
expect(d).toMatchObject({ publish: true });
|
|
201
203
|
});
|
|
202
204
|
|
|
205
|
+
test("a stable without a matching rc is refused — vault#697", () => {
|
|
206
|
+
// Hub 0.7.13–0.7.16 shipped @latest with no rc of the same X.Y.Z.
|
|
207
|
+
// Vault's previous plan (and the inline shell it replaced) did the same.
|
|
208
|
+
const d = decidePublish(
|
|
209
|
+
"0.7.9",
|
|
210
|
+
{
|
|
211
|
+
versionExists: false,
|
|
212
|
+
currentDistTagVersion: "0.7.8",
|
|
213
|
+
publishedVersions: ["0.7.8", "0.7.8-rc.2"],
|
|
214
|
+
},
|
|
215
|
+
{ branch: "main" },
|
|
216
|
+
);
|
|
217
|
+
expect(d).toMatchObject({ refuse: true });
|
|
218
|
+
expect("refuse" in d && d.reason).toMatch(/0\.7\.9-rc/);
|
|
219
|
+
expect("refuse" in d && d.reason).toMatch(/suffix-drop|Cut an rc first/i);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("a stable whose only published rcs are a different X.Y.Z is still refused", () => {
|
|
223
|
+
const d = decidePublish(
|
|
224
|
+
"0.7.9",
|
|
225
|
+
{
|
|
226
|
+
versionExists: false,
|
|
227
|
+
currentDistTagVersion: "0.7.8",
|
|
228
|
+
publishedVersions: ["0.7.8", "0.7.8-rc.1"],
|
|
229
|
+
},
|
|
230
|
+
{ branch: "main" },
|
|
231
|
+
);
|
|
232
|
+
expect(d).toMatchObject({ refuse: true });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("a stable with a matching rc publishes from main", () => {
|
|
236
|
+
const d = decidePublish(
|
|
237
|
+
"0.7.9",
|
|
238
|
+
{
|
|
239
|
+
versionExists: false,
|
|
240
|
+
currentDistTagVersion: "0.7.8",
|
|
241
|
+
publishedVersions: ["0.7.8", "0.7.9-rc.1"],
|
|
242
|
+
},
|
|
243
|
+
{ branch: "main" },
|
|
244
|
+
);
|
|
245
|
+
expect(d).toMatchObject({ publish: true });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("omitted publishedVersions still refuses a stable when latest already exists", () => {
|
|
249
|
+
const d = decidePublish(
|
|
250
|
+
"0.7.9",
|
|
251
|
+
{
|
|
252
|
+
versionExists: false,
|
|
253
|
+
currentDistTagVersion: "0.7.8",
|
|
254
|
+
},
|
|
255
|
+
{ branch: "main" },
|
|
256
|
+
);
|
|
257
|
+
expect(d).toMatchObject({ refuse: true });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("an rc publishes even with no prior rc of the same core", () => {
|
|
261
|
+
const d = decidePublish(
|
|
262
|
+
"0.7.9-rc.1",
|
|
263
|
+
{
|
|
264
|
+
versionExists: false,
|
|
265
|
+
currentDistTagVersion: "0.7.8-rc.5",
|
|
266
|
+
publishedVersions: ["0.7.8", "0.7.8-rc.5"],
|
|
267
|
+
},
|
|
268
|
+
{ branch: "next" },
|
|
269
|
+
);
|
|
270
|
+
expect(d).toMatchObject({ publish: true });
|
|
271
|
+
});
|
|
272
|
+
|
|
203
273
|
test("a tag push of a stable does NOT override the main-only gate", () => {
|
|
204
274
|
const d = decidePublish(
|
|
205
275
|
"0.7.9",
|
|
@@ -281,6 +351,25 @@ describe("readRegistry", () => {
|
|
|
281
351
|
});
|
|
282
352
|
});
|
|
283
353
|
|
|
354
|
+
describe("matchingRcVersions", () => {
|
|
355
|
+
test("matches only the same X.Y.Z rc chain", () => {
|
|
356
|
+
expect(
|
|
357
|
+
matchingRcVersions("0.7.9", ["0.7.8", "0.7.8-rc.1", "0.7.9-rc.1", "0.7.9-rc.2"]),
|
|
358
|
+
).toEqual(["0.7.9-rc.1", "0.7.9-rc.2"]);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("a prerelease still matches siblings of its core", () => {
|
|
362
|
+
expect(matchingRcVersions("0.7.9-rc.3", ["0.7.9-rc.1", "0.7.8-rc.1"])).toEqual(["0.7.9-rc.1"]);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe("coreVersion", () => {
|
|
367
|
+
test("strips the rc suffix and leaves a stable alone", () => {
|
|
368
|
+
expect(coreVersion("0.7.9-rc.1")).toBe("0.7.9");
|
|
369
|
+
expect(coreVersion("0.7.9")).toBe("0.7.9");
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
284
373
|
describe("unpublishedDrift", () => {
|
|
285
374
|
test("no commits → not drifted", () => {
|
|
286
375
|
expect(unpublishedDrift([]).drifted).toBe(false);
|
package/src/routes.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
getUnresolvedLinksForNote,
|
|
33
33
|
getUnresolvedLinksForNotes,
|
|
34
34
|
listZeroVisibleAmbiguousAsUnresolved,
|
|
35
|
+
listResolvedToInvisibleAsUnresolved,
|
|
35
36
|
narrowByVisibleBrokenness,
|
|
36
37
|
sqlHasBrokenLinks,
|
|
37
38
|
getAmbiguousLinksForNote,
|
|
@@ -1026,7 +1027,7 @@ export function parseNotesQueryOpts(url: URL, tagScope: TagScopeCtx = NO_TAG_SCO
|
|
|
1026
1027
|
expand,
|
|
1027
1028
|
excludeTags: parseQueryList(url, "exclude_tag"),
|
|
1028
1029
|
hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
|
|
1029
|
-
hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
1030
|
+
hasLinks: linkOps.sqlHasLinks(parseBoolOrUndef(parseQuery(url, "has_links")), tagScope.raw !== null),
|
|
1030
1031
|
// Presence filter on dangling outbound wikilinks/structured links
|
|
1031
1032
|
// (vault#555) — see core/src/types.ts QueryOpts.hasBrokenLinks.
|
|
1032
1033
|
// vault#239: for a TAG-SCOPED reader NEITHER polarity is safe to push
|
|
@@ -1376,7 +1377,7 @@ async function handleNotesInner(
|
|
|
1376
1377
|
// linkCount injected after filterMetadata on purpose — same as
|
|
1377
1378
|
// links/attachments above; filterMetadata only touches `metadata`.
|
|
1378
1379
|
if (parseBool(parseQuery(url, "include_link_count"), false)) {
|
|
1379
|
-
result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url)).get(note.id) ?? 0;
|
|
1380
|
+
result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url), ambiguityVisibilityFor(db, tagScope)).get(note.id) ?? 0;
|
|
1380
1381
|
}
|
|
1381
1382
|
return json(result);
|
|
1382
1383
|
}
|
|
@@ -1451,7 +1452,26 @@ async function handleNotesInner(
|
|
|
1451
1452
|
embeddingsPendingWarning(semanticResult.pendingCount, semanticResult.totalCandidates),
|
|
1452
1453
|
);
|
|
1453
1454
|
}
|
|
1454
|
-
|
|
1455
|
+
let filtered = filterNotesByTagScope(semanticResult.notes, tagScope.allowed, tagScope.raw);
|
|
1456
|
+
// Apply the same scoped presence filters as the structured list.
|
|
1457
|
+
filtered = narrowByVisibleAmbiguity(
|
|
1458
|
+
db,
|
|
1459
|
+
filtered,
|
|
1460
|
+
parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
|
|
1461
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1462
|
+
);
|
|
1463
|
+
filtered = narrowByVisibleBrokenness(
|
|
1464
|
+
db,
|
|
1465
|
+
filtered,
|
|
1466
|
+
parseBoolOrUndef(parseQuery(url, "has_broken_links")),
|
|
1467
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1468
|
+
);
|
|
1469
|
+
filtered = linkOps.narrowByVisibleLinks(
|
|
1470
|
+
db,
|
|
1471
|
+
filtered,
|
|
1472
|
+
parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
1473
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1474
|
+
);
|
|
1455
1475
|
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
1456
1476
|
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
1457
1477
|
if (contentRange.error) return contentRange.error;
|
|
@@ -1477,7 +1497,7 @@ async function handleNotesInner(
|
|
|
1477
1497
|
output = output.map((n: any) => filterMetadata(n, inclMeta));
|
|
1478
1498
|
}
|
|
1479
1499
|
if (parseBool(parseQuery(url, "include_link_count"), false)) {
|
|
1480
|
-
const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), parseLinkCountDirection(url));
|
|
1500
|
+
const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), parseLinkCountDirection(url), ambiguityVisibilityFor(db, tagScope));
|
|
1481
1501
|
for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
|
|
1482
1502
|
}
|
|
1483
1503
|
return jsonWithWarnings(output, semanticWarnings);
|
|
@@ -1646,7 +1666,26 @@ async function handleNotesInner(
|
|
|
1646
1666
|
// Tag-scope: drop any result the token isn't permitted to see. Filter
|
|
1647
1667
|
// happens after the store query so an empty post-filter list still
|
|
1648
1668
|
// returns 200 [] (consistent with "no matches"), not 403.
|
|
1649
|
-
|
|
1669
|
+
let results = filterNotesByTagScope(rawResults, tagScope.allowed, tagScope.raw);
|
|
1670
|
+
// Apply the same scoped presence filters as the structured list.
|
|
1671
|
+
results = narrowByVisibleAmbiguity(
|
|
1672
|
+
db,
|
|
1673
|
+
results,
|
|
1674
|
+
parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
|
|
1675
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1676
|
+
);
|
|
1677
|
+
results = narrowByVisibleBrokenness(
|
|
1678
|
+
db,
|
|
1679
|
+
results,
|
|
1680
|
+
parseBoolOrUndef(parseQuery(url, "has_broken_links")),
|
|
1681
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1682
|
+
);
|
|
1683
|
+
results = linkOps.narrowByVisibleLinks(
|
|
1684
|
+
db,
|
|
1685
|
+
results,
|
|
1686
|
+
parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
1687
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1688
|
+
);
|
|
1650
1689
|
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
1651
1690
|
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
1652
1691
|
if (contentRange.error) return contentRange.error;
|
|
@@ -1680,6 +1719,7 @@ async function handleNotesInner(
|
|
|
1680
1719
|
db,
|
|
1681
1720
|
output.map((n: any) => n.id),
|
|
1682
1721
|
parseLinkCountDirection(url),
|
|
1722
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1683
1723
|
);
|
|
1684
1724
|
for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
|
|
1685
1725
|
}
|
|
@@ -1807,6 +1847,12 @@ async function handleNotesInner(
|
|
|
1807
1847
|
parseBoolOrUndef(parseQuery(url, "has_broken_links")),
|
|
1808
1848
|
ambiguityVisibilityFor(db, tagScope),
|
|
1809
1849
|
);
|
|
1850
|
+
visible = linkOps.narrowByVisibleLinks(
|
|
1851
|
+
db,
|
|
1852
|
+
visible,
|
|
1853
|
+
parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
1854
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1855
|
+
);
|
|
1810
1856
|
// Always run the rollup, even on an empty visible set: ungrouped
|
|
1811
1857
|
// count (vault#626) must return `[{group:null,value:0}]`, not `[]`.
|
|
1812
1858
|
rows = await store.aggregateNotes({ ids: visible.map((n) => n.id), aggregate: aggregateParsed.aggregate });
|
|
@@ -1980,6 +2026,12 @@ async function handleNotesInner(
|
|
|
1980
2026
|
parseBoolOrUndef(parseQuery(url, "has_broken_links")),
|
|
1981
2027
|
ambiguityVisibilityFor(db, tagScope),
|
|
1982
2028
|
);
|
|
2029
|
+
results = linkOps.narrowByVisibleLinks(
|
|
2030
|
+
db,
|
|
2031
|
+
results,
|
|
2032
|
+
parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
2033
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
2034
|
+
);
|
|
1983
2035
|
|
|
1984
2036
|
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
1985
2037
|
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
@@ -2059,6 +2111,7 @@ async function handleNotesInner(
|
|
|
2059
2111
|
db,
|
|
2060
2112
|
output.map((n: any) => n.id),
|
|
2061
2113
|
parseLinkCountDirection(url),
|
|
2114
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
2062
2115
|
);
|
|
2063
2116
|
for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
|
|
2064
2117
|
}
|
|
@@ -2826,7 +2879,7 @@ async function handleNotesInner(
|
|
|
2826
2879
|
result.attachments = await store.getAttachments(note.id);
|
|
2827
2880
|
}
|
|
2828
2881
|
if (parseBool(parseQuery(url, "include_link_count"), false)) {
|
|
2829
|
-
result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url)).get(note.id) ?? 0;
|
|
2882
|
+
result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url), ambiguityVisibilityFor(db, tagScope)).get(note.id) ?? 0;
|
|
2830
2883
|
}
|
|
2831
2884
|
return json(result);
|
|
2832
2885
|
}
|
|
@@ -4264,9 +4317,16 @@ export function handleUnresolvedWikilinks(
|
|
|
4264
4317
|
// target); the two tables are disjoint by construction, so this only
|
|
4265
4318
|
// guards against a stale row.
|
|
4266
4319
|
const seen = new Set(filtered.map((r) => `${r.source_id}\u0000${r.relationship}\u0000${r.target_path.toLowerCase()}`));
|
|
4267
|
-
const
|
|
4268
|
-
|
|
4269
|
-
|
|
4320
|
+
const visible = ambiguityVisibilityFor(db, tagScope)!;
|
|
4321
|
+
const collapsed = [
|
|
4322
|
+
...listZeroVisibleAmbiguousAsUnresolved(db, visible, limit),
|
|
4323
|
+
...listResolvedToInvisibleAsUnresolved(db, visible, limit),
|
|
4324
|
+
].filter((row) => {
|
|
4325
|
+
const key = `${row.source_id}\u0000${row.relationship}\u0000${row.target_path.toLowerCase()}`;
|
|
4326
|
+
if (!inScope(row) || seen.has(key)) return false;
|
|
4327
|
+
seen.add(key);
|
|
4328
|
+
return true;
|
|
4329
|
+
});
|
|
4270
4330
|
|
|
4271
4331
|
const merged = [...filtered, ...collapsed].slice(0, limit);
|
|
4272
4332
|
return Response.json({ unresolved: merged, count: merged.length });
|