@openparachute/vault 0.7.0-rc.7 → 0.7.0-rc.9
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 +895 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/mcp.ts +439 -49
- package/core/src/notes.ts +39 -4
- package/core/src/seed-packs.test.ts +14 -0
- package/core/src/seed-packs.ts +23 -0
- package/core/src/store.ts +22 -15
- package/core/src/tag-schemas.ts +115 -19
- package/core/src/types.ts +9 -0
- package/core/src/wikilinks.test.ts +113 -1
- package/core/src/wikilinks.ts +230 -21
- package/package.json +1 -1
- package/src/contract-errors.test.ts +73 -0
- package/src/mcp-http.test.ts +140 -0
- package/src/mcp-http.ts +73 -16
- package/src/mcp-tools.ts +36 -3
- package/src/routes.ts +363 -39
- package/src/tag-field-conflict-scope.test.ts +44 -0
- package/src/tag-scope.ts +60 -0
- package/src/vault.test.ts +745 -4
package/core/src/mcp.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Store, Note } from "./types.js";
|
|
|
3
3
|
import { transactionAsync } from "./txn.js";
|
|
4
4
|
import * as noteOps from "./notes.js";
|
|
5
5
|
import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
|
|
6
|
+
import { normalizePath } from "./paths.js";
|
|
6
7
|
import { QueryError } from "./query-operators.js";
|
|
7
8
|
import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "./tag-hierarchy.js";
|
|
8
9
|
import {
|
|
@@ -15,6 +16,12 @@ import {
|
|
|
15
16
|
} from "./query-warnings.js";
|
|
16
17
|
import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "./search-query.js";
|
|
17
18
|
import * as linkOps from "./links.js";
|
|
19
|
+
import {
|
|
20
|
+
resolveOrQueueLink,
|
|
21
|
+
resolveStructuredLinkNote,
|
|
22
|
+
getUnresolvedLinksForNote,
|
|
23
|
+
getUnresolvedLinksForNotes,
|
|
24
|
+
} from "./wikilinks.js";
|
|
18
25
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
19
26
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
20
27
|
import {
|
|
@@ -194,6 +201,25 @@ export interface GenerateMcpToolsOpts {
|
|
|
194
201
|
* Omitted (unscoped / internal callers) → the full graph is walked.
|
|
195
202
|
*/
|
|
196
203
|
nearTraversable?: (noteId: string) => boolean;
|
|
204
|
+
/**
|
|
205
|
+
* `ifExistsVisible` (vault#555 auth-review must-fix) is an OPTIONAL per-note
|
|
206
|
+
* predicate gating the `create-note` `if_exists` upsert. `if_exists` resolves
|
|
207
|
+
* the target `path` VAULT-WIDE (`getNoteByPath`) and then returns (ignore) /
|
|
208
|
+
* updates / replaces the found note — so without a scope gate a tag-scoped
|
|
209
|
+
* caller could READ or OVERWRITE an out-of-scope note just by naming its
|
|
210
|
+
* path. When provided, `applyExistingNote` calls this predicate on the
|
|
211
|
+
* RESOLVED existing note and, if it returns false, throws `PathConflictError`
|
|
212
|
+
* instead (the path is taken but invisible to this caller — no content
|
|
213
|
+
* exposed, nothing mutated). It's applied INSIDE `applyExistingNote`, which
|
|
214
|
+
* BOTH the proactive-check site AND the concurrent-INSERT race-backstop site
|
|
215
|
+
* funnel through — so a TOCTOU race (both existence checks miss, the real
|
|
216
|
+
* INSERT then loses to a concurrent writer's out-of-scope note) can't slip a
|
|
217
|
+
* note past it. Core stays scope-unaware: it receives a plain
|
|
218
|
+
* `(note) => boolean` closure and never imports the server's tag-scope
|
|
219
|
+
* module. Omitted (unscoped / internal callers) → `if_exists` resolves
|
|
220
|
+
* against the full vault exactly as before.
|
|
221
|
+
*/
|
|
222
|
+
ifExistsVisible?: (note: Note) => boolean;
|
|
197
223
|
}
|
|
198
224
|
|
|
199
225
|
/**
|
|
@@ -207,6 +233,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
207
233
|
const db: Database = store.db;
|
|
208
234
|
const expandVisibility = opts?.expandVisibility;
|
|
209
235
|
const nearTraversable = opts?.nearTraversable;
|
|
236
|
+
const ifExistsVisible = opts?.ifExistsVisible;
|
|
210
237
|
// Write-attribution (vault#298) — captured once at tool-generation time
|
|
211
238
|
// (a fresh tool set is generated per MCP request, so this is request-scoped)
|
|
212
239
|
// and folded into every create/update the tools perform.
|
|
@@ -263,10 +290,14 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
263
290
|
|
|
264
291
|
Defaults: include_content=true for single note, false for lists. include_links=false. tag_match="any".
|
|
265
292
|
|
|
293
|
+
Each result carries \`validation_status\` when any tag it carries declares \`fields\` (vault#555) — same advisory-warnings shape create-note/update-note attach, now also on reads (an out-of-enum value on a non-strict field is stored and findable, but still surfaces its \`enum_mismatch\` warning here, not just on the write that introduced it). Absent entirely when no tag on the note declares a schema.
|
|
294
|
+
|
|
266
295
|
Large notes: pass \`content_offset\` / \`content_length\` (UTF-8 bytes) for a bounded read of note content — the response carries the slice plus \`content_total_length\` and \`content_next_offset\` (null when complete). Loop, feeding \`content_next_offset\` back as \`content_offset\`, to read a note too large for one response.
|
|
267
296
|
|
|
268
297
|
Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returned content. Tune with \`expand_depth\` (1–3, default 1) and \`expand_mode\` ("full" inlines full content, "summary" inlines only metadata.summary). Expansions are deduplicated across the query and cycle-guarded.
|
|
269
298
|
|
|
299
|
+
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\`.
|
|
300
|
+
|
|
270
301
|
Response shape (vault#550 — three variants, pick by what you passed):
|
|
271
302
|
- Default (no \`cursor\`, no warnings): a bare array of notes.
|
|
272
303
|
- Cursor mode (\`cursor\` param present — including \`cursor: ""\` to bootstrap): \`{notes: [...], next_cursor}\`. See \`cursor\` below for the bootstrap flow.
|
|
@@ -319,6 +350,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
319
350
|
},
|
|
320
351
|
has_tags: { type: "boolean", description: "Presence filter: true = only notes with at least one tag; false = only untagged notes. Ignored when `tag` is set." },
|
|
321
352
|
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)." },
|
|
353
|
+
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)." },
|
|
322
354
|
path: { type: "string", description: "Exact path match (case-insensitive)" },
|
|
323
355
|
path_prefix: { type: "string", description: "Path prefix match (e.g., 'Projects/')" },
|
|
324
356
|
extension: {
|
|
@@ -401,6 +433,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
401
433
|
description: "Control metadata in response: true (all, default), false (none), or array of field names to include",
|
|
402
434
|
},
|
|
403
435
|
include_links: { type: "boolean", description: "Include inbound + outbound links per note (default: false)" },
|
|
436
|
+
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`." },
|
|
404
437
|
include_link_count: {
|
|
405
438
|
type: "boolean",
|
|
406
439
|
description:
|
|
@@ -458,6 +491,24 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
458
491
|
// `expand`).
|
|
459
492
|
if (contentRange && !includeContent) throw contentRangeRequiresContent();
|
|
460
493
|
let result: any = includeContent ? { ...note } : noteOps.toNoteIndex(note);
|
|
494
|
+
// --- Attach validation_status (vault#555 fix 3) ---
|
|
495
|
+
// Mirrors create/update-note's `attachValidationStatus` — additive,
|
|
496
|
+
// present only when at least one tag on the note declares
|
|
497
|
+
// `fields`. Before this, `validation_status` (including an
|
|
498
|
+
// `enum_mismatch` on a non-strict indexed field) was surfaced ONLY
|
|
499
|
+
// on the one-time create/update WRITE response; a caller reading
|
|
500
|
+
// the note back via `query-notes` (the natural way to re-check a
|
|
501
|
+
// value after write) saw nothing at all — contradicting the
|
|
502
|
+
// documented "advisory violations surface as warnings" for every
|
|
503
|
+
// read after the initial write.
|
|
504
|
+
{
|
|
505
|
+
const status = store.validateNoteAgainstSchemas({
|
|
506
|
+
path: note.path,
|
|
507
|
+
tags: note.tags,
|
|
508
|
+
metadata: note.metadata as Record<string, unknown> | undefined,
|
|
509
|
+
});
|
|
510
|
+
if (status) result.validation_status = status;
|
|
511
|
+
}
|
|
461
512
|
if (expandCtx && includeContent && typeof result.content === "string") {
|
|
462
513
|
// Mark the top-level note as already expanded so it can't recursively inline itself.
|
|
463
514
|
expandCtx.expanded.add(note.id);
|
|
@@ -471,6 +522,9 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
471
522
|
if (params.include_links) {
|
|
472
523
|
result.links = linkOps.getLinksHydrated(db, note.id);
|
|
473
524
|
}
|
|
525
|
+
if (params.include_broken_links) {
|
|
526
|
+
result.broken_links = getUnresolvedLinksForNote(db, note.id);
|
|
527
|
+
}
|
|
474
528
|
if (params.include_attachments) {
|
|
475
529
|
result.attachments = await store.getAttachments(note.id);
|
|
476
530
|
}
|
|
@@ -661,6 +715,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
661
715
|
excludeTags,
|
|
662
716
|
hasTags: params.has_tags as boolean | undefined,
|
|
663
717
|
hasLinks: params.has_links as boolean | undefined,
|
|
718
|
+
hasBrokenLinks: params.has_broken_links as boolean | undefined,
|
|
664
719
|
path: params.path as string | undefined,
|
|
665
720
|
pathPrefix: params.path_prefix as string | undefined,
|
|
666
721
|
extension: params.extension as string | string[] | undefined,
|
|
@@ -738,6 +793,29 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
738
793
|
for (const n of output) applyContentRange(n, contentRange);
|
|
739
794
|
}
|
|
740
795
|
|
|
796
|
+
// --- Attach validation_status (vault#555 fix 3) ---
|
|
797
|
+
// Same reasoning as the single-note path above — additive, present
|
|
798
|
+
// only when at least one tag on the note declares `fields`. Runs
|
|
799
|
+
// BEFORE metadata filtering so it sees the note's full metadata
|
|
800
|
+
// regardless of `include_metadata`; the `validation_status` key
|
|
801
|
+
// itself survives `filterMetadata` (which only touches `metadata`).
|
|
802
|
+
{
|
|
803
|
+
const statusById = new Map(
|
|
804
|
+
results.map((n) => [
|
|
805
|
+
n.id,
|
|
806
|
+
store.validateNoteAgainstSchemas({
|
|
807
|
+
path: n.path,
|
|
808
|
+
tags: n.tags,
|
|
809
|
+
metadata: n.metadata as Record<string, unknown> | undefined,
|
|
810
|
+
}),
|
|
811
|
+
]),
|
|
812
|
+
);
|
|
813
|
+
for (const n of output as any[]) {
|
|
814
|
+
const status = statusById.get(n.id);
|
|
815
|
+
if (status) n.validation_status = status;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
741
819
|
// --- Apply metadata filtering ---
|
|
742
820
|
if (includeMetadata !== undefined && includeMetadata !== true) {
|
|
743
821
|
output = output.map((n: any) => filterMetadata(n, includeMetadata));
|
|
@@ -756,8 +834,8 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
756
834
|
for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
|
|
757
835
|
}
|
|
758
836
|
|
|
759
|
-
// --- Hydrate links/attachments per note if requested ---
|
|
760
|
-
if (params.include_links || params.include_attachments) {
|
|
837
|
+
// --- Hydrate links/attachments/broken-links per note if requested ---
|
|
838
|
+
if (params.include_links || params.include_attachments || params.include_broken_links) {
|
|
761
839
|
// Links hydrate for the WHOLE page in a constant number of
|
|
762
840
|
// queries (see getLinksHydratedForNotes) — the per-note variant
|
|
763
841
|
// cost (1 link query + 1 summary query + N tag queries) × page
|
|
@@ -765,10 +843,15 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
765
843
|
const linksByNote = params.include_links
|
|
766
844
|
? linkOps.getLinksHydratedForNotes(db, (output as any[]).map((n: any) => n.id))
|
|
767
845
|
: null;
|
|
846
|
+
// Same one-batched-query-for-the-page shape as links (vault#555).
|
|
847
|
+
const brokenLinksByNote = params.include_broken_links
|
|
848
|
+
? getUnresolvedLinksForNotes(db, (output as any[]).map((n: any) => n.id))
|
|
849
|
+
: null;
|
|
768
850
|
const enrichedOut: any[] = [];
|
|
769
851
|
for (const n of output as any[]) {
|
|
770
852
|
const enriched: any = { ...n };
|
|
771
853
|
if (linksByNote) enriched.links = linksByNote.get(n.id) ?? [];
|
|
854
|
+
if (brokenLinksByNote) enriched.broken_links = brokenLinksByNote.get(n.id) ?? [];
|
|
772
855
|
if (params.include_attachments) enriched.attachments = await store.getAttachments(n.id);
|
|
773
856
|
enrichedOut.push(enriched);
|
|
774
857
|
}
|
|
@@ -806,7 +889,17 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
806
889
|
{
|
|
807
890
|
name: "create-note",
|
|
808
891
|
requiredVerb: "write",
|
|
809
|
-
description: `Create one or more notes. Pass a single note's fields directly, or pass a \`notes\` array for batch creation. Each note accepts content, path, metadata, tags, links, and created_at
|
|
892
|
+
description: `Create one or more notes. Pass a single note's fields directly, or pass a \`notes\` array for batch creation. Each note accepts content, path, metadata, tags, links, and created_at.
|
|
893
|
+
|
|
894
|
+
**Path-conflict handling** — \`if_exists: "error"|"ignore"|"update"|"replace"\` (vault#555, default \`"error"\`): what to do when the note's \`path\` already names an existing note.
|
|
895
|
+
- \`"error"\` (DEFAULT — unchanged behavior): the write is rejected with a \`path_conflict\` error (409); nothing is mutated.
|
|
896
|
+
- \`"ignore"\`: return the existing note UNCHANGED — no error and no mutation of any kind (content/metadata/tags/links untouched; no schema-default backfill runs either). Response carries \`existed: true\`. The idempotent-retry primitive: a crash-replay or the losing side of a create-race gets back the same note a first-time caller would have created, safely, any number of times.
|
|
897
|
+
- \`"update"\`: merge this payload into the existing note — \`content\` (if provided) fully replaces the existing content, exactly like \`update-note\`'s \`content\` field (omit to leave it untouched); \`metadata\` (if provided) is RFC-7386 merged — existing keys preserved, incoming keys overwrite, an incoming \`null\` value deletes a key — same semantics as \`update-note\`; \`tags\`/\`links\` (if provided) are ADDED to the existing set (union — nothing already there is removed). Response carries \`existed: true\`.
|
|
898
|
+
- \`"replace"\`: overwrite \`content\` and \`metadata\` WHOLESALE — \`content\` becomes exactly the incoming value (or \`""\` if omitted) and \`metadata\` becomes exactly the incoming object (or \`{}\` if omitted), NOT merged, so a prior metadata key absent from this payload is dropped. \`tags\`/\`links\` stay additive (same union behavior as \`"update"\`) — a replace targets the free-form fields, not the taxonomy/graph, so it can't silently orphan links or detach tags the caller didn't mention. The note's \`id\` and \`created_at\` are preserved either way.
|
|
899
|
+
|
|
900
|
+
A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` was one of \`"ignore"\`/\`"update"\`/\`"replace"\` — \`true\` when the collision branch fired, \`false\` when a normal fresh insert happened instead (including when \`path\` was never set, so there was nothing to conflict with — \`if_exists\` is a no-op without a \`path\`, but still reports \`existed: false\`). Absent entirely under the default \`"error"\` mode — a plain create-note call's response shape is byte-identical to before this feature. Batch-aware, per-item (like \`if_missing\` on \`update-note\`): set \`if_exists\` inside each \`notes[]\` entry — a top-level \`if_exists\` alongside a \`notes\` array is NOT inherited by items that omit their own (it only takes effect on the single-note form, where \`params\` IS the one item).
|
|
901
|
+
|
|
902
|
+
**Batch summary** — pass \`summary: true\` (batch/\`notes\` calls only; ignored on a single-note call) to receive a compact \`{created, ids, failed}\` shape instead of N full note objects: \`created\` counts items that resulted in a BRAND-NEW insert (excludes \`if_exists\` collisions); \`ids\` lists every resulting note id in item order (fresh creates AND \`existed\` hits alike); \`failed\` is reserved for future partial-batch-failure reporting — today a batch create is all-or-nothing (any thrown error aborts and rolls back the WHOLE call, same with or without \`summary\`), so it's always \`[]\`.`,
|
|
810
903
|
inputSchema: {
|
|
811
904
|
type: "object",
|
|
812
905
|
properties: {
|
|
@@ -826,24 +919,33 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
826
919
|
},
|
|
827
920
|
required: ["target", "relationship"],
|
|
828
921
|
},
|
|
829
|
-
description: "Links to create from this note",
|
|
922
|
+
description: "Links to create from this note. `target` resolves with the SAME semantics as a [[wikilink]] (vault#555) — ID, then exact path, then basename/title. A target created LATER in the same `notes` batch, or by a future call, resolves automatically (queued + backfilled) — the response carries an `unresolved_link` warning naming the target in the meantime; never silently dropped.",
|
|
830
923
|
},
|
|
831
924
|
created_at: { type: "string", description: "ISO timestamp (defaults to now)" },
|
|
925
|
+
if_exists: {
|
|
926
|
+
type: "string",
|
|
927
|
+
enum: ["error", "ignore", "update", "replace"],
|
|
928
|
+
description: "What to do when `path` already names an existing note (vault#555). See the tool description for the full contract of each mode. Default \"error\" — unchanged path_conflict behavior.",
|
|
929
|
+
},
|
|
930
|
+
summary: {
|
|
931
|
+
type: "boolean",
|
|
932
|
+
description: "Batch calls only (a `notes` array): return a compact `{created, ids, failed}` shape instead of N full note objects. See the tool description. Ignored on a single-note call.",
|
|
933
|
+
},
|
|
832
934
|
// Batch
|
|
833
935
|
notes: {
|
|
834
936
|
type: "array",
|
|
835
937
|
items: {
|
|
836
938
|
type: "object",
|
|
837
939
|
properties: {
|
|
838
|
-
content: { type: "string" },
|
|
940
|
+
content: { type: "string", description: "Optional — defaults to \"\" (vault#555 fix: this item's schema previously marked it `required`, but it was never enforced; an empty-content batch item has always succeeded)." },
|
|
839
941
|
path: { type: "string" },
|
|
840
942
|
extension: { type: "string", description: "File extension (vault#328). See top-level docs." },
|
|
841
943
|
metadata: { type: "object" },
|
|
842
944
|
tags: { type: "array", items: { type: "string" } },
|
|
843
945
|
links: { type: "array" },
|
|
844
946
|
created_at: { type: "string" },
|
|
947
|
+
if_exists: { type: "string", enum: ["error", "ignore", "update", "replace"], description: "Per-item: see top-level `if_exists` docs. Each batch item carries its own setting." },
|
|
845
948
|
},
|
|
846
|
-
required: ["content"],
|
|
847
949
|
},
|
|
848
950
|
description: "Array of notes for batch creation",
|
|
849
951
|
},
|
|
@@ -858,12 +960,152 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
858
960
|
}
|
|
859
961
|
|
|
860
962
|
const created: Note[] = [];
|
|
963
|
+
// Structured `links` are resolved in a SECOND pass, after every
|
|
964
|
+
// note in this batch has been created (vault#555) — resolving
|
|
965
|
+
// inline (the old behavior) meant a link from item 0 to item 1's
|
|
966
|
+
// path silently dropped, since item 1 didn't exist yet at the
|
|
967
|
+
// moment item 0's links were processed. Deferring makes
|
|
968
|
+
// within-batch forward-refs resolve exactly like a same-content
|
|
969
|
+
// [[wikilink]] already does. `pendingLinks` carries the SOURCE
|
|
970
|
+
// note id (known immediately — createNote assigns it synchronously,
|
|
971
|
+
// and so does the `if_exists` existing-note branch below) alongside
|
|
972
|
+
// each item's raw `links` array.
|
|
973
|
+
const pendingLinks: { sourceId: string; links: { target: string; relationship: string }[] }[] = [];
|
|
974
|
+
// Per-note `unresolved_link` warnings (vault#555 — "never silent").
|
|
975
|
+
// Populated in the second pass; folded into each note's response
|
|
976
|
+
// below, same pattern as `validation_status`.
|
|
977
|
+
const linkWarningsByNote = new Map<string, QueryWarning[]>();
|
|
978
|
+
// `if_exists` bookkeeping (vault#555). `existedMap` is set ONLY for
|
|
979
|
+
// items whose `if_exists` engaged (ignore/update/replace) — absent
|
|
980
|
+
// means the default "error" path ran, so a plain create-note call
|
|
981
|
+
// sees zero response-shape change. `true` = the path already named
|
|
982
|
+
// a note and that branch fired; `false` = if_exists was set but no
|
|
983
|
+
// conflict occurred (a normal fresh insert). `noMutateIds` marks
|
|
984
|
+
// "ignore" hits so the schema-defaults pass below skips them
|
|
985
|
+
// entirely — "ignore" promises NO mutation, including backfill.
|
|
986
|
+
const existedMap = new Map<string, boolean>();
|
|
987
|
+
const noMutateIds = new Set<string>();
|
|
988
|
+
/** Record an `if_exists` collision outcome — shared by both call sites below. */
|
|
989
|
+
const recordExistedBranch = (resultNote: Note, noMutate: boolean): void => {
|
|
990
|
+
existedMap.set(resultNote.id, true);
|
|
991
|
+
if (noMutate) noMutateIds.add(resultNote.id);
|
|
992
|
+
created.push(resultNote);
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Handle an `if_exists` collision against an already-resolved
|
|
997
|
+
* `existingNote` — shared by the proactive pre-check (the common,
|
|
998
|
+
* non-racing path) and the insert-time PathConflictError catch
|
|
999
|
+
* (the race-closing backstop: a concurrent writer's INSERT
|
|
1000
|
+
* committed between our check and ours). Returns the note to put
|
|
1001
|
+
* in the response and whether it must be excluded from the
|
|
1002
|
+
* schema-defaults mutation pass ("ignore" — genuinely untouched).
|
|
1003
|
+
*/
|
|
1004
|
+
const applyExistingNote = async (
|
|
1005
|
+
item: any,
|
|
1006
|
+
existingNote: Note,
|
|
1007
|
+
mode: "ignore" | "update" | "replace",
|
|
1008
|
+
): Promise<{ note: Note; noMutate: boolean }> => {
|
|
1009
|
+
// CRITICAL scope guard (vault#555 auth-review must-fix). `existingNote`
|
|
1010
|
+
// was resolved VAULT-WIDE by path (getNoteByPath) at whichever call
|
|
1011
|
+
// site reached here — the proactive check OR the concurrent-INSERT
|
|
1012
|
+
// race backstop. A tag-scoped MCP session injects `ifExistsVisible`
|
|
1013
|
+
// (see GenerateMcpToolsOpts); when the resolved note fails it, the
|
|
1014
|
+
// caller must not read (ignore) / update / replace an out-of-scope
|
|
1015
|
+
// note it named only by path — throw PathConflictError (path taken,
|
|
1016
|
+
// invisible to this caller) BEFORE any read or mutation, so no
|
|
1017
|
+
// content leaks and nothing is written. Placing it HERE — not only in
|
|
1018
|
+
// the server-layer wrapper's pre-check — is what closes the
|
|
1019
|
+
// race-backstop TOCTOU: the pre-check + core's proactive check can
|
|
1020
|
+
// BOTH miss a note a concurrent writer then INSERTs, and the backstop
|
|
1021
|
+
// re-resolves it into this exact call. Core stays scope-unaware: it
|
|
1022
|
+
// only invokes the injected closure. Unscoped/internal callers pass no
|
|
1023
|
+
// predicate → unchanged behavior.
|
|
1024
|
+
if (ifExistsVisible && !ifExistsVisible(existingNote)) {
|
|
1025
|
+
throw new noteOps.PathConflictError(existingNote.path ?? "");
|
|
1026
|
+
}
|
|
1027
|
+
if (mode === "ignore") {
|
|
1028
|
+
// No error, no mutation of any kind — not even schema-default
|
|
1029
|
+
// backfill. Return the existing note exactly as stored.
|
|
1030
|
+
return { note: existingNote, noMutate: true };
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// "update" / "replace" both apply tags/links additively (union) —
|
|
1034
|
+
// they differ only in how content/metadata combine with the
|
|
1035
|
+
// existing values. See the tool description for the full contract.
|
|
1036
|
+
const updates: { content?: string; metadata?: Record<string, unknown> } = {};
|
|
1037
|
+
if (mode === "update") {
|
|
1038
|
+
if (item.content !== undefined) updates.content = item.content as string;
|
|
1039
|
+
if (item.metadata !== undefined) {
|
|
1040
|
+
updates.metadata = noteOps.mergeMetadata(
|
|
1041
|
+
existingNote.metadata as Record<string, unknown> | null | undefined,
|
|
1042
|
+
item.metadata as Record<string, unknown>,
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
} else {
|
|
1046
|
+
// "replace" — PUT semantics: content/metadata become exactly
|
|
1047
|
+
// this payload (empty default when omitted), never merged.
|
|
1048
|
+
updates.content = (item.content as string | undefined) ?? "";
|
|
1049
|
+
updates.metadata = (item.metadata as Record<string, unknown> | undefined) ?? {};
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const incomingTags = (item.tags as string[] | undefined) ?? [];
|
|
1053
|
+
const projectedTags = new Set<string>([...(existingNote.tags ?? []), ...incomingTags]);
|
|
1054
|
+
const projectedMetadata =
|
|
1055
|
+
updates.metadata ?? ((existingNote.metadata as Record<string, unknown>) ?? {});
|
|
1056
|
+
// Strict-schema gate (vault#299) against the PROJECTED final shape
|
|
1057
|
+
// (existing ∪ incoming), not the raw incoming item — a caller
|
|
1058
|
+
// updating one field of an already-conforming note shouldn't have
|
|
1059
|
+
// to re-supply every `required` field just to pass validation.
|
|
1060
|
+
enforceStrict({ path: existingNote.path, tags: [...projectedTags], metadata: projectedMetadata });
|
|
1061
|
+
|
|
1062
|
+
// vault#555 fix 2 (W8 fix-2 bug class, generalist must-fix): a
|
|
1063
|
+
// tags-only or links-only "update" leaves `updates` empty, so
|
|
1064
|
+
// gating store.updateNote on `updates` ALONE would skip it — and
|
|
1065
|
+
// then a genuine tag/link mutation (store.tagNote below) would never
|
|
1066
|
+
// bump `updated_at`, breaking cursor polling (`ORDER BY updated_at`)
|
|
1067
|
+
// and any since-last-check sync. Gate on the tag/link mutation too,
|
|
1068
|
+
// mirroring the update-note/PATCH path's hasTagMutation||hasLinkMutation.
|
|
1069
|
+
// store.updateNote with empty core fields still issues a real UPDATE
|
|
1070
|
+
// that bumps updated_at (+ last_updated_by/via). "replace" always
|
|
1071
|
+
// sets content+metadata, so it was never affected.
|
|
1072
|
+
const hasLinkMutation = item.links !== undefined;
|
|
1073
|
+
let result: Note = existingNote;
|
|
1074
|
+
if (Object.keys(updates).length > 0 || incomingTags.length > 0 || hasLinkMutation) {
|
|
1075
|
+
result = await store.updateNote(existingNote.id, {
|
|
1076
|
+
...updates,
|
|
1077
|
+
actor: writeActor,
|
|
1078
|
+
via: writeVia,
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (incomingTags.length > 0) {
|
|
1083
|
+
await store.tagNote(result.id, incomingTags);
|
|
1084
|
+
// Note: applySchemaDefaults also runs in the outer batch loop for
|
|
1085
|
+
// this note (it's not in `noMutateIds` — only "ignore" hits are),
|
|
1086
|
+
// so this inline call is redundant. Harmless (idempotent — fills
|
|
1087
|
+
// only still-missing fields), kept so the update/replace branch is
|
|
1088
|
+
// self-contained; left un-gated to avoid coupling to the outer loop.
|
|
1089
|
+
await applySchemaDefaults(store, db, [result.id], incomingTags);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
if (item.links) {
|
|
1093
|
+
pendingLinks.push({ sourceId: result.id, links: item.links as { target: string; relationship: string }[] });
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
return { note: noteOps.getNote(db, result.id) ?? result, noMutate: false };
|
|
1097
|
+
};
|
|
1098
|
+
|
|
861
1099
|
// Wrap multi-item batches in a SQLite transaction so a mid-batch
|
|
862
1100
|
// failure rolls back every prior insert — see #236. This guards
|
|
863
1101
|
// anything thrown from store.createNote / createLink (path
|
|
864
1102
|
// conflict, etc.). Single-item calls skip the wrap to avoid
|
|
865
1103
|
// colliding with concurrent callers on the shared bun:sqlite
|
|
866
|
-
// connection.
|
|
1104
|
+
// connection. Catching a PathConflictError INSIDE this callback
|
|
1105
|
+
// (the if_exists race backstop below) does NOT roll back the
|
|
1106
|
+
// transaction — only a throw that escapes `runBatch` does (see
|
|
1107
|
+
// core/src/txn.ts) — so the fallback-to-existing-note path commits
|
|
1108
|
+
// normally alongside every other item in the batch.
|
|
867
1109
|
const batched = items.length > 1;
|
|
868
1110
|
const runBatch = async (): Promise<void> => {
|
|
869
1111
|
for (const item of items) {
|
|
@@ -873,6 +1115,28 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
873
1115
|
const extension = item.extension !== undefined
|
|
874
1116
|
? validateExtension(item.extension)
|
|
875
1117
|
: undefined;
|
|
1118
|
+
const effectiveExtension = extension ?? "md";
|
|
1119
|
+
const ifExists = (item.if_exists as string | undefined) ?? "error";
|
|
1120
|
+
const upsertMode = ifExists === "ignore" || ifExists === "update" || ifExists === "replace";
|
|
1121
|
+
|
|
1122
|
+
// Proactive existence check (vault#555) — only possible when a
|
|
1123
|
+
// path is set (a pathless create can never conflict). Handles
|
|
1124
|
+
// the common, non-racing case cleanly: skip the raw-item strict
|
|
1125
|
+
// gate entirely (it would validate the wrong shape — see
|
|
1126
|
+
// `applyExistingNote`) and go straight to the ignore/update/
|
|
1127
|
+
// replace branch. The insert-time catch below is the backstop
|
|
1128
|
+
// for the rare true race.
|
|
1129
|
+
let existingNote: Note | null = null;
|
|
1130
|
+
if (upsertMode && item.path) {
|
|
1131
|
+
const normalized = normalizePath(item.path as string);
|
|
1132
|
+
existingNote = normalized ? noteOps.getNoteByPath(db, normalized, effectiveExtension) : null;
|
|
1133
|
+
}
|
|
1134
|
+
if (existingNote) {
|
|
1135
|
+
const { note: resultNote, noMutate } = await applyExistingNote(item, existingNote, ifExists as "ignore" | "update" | "replace");
|
|
1136
|
+
recordExistedBranch(resultNote, noMutate);
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
876
1140
|
// Strict-schema gate (vault#299) — reject before any write so a
|
|
877
1141
|
// mid-batch violation rolls back the batch transaction.
|
|
878
1142
|
enforceStrict({
|
|
@@ -880,30 +1144,70 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
880
1144
|
tags: item.tags as string[] | undefined,
|
|
881
1145
|
metadata: item.metadata as Record<string, unknown> | undefined,
|
|
882
1146
|
});
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1147
|
+
let note: Note;
|
|
1148
|
+
try {
|
|
1149
|
+
note = await store.createNote(item.content as string ?? "", {
|
|
1150
|
+
path: item.path as string | undefined,
|
|
1151
|
+
tags: item.tags as string[] | undefined,
|
|
1152
|
+
metadata: item.metadata as Record<string, unknown> | undefined,
|
|
1153
|
+
created_at: item.created_at as string | undefined,
|
|
1154
|
+
...(extension !== undefined ? { extension } : {}),
|
|
1155
|
+
// Write-attribution (vault#298) — same actor/via for every item
|
|
1156
|
+
// in a batch (the whole call came from one authenticated session).
|
|
1157
|
+
actor: writeActor,
|
|
1158
|
+
via: writeVia,
|
|
1159
|
+
});
|
|
1160
|
+
} catch (err) {
|
|
1161
|
+
// Race backstop (vault#555): a concurrent writer's INSERT
|
|
1162
|
+
// committed between our proactive check (skipped or missed
|
|
1163
|
+
// above) and this one. Re-resolve the now-existing row and
|
|
1164
|
+
// fall through to the SAME branch a proactive hit would have
|
|
1165
|
+
// taken — closes the create-race gap even under true
|
|
1166
|
+
// concurrency, not just the sequential common case.
|
|
1167
|
+
if (upsertMode && err instanceof noteOps.PathConflictError) {
|
|
1168
|
+
const winner = noteOps.getNoteByPath(db, err.path, effectiveExtension);
|
|
1169
|
+
if (winner) {
|
|
1170
|
+
const { note: resultNote, noMutate } = await applyExistingNote(item, winner, ifExists as "ignore" | "update" | "replace");
|
|
1171
|
+
recordExistedBranch(resultNote, noMutate);
|
|
1172
|
+
continue;
|
|
901
1173
|
}
|
|
902
1174
|
}
|
|
1175
|
+
throw err;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
if (upsertMode) existedMap.set(note.id, false);
|
|
1179
|
+
|
|
1180
|
+
if (item.links) {
|
|
1181
|
+
pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
|
|
903
1182
|
}
|
|
904
1183
|
|
|
905
1184
|
created.push(noteOps.getNote(db, note.id) ?? note);
|
|
906
1185
|
}
|
|
1186
|
+
|
|
1187
|
+
// --- Resolve structured links (vault#555) ---
|
|
1188
|
+
// Same semantics as [[wikilinks]]: ID or path/title match, tried
|
|
1189
|
+
// now that every sibling note in this batch exists. A target that
|
|
1190
|
+
// STILL doesn't resolve (typo, or a note that arrives in a later
|
|
1191
|
+
// call) is queued for lazy resolution — it backfills automatically
|
|
1192
|
+
// the moment a matching note is created — and surfaces an
|
|
1193
|
+
// `unresolved_link` warning naming the target. Never silent.
|
|
1194
|
+
for (const { sourceId, links } of pendingLinks) {
|
|
1195
|
+
for (const link of links) {
|
|
1196
|
+
const targetId = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
|
|
1197
|
+
if (targetId) {
|
|
1198
|
+
await store.createLink(sourceId, targetId, link.relationship);
|
|
1199
|
+
} else {
|
|
1200
|
+
const list = linkWarningsByNote.get(sourceId) ?? [];
|
|
1201
|
+
list.push({
|
|
1202
|
+
code: "unresolved_link",
|
|
1203
|
+
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.`,
|
|
1204
|
+
target: link.target,
|
|
1205
|
+
relationship: link.relationship,
|
|
1206
|
+
});
|
|
1207
|
+
linkWarningsByNote.set(sourceId, list);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
907
1211
|
};
|
|
908
1212
|
await (batched ? transactionAsync(db, runBatch) : runBatch());
|
|
909
1213
|
|
|
@@ -914,9 +1218,12 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
914
1218
|
// update-note path, which already re-reads post-defaults. The re-read
|
|
915
1219
|
// is batched (`getNotes` = one `WHERE id IN (...)`) and skipped
|
|
916
1220
|
// entirely when no defaults were applied, so the common no-defaults
|
|
917
|
-
// path adds zero extra reads.
|
|
1221
|
+
// path adds zero extra reads. `noMutateIds` ("ignore" hits) are
|
|
1222
|
+
// skipped entirely — "ignore" promises zero mutation, including
|
|
1223
|
+
// backfill of a since-added default the existing note predates.
|
|
918
1224
|
const mutatedIds = new Set<string>();
|
|
919
1225
|
for (const note of created) {
|
|
1226
|
+
if (noMutateIds.has(note.id)) continue;
|
|
920
1227
|
if (note.tags && note.tags.length > 0) {
|
|
921
1228
|
for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
|
|
922
1229
|
mutatedIds.add(id);
|
|
@@ -934,8 +1241,36 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
934
1241
|
})();
|
|
935
1242
|
|
|
936
1243
|
// Attach `validation_status` from any tag's `fields` declaration that
|
|
937
|
-
// applies to this note, against the post-defaults state.
|
|
938
|
-
|
|
1244
|
+
// applies to this note, against the post-defaults state. Fold in any
|
|
1245
|
+
// `unresolved_link` warnings collected above (vault#555) — additive,
|
|
1246
|
+
// present only when this note's `links` had a target that didn't
|
|
1247
|
+
// resolve. `existed` (vault#555) is attached ONLY for items whose
|
|
1248
|
+
// `if_exists` actually engaged — the default "error" path's response
|
|
1249
|
+
// shape is untouched.
|
|
1250
|
+
const final = refreshed.map((n) => {
|
|
1251
|
+
const validated = attachValidationStatus(store, db, n);
|
|
1252
|
+
const warnings = linkWarningsByNote.get(n.id);
|
|
1253
|
+
const existed = existedMap.get(n.id);
|
|
1254
|
+
let out: Note & { warnings?: QueryWarning[]; existed?: boolean } = validated;
|
|
1255
|
+
if (warnings && warnings.length > 0) out = { ...out, warnings };
|
|
1256
|
+
if (existed !== undefined) out = { ...out, existed };
|
|
1257
|
+
return out;
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
// Batch summary (vault#555) — compact shape instead of N full note
|
|
1261
|
+
// objects. Batch-only (a `notes` array); `summary` is ignored on a
|
|
1262
|
+
// single-note call, matching `if_exists`'s per-item batch scoping.
|
|
1263
|
+
if (batch && params.summary === true) {
|
|
1264
|
+
return {
|
|
1265
|
+
created: final.filter((n: any) => n.existed !== true).length,
|
|
1266
|
+
ids: final.map((n) => n.id),
|
|
1267
|
+
// Reserved for future partial-batch-failure reporting — a batch
|
|
1268
|
+
// create is all-or-nothing today (see the tool description), so
|
|
1269
|
+
// this is always empty.
|
|
1270
|
+
failed: [] as unknown[],
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
939
1274
|
return batch ? final : final[0];
|
|
940
1275
|
},
|
|
941
1276
|
},
|
|
@@ -1028,7 +1363,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1028
1363
|
},
|
|
1029
1364
|
},
|
|
1030
1365
|
},
|
|
1031
|
-
description: "Links to add/remove",
|
|
1366
|
+
description: "Links to add/remove. `add[].target` resolves with the SAME semantics as a [[wikilink]] (vault#555) — ID, then exact path, then basename/title — and lazily backfills (queued) when the target arrives later; the response carries an `unresolved_link` warning naming the target in the meantime, never a silent drop.",
|
|
1032
1367
|
},
|
|
1033
1368
|
include_content: {
|
|
1034
1369
|
type: "boolean",
|
|
@@ -1122,6 +1457,14 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1122
1457
|
// key this so the create-on-missing branch, which assigns the id
|
|
1123
1458
|
// late, can register correctly.
|
|
1124
1459
|
const echoLinkIds = new Set<string>();
|
|
1460
|
+
// Structured `links.add` entries, deferred to a second pass AFTER
|
|
1461
|
+
// every item in this batch has been created/updated (vault#555) —
|
|
1462
|
+
// resolving inline would silently drop a link from item 0 to a note
|
|
1463
|
+
// item 1 (later in the same batch) creates via `if_missing:
|
|
1464
|
+
// "create"`. Same fix as create-note's `pendingLinks`.
|
|
1465
|
+
const pendingLinks: { sourceId: string; links: { target: string; relationship: string; metadata?: Record<string, unknown> }[] }[] = [];
|
|
1466
|
+
// Per-note `unresolved_link` warnings (vault#555 — "never silent").
|
|
1467
|
+
const linkWarningsByNote = new Map<string, QueryWarning[]>();
|
|
1125
1468
|
// Wrap multi-item batches in a SQLite transaction so any mid-batch
|
|
1126
1469
|
// failure (precondition error, content_edit miss, ConflictError, …)
|
|
1127
1470
|
// rolls back every prior mutation in the batch — see #236.
|
|
@@ -1212,13 +1555,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1212
1555
|
});
|
|
1213
1556
|
const created = await store.createNote(content, createOpts);
|
|
1214
1557
|
await applySchemaDefaults(store, db, [created.id], created.tags ?? []);
|
|
1215
|
-
//
|
|
1558
|
+
// Defer links.add resolution to the second pass (vault#555)
|
|
1559
|
+
// — same reasoning as create-note.
|
|
1216
1560
|
const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
|
|
1217
1561
|
if (linksAdd) {
|
|
1218
|
-
|
|
1219
|
-
const target = resolveNote(db, link.target);
|
|
1220
|
-
if (target) await store.createLink(created.id, target.id, link.relationship, link.metadata);
|
|
1221
|
-
}
|
|
1562
|
+
pendingLinks.push({ sourceId: created.id, links: linksAdd });
|
|
1222
1563
|
}
|
|
1223
1564
|
const fresh = noteOps.getNote(db, created.id) ?? created;
|
|
1224
1565
|
updated.push(fresh);
|
|
@@ -1326,7 +1667,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1326
1667
|
const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
|
|
1327
1668
|
if (linksRemove) {
|
|
1328
1669
|
for (const link of linksRemove) {
|
|
1329
|
-
const target =
|
|
1670
|
+
const target = resolveStructuredLinkNote(db, link.target);
|
|
1330
1671
|
if (!target) continue;
|
|
1331
1672
|
resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
|
|
1332
1673
|
if (link.relationship === "wikilink" && target.path) {
|
|
@@ -1400,8 +1741,25 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1400
1741
|
enforceStrict({ path: note.path, tags: [...projectedTags], metadata: projectedMeta });
|
|
1401
1742
|
}
|
|
1402
1743
|
|
|
1744
|
+
// vault#555 fix 2 — tag and link mutations must bump `updated_at`
|
|
1745
|
+
// too, not just core-field (content/path/metadata) changes. Before
|
|
1746
|
+
// this, a tags-only or links-only call with `force: true` (no
|
|
1747
|
+
// `if_updated_at`) left `updates` completely empty — the branch
|
|
1748
|
+
// below skipped `store.updateNote` ENTIRELY, so `updated_at` never
|
|
1749
|
+
// moved even though tags/note_tags or links genuinely changed.
|
|
1750
|
+
// (A tags-only call that happened to pass `if_updated_at` already
|
|
1751
|
+
// worked by accident — it populated `updates.if_updated_at` — which
|
|
1752
|
+
// is why this was easy to miss in ad hoc testing.) This breaks
|
|
1753
|
+
// cursor polling (`ORDER BY updated_at`) and any `updated_at`-based
|
|
1754
|
+
// sync filter: the mutation is real but invisible to a
|
|
1755
|
+
// since-last-check loop.
|
|
1756
|
+
const hasTagMutation = ((item.tags as any)?.add?.length ?? 0) > 0
|
|
1757
|
+
|| ((item.tags as any)?.remove?.length ?? 0) > 0;
|
|
1758
|
+
const hasLinkMutation = (item.links as any)?.add !== undefined
|
|
1759
|
+
|| (item.links as any)?.remove !== undefined;
|
|
1760
|
+
|
|
1403
1761
|
let result: Note;
|
|
1404
|
-
if (Object.keys(updates).length > 0) {
|
|
1762
|
+
if (Object.keys(updates).length > 0 || hasTagMutation || hasLinkMutation) {
|
|
1405
1763
|
// Write-attribution (vault#298): stamp the most-recent-write
|
|
1406
1764
|
// columns on the same UPDATE that bumps `updated_at`. Only set when
|
|
1407
1765
|
// there's a real change to write (the empty-updates branch below
|
|
@@ -1412,7 +1770,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1412
1770
|
// store.updateNote routes through noteOps.updateNote, which runs
|
|
1413
1771
|
// the UPDATE (with optional `AND updated_at IS ?`) atomically and
|
|
1414
1772
|
// throws ConflictError on mismatch. No mutations have happened
|
|
1415
|
-
// yet, so a throw here leaves the note untouched.
|
|
1773
|
+
// yet, so a throw here leaves the note untouched. `updates` may
|
|
1774
|
+
// carry no core fields at all (a pure tag/link mutation) — that's
|
|
1775
|
+
// fine: `noteOps.updateNote` unconditionally SETs
|
|
1776
|
+
// `updated_at`/`last_updated_by`/`last_updated_via` whenever
|
|
1777
|
+
// `skipUpdatedAt` isn't set, so this still issues a real UPDATE
|
|
1778
|
+
// (not the true no-op/precondition-only branch, which only fires
|
|
1779
|
+
// when the caller passes ZERO fields AND no mutation is pending).
|
|
1416
1780
|
result = await store.updateNote(note.id, updates);
|
|
1417
1781
|
} else {
|
|
1418
1782
|
result = note;
|
|
@@ -1433,15 +1797,10 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1433
1797
|
await store.untagNote(note.id, tagsOp.remove);
|
|
1434
1798
|
}
|
|
1435
1799
|
|
|
1436
|
-
// --- Add links ---
|
|
1800
|
+
// --- Add links (deferred to the second pass — vault#555) ---
|
|
1437
1801
|
const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
|
|
1438
1802
|
if (linksAdd) {
|
|
1439
|
-
|
|
1440
|
-
const target = resolveNote(db, link.target);
|
|
1441
|
-
if (target) {
|
|
1442
|
-
await store.createLink(note.id, target.id, link.relationship, link.metadata);
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1803
|
+
pendingLinks.push({ sourceId: note.id, links: linksAdd });
|
|
1445
1804
|
}
|
|
1446
1805
|
|
|
1447
1806
|
// Echo links if this update mutated them (`links.add`/`links.remove`)
|
|
@@ -1454,6 +1813,32 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1454
1813
|
// Re-read for final state
|
|
1455
1814
|
updated.push(noteOps.getNote(db, note.id) ?? result);
|
|
1456
1815
|
}
|
|
1816
|
+
|
|
1817
|
+
// --- Resolve structured `links.add` (vault#555) ---
|
|
1818
|
+
// Deferred until every item in this batch has been created/
|
|
1819
|
+
// updated — same [[wikilink]] semantics (ID or path/title match),
|
|
1820
|
+
// now tried against the FULL post-batch note set so a forward-ref
|
|
1821
|
+
// to a sibling item resolves. A target that still doesn't resolve
|
|
1822
|
+
// is queued for lazy resolution (backfills when a matching note
|
|
1823
|
+
// is created later) and surfaces an `unresolved_link` warning —
|
|
1824
|
+
// never silently dropped.
|
|
1825
|
+
for (const { sourceId, links } of pendingLinks) {
|
|
1826
|
+
for (const link of links) {
|
|
1827
|
+
const targetId = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
|
|
1828
|
+
if (targetId) {
|
|
1829
|
+
await store.createLink(sourceId, targetId, link.relationship, link.metadata);
|
|
1830
|
+
} else {
|
|
1831
|
+
const list = linkWarningsByNote.get(sourceId) ?? [];
|
|
1832
|
+
list.push({
|
|
1833
|
+
code: "unresolved_link",
|
|
1834
|
+
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.`,
|
|
1835
|
+
target: link.target,
|
|
1836
|
+
relationship: link.relationship,
|
|
1837
|
+
});
|
|
1838
|
+
linkWarningsByNote.set(sourceId, list);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1457
1842
|
};
|
|
1458
1843
|
await (batched ? transactionAsync(db, runBatch) : runBatch());
|
|
1459
1844
|
|
|
@@ -1475,9 +1860,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1475
1860
|
// when triggered — mirrors the GET / query-notes shape exactly via
|
|
1476
1861
|
// the shared `linkOps.getLinksHydrated` call. vault feedback #8.
|
|
1477
1862
|
const echoLinks = echoLinkIds.has(n.id);
|
|
1863
|
+
// `unresolved_link` warnings (vault#555) — additive, present only
|
|
1864
|
+
// when this note's `links.add` had a target that didn't resolve.
|
|
1865
|
+
const warnings = linkWarningsByNote.get(n.id);
|
|
1478
1866
|
if (includeContent) {
|
|
1479
1867
|
const full: any = { ...validated, created };
|
|
1480
1868
|
if (echoLinks) full.links = linkOps.getLinksHydrated(db, n.id);
|
|
1869
|
+
if (warnings && warnings.length > 0) full.warnings = warnings;
|
|
1481
1870
|
return full as Note & { created: boolean };
|
|
1482
1871
|
}
|
|
1483
1872
|
const lean: any = noteOps.toNoteIndex(validated);
|
|
@@ -1487,6 +1876,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1487
1876
|
// Carry the link echo across the lean conversion — `toNoteIndex`
|
|
1488
1877
|
// drops unknown fields.
|
|
1489
1878
|
if (echoLinks) lean.links = linkOps.getLinksHydrated(db, n.id);
|
|
1879
|
+
if (warnings && warnings.length > 0) lean.warnings = warnings;
|
|
1490
1880
|
return lean;
|
|
1491
1881
|
});
|
|
1492
1882
|
return batch ? final : final[0];
|
|
@@ -1527,7 +1917,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1527
1917
|
{
|
|
1528
1918
|
name: "list-tags",
|
|
1529
1919
|
requiredVerb: "read",
|
|
1530
|
-
description: `List tags with usage counts. Each row carries \`count\` (notes carrying the EXACT tag) and \`expanded_count\` (vault#550 — distinct notes matching the tag OR any transitive descendant under the default subtypes expansion; use this to see a parent tag's true rollup when its notes are actually tagged with a more specific child). Pass \`tag\` to get a single tag's full record (description, fields, relationships, parent_names, timestamps) — errors with \`error_type: "tag_not_found"\` (plus a \`did_you_mean\` hint when a close match exists) if the tag has no identity row and no notes. Pass \`include_schema: true\` to include the full record for every tag.`,
|
|
1920
|
+
description: `List tags with usage counts. Each row carries \`count\` (notes carrying the EXACT tag) and \`expanded_count\` (vault#550 — distinct notes matching the tag OR any transitive descendant under the default subtypes expansion; use this to see a parent tag's true rollup when its notes are actually tagged with a more specific child). Pass \`tag\` to get a single tag's full record (description, fields, relationships, parent_names, timestamps) — errors with \`error_type: "tag_not_found"\` (plus a \`did_you_mean\` hint when a close match exists) if the tag has no identity row and no notes. Pass \`include_schema: true\` to include the full record for every tag. NOTE (vault#555): this list includes zero-membership tags (\`count: 0\` — a declared schema never yet applied, or a tag every note was since untagged from), so its length can run higher than \`vault-info\`'s stats \`tagCount\`, which counts only tags at least one note currently carries.`,
|
|
1531
1921
|
inputSchema: {
|
|
1532
1922
|
type: "object",
|
|
1533
1923
|
properties: {
|
|
@@ -1615,7 +2005,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1615
2005
|
additionalProperties: {
|
|
1616
2006
|
type: "object",
|
|
1617
2007
|
properties: {
|
|
1618
|
-
type: { type: "string", description: "Field type: string, boolean, integer, number, array, object — all six are accepted for storage + advisory validation. Only string/integer/boolean are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field)." },
|
|
2008
|
+
type: { type: "string", description: "Field type: string, boolean, integer, number, array, object — all six are accepted for storage + advisory validation; any OTHER value is rejected outright (error_type invalid_field_type, vault#555 — bundled with every other violation in the same call, see the `update-tag` tool description). Only string/integer/boolean are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field)." },
|
|
1619
2009
|
description: { type: "string" },
|
|
1620
2010
|
enum: { type: "array", items: { type: "string" }, description: "Allowed values. Does NOT auto-backfill — a note that omits this field stays without it unless `default` is also set (vault#553; the pre-0.7.0 behavior of silently defaulting to the first enum value is retired). Set `default` explicitly if you want backfill." },
|
|
1621
2011
|
default: { description: "Explicit backfill value (vault#553) applied when a note gains this tag without setting the field. Must conform to this field's own `type` (and `enum`, if declared) — a non-conforming default is rejected (invalid_default / invalid_field_default) rather than silently stored. Omit entirely to leave the field ABSENT (not backfilled) on notes that don't set it — this is what makes `exists:false` a trustworthy \"never set\" query." },
|
|
@@ -1742,7 +2132,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1742
2132
|
// mgmt + future config writes; deletes are write-tier mutations.
|
|
1743
2133
|
// See delete-note rationale.
|
|
1744
2134
|
requiredVerb: "write",
|
|
1745
|
-
description: "Delete a tag, remove it from all notes, and delete its schema. Notes themselves are NOT deleted — just untagged. Refused with error_type \"tag_referenced_as_parent\" (vault#552) when another tag's parent_names still names this one — pass cascade OR detach (either — both mean the same thing: strip the stale reference from the referencing tag(s)' parent_names, never delete them) to proceed anyway.",
|
|
2135
|
+
description: "Delete a tag, remove it from all notes, and delete its schema. Notes themselves are NOT deleted — just untagged. Refused with error_type \"tag_referenced_as_parent\" (vault#552) when another tag's parent_names still names this one — pass cascade OR detach (either — both mean the same thing: strip the stale reference from the referencing tag(s)' parent_names, never delete them) to proceed anyway. Also refused with error_type \"tag_in_use_by_tokens\" (vault#555 fix — this case existed pre-#555 but was undocumented here; see \"merge-tags\" for the identical guard) when the tag is referenced by a tag-scoped token's allowlist — revoke or re-mint the token(s) first. A no-op on a tag with no identity row and no notes returns {deleted: false, notes_untagged: 0} rather than erroring.",
|
|
1746
2136
|
inputSchema: {
|
|
1747
2137
|
type: "object",
|
|
1748
2138
|
properties: {
|
|
@@ -1898,7 +2288,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1898
2288
|
// overrideVaultInfo in src/mcp-tools.ts) — do not promote this to
|
|
1899
2289
|
// `write` or read-only callers lose the stats projection.
|
|
1900
2290
|
requiredVerb: "read",
|
|
1901
|
-
description: "Get a comprehensive vault projection: name, description, tags-with-schemas (own + effective parents/fields per #270 inheritance), indexed metadata fields catalog, and
|
|
2291
|
+
description: "Get a comprehensive vault projection: name, description, `coordinates` (this vault's own REST/MCP URL templates — `{name, base_url, rest_api, mcp}`, always present), tags-with-schemas (own + effective parents/fields per #270 inheritance), indexed metadata fields catalog, query hints, and (when a seeded onboarding guide exists) a `getting_started` note pointer. Pass `include_stats: true` to add note/tag/link counts and the monthly distribution as a `stats` field. Pass `description` to update the vault description (changes how AI agents behave in future sessions) — requires the `vault:write` scope for this vault even though the tool itself is read-gated (vault#555: a `vault:read`-only caller passing `description` gets a `Forbidden` rejection, not a silent no-op). Call this anytime mid-session to refresh schema context. NOTE (vault#555): the stats `tagCount` counts only tags at least one note currently carries (`COUNT(DISTINCT tag_name)` over note-tag memberships) — `list-tags`'s row count can run higher because it also lists zero-membership tags (an identity row from a declared schema or a since-untagged tag). Neither is wrong; they answer different questions.",
|
|
1902
2292
|
inputSchema: {
|
|
1903
2293
|
type: "object",
|
|
1904
2294
|
properties: {
|
|
@@ -1958,7 +2348,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1958
2348
|
// vault's taxonomy, not scoped to any one tag's write authority.
|
|
1959
2349
|
requiredVerb: "admin",
|
|
1960
2350
|
description:
|
|
1961
|
-
"Read-only integrity scan across the tag/metadata taxonomy — run this after any bulk tag reorg (rename/merge/delete/subtree move) to confirm nothing leaked.
|
|
2351
|
+
"Read-only integrity scan across the tag/metadata taxonomy — run this after any bulk tag reorg (rename/merge/delete/subtree move) to confirm nothing leaked. Returns {findings, summary, scanned_at} — findings is an array, each entry {type, severity, subject, detail, remedy} — NEVER auto-fixes; apply the suggested remedy (usually rename-tag/merge-tags/update-tag/prune-schema) yourself. Finding types: dangling_parent_name (a parent_names entry naming a tag with no identity row), parent_names_cycle (a tag reaching itself through its ancestor chain — traversal tolerates this, but it's dishonest hierarchy state), mixed_type_indexed_field (a note's metadata value for an indexed field has a JSON type disagreeing with the field's declared storage type — the ordering/filtering-goes-silently-wrong precursor), orphaned_indexed_field_declarer (an indexed field naming a dead declarer tag — see prune-schema), and dead_tag_metadata_reference (HEURISTIC, always carries heuristic:true — a metadata value that looks like a stale reference to a renamed/merged/deleted tag, inferred from sibling notes using the same metadata key with values that ARE live tags; can never be certain since vault keeps no tag-rename history).",
|
|
1962
2352
|
inputSchema: { type: "object", properties: {} },
|
|
1963
2353
|
execute: async () => {
|
|
1964
2354
|
return await store.doctor();
|