@openparachute/vault 0.7.0-rc.3 → 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 +66 -42
- package/core/src/notes.ts +9 -0
- package/core/src/tag-schemas.ts +186 -4
- package/package.json +1 -1
- package/src/contract-errors.test.ts +178 -12
- package/src/mcp-http.ts +109 -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 +276 -78
- package/src/tag-field-conflict-scope.test.ts +333 -0
- package/src/tag-scope.ts +66 -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
|
@@ -10,7 +10,6 @@ import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMo
|
|
|
10
10
|
import * as linkOps from "./links.js";
|
|
11
11
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
12
12
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
13
|
-
import * as indexedFieldOps from "./indexed-fields.js";
|
|
14
13
|
import {
|
|
15
14
|
SchemaValidationError,
|
|
16
15
|
strictViolations,
|
|
@@ -53,6 +52,22 @@ export interface McpToolDef {
|
|
|
53
52
|
// Helpers
|
|
54
53
|
// ---------------------------------------------------------------------------
|
|
55
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
|
+
|
|
56
71
|
/**
|
|
57
72
|
* Resolve a note identifier — tries ID first, then case-insensitive
|
|
58
73
|
* path match. Works everywhere a note reference is accepted.
|
|
@@ -86,7 +101,9 @@ function resolveNote(db: Database, idOrPath: string): Note | null {
|
|
|
86
101
|
|
|
87
102
|
function requireNote(db: Database, idOrPath: string): Note {
|
|
88
103
|
const note = resolveNote(db, idOrPath);
|
|
89
|
-
if (!note)
|
|
104
|
+
if (!note) {
|
|
105
|
+
throw structuredError(`Note not found: "${idOrPath}"`, { error_type: "not_found", field: "id" });
|
|
106
|
+
}
|
|
90
107
|
return note;
|
|
91
108
|
}
|
|
92
109
|
|
|
@@ -423,7 +440,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
423
440
|
// --- Single note by ID/path ---
|
|
424
441
|
if (params.id) {
|
|
425
442
|
const note = resolveNote(db, params.id as string);
|
|
426
|
-
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 };
|
|
427
444
|
const includeContent = params.include_content !== false; // default true for single
|
|
428
445
|
// Range params are meaningless on a content-less shape — error
|
|
429
446
|
// rather than silently ignore (same loud-validation policy as
|
|
@@ -471,7 +488,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
471
488
|
if (params.near) {
|
|
472
489
|
const near = params.near as { note_id: string; depth?: number; relationship?: string };
|
|
473
490
|
const anchor = resolveNote(db, near.note_id);
|
|
474
|
-
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 };
|
|
475
492
|
const depth = Math.min(near.depth ?? 2, 5);
|
|
476
493
|
const traversed = linkOps.traverseLinks(db, anchor.id, {
|
|
477
494
|
max_depth: depth,
|
|
@@ -914,7 +931,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
914
931
|
- \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
|
|
915
932
|
- When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
|
|
916
933
|
- For batch: pass a \`notes\` array, each with an \`id\` field.
|
|
917
|
-
- **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.
|
|
918
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.
|
|
919
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.
|
|
920
937
|
|
|
@@ -1043,7 +1060,23 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1043
1060
|
},
|
|
1044
1061
|
execute: async (params) => {
|
|
1045
1062
|
const batch = params.notes as any[] | undefined;
|
|
1046
|
-
|
|
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];
|
|
1047
1080
|
|
|
1048
1081
|
if (items.length > MAX_BATCH_SIZE) {
|
|
1049
1082
|
throw new BatchTooLargeError(items.length);
|
|
@@ -1175,7 +1208,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1175
1208
|
// Fallthrough: not-found + no if_missing → existing error
|
|
1176
1209
|
// contract. Match `requireNote`'s message shape so existing
|
|
1177
1210
|
// callers see no behavior change.
|
|
1178
|
-
throw
|
|
1211
|
+
throw structuredError(`Note not found: "${item.id}"`, { error_type: "not_found", field: "id" });
|
|
1179
1212
|
}
|
|
1180
1213
|
const note = resolved;
|
|
1181
1214
|
|
|
@@ -1185,8 +1218,9 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1185
1218
|
const hasContentEdit = item.content_edit !== undefined;
|
|
1186
1219
|
const contentModes = (hasContent ? 1 : 0) + (hasAppendPrepend ? 1 : 0) + (hasContentEdit ? 1 : 0);
|
|
1187
1220
|
if (contentModes > 1) {
|
|
1188
|
-
throw
|
|
1221
|
+
throw structuredError(
|
|
1189
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`" },
|
|
1190
1224
|
);
|
|
1191
1225
|
}
|
|
1192
1226
|
|
|
@@ -1237,20 +1271,23 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1237
1271
|
if (hasContentEdit) {
|
|
1238
1272
|
const ce = item.content_edit as { old_text: string; new_text: string };
|
|
1239
1273
|
if (typeof ce?.old_text !== "string" || typeof ce?.new_text !== "string") {
|
|
1240
|
-
throw
|
|
1274
|
+
throw structuredError(
|
|
1241
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 }" },
|
|
1242
1277
|
);
|
|
1243
1278
|
}
|
|
1244
1279
|
const idx = note.content.indexOf(ce.old_text);
|
|
1245
1280
|
if (idx < 0) {
|
|
1246
|
-
throw
|
|
1281
|
+
throw structuredError(
|
|
1247
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" },
|
|
1248
1284
|
);
|
|
1249
1285
|
}
|
|
1250
1286
|
const second = note.content.indexOf(ce.old_text, idx + 1);
|
|
1251
1287
|
if (second >= 0) {
|
|
1252
|
-
throw
|
|
1288
|
+
throw structuredError(
|
|
1253
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" },
|
|
1254
1291
|
);
|
|
1255
1292
|
}
|
|
1256
1293
|
contentOverride = note.content.slice(0, idx) + ce.new_text + note.content.slice(idx + ce.old_text.length);
|
|
@@ -1315,8 +1352,9 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1315
1352
|
const stItem = item.state_transition as { field?: unknown; from?: unknown; to?: unknown } | undefined;
|
|
1316
1353
|
if (stItem !== undefined) {
|
|
1317
1354
|
if (typeof stItem.field !== "string" || stItem.field.length === 0) {
|
|
1318
|
-
throw
|
|
1355
|
+
throw structuredError(
|
|
1319
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" },
|
|
1320
1358
|
);
|
|
1321
1359
|
}
|
|
1322
1360
|
updates.state_transition = { field: stItem.field, from: stItem.from, to: stItem.to };
|
|
@@ -1605,34 +1643,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1605
1643
|
|
|
1606
1644
|
// Validate cross-tag consistency on fields being (re)declared in this
|
|
1607
1645
|
// call. `type` and `indexed` are global — all declarers must agree.
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
if (!otherSpec) continue;
|
|
1616
|
-
if (otherSpec.type !== spec.type) {
|
|
1617
|
-
throw new Error(
|
|
1618
|
-
`field "${fieldName}" type conflict: tag "${tag}" declares "${spec.type}"; tag "${other.tag}" declares "${otherSpec.type}". Types must agree across all declarers.`,
|
|
1619
|
-
);
|
|
1620
|
-
}
|
|
1621
|
-
if ((otherSpec.indexed === true) !== incomingIndexed) {
|
|
1622
|
-
throw new Error(
|
|
1623
|
-
`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.`,
|
|
1624
|
-
);
|
|
1625
|
-
}
|
|
1626
|
-
}
|
|
1627
|
-
if (incomingIndexed) {
|
|
1628
|
-
const mapped = indexedFieldOps.mapFieldType(spec.type);
|
|
1629
|
-
if (!mapped) {
|
|
1630
|
-
throw new Error(
|
|
1631
|
-
`field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean)`,
|
|
1632
|
-
);
|
|
1633
|
-
}
|
|
1634
|
-
indexedFieldOps.validateFieldName(fieldName);
|
|
1635
|
-
}
|
|
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);
|
|
1636
1653
|
}
|
|
1637
1654
|
|
|
1638
1655
|
// ---- relationships: replace wholesale when provided. `relationships`
|
|
@@ -1655,7 +1672,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1655
1672
|
parentNamesPatch = null;
|
|
1656
1673
|
} else if (params.parent_names !== undefined) {
|
|
1657
1674
|
if (!Array.isArray(params.parent_names)) {
|
|
1658
|
-
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
|
+
});
|
|
1659
1680
|
}
|
|
1660
1681
|
const cleaned = (params.parent_names as unknown[])
|
|
1661
1682
|
.filter((p): p is string => typeof p === "string" && p.length > 0);
|
|
@@ -1762,7 +1783,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1762
1783
|
execute: () => {
|
|
1763
1784
|
// This is a placeholder — vault-info needs access to vault config,
|
|
1764
1785
|
// which is only available in the server layer (mcp-tools.ts).
|
|
1765
|
-
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" };
|
|
1766
1787
|
},
|
|
1767
1788
|
},
|
|
1768
1789
|
|
|
@@ -1994,6 +2015,9 @@ export class PreconditionRequiredError extends Error {
|
|
|
1994
2015
|
*/
|
|
1995
2016
|
export class BatchTooLargeError extends Error {
|
|
1996
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;
|
|
1997
2021
|
limit: number;
|
|
1998
2022
|
got: number;
|
|
1999
2023
|
|
package/core/src/notes.ts
CHANGED
|
@@ -284,6 +284,11 @@ export class TransitionConflictError extends Error {
|
|
|
284
284
|
*/
|
|
285
285
|
export class PathConflictError extends Error {
|
|
286
286
|
code = "PATH_CONFLICT" as const;
|
|
287
|
+
// Stable error_type (vault#554) — additive; mirrors the `error_type` REST
|
|
288
|
+
// has hardcoded in its json response since #126. Lets the generic MCP
|
|
289
|
+
// domain-error mapping (src/mcp-http.ts) pick this class up without a
|
|
290
|
+
// bespoke branch, the same way REST's catch already does explicitly.
|
|
291
|
+
error_type = "path_conflict" as const;
|
|
287
292
|
path: string;
|
|
288
293
|
|
|
289
294
|
constructor(path: string) {
|
|
@@ -306,6 +311,8 @@ export class PathConflictError extends Error {
|
|
|
306
311
|
*/
|
|
307
312
|
export class AmbiguousPathError extends Error {
|
|
308
313
|
code = "AMBIGUOUS_PATH" as const;
|
|
314
|
+
// Stable error_type (vault#554) — see PathConflictError's comment.
|
|
315
|
+
error_type = "ambiguous_path" as const;
|
|
309
316
|
path: string;
|
|
310
317
|
candidates: { id: string; extension: string }[];
|
|
311
318
|
|
|
@@ -346,6 +353,8 @@ export const EXTENSION_PATTERN = /^[a-z0-9]{1,16}$/;
|
|
|
346
353
|
|
|
347
354
|
export class ExtensionValidationError extends Error {
|
|
348
355
|
code = "INVALID_EXTENSION" as const;
|
|
356
|
+
// Stable error_type (vault#554) — see PathConflictError's comment.
|
|
357
|
+
error_type = "invalid_extension" as const;
|
|
349
358
|
extension: string;
|
|
350
359
|
reason: string;
|
|
351
360
|
|