@openparachute/vault 0.7.9-rc.2 → 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/core.test.ts +546 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/link-count.test.ts +29 -0
- package/core/src/links.ts +43 -0
- package/core/src/mcp-manifest.ts +10 -6
- package/core/src/mcp.ts +129 -44
- package/core/src/notes.ts +51 -2
- package/core/src/schema-v28-unresolved-wikilinks.test.ts +103 -0
- package/core/src/schema.ts +28 -1
- package/core/src/store.ts +180 -57
- package/core/src/txn.test.ts +33 -1
- package/core/src/txn.ts +80 -7
- package/core/src/types.ts +11 -0
- package/core/src/vault-projection.ts +8 -1
- package/core/src/wikilinks.ts +873 -44
- package/package.json +2 -2
- package/src/aggregate-routes.test.ts +76 -0
- package/src/config.ts +8 -2
- package/src/mcp-http.test.ts +51 -1
- package/src/mcp-tools.ts +106 -5
- package/src/mirror-routes.test.ts +47 -0
- package/src/release-plan.test.ts +90 -1
- package/src/routes.ts +280 -66
- package/src/routing.test.ts +67 -0
- package/src/tag-scope-query-tag.test.ts +374 -0
- package/src/tag-scope.ts +116 -0
- 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 +928 -1
- package/src/write-warnings-scope.test.ts +344 -0
- package/src/ws-server.ts +16 -3
- package/src/ws-subscribe.test.ts +76 -2
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
|
@@ -65,6 +65,8 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
65
65
|
|
|
66
66
|
Broken links (vault#555): a \`[[wikilink]]\` or structured \`links\` target that never resolved to a note used to be invisible — silently dropped from the response with no signal it existed. Pass \`has_broken_links: true\`/\`false\` to filter notes by whether they have any dangling outbound link, and/or \`include_broken_links: true\` to attach each note's pending targets as \`broken_links: [{target, relationship}]\` (empty array when none). Both read the vault's pending-resolution table — the same source \`create-note\`/\`update-note\`'s \`unresolved_link\` warning draws from; a target created later (this session or any future one) backfills the edge automatically and the note drops out of \`has_broken_links: true\`.
|
|
67
67
|
|
|
68
|
+
Ambiguous links (vault#581): the twin of the above, for a target that matched TOO MANY notes rather than none — \`[[Dup]]\` when two notes share that basename or H1 title. No link is created and none is guessed at; before #581 that was visible only in the write-time \`ambiguous_link\` warning, so a later audit couldn't find it. Pass \`has_ambiguous_links: true\`/\`false\` to filter, and/or \`include_ambiguous_links: true\` to attach \`ambiguous_links: [{target, relationship, candidate_count}]\` (empty array when none). Disjoint from \`has_broken_links\`: dangling = matched nothing, ambiguous = matched several. The record is persisted, so it survives restarts — and it self-heals: delete or rename one of the colliding notes and the link resolves for real (the note drops out of \`has_ambiguous_links: true\`); delete them ALL and it demotes to an ordinary broken link. (For a tag-scoped session that demotion has already happened at read time — see \`has_broken_links\`.)
|
|
69
|
+
|
|
68
70
|
Response shape (vault#550 — three variants, pick by what you passed):
|
|
69
71
|
- Default (no \`cursor\`, no warnings): a bare array of notes.
|
|
70
72
|
- Cursor mode (\`cursor\` param present — including \`cursor: ""\` to bootstrap): \`{notes: [...], next_cursor}\`. See \`cursor\` below for the bootstrap flow.
|
|
@@ -119,8 +121,9 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
119
121
|
description: "Alias for `exclude_tags` (singular). Same shape and semantics — accepts a single tag or an array.",
|
|
120
122
|
},
|
|
121
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." },
|
|
122
|
-
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)." },
|
|
123
|
-
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)." },
|
|
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
|
+
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." },
|
|
124
127
|
path: { type: "string", description: "Exact path match (case-insensitive)" },
|
|
125
128
|
path_prefix: { type: "string", description: "Path prefix match (e.g., 'Projects/')" },
|
|
126
129
|
exclude_path_prefix: {
|
|
@@ -173,7 +176,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
173
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." },
|
|
174
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." },
|
|
175
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." },
|
|
176
|
-
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." },
|
|
177
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 }`." },
|
|
178
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 }`." },
|
|
179
182
|
date_filter: {
|
|
@@ -237,17 +240,18 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
237
240
|
description: "Control metadata in response: true (all, default), false (none), or array of field names to include",
|
|
238
241
|
},
|
|
239
242
|
include_links: { type: "boolean", description: "Include inbound + outbound links per note (default: false)" },
|
|
240
|
-
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`." },
|
|
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
|
+
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`." },
|
|
241
245
|
include_link_count: {
|
|
242
246
|
type: "boolean",
|
|
243
247
|
description:
|
|
244
|
-
"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.",
|
|
245
249
|
},
|
|
246
250
|
link_count_direction: {
|
|
247
251
|
type: "string",
|
|
248
252
|
enum: ["both", "outbound", "inbound"],
|
|
249
253
|
description:
|
|
250
|
-
"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.",
|
|
251
255
|
},
|
|
252
256
|
include_attachments: { type: "boolean", description: "Include attachment records (default: false)" },
|
|
253
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
|
@@ -23,7 +23,15 @@ import {
|
|
|
23
23
|
resolveStructuredLinkNote,
|
|
24
24
|
getUnresolvedLinksForNote,
|
|
25
25
|
getUnresolvedLinksForNotes,
|
|
26
|
+
narrowByVisibleBrokenness,
|
|
27
|
+
sqlHasBrokenLinks,
|
|
28
|
+
getAmbiguousLinksForNote,
|
|
29
|
+
getAmbiguousLinksForNotes,
|
|
30
|
+
narrowByVisibleAmbiguity,
|
|
31
|
+
sqlHasAmbiguousLinks,
|
|
26
32
|
getContentWikilinkWarnings,
|
|
33
|
+
ambiguousLinkWarning,
|
|
34
|
+
unresolvedLinkWarning,
|
|
27
35
|
} from "./wikilinks.js";
|
|
28
36
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
29
37
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
@@ -143,6 +151,17 @@ function structuredError(
|
|
|
143
151
|
return Object.assign(new Error(message), fields);
|
|
144
152
|
}
|
|
145
153
|
|
|
154
|
+
function requireNoteReference(value: unknown): string {
|
|
155
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
156
|
+
throw structuredError("`id` is required", {
|
|
157
|
+
error_type: "missing_required_field",
|
|
158
|
+
field: "id",
|
|
159
|
+
hint: "pass the note's id or path (or its unique H1 title)",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
|
|
146
165
|
/**
|
|
147
166
|
* Resolve a note identifier — tries ID first, then case-insensitive
|
|
148
167
|
* path match, then (additive fallback) an H1-title match. Works
|
|
@@ -470,6 +489,23 @@ export interface GenerateMcpToolsOpts {
|
|
|
470
489
|
* path with no extra fetch.
|
|
471
490
|
*/
|
|
472
491
|
aggregateVisibility?: (note: Note) => boolean;
|
|
492
|
+
/**
|
|
493
|
+
* `ambiguityVisible` (vault#581 auth review) is an OPTIONAL per-note
|
|
494
|
+
* predicate gating everything the ambiguous-links surface discloses.
|
|
495
|
+
* `candidate_count` is derived VAULT-WIDE, so a stored `2` on a `[[Dup]]`
|
|
496
|
+
* split across scopes tells a tag-scoped reader that a note it cannot see
|
|
497
|
+
* exists — and `has_ambiguous_links: true` would let it sweep its whole
|
|
498
|
+
* in-scope corpus for such collisions. When provided, each persisted row
|
|
499
|
+
* is re-resolved and its candidates narrowed to the visible ones: a row
|
|
500
|
+
* is disclosed only when ≥2 remain, `candidate_count` is the visible
|
|
501
|
+
* count, and `has_ambiguous_links` is answered against that same narrowed
|
|
502
|
+
* view (see `narrowByVisibleAmbiguity` / `sqlHasAmbiguousLinks` in
|
|
503
|
+
* core/src/wikilinks.ts). Same contract as `nearTraversable`: core stays
|
|
504
|
+
* scope-unaware and only invokes the injected `(noteId) => boolean`
|
|
505
|
+
* closure. Omitted (unscoped / internal callers) → the persisted counts
|
|
506
|
+
* are returned as-is and the SQL filter answers directly, unchanged.
|
|
507
|
+
*/
|
|
508
|
+
ambiguityVisible?: (noteId: string) => boolean;
|
|
473
509
|
/**
|
|
474
510
|
* `AttachmentTicketProvider` seam (vault attachment-tickets design,
|
|
475
511
|
* Wave 1 — D10 "tools omitted when unwired"). When provided,
|
|
@@ -549,6 +585,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
549
585
|
const nearTraversable = opts?.nearTraversable;
|
|
550
586
|
const ifExistsVisible = opts?.ifExistsVisible;
|
|
551
587
|
const aggregateVisibility = opts?.aggregateVisibility;
|
|
588
|
+
const ambiguityVisible = opts?.ambiguityVisible;
|
|
552
589
|
// Write-attribution (vault#298) — captured once at tool-generation time
|
|
553
590
|
// (a fresh tool set is generated per MCP request, so this is request-scoped)
|
|
554
591
|
// and folded into every create/update the tools perform.
|
|
@@ -600,6 +637,32 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
600
637
|
{
|
|
601
638
|
name: "query-notes",
|
|
602
639
|
execute: async (params) => {
|
|
640
|
+
// --- Ambiguous-links scope split (vault#581 auth review) ---
|
|
641
|
+
// `requestedHasAmbiguous` is what the caller asked for;
|
|
642
|
+
// `sqlHasAmbiguous` is what is safe to push into SQL for a reader
|
|
643
|
+
// that will be narrowed by `narrowByVisibleAmbiguity` afterwards
|
|
644
|
+
// (`true` is a superset and stays; `false` is lifted). Identical for
|
|
645
|
+
// an unscoped reader, where no predicate is injected and the SQL
|
|
646
|
+
// filter alone is the whole answer.
|
|
647
|
+
const requestedHasAmbiguous = params.has_ambiguous_links as boolean | undefined;
|
|
648
|
+
const sqlHasAmbiguous = sqlHasAmbiguousLinks(requestedHasAmbiguous, Boolean(ambiguityVisible));
|
|
649
|
+
|
|
650
|
+
// --- Broken-links scope split (vault#239) ---
|
|
651
|
+
// Same shape as the ambiguity split above, one polarity stricter:
|
|
652
|
+
// NEITHER `true` nor `false` is safe to push into SQL for a scoped
|
|
653
|
+
// reader, because a note can be broken in that reader's sub-vault
|
|
654
|
+
// while carrying no `unresolved_wikilinks` row at all (its target's
|
|
655
|
+
// only candidates are invisible, so the row sits in
|
|
656
|
+
// `ambiguous_wikilinks`). `sqlHasBrokenLinks` lifts both and
|
|
657
|
+
// `narrowByVisibleBrokenness` re-decides per note. Identical for an
|
|
658
|
+
// unscoped reader, where the SQL filter alone is the whole answer.
|
|
659
|
+
const requestedHasBroken = params.has_broken_links as boolean | undefined;
|
|
660
|
+
const sqlHasBroken = sqlHasBrokenLinks(requestedHasBroken, Boolean(ambiguityVisible));
|
|
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
|
+
|
|
603
666
|
// --- Link expansion config (shared across single + list paths) ---
|
|
604
667
|
const expandLinks = params.expand_links === true;
|
|
605
668
|
const expandMode = (params.expand_mode as ExpandMode) ?? "full";
|
|
@@ -671,7 +734,10 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
671
734
|
result.links = linkOps.getLinksHydrated(db, note.id);
|
|
672
735
|
}
|
|
673
736
|
if (params.include_broken_links) {
|
|
674
|
-
result.broken_links = getUnresolvedLinksForNote(db, note.id);
|
|
737
|
+
result.broken_links = getUnresolvedLinksForNote(db, note.id, ambiguityVisible);
|
|
738
|
+
}
|
|
739
|
+
if (params.include_ambiguous_links) {
|
|
740
|
+
result.ambiguous_links = getAmbiguousLinksForNote(db, note.id, ambiguityVisible);
|
|
675
741
|
}
|
|
676
742
|
if (params.include_attachments) {
|
|
677
743
|
result.attachments = await store.getAttachments(note.id);
|
|
@@ -680,7 +746,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
680
746
|
// links/attachments above; filterMetadata only touches `metadata`.
|
|
681
747
|
if (params.include_link_count) {
|
|
682
748
|
const dir = normalizeLinkCountDirection(params.link_count_direction);
|
|
683
|
-
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;
|
|
684
750
|
}
|
|
685
751
|
return result;
|
|
686
752
|
}
|
|
@@ -815,8 +881,9 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
815
881
|
expand,
|
|
816
882
|
excludeTags: aggExcludeTags,
|
|
817
883
|
hasTags: params.has_tags as boolean | undefined,
|
|
818
|
-
hasLinks:
|
|
819
|
-
hasBrokenLinks:
|
|
884
|
+
hasLinks: sqlLinks,
|
|
885
|
+
hasBrokenLinks: sqlHasBroken,
|
|
886
|
+
hasAmbiguousLinks: sqlHasAmbiguous,
|
|
820
887
|
path: params.path as string | undefined,
|
|
821
888
|
pathPrefix: params.path_prefix as string | undefined,
|
|
822
889
|
excludePathPrefix: normalizeTags(params.exclude_path_prefix ?? params.excludePathPrefix),
|
|
@@ -842,7 +909,28 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
842
909
|
// set (reusing the `ids` semijoin `near` already pushes into SQL).
|
|
843
910
|
// Core stays scope-unaware — it only invokes the plain closure.
|
|
844
911
|
const aggAllMatches = await store.queryNotes({ ...aggFilterOpts, limit: 1000000 });
|
|
845
|
-
|
|
912
|
+
// vault#581 auth review: a rollup over `has_ambiguous_links` is the
|
|
913
|
+
// same oracle as the note-level filter (a non-zero count still
|
|
914
|
+
// reveals the collision), so narrow the visible id set by the
|
|
915
|
+
// reader's own view of each row before aggregating.
|
|
916
|
+
// vault#239: the `has_broken_links` rollup is the same oracle for
|
|
917
|
+
// the same reason — re-decide brokenness on the sub-vault too.
|
|
918
|
+
const aggVisibleIds = linkOps.narrowByVisibleLinks(
|
|
919
|
+
db,
|
|
920
|
+
narrowByVisibleBrokenness(
|
|
921
|
+
db,
|
|
922
|
+
narrowByVisibleAmbiguity(
|
|
923
|
+
db,
|
|
924
|
+
aggAllMatches.filter(aggregateVisibility),
|
|
925
|
+
requestedHasAmbiguous,
|
|
926
|
+
ambiguityVisible,
|
|
927
|
+
),
|
|
928
|
+
requestedHasBroken,
|
|
929
|
+
ambiguityVisible,
|
|
930
|
+
),
|
|
931
|
+
requestedHasLinks,
|
|
932
|
+
ambiguityVisible,
|
|
933
|
+
).map((n) => n.id);
|
|
846
934
|
// Always run the rollup, even on an empty visible set: ungrouped
|
|
847
935
|
// count (vault#626) must return `[{group:null,value:0}]`, not `[]`.
|
|
848
936
|
return await store.aggregateNotes({ ids: aggVisibleIds, aggregate: aggregateSpec });
|
|
@@ -938,8 +1026,9 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
938
1026
|
expand,
|
|
939
1027
|
excludeTags,
|
|
940
1028
|
hasTags: params.has_tags as boolean | undefined,
|
|
941
|
-
hasLinks:
|
|
942
|
-
hasBrokenLinks:
|
|
1029
|
+
hasLinks: sqlLinks,
|
|
1030
|
+
hasBrokenLinks: sqlHasBroken,
|
|
1031
|
+
hasAmbiguousLinks: sqlHasAmbiguous,
|
|
943
1032
|
path: params.path as string | undefined,
|
|
944
1033
|
pathPrefix: params.path_prefix as string | undefined,
|
|
945
1034
|
excludePathPrefix: normalizeTags(params.exclude_path_prefix ?? params.excludePathPrefix),
|
|
@@ -1021,8 +1110,9 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1021
1110
|
expand,
|
|
1022
1111
|
excludeTags,
|
|
1023
1112
|
hasTags: params.has_tags as boolean | undefined,
|
|
1024
|
-
hasLinks:
|
|
1025
|
-
hasBrokenLinks:
|
|
1113
|
+
hasLinks: sqlLinks,
|
|
1114
|
+
hasBrokenLinks: sqlHasBroken,
|
|
1115
|
+
hasAmbiguousLinks: sqlHasAmbiguous,
|
|
1026
1116
|
path: params.path as string | undefined,
|
|
1027
1117
|
pathPrefix: params.path_prefix as string | undefined,
|
|
1028
1118
|
excludePathPrefix: normalizeTags(params.exclude_path_prefix ?? params.excludePathPrefix),
|
|
@@ -1090,8 +1180,9 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1090
1180
|
expand,
|
|
1091
1181
|
excludeTags,
|
|
1092
1182
|
hasTags: params.has_tags as boolean | undefined,
|
|
1093
|
-
hasLinks:
|
|
1094
|
-
hasBrokenLinks:
|
|
1183
|
+
hasLinks: sqlLinks,
|
|
1184
|
+
hasBrokenLinks: sqlHasBroken,
|
|
1185
|
+
hasAmbiguousLinks: sqlHasAmbiguous,
|
|
1095
1186
|
path: params.path as string | undefined,
|
|
1096
1187
|
pathPrefix: params.path_prefix as string | undefined,
|
|
1097
1188
|
excludePathPrefix: normalizeTags(params.exclude_path_prefix ?? params.excludePathPrefix),
|
|
@@ -1148,6 +1239,18 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1148
1239
|
results = results.filter((n) => nearScope!.has(n.id));
|
|
1149
1240
|
}
|
|
1150
1241
|
|
|
1242
|
+
// vault#581 auth review — the `has_ambiguous_links` filter must
|
|
1243
|
+
// answer on the reader's OWN sub-vault, not the whole vault. No-op
|
|
1244
|
+
// unscoped (no predicate injected) and when the filter wasn't asked
|
|
1245
|
+
// for. See `narrowByVisibleAmbiguity` for the superset contract and
|
|
1246
|
+
// the page-shortening effect.
|
|
1247
|
+
results = narrowByVisibleAmbiguity(db, results, requestedHasAmbiguous, ambiguityVisible);
|
|
1248
|
+
// vault#239 — same rule for `has_broken_links`: the SQL filter was
|
|
1249
|
+
// lifted for a scoped reader, so the real predicate is applied here
|
|
1250
|
+
// on that reader's own sub-vault. No-op unscoped.
|
|
1251
|
+
results = narrowByVisibleBrokenness(db, results, requestedHasBroken, ambiguityVisible);
|
|
1252
|
+
results = linkOps.narrowByVisibleLinks(db, results, requestedHasLinks, ambiguityVisible);
|
|
1253
|
+
|
|
1151
1254
|
// --- Format output ---
|
|
1152
1255
|
const includeContent = params.include_content === true; // default false for list
|
|
1153
1256
|
// Range params require content in the response — on lists that
|
|
@@ -1214,12 +1317,12 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1214
1317
|
// survives. Don't casually swap the order.
|
|
1215
1318
|
if (params.include_link_count) {
|
|
1216
1319
|
const dir = normalizeLinkCountDirection(params.link_count_direction);
|
|
1217
|
-
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);
|
|
1218
1321
|
for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
|
|
1219
1322
|
}
|
|
1220
1323
|
|
|
1221
1324
|
// --- Hydrate links/attachments/broken-links per note if requested ---
|
|
1222
|
-
if (params.include_links || params.include_attachments || params.include_broken_links) {
|
|
1325
|
+
if (params.include_links || params.include_attachments || params.include_broken_links || params.include_ambiguous_links) {
|
|
1223
1326
|
// Links hydrate for the WHOLE page in a constant number of
|
|
1224
1327
|
// queries (see getLinksHydratedForNotes) — the per-note variant
|
|
1225
1328
|
// cost (1 link query + 1 summary query + N tag queries) × page
|
|
@@ -1229,13 +1332,18 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1229
1332
|
: null;
|
|
1230
1333
|
// Same one-batched-query-for-the-page shape as links (vault#555).
|
|
1231
1334
|
const brokenLinksByNote = params.include_broken_links
|
|
1232
|
-
? getUnresolvedLinksForNotes(db, (output as any[]).map((n: any) => n.id))
|
|
1335
|
+
? getUnresolvedLinksForNotes(db, (output as any[]).map((n: any) => n.id), ambiguityVisible)
|
|
1336
|
+
: null;
|
|
1337
|
+
// Same one-batched-query-for-the-page shape for the ambiguity twin (vault#581).
|
|
1338
|
+
const ambiguousLinksByNote = params.include_ambiguous_links
|
|
1339
|
+
? getAmbiguousLinksForNotes(db, (output as any[]).map((n: any) => n.id), ambiguityVisible)
|
|
1233
1340
|
: null;
|
|
1234
1341
|
const enrichedOut: any[] = [];
|
|
1235
1342
|
for (const n of output as any[]) {
|
|
1236
1343
|
const enriched: any = { ...n };
|
|
1237
1344
|
if (linksByNote) enriched.links = linksByNote.get(n.id) ?? [];
|
|
1238
1345
|
if (brokenLinksByNote) enriched.broken_links = brokenLinksByNote.get(n.id) ?? [];
|
|
1346
|
+
if (ambiguousLinksByNote) enriched.ambiguous_links = ambiguousLinksByNote.get(n.id) ?? [];
|
|
1239
1347
|
if (params.include_attachments) enriched.attachments = await store.getAttachments(n.id);
|
|
1240
1348
|
enrichedOut.push(enriched);
|
|
1241
1349
|
}
|
|
@@ -1559,20 +1667,9 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1559
1667
|
if (outcome.status === "resolved") {
|
|
1560
1668
|
await store.createLink(sourceId, outcome.note_id, link.relationship);
|
|
1561
1669
|
} else if (outcome.status === "ambiguous") {
|
|
1562
|
-
pushLinkWarning(sourceId,
|
|
1563
|
-
code: "ambiguous_link",
|
|
1564
|
-
message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
|
|
1565
|
-
target: link.target,
|
|
1566
|
-
relationship: link.relationship,
|
|
1567
|
-
candidate_count: outcome.candidates.length,
|
|
1568
|
-
});
|
|
1670
|
+
pushLinkWarning(sourceId, ambiguousLinkWarning(link.target, link.relationship, outcome.candidates.length));
|
|
1569
1671
|
} else {
|
|
1570
|
-
pushLinkWarning(sourceId,
|
|
1571
|
-
code: "unresolved_link",
|
|
1572
|
-
message: `link target "${link.target}" (relationship "${link.relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
|
|
1573
|
-
target: link.target,
|
|
1574
|
-
relationship: link.relationship,
|
|
1575
|
-
});
|
|
1672
|
+
pushLinkWarning(sourceId, unresolvedLinkWarning(link.target, link.relationship));
|
|
1576
1673
|
}
|
|
1577
1674
|
}
|
|
1578
1675
|
}
|
|
@@ -1730,7 +1827,8 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1730
1827
|
// branch using this same item's payload. Otherwise mirror the
|
|
1731
1828
|
// existing `requireNote` behavior (throw "Note not found").
|
|
1732
1829
|
// vault#309.
|
|
1733
|
-
const
|
|
1830
|
+
const idOrPath = requireNoteReference(item.id);
|
|
1831
|
+
const resolved = resolveNote(db, idOrPath);
|
|
1734
1832
|
if (!resolved) {
|
|
1735
1833
|
if (item.if_missing === "create") {
|
|
1736
1834
|
// Treat the update payload as a create payload. Minimum:
|
|
@@ -1765,7 +1863,6 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
1765
1863
|
// processed and used by Gitcoin's sync; the
|
|
1766
1864
|
// misleading wording is fixed here so a future
|
|
1767
1865
|
// reader doesn't trust it and break the workflow.
|
|
1768
|
-
const idOrPath = item.id as string;
|
|
1769
1866
|
// Heuristic: if `path` isn't set AND the `id` looks like a
|
|
1770
1867
|
// path (contains "/" or doesn't match a typical opaque-id
|
|
1771
1868
|
// shape), use it as the path too. Otherwise treat it as a
|
|
@@ -2118,20 +2215,9 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
2118
2215
|
if (outcome.status === "resolved") {
|
|
2119
2216
|
await store.createLink(sourceId, outcome.note_id, link.relationship, link.metadata);
|
|
2120
2217
|
} else if (outcome.status === "ambiguous") {
|
|
2121
|
-
pushLinkWarning(sourceId,
|
|
2122
|
-
code: "ambiguous_link",
|
|
2123
|
-
message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
|
|
2124
|
-
target: link.target,
|
|
2125
|
-
relationship: link.relationship,
|
|
2126
|
-
candidate_count: outcome.candidates.length,
|
|
2127
|
-
});
|
|
2218
|
+
pushLinkWarning(sourceId, ambiguousLinkWarning(link.target, link.relationship, outcome.candidates.length));
|
|
2128
2219
|
} else {
|
|
2129
|
-
pushLinkWarning(sourceId,
|
|
2130
|
-
code: "unresolved_link",
|
|
2131
|
-
message: `link target "${link.target}" (relationship "${link.relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
|
|
2132
|
-
target: link.target,
|
|
2133
|
-
relationship: link.relationship,
|
|
2134
|
-
});
|
|
2220
|
+
pushLinkWarning(sourceId, unresolvedLinkWarning(link.target, link.relationship));
|
|
2135
2221
|
}
|
|
2136
2222
|
}
|
|
2137
2223
|
}
|
|
@@ -2193,7 +2279,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
2193
2279
|
{
|
|
2194
2280
|
name: "delete-note",
|
|
2195
2281
|
execute: async (params) => {
|
|
2196
|
-
const note = requireNote(db, params.id
|
|
2282
|
+
const note = requireNote(db, requireNoteReference(params.id));
|
|
2197
2283
|
await store.deleteNote(note.id);
|
|
2198
2284
|
return { deleted: true, id: note.id };
|
|
2199
2285
|
},
|
|
@@ -3032,4 +3118,3 @@ export class BatchTooLargeError extends Error {
|
|
|
3032
3118
|
this.got = got;
|
|
3033
3119
|
}
|
|
3034
3120
|
}
|
|
3035
|
-
|
package/core/src/notes.ts
CHANGED
|
@@ -1053,8 +1053,9 @@ export function buildFilterConditions(db: Database, opts: QueryOpts): { conditio
|
|
|
1053
1053
|
// Presence: has_broken_links (vault#555) — a dangling outbound wikilink or
|
|
1054
1054
|
// structured `links` target that never resolved. The `unresolved_wikilinks`
|
|
1055
1055
|
// table is created lazily (see wikilinks.ts:ensureUnresolvedTable) only when
|
|
1056
|
-
// a link actually goes unresolved —
|
|
1057
|
-
//
|
|
1056
|
+
// a link actually goes unresolved — migrateToV28 heals a pre-#555 2-column
|
|
1057
|
+
// table at boot but does NOT create the table on a vault that never queued
|
|
1058
|
+
// one. Check existence first rather than reference it
|
|
1058
1059
|
// unconditionally: a read-only query filter shouldn't have the side effect
|
|
1059
1060
|
// of creating a table, and a bare `EXISTS`/`NOT EXISTS` against a missing
|
|
1060
1061
|
// table would throw "no such table" instead of the correct empty answer.
|
|
@@ -1076,6 +1077,28 @@ export function buildFilterConditions(db: Database, opts: QueryOpts): { conditio
|
|
|
1076
1077
|
}
|
|
1077
1078
|
}
|
|
1078
1079
|
|
|
1080
|
+
// Presence: has_ambiguous_links (vault#581) — an outbound `[[wikilink]]` or
|
|
1081
|
+
// structured `links`/`reference` target that matched ≥2 notes, so no link
|
|
1082
|
+
// was created. Same lazily-created-table dance as `has_broken_links` above
|
|
1083
|
+
// (`ambiguous_wikilinks` is only created once a link actually goes
|
|
1084
|
+
// ambiguous), and for the same reasons: a read-only filter must not create
|
|
1085
|
+
// the table, and a bare EXISTS against a missing one throws instead of
|
|
1086
|
+
// answering "none".
|
|
1087
|
+
if (opts.hasAmbiguousLinks !== undefined) {
|
|
1088
|
+
const ambiguousTableExists = db.prepare(
|
|
1089
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'ambiguous_wikilinks'",
|
|
1090
|
+
).get() !== null;
|
|
1091
|
+
if (!ambiguousTableExists) {
|
|
1092
|
+
if (opts.hasAmbiguousLinks) conditions.push("0 = 1");
|
|
1093
|
+
} else {
|
|
1094
|
+
conditions.push(
|
|
1095
|
+
opts.hasAmbiguousLinks
|
|
1096
|
+
? `EXISTS (SELECT 1 FROM ambiguous_wikilinks ual WHERE ual.source_id = n.id)`
|
|
1097
|
+
: `NOT EXISTS (SELECT 1 FROM ambiguous_wikilinks ual WHERE ual.source_id = n.id)`,
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1079
1102
|
// ID set filter — used by `near` to push neighborhood scoping into SQL so
|
|
1080
1103
|
// that LIMIT applies to the neighborhood, not the whole notes table.
|
|
1081
1104
|
if (opts.ids !== undefined) {
|
|
@@ -1694,6 +1717,7 @@ function toQueryHashInputs(opts: QueryOpts): QueryHashInputs {
|
|
|
1694
1717
|
hasTags: opts.hasTags,
|
|
1695
1718
|
hasLinks: opts.hasLinks,
|
|
1696
1719
|
hasBrokenLinks: opts.hasBrokenLinks,
|
|
1720
|
+
hasAmbiguousLinks: opts.hasAmbiguousLinks,
|
|
1697
1721
|
path: opts.path,
|
|
1698
1722
|
pathPrefix: opts.pathPrefix,
|
|
1699
1723
|
excludePathPrefix: opts.excludePathPrefix,
|
|
@@ -2767,6 +2791,8 @@ export function mergeTags(
|
|
|
2767
2791
|
const deleteNoteTagsStmt = db.prepare("DELETE FROM note_tags WHERE tag_name = ?");
|
|
2768
2792
|
const deleteTagStmt = db.prepare("DELETE FROM tags WHERE name = ?");
|
|
2769
2793
|
const countStmt = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?");
|
|
2794
|
+
const sourceNoteIdsStmt = db.prepare("SELECT note_id FROM note_tags WHERE tag_name = ?");
|
|
2795
|
+
const affectedIds = new Set<string>();
|
|
2770
2796
|
|
|
2771
2797
|
for (const source of uniqueSources) {
|
|
2772
2798
|
const exists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(source);
|
|
@@ -2775,6 +2801,11 @@ export function mergeTags(
|
|
|
2775
2801
|
continue;
|
|
2776
2802
|
}
|
|
2777
2803
|
const before = (countStmt.get(source) as { c: number }).c;
|
|
2804
|
+
// Collect BEFORE the delete so we can bump updated_at on every note
|
|
2805
|
+
// whose tags actually change (vault#567).
|
|
2806
|
+
for (const row of sourceNoteIdsStmt.all(source) as { note_id: string }[]) {
|
|
2807
|
+
affectedIds.add(row.note_id);
|
|
2808
|
+
}
|
|
2778
2809
|
retagStmt.run(target, source);
|
|
2779
2810
|
deleteNoteTagsStmt.run(source);
|
|
2780
2811
|
// Dropping the tag row drops its identity (description, fields,
|
|
@@ -2783,6 +2814,24 @@ export function mergeTags(
|
|
|
2783
2814
|
deleteTagStmt.run(source);
|
|
2784
2815
|
merged[source] = before;
|
|
2785
2816
|
}
|
|
2817
|
+
|
|
2818
|
+
// vault#567: a tags-only `update-note` already bumps `updated_at` so
|
|
2819
|
+
// cursor/sync consumers see the retag. `merge-tags` used to skip the
|
|
2820
|
+
// bump (flood-avoidance), which made the equivalent bulk retag
|
|
2821
|
+
// invisible to since-last-check loops. Bump every note that actually
|
|
2822
|
+
// lost a source tag; notes that never carried a merged-away source
|
|
2823
|
+
// are left untouched. `updated_at_ms` moves with `updated_at`
|
|
2824
|
+
// (vault#586) so the cursor keyset surfaces the change.
|
|
2825
|
+
if (affectedIds.size > 0) {
|
|
2826
|
+
const now = new Date().toISOString();
|
|
2827
|
+
const nowMs = timestampToMs(now) ?? Date.now();
|
|
2828
|
+
const bumpStmt = db.prepare(
|
|
2829
|
+
"UPDATE notes SET updated_at = ?, updated_at_ms = ? WHERE id = ?",
|
|
2830
|
+
);
|
|
2831
|
+
for (const id of affectedIds) {
|
|
2832
|
+
bumpStmt.run(now, nowMs, id);
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2786
2835
|
});
|
|
2787
2836
|
|
|
2788
2837
|
return { merged, target };
|