@openparachute/vault 0.7.0-rc.2 → 0.7.0-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/contract-typed-index.test.ts +92 -3
- package/core/src/indexed-fields.test.ts +30 -5
- package/core/src/indexed-fields.ts +24 -2
- package/core/src/mcp.ts +161 -61
- package/core/src/notes.ts +93 -9
- package/core/src/query-warnings.ts +35 -0
- package/core/src/search-query.test.ts +149 -0
- package/core/src/search-query.ts +0 -0
- package/core/src/store.ts +2 -1
- package/core/src/tag-schemas.ts +186 -4
- package/core/src/types.ts +13 -1
- package/package.json +1 -1
- package/src/contract-errors.test.ts +178 -12
- package/src/contract-search.test.ts +263 -31
- package/src/mcp-http.ts +122 -5
- package/src/mcp-list-tags-scope.test.ts +36 -0
- package/src/mcp-query-notes-search-scope.test.ts +136 -0
- package/src/mcp-tools.ts +48 -4
- package/src/routes.ts +378 -93
- package/src/tag-field-conflict-scope.test.ts +333 -0
- package/src/tag-scope.ts +66 -0
- package/src/ws-subscribe.test.ts +14 -0
|
@@ -22,6 +22,7 @@ import { Database } from "bun:sqlite";
|
|
|
22
22
|
import { SqliteStore } from "./store.js";
|
|
23
23
|
import { generateMcpTools } from "./mcp.js";
|
|
24
24
|
import { TransitionConflictError } from "./notes.js";
|
|
25
|
+
import { TagFieldConflictError } from "./tag-schemas.js";
|
|
25
26
|
|
|
26
27
|
let store: SqliteStore;
|
|
27
28
|
let db: Database;
|
|
@@ -97,10 +98,98 @@ describe("contract: typed indexes — todo (#553)", () => {
|
|
|
97
98
|
test.todo(
|
|
98
99
|
`#553: an unset enum field stays ABSENT — no first-value backfill — so exists:false correctly matches a note that never set the field (today: applySchemaDefaults in core/src/mcp.ts backfills the schema's first enum value onto every note that gains the tag, so "never set" is indistinguishable from "explicitly set to the default" and exists:false never matches)`,
|
|
99
100
|
);
|
|
100
|
-
test.todo(
|
|
101
|
-
`#553: update-tag reports ALL invalid fields in one call AND states explicitly that no changes were applied (today: the cross-tag type/indexed-flag validation loop in core/src/mcp.ts throws on the FIRST offending field and never reports the rest, and the thrown Error's message doesn't say whether the tag's other, valid fields were partially applied — reproduced live: declaring two invalid fields in one update-tag call surfaces only the first field's error)`,
|
|
102
|
-
);
|
|
103
101
|
test.todo(
|
|
104
102
|
`#553: the indexed-field type list is honest about what's actually indexable (core/src/mcp.ts's update-tag field-type description advertises "string, boolean, integer, number, array, object" but indexed-fields.ts's TYPE_MAP only supports string/integer/boolean — "number" and the container types are accepted as declared but silently un-indexable)`,
|
|
105
103
|
);
|
|
106
104
|
});
|
|
105
|
+
|
|
106
|
+
describe("contract: update-tag messaging — #553/#554 (flipped from todo)", () => {
|
|
107
|
+
it("update-tag reports ALL invalid fields in one call (not just the first) and states explicitly that no changes were applied", async () => {
|
|
108
|
+
// Tag "a" declares two fields. Tag "b" then redeclares BOTH with
|
|
109
|
+
// conflicting specs — a NON-indexed type conflict on "x" AND an
|
|
110
|
+
// indexed-flag conflict on "y" — in the SAME update-tag call. Pre-#553
|
|
111
|
+
// the cross-tag validation loop threw on the first offending field
|
|
112
|
+
// ("x") and never even evaluated "y"; two testers independently assumed
|
|
113
|
+
// the whole call (including "y") had partially landed. (The
|
|
114
|
+
// BOTH-indexed type-conflict case is deliberately absent here — it
|
|
115
|
+
// keeps its pre-existing declareField → IndexedFieldError path; see
|
|
116
|
+
// `collectCrossTagFieldViolations`'s doc-comment exclusion 2 and the
|
|
117
|
+
// both-indexed test below.)
|
|
118
|
+
await store.upsertTagRecord("a", {
|
|
119
|
+
fields: {
|
|
120
|
+
x: { type: "string" },
|
|
121
|
+
y: { type: "boolean", indexed: true },
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const tools = generateMcpTools(store);
|
|
126
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
127
|
+
|
|
128
|
+
let caught: unknown;
|
|
129
|
+
try {
|
|
130
|
+
await updateTag.execute({
|
|
131
|
+
tag: "b",
|
|
132
|
+
fields: {
|
|
133
|
+
x: { type: "integer" }, // non-indexed type_conflict vs tag "a"
|
|
134
|
+
y: { type: "boolean", indexed: false }, // indexed_flag_conflict vs tag "a"
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
} catch (e) {
|
|
138
|
+
caught = e;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
expect(caught).toBeInstanceOf(TagFieldConflictError);
|
|
142
|
+
const err = caught as TagFieldConflictError;
|
|
143
|
+
expect(err.violations).toHaveLength(2);
|
|
144
|
+
const byField = new Map(err.violations.map((v) => [v.field, v.reason]));
|
|
145
|
+
expect(byField.get("x")).toBe("type_conflict");
|
|
146
|
+
expect(byField.get("y")).toBe("indexed_flag_conflict");
|
|
147
|
+
// States explicitly that no changes were applied.
|
|
148
|
+
expect(err.message).toContain("no changes were applied");
|
|
149
|
+
// Structured conflicting-declarer field (scrubbed by the server layer
|
|
150
|
+
// for tag-scoped sessions; full detail here — core is scope-unaware).
|
|
151
|
+
expect(err.violations[0]!.other_tag).toBe("a");
|
|
152
|
+
|
|
153
|
+
// Nothing partially landed — tag "b" has no field declarations at all.
|
|
154
|
+
const bRecord = await store.getTagRecord("b");
|
|
155
|
+
expect(bRecord?.fields ?? null).toBeFalsy();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("a BOTH-indexed cross-tag type conflict keeps its pre-existing IndexedFieldError path (not TagFieldConflictError)", async () => {
|
|
159
|
+
// Wire-contract floor (vault#554): this exact case already errored on
|
|
160
|
+
// main via store.upsertTagRecord → declareField's cross-declarer
|
|
161
|
+
// sqlite-type check → IndexedFieldError (REST maps it to 400
|
|
162
|
+
// invalid_indexed_field). The new pre-check must not intercept it.
|
|
163
|
+
await store.upsertTagRecord("a", {
|
|
164
|
+
fields: { x: { type: "string", indexed: true } },
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const tools = generateMcpTools(store);
|
|
168
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
169
|
+
|
|
170
|
+
let caught: any;
|
|
171
|
+
try {
|
|
172
|
+
await updateTag.execute({
|
|
173
|
+
tag: "b",
|
|
174
|
+
fields: { x: { type: "integer", indexed: true } },
|
|
175
|
+
});
|
|
176
|
+
} catch (e) {
|
|
177
|
+
caught = e;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
expect(caught).toBeDefined();
|
|
181
|
+
expect(caught).not.toBeInstanceOf(TagFieldConflictError);
|
|
182
|
+
expect(caught.name).toBe("IndexedFieldError");
|
|
183
|
+
expect(caught.error_type).toBe("invalid_indexed_field");
|
|
184
|
+
// The structured declarer context the server's tag-scope scrub keys on.
|
|
185
|
+
expect(caught.field).toBe("x");
|
|
186
|
+
expect(caught.declarer_tags).toEqual(["a"]);
|
|
187
|
+
|
|
188
|
+
// The store's transaction rolled back — nothing persisted for "b".
|
|
189
|
+
const bRecord = await store.getTagRecord("b");
|
|
190
|
+
expect(bRecord?.fields ?? null).toBeFalsy();
|
|
191
|
+
});
|
|
192
|
+
// REST `PUT /api/tags/:name` coverage for the same behavior lives in
|
|
193
|
+
// src/contract-errors.test.ts (core/ shouldn't import from src/ — that
|
|
194
|
+
// boundary runs one direction only).
|
|
195
|
+
});
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
validateFieldName,
|
|
15
15
|
} from "./indexed-fields.js";
|
|
16
16
|
import { buildVaultProjection } from "./vault-projection.js";
|
|
17
|
+
import { TagFieldConflictError } from "./tag-schemas.js";
|
|
17
18
|
|
|
18
19
|
let db: Database;
|
|
19
20
|
let store: SqliteStore;
|
|
@@ -186,9 +187,17 @@ describe("update-tag: indexed flag", () => {
|
|
|
186
187
|
it("type conflict across declarers throws and names the other tag", async () => {
|
|
187
188
|
const t = findTool("update-tag");
|
|
188
189
|
await t.execute({ tag: "project", fields: { status: { type: "string", indexed: true } } });
|
|
190
|
+
// vault#554 wire review: a BOTH-indexed cross-tag type conflict is
|
|
191
|
+
// deliberately excluded from the update-tag pre-check (see
|
|
192
|
+
// `collectCrossTagFieldViolations` doc-comment exclusion 2) so it keeps
|
|
193
|
+
// its pre-existing declareField → IndexedFieldError path — REST maps
|
|
194
|
+
// that to the established 400 invalid_indexed_field. The declarer is
|
|
195
|
+
// still named (declareField's message shape), which is this test's
|
|
196
|
+
// intent; the pre-#554 inline-loop wording (`tag "project" declares
|
|
197
|
+
// "string"`) is gone along with the loop.
|
|
189
198
|
expect(() =>
|
|
190
199
|
t.execute({ tag: "ticket", fields: { status: { type: "integer", indexed: true } } }),
|
|
191
|
-
).toThrow(/tag
|
|
200
|
+
).toThrow(/declared by tag\(s\) \[project\]/);
|
|
192
201
|
});
|
|
193
202
|
|
|
194
203
|
it("indexed-flag conflict across declarers throws", async () => {
|
|
@@ -209,12 +218,28 @@ describe("update-tag: indexed flag", () => {
|
|
|
209
218
|
});
|
|
210
219
|
|
|
211
220
|
it("invalid field name for indexing throws", async () => {
|
|
212
|
-
|
|
213
|
-
|
|
221
|
+
// vault#553/#554: update-tag's cross-tag field validation now collects
|
|
222
|
+
// EVERY violation into one `TagFieldConflictError` (carrying a
|
|
223
|
+
// `violations` array) instead of throwing the first `IndexedFieldError`
|
|
224
|
+
// it hits — see core/src/tag-schemas.ts `collectTagFieldViolations`.
|
|
225
|
+
// The underlying reason ("invalid_field_name") and message text are
|
|
226
|
+
// unchanged; only the thrown class + the "collect everything" framing
|
|
227
|
+
// are new.
|
|
228
|
+
let caught: unknown;
|
|
229
|
+
try {
|
|
230
|
+
await findTool("update-tag").execute({
|
|
214
231
|
tag: "project",
|
|
215
232
|
fields: { "bad-name": { type: "string", indexed: true } },
|
|
216
|
-
})
|
|
217
|
-
|
|
233
|
+
});
|
|
234
|
+
} catch (e) {
|
|
235
|
+
caught = e;
|
|
236
|
+
}
|
|
237
|
+
expect(caught).toBeInstanceOf(TagFieldConflictError);
|
|
238
|
+
const err = caught as TagFieldConflictError;
|
|
239
|
+
expect(err.violations).toHaveLength(1);
|
|
240
|
+
expect(err.violations[0]!.field).toBe("bad-name");
|
|
241
|
+
expect(err.violations[0]!.reason).toBe("invalid_field_name");
|
|
242
|
+
expect(err.message).toContain("no changes were applied");
|
|
218
243
|
});
|
|
219
244
|
|
|
220
245
|
it("rejects non-atomic indexed-flag change while other declarers hold it true", async () => {
|
|
@@ -45,6 +45,22 @@ interface IndexedFieldRow {
|
|
|
45
45
|
|
|
46
46
|
export class IndexedFieldError extends Error {
|
|
47
47
|
override name = "IndexedFieldError";
|
|
48
|
+
// Stable error_type (vault#554) — additive; matches the string REST has
|
|
49
|
+
// hardcoded in its json response since vault#478. Lets the generic MCP
|
|
50
|
+
// domain-error mapping (src/mcp-http.ts) pick this class up.
|
|
51
|
+
error_type = "invalid_indexed_field" as const;
|
|
52
|
+
/**
|
|
53
|
+
* Structured context for the CROSS-DECLARER sqlite-type conflict thrown
|
|
54
|
+
* by `declareField` (vault#554 auth-and-scope fold): the message names
|
|
55
|
+
* the other declarer tag(s), which a tag-scoped caller must not learn
|
|
56
|
+
* when they're outside its allowlist. `declarer_tags` lets the server
|
|
57
|
+
* layer (`scrubIndexedFieldConflictError` in src/tag-scope.ts) detect
|
|
58
|
+
* that case and generalize the message. Absent on the solo own-field
|
|
59
|
+
* throws (invalid name, unsupported type), whose messages name no other
|
|
60
|
+
* tag.
|
|
61
|
+
*/
|
|
62
|
+
field?: string;
|
|
63
|
+
declarer_tags?: string[];
|
|
48
64
|
}
|
|
49
65
|
|
|
50
66
|
// Restrict field names to safe SQL identifiers. This also bounds the
|
|
@@ -172,8 +188,14 @@ export function declareField(
|
|
|
172
188
|
if (existing.sqliteType !== sqliteType) {
|
|
173
189
|
const others = existing.declarerTags.filter((t) => t !== tag);
|
|
174
190
|
if (others.length > 0) {
|
|
175
|
-
|
|
176
|
-
|
|
191
|
+
// `field` + `declarer_tags` stamped so the server's tag-scope layer
|
|
192
|
+
// can generalize this message when a declarer is outside a scoped
|
|
193
|
+
// caller's allowlist (see IndexedFieldError's doc comment).
|
|
194
|
+
throw Object.assign(
|
|
195
|
+
new IndexedFieldError(
|
|
196
|
+
`field "${field}" is declared by tag(s) [${others.join(", ")}] with sqlite type ${existing.sqliteType}; tag "${tag}" requested ${sqliteType}`,
|
|
197
|
+
),
|
|
198
|
+
{ field, declarer_tags: others },
|
|
177
199
|
);
|
|
178
200
|
}
|
|
179
201
|
dropColumnAndIndex(db, field);
|
package/core/src/mcp.ts
CHANGED
|
@@ -5,11 +5,11 @@ import * as noteOps from "./notes.js";
|
|
|
5
5
|
import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
|
|
6
6
|
import { QueryError } from "./query-operators.js";
|
|
7
7
|
import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "./tag-hierarchy.js";
|
|
8
|
-
import { collectUnknownTagWarnings, type QueryWarning } from "./query-warnings.js";
|
|
8
|
+
import { collectUnknownTagWarnings, emptySearchWarning, ignoredParamWarning, type QueryWarning } from "./query-warnings.js";
|
|
9
|
+
import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "./search-query.js";
|
|
9
10
|
import * as linkOps from "./links.js";
|
|
10
11
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
11
12
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
12
|
-
import * as indexedFieldOps from "./indexed-fields.js";
|
|
13
13
|
import {
|
|
14
14
|
SchemaValidationError,
|
|
15
15
|
strictViolations,
|
|
@@ -52,6 +52,22 @@ export interface McpToolDef {
|
|
|
52
52
|
// Helpers
|
|
53
53
|
// ---------------------------------------------------------------------------
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Build a plain `Error` carrying a stable `error_type` (+ optional `field`/
|
|
57
|
+
* `hint`) as duck-typed extra properties — the same pattern `QueryError`
|
|
58
|
+
* uses for its optional structured fields. For validation leaves that don't
|
|
59
|
+
* warrant a dedicated exported class (one caller-facing shape, not a reusable
|
|
60
|
+
* domain error), this is what lets the generic domain-error mapping in
|
|
61
|
+
* `src/mcp-http.ts` surface `data.error_type` instead of falling through to
|
|
62
|
+
* the unstructured `isError: true` text fallback (vault#554).
|
|
63
|
+
*/
|
|
64
|
+
function structuredError(
|
|
65
|
+
message: string,
|
|
66
|
+
fields: { error_type: string; field?: string; hint?: string },
|
|
67
|
+
): Error {
|
|
68
|
+
return Object.assign(new Error(message), fields);
|
|
69
|
+
}
|
|
70
|
+
|
|
55
71
|
/**
|
|
56
72
|
* Resolve a note identifier — tries ID first, then case-insensitive
|
|
57
73
|
* path match. Works everywhere a note reference is accepted.
|
|
@@ -85,7 +101,9 @@ function resolveNote(db: Database, idOrPath: string): Note | null {
|
|
|
85
101
|
|
|
86
102
|
function requireNote(db: Database, idOrPath: string): Note {
|
|
87
103
|
const note = resolveNote(db, idOrPath);
|
|
88
|
-
if (!note)
|
|
104
|
+
if (!note) {
|
|
105
|
+
throw structuredError(`Note not found: "${idOrPath}"`, { error_type: "not_found", field: "id" });
|
|
106
|
+
}
|
|
89
107
|
return note;
|
|
90
108
|
}
|
|
91
109
|
|
|
@@ -243,7 +261,9 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
243
261
|
Response shape (vault#550 — three variants, pick by what you passed):
|
|
244
262
|
- Default (no \`cursor\`, no warnings): a bare array of notes.
|
|
245
263
|
- Cursor mode (\`cursor\` param present — including \`cursor: ""\` to bootstrap): \`{notes: [...], next_cursor}\`. See \`cursor\` below for the bootstrap flow.
|
|
246
|
-
- Warnings present (e.g. an unrecognized \`tag\`) and NOT in cursor mode: \`{notes: [...], warnings: [...]}\`. Cursor mode + warnings compose: \`{notes, next_cursor, warnings}\`. Absent \`warnings\` key means nothing to flag — don't assume its presence either way
|
|
264
|
+
- Warnings present (e.g. an unrecognized \`tag\`) and NOT in cursor mode: \`{notes: [...], warnings: [...]}\`. Cursor mode + warnings compose: \`{notes, next_cursor, warnings}\`. Absent \`warnings\` key means nothing to flag — don't assume its presence either way.
|
|
265
|
+
|
|
266
|
+
\`search\` is literal-by-default (vault#551): your text is escaped and phrase-quoted before it reaches FTS5, so ordinary punctuation ("didn't", "eleven-day", "18.6") is matched as literal content instead of being parsed as query syntax (a bare hyphen used to mean NOT; an apostrophe or decimal point used to break the parse and silently return \`[]\`). Pass \`search_mode: "advanced"\` to opt back into raw FTS5 syntax (AND/OR/NOT, manual phrase quoting, prefix \`*\`) — a malformed advanced query now throws a structured error instead of silently returning \`[]\`. \`sort\` is honored under \`search\` too: omit it for relevance ranking (default), or pass "asc"/"desc" to order by \`created_at\` instead.`,
|
|
247
267
|
inputSchema: {
|
|
248
268
|
type: "object",
|
|
249
269
|
properties: {
|
|
@@ -297,7 +317,17 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
297
317
|
],
|
|
298
318
|
description: "Filter by file extension (vault#328). Pass a single extension (e.g. \"csv\") or an array (e.g. [\"csv\", \"yaml\", \"json\"]). Notes default to \"md\"; case-insensitive match.",
|
|
299
319
|
},
|
|
300
|
-
search: {
|
|
320
|
+
search: {
|
|
321
|
+
type: "string",
|
|
322
|
+
description:
|
|
323
|
+
'Full-text search query. Literal-by-default (vault#551): your text is escaped and phrase-quoted before reaching FTS5, so punctuation ("didn\'t", "eleven-day", "18.6") is matched as literal content rather than parsed as FTS5 query syntax. Pass `search_mode: "advanced"` for raw FTS5 syntax (boolean/phrase/prefix operators). `sort` is honored under search (see below) — default is relevance ranking.',
|
|
324
|
+
},
|
|
325
|
+
search_mode: {
|
|
326
|
+
type: "string",
|
|
327
|
+
enum: [...SEARCH_MODES],
|
|
328
|
+
description:
|
|
329
|
+
'How `search` text is turned into an FTS5 query (vault#551). "literal" (DEFAULT): escape + phrase-quote the text so punctuation is literal content, not FTS5 syntax — the fix for `search: "didn\'t"` / "eleven-day" / "18.6" silently returning `[]`. "advanced": pass the text through to FTS5 raw, for callers who want boolean (AND/OR/NOT), manual phrase quoting, or prefix (`*`) syntax — a malformed advanced query throws a structured error (`error_type: "invalid_search_syntax"`) instead of silently returning `[]`. Has no effect without `search` (an `ignored_param` warning fires if you pass it without `search`). Omit for the default ("literal").',
|
|
330
|
+
},
|
|
301
331
|
metadata: {
|
|
302
332
|
type: "object",
|
|
303
333
|
description: "Filter by metadata values. Each value is either a primitive (exact match, scans JSON) or an operator object: `{eq|ne|gt|gte|lt|lte|in|not_in|exists: value}`. Operator objects require the field to be declared `indexed: true` in a tag schema — they route through the backing B-tree index. Multiple operators on one field AND together (e.g. `{gt: 5, lt: 10}`). `in`/`not_in` take arrays; `exists` takes a boolean.",
|
|
@@ -328,7 +358,12 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
328
358
|
required: ["note_id"],
|
|
329
359
|
description: "Scope results to notes within N hops of an anchor note",
|
|
330
360
|
},
|
|
331
|
-
sort: {
|
|
361
|
+
sort: {
|
|
362
|
+
type: "string",
|
|
363
|
+
enum: ["asc", "desc"],
|
|
364
|
+
description:
|
|
365
|
+
'Sort by created_at. Under a structured query this is the only ordering (default "asc"). Under `search` (vault#551): omit for FTS5 relevance ranking (default, unchanged) — pass "asc"/"desc" to EXPLICITLY switch to created_at ordering instead of relevance.',
|
|
366
|
+
},
|
|
332
367
|
limit: { type: "number", description: "Max results (default 50)" },
|
|
333
368
|
offset: { type: "number", description: "Pagination offset (default 0)" },
|
|
334
369
|
cursor: {
|
|
@@ -405,7 +440,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
405
440
|
// --- Single note by ID/path ---
|
|
406
441
|
if (params.id) {
|
|
407
442
|
const note = resolveNote(db, params.id as string);
|
|
408
|
-
if (!note) return { error: "Note not found", id: params.id };
|
|
443
|
+
if (!note) return { error: "Note not found", error_type: "not_found", id: params.id };
|
|
409
444
|
const includeContent = params.include_content !== false; // default true for single
|
|
410
445
|
// Range params are meaningless on a content-less shape — error
|
|
411
446
|
// rather than silently ignore (same loud-validation policy as
|
|
@@ -453,7 +488,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
453
488
|
if (params.near) {
|
|
454
489
|
const near = params.near as { note_id: string; depth?: number; relationship?: string };
|
|
455
490
|
const anchor = resolveNote(db, near.note_id);
|
|
456
|
-
if (!anchor) return { error: "Anchor note not found", note_id: near.note_id };
|
|
491
|
+
if (!anchor) return { error: "Anchor note not found", error_type: "not_found", note_id: near.note_id };
|
|
457
492
|
const depth = Math.min(near.depth ?? 2, 5);
|
|
458
493
|
const traversed = linkOps.traverseLinks(db, anchor.id, {
|
|
459
494
|
max_depth: depth,
|
|
@@ -502,13 +537,37 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
502
537
|
expand = params.expand as TagExpandMode;
|
|
503
538
|
}
|
|
504
539
|
|
|
540
|
+
// `search_mode` (vault#551) — validate loudly (same policy as
|
|
541
|
+
// `expand` above) so a typo'd value doesn't silently fall back to
|
|
542
|
+
// the default. Resolved here (before the search/structured branch)
|
|
543
|
+
// because BOTH branches need to know whether it was passed: the
|
|
544
|
+
// search branch to select escaping behavior, the structured branch
|
|
545
|
+
// to warn that it's being ignored.
|
|
546
|
+
let searchMode: SearchMode | undefined;
|
|
547
|
+
if (params.search_mode !== undefined && params.search_mode !== null) {
|
|
548
|
+
if (typeof params.search_mode !== "string" || !isValidSearchMode(params.search_mode)) {
|
|
549
|
+
throw new QueryError(
|
|
550
|
+
`invalid \`search_mode\` value ${JSON.stringify(params.search_mode)} — must be one of ${SEARCH_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("literal").`,
|
|
551
|
+
"INVALID_QUERY",
|
|
552
|
+
{
|
|
553
|
+
error_type: "invalid_query",
|
|
554
|
+
field: "search_mode",
|
|
555
|
+
got: params.search_mode,
|
|
556
|
+
hint: `pass "literal" or "advanced", or omit for the default ("literal")`,
|
|
557
|
+
},
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
searchMode = params.search_mode;
|
|
561
|
+
}
|
|
562
|
+
|
|
505
563
|
// --- Full-text search ---
|
|
506
564
|
let results: Note[];
|
|
507
565
|
let nextCursor: string | null = null;
|
|
508
|
-
// Warnings channel (vault#550)
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
566
|
+
// Warnings channel (vault#550). `search=` warnings (`empty_search`,
|
|
567
|
+
// `ignored_param` for a stray `search_mode`) joined the channel at
|
|
568
|
+
// #551 — the rest (`unknown_tag`) stays structured-query only.
|
|
569
|
+
// Scope-unaware by design (see `core/src/query-warnings.ts` doc
|
|
570
|
+
// comment) — a tag-scoped MCP session gets these stripped by the
|
|
512
571
|
// `applyTagScopeWrappers` query-notes wrapper in `src/mcp-tools.ts`
|
|
513
572
|
// before the result reaches the caller, so an out-of-scope tag name
|
|
514
573
|
// never leaks via `did_you_mean`.
|
|
@@ -516,18 +575,47 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
516
575
|
if (params.search) {
|
|
517
576
|
// Normalize tag param
|
|
518
577
|
const tags = normalizeTags(params.tag);
|
|
519
|
-
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
}
|
|
578
|
+
const mode: SearchMode = searchMode ?? "literal";
|
|
579
|
+
// "Only whitespace/quotes" (vault#551 edge case): short-circuit
|
|
580
|
+
// BEFORE ever calling FTS5 — an empty/all-punctuation phrase can
|
|
581
|
+
// be a syntax error (or a meaningless always-empty query)
|
|
582
|
+
// depending on exactly how it degenerates, so this is checked
|
|
583
|
+
// here rather than left to the DB layer to (maybe) reject.
|
|
584
|
+
if (mode === "literal" && buildLiteralSearchQuery(params.search as string).isEmpty) {
|
|
585
|
+
results = [];
|
|
586
|
+
queryWarnings.push(emptySearchWarning());
|
|
587
|
+
} else {
|
|
588
|
+
// Route through `store.searchNotes` (not `noteOps.searchNotes`) so
|
|
589
|
+
// tag-hierarchy expansion fires for MCP callers the same as for
|
|
590
|
+
// HTTP REST callers — `tag: "manual"` matches descendants declared
|
|
591
|
+
// via `_tags/*` config notes. Mirrors the structured-query fix
|
|
592
|
+
// from #214; same class of bypass bug (tracked as #227). A
|
|
593
|
+
// malformed advanced-mode query throws here (structured
|
|
594
|
+
// `invalid_search_syntax`, vault#551) — uncaught on purpose, it
|
|
595
|
+
// propagates to `src/mcp-http.ts`, which maps it to a JSON-RPC
|
|
596
|
+
// error the same way it maps `invalid_query`.
|
|
597
|
+
results = await store.searchNotes(params.search as string, {
|
|
598
|
+
tags,
|
|
599
|
+
limit: (params.limit as number) ?? 50,
|
|
600
|
+
expand,
|
|
601
|
+
mode,
|
|
602
|
+
sort: params.sort as "asc" | "desc" | undefined,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
529
605
|
} else {
|
|
530
606
|
// --- Structured query ---
|
|
607
|
+
// `search_mode` only shapes how `search` text becomes an FTS5
|
|
608
|
+
// query — passing it without `search` is almost always a mistake
|
|
609
|
+
// (meant to pass `search` too), so flag it rather than silently
|
|
610
|
+
// doing nothing with it.
|
|
611
|
+
if (searchMode !== undefined) {
|
|
612
|
+
queryWarnings.push(
|
|
613
|
+
ignoredParamWarning(
|
|
614
|
+
"search_mode",
|
|
615
|
+
"no `search` was provided — search_mode only affects full-text search query parsing",
|
|
616
|
+
),
|
|
617
|
+
);
|
|
618
|
+
}
|
|
531
619
|
const tags = normalizeTags(params.tag);
|
|
532
620
|
// Accept canonical `exclude_tags` plus camelCase / singular aliases.
|
|
533
621
|
// LLM callers frequently pick the wrong name (training-data drift
|
|
@@ -574,7 +662,12 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
574
662
|
offset: params.offset as number | undefined,
|
|
575
663
|
cursor: cursorMode ? (params.cursor as string) : undefined,
|
|
576
664
|
};
|
|
577
|
-
|
|
665
|
+
// Concatenate (not overwrite) — `queryWarnings` may already carry
|
|
666
|
+
// the `ignored_param` warning for a stray `search_mode` pushed
|
|
667
|
+
// above (vault#551).
|
|
668
|
+
queryWarnings = queryWarnings.concat(
|
|
669
|
+
collectUnknownTagWarnings(db, queryOpts.tags, queryOpts.expand, store.getTagHierarchy()),
|
|
670
|
+
);
|
|
578
671
|
if (cursorMode) {
|
|
579
672
|
const page = await store.queryNotesPaged(queryOpts);
|
|
580
673
|
results = page.notes;
|
|
@@ -838,7 +931,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
838
931
|
- \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
|
|
839
932
|
- When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
|
|
840
933
|
- For batch: pass a \`notes\` array, each with an \`id\` field.
|
|
841
|
-
- **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`force\` only waives the *requirement to supply* \`if_updated_at\` — if you pass both, the precondition you supplied still applies and a mismatch returns a conflict error. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design).
|
|
934
|
+
- **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`force\` only waives the *requirement to supply* \`if_updated_at\` — if you pass both, the precondition you supplied still applies and a mismatch returns a conflict error. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design). **Batch default (vault#554):** a top-level \`force\` and/or \`if_updated_at\` alongside a \`notes\` array applies as the DEFAULT for every item that doesn't set its own — e.g. \`{force: true, notes: [{id: "a", content: "..."}, {id: "b", content: "...", if_updated_at: "..."}]}\` forces item "a" but still enforces the precondition on item "b" (its own \`if_updated_at\` wins). Per-item values always take precedence over the top-level default.
|
|
842
935
|
- **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
|
|
843
936
|
- \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`createdBy\`, \`createdVia\`, \`lastUpdatedBy\`, \`lastUpdatedVia\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present.
|
|
844
937
|
|
|
@@ -967,7 +1060,23 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
967
1060
|
},
|
|
968
1061
|
execute: async (params) => {
|
|
969
1062
|
const batch = params.notes as any[] | undefined;
|
|
970
|
-
|
|
1063
|
+
// vault#554: top-level `force` / `if_updated_at` apply as per-item
|
|
1064
|
+
// DEFAULTS in a batch call — item-level values win when both are
|
|
1065
|
+
// present. Before this fix `items = batch ?? [params]` never merged
|
|
1066
|
+
// the top-level fields into batch items at all, so a caller passing
|
|
1067
|
+
// `{force: true, notes: [...]}` had it silently ignored: every item
|
|
1068
|
+
// without its OWN `force`/`if_updated_at` still threw
|
|
1069
|
+
// `PreconditionRequiredError` (a gardener-reported round-trip cost).
|
|
1070
|
+
// The single-item form (`items = [params]`) already behaved this
|
|
1071
|
+
// way implicitly (params IS the item), so only the batch branch
|
|
1072
|
+
// needs the merge.
|
|
1073
|
+
const items = batch
|
|
1074
|
+
? batch.map((item: any) => ({
|
|
1075
|
+
...(params.force !== undefined ? { force: params.force } : {}),
|
|
1076
|
+
...(params.if_updated_at !== undefined ? { if_updated_at: params.if_updated_at } : {}),
|
|
1077
|
+
...item,
|
|
1078
|
+
}))
|
|
1079
|
+
: [params];
|
|
971
1080
|
|
|
972
1081
|
if (items.length > MAX_BATCH_SIZE) {
|
|
973
1082
|
throw new BatchTooLargeError(items.length);
|
|
@@ -1099,7 +1208,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1099
1208
|
// Fallthrough: not-found + no if_missing → existing error
|
|
1100
1209
|
// contract. Match `requireNote`'s message shape so existing
|
|
1101
1210
|
// callers see no behavior change.
|
|
1102
|
-
throw
|
|
1211
|
+
throw structuredError(`Note not found: "${item.id}"`, { error_type: "not_found", field: "id" });
|
|
1103
1212
|
}
|
|
1104
1213
|
const note = resolved;
|
|
1105
1214
|
|
|
@@ -1109,8 +1218,9 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1109
1218
|
const hasContentEdit = item.content_edit !== undefined;
|
|
1110
1219
|
const contentModes = (hasContent ? 1 : 0) + (hasAppendPrepend ? 1 : 0) + (hasContentEdit ? 1 : 0);
|
|
1111
1220
|
if (contentModes > 1) {
|
|
1112
|
-
throw
|
|
1221
|
+
throw structuredError(
|
|
1113
1222
|
`update-note: \`content\`, \`append\`/\`prepend\`, and \`content_edit\` are mutually exclusive — pick one mode of content update for note "${note.id}".`,
|
|
1223
|
+
{ error_type: "mutually_exclusive", hint: "pass exactly one of `content`, `append`/`prepend`, or `content_edit`" },
|
|
1114
1224
|
);
|
|
1115
1225
|
}
|
|
1116
1226
|
|
|
@@ -1161,20 +1271,23 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1161
1271
|
if (hasContentEdit) {
|
|
1162
1272
|
const ce = item.content_edit as { old_text: string; new_text: string };
|
|
1163
1273
|
if (typeof ce?.old_text !== "string" || typeof ce?.new_text !== "string") {
|
|
1164
|
-
throw
|
|
1274
|
+
throw structuredError(
|
|
1165
1275
|
"update-note: `content_edit` requires { old_text: string, new_text: string }.",
|
|
1276
|
+
{ error_type: "invalid_content_edit", field: "content_edit", hint: "pass { old_text: string, new_text: string }" },
|
|
1166
1277
|
);
|
|
1167
1278
|
}
|
|
1168
1279
|
const idx = note.content.indexOf(ce.old_text);
|
|
1169
1280
|
if (idx < 0) {
|
|
1170
|
-
throw
|
|
1281
|
+
throw structuredError(
|
|
1171
1282
|
`update-note content_edit: \`old_text\` not found in note "${note.id}". The note may have been edited — re-read and retry.`,
|
|
1283
|
+
{ error_type: "content_edit_not_found", field: "content_edit.old_text", hint: "re-read the note's current content and retry with an old_text that occurs exactly once" },
|
|
1172
1284
|
);
|
|
1173
1285
|
}
|
|
1174
1286
|
const second = note.content.indexOf(ce.old_text, idx + 1);
|
|
1175
1287
|
if (second >= 0) {
|
|
1176
|
-
throw
|
|
1288
|
+
throw structuredError(
|
|
1177
1289
|
`update-note content_edit: \`old_text\` matches multiple times in note "${note.id}" — must match exactly once. Add surrounding context to disambiguate.`,
|
|
1290
|
+
{ error_type: "content_edit_ambiguous", field: "content_edit.old_text", hint: "add surrounding context to old_text so it matches exactly once" },
|
|
1178
1291
|
);
|
|
1179
1292
|
}
|
|
1180
1293
|
contentOverride = note.content.slice(0, idx) + ce.new_text + note.content.slice(idx + ce.old_text.length);
|
|
@@ -1239,8 +1352,9 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1239
1352
|
const stItem = item.state_transition as { field?: unknown; from?: unknown; to?: unknown } | undefined;
|
|
1240
1353
|
if (stItem !== undefined) {
|
|
1241
1354
|
if (typeof stItem.field !== "string" || stItem.field.length === 0) {
|
|
1242
|
-
throw
|
|
1355
|
+
throw structuredError(
|
|
1243
1356
|
`update-note: \`state_transition.field\` must be a non-empty string (note "${note.id}").`,
|
|
1357
|
+
{ error_type: "invalid_state_transition", field: "state_transition.field", hint: "pass a non-empty string naming the metadata field to transition" },
|
|
1244
1358
|
);
|
|
1245
1359
|
}
|
|
1246
1360
|
updates.state_transition = { field: stItem.field, from: stItem.from, to: stItem.to };
|
|
@@ -1529,34 +1643,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1529
1643
|
|
|
1530
1644
|
// Validate cross-tag consistency on fields being (re)declared in this
|
|
1531
1645
|
// call. `type` and `indexed` are global — all declarers must agree.
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
if (!otherSpec) continue;
|
|
1540
|
-
if (otherSpec.type !== spec.type) {
|
|
1541
|
-
throw new Error(
|
|
1542
|
-
`field "${fieldName}" type conflict: tag "${tag}" declares "${spec.type}"; tag "${other.tag}" declares "${otherSpec.type}". Types must agree across all declarers.`,
|
|
1543
|
-
);
|
|
1544
|
-
}
|
|
1545
|
-
if ((otherSpec.indexed === true) !== incomingIndexed) {
|
|
1546
|
-
throw new Error(
|
|
1547
|
-
`field "${fieldName}" indexed-flag conflict: tag "${tag}" sets indexed=${incomingIndexed}; tag "${other.tag}" sets indexed=${otherSpec.indexed === true}. Must match across all declarers — change them atomically or not at all.`,
|
|
1548
|
-
);
|
|
1549
|
-
}
|
|
1550
|
-
}
|
|
1551
|
-
if (incomingIndexed) {
|
|
1552
|
-
const mapped = indexedFieldOps.mapFieldType(spec.type);
|
|
1553
|
-
if (!mapped) {
|
|
1554
|
-
throw new Error(
|
|
1555
|
-
`field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean)`,
|
|
1556
|
-
);
|
|
1557
|
-
}
|
|
1558
|
-
indexedFieldOps.validateFieldName(fieldName);
|
|
1559
|
-
}
|
|
1646
|
+
// vault#553/#554: collects EVERY violation instead of throwing on the
|
|
1647
|
+
// first — a caller declaring two bad fields sees both in one
|
|
1648
|
+
// response, and the thrown error states explicitly that no changes
|
|
1649
|
+
// were applied (nothing is persisted before this check runs).
|
|
1650
|
+
const fieldViolations = tagSchemaOps.collectTagFieldViolations(db, tag, incomingFields);
|
|
1651
|
+
if (fieldViolations.length > 0) {
|
|
1652
|
+
throw new tagSchemaOps.TagFieldConflictError(tag, fieldViolations);
|
|
1560
1653
|
}
|
|
1561
1654
|
|
|
1562
1655
|
// ---- relationships: replace wholesale when provided. `relationships`
|
|
@@ -1579,7 +1672,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1579
1672
|
parentNamesPatch = null;
|
|
1580
1673
|
} else if (params.parent_names !== undefined) {
|
|
1581
1674
|
if (!Array.isArray(params.parent_names)) {
|
|
1582
|
-
throw
|
|
1675
|
+
throw structuredError("parent_names must be an array of tag names", {
|
|
1676
|
+
error_type: "invalid_parent_names",
|
|
1677
|
+
field: "parent_names",
|
|
1678
|
+
hint: "pass an array of tag name strings, or null to clear",
|
|
1679
|
+
});
|
|
1583
1680
|
}
|
|
1584
1681
|
const cleaned = (params.parent_names as unknown[])
|
|
1585
1682
|
.filter((p): p is string => typeof p === "string" && p.length > 0);
|
|
@@ -1686,7 +1783,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1686
1783
|
execute: () => {
|
|
1687
1784
|
// This is a placeholder — vault-info needs access to vault config,
|
|
1688
1785
|
// which is only available in the server layer (mcp-tools.ts).
|
|
1689
|
-
return { error: "vault-info must be configured by the server layer" };
|
|
1786
|
+
return { error: "vault-info must be configured by the server layer", error_type: "not_configured" };
|
|
1690
1787
|
},
|
|
1691
1788
|
},
|
|
1692
1789
|
|
|
@@ -1918,6 +2015,9 @@ export class PreconditionRequiredError extends Error {
|
|
|
1918
2015
|
*/
|
|
1919
2016
|
export class BatchTooLargeError extends Error {
|
|
1920
2017
|
code = "BATCH_TOO_LARGE" as const;
|
|
2018
|
+
// Stable error_type (vault#554) — additive; matches the string REST has
|
|
2019
|
+
// hardcoded in its json response since #213.
|
|
2020
|
+
error_type = "batch_too_large" as const;
|
|
1921
2021
|
limit: number;
|
|
1922
2022
|
got: number;
|
|
1923
2023
|
|