@openparachute/vault 0.7.0-rc.5 → 0.7.0-rc.7
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/attribution.test.ts +11 -3
- package/core/src/contract-typed-index.test.ts +228 -11
- package/core/src/core.test.ts +38 -8
- package/core/src/doctor.ts +43 -13
- package/core/src/mcp.ts +66 -26
- package/core/src/notes.ts +85 -20
- package/core/src/query-warnings.ts +110 -0
- package/core/src/schema-defaults.ts +48 -7
- package/core/src/schema.ts +314 -10
- package/core/src/search-fts-v25.test.ts +362 -0
- package/core/src/search-query.ts +0 -0
- package/core/src/seed-packs.ts +20 -13
- package/core/src/store.ts +18 -0
- package/core/src/tag-schemas.ts +121 -6
- package/core/src/types.ts +20 -0
- package/package.json +1 -1
- package/src/contract-search.test.ts +168 -0
- package/src/mcp-query-notes-search-scope.test.ts +53 -0
- package/src/onboarding-seed.test.ts +5 -2
- package/src/routes.ts +42 -53
- package/src/vault.test.ts +33 -27
|
@@ -42,8 +42,12 @@ function rawRow(id: string) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
describe("write-attribution — schema", () => {
|
|
45
|
-
it("bumped SCHEMA_VERSION to 23", () => {
|
|
46
|
-
|
|
45
|
+
it("bumped SCHEMA_VERSION to at least 23 (write-attribution columns landed)", () => {
|
|
46
|
+
// Was a literal `toBe(23)` pin — that goes stale every time a later PR
|
|
47
|
+
// bumps SCHEMA_VERSION further (vault#553 bumped it to 24). This test's
|
|
48
|
+
// actual claim is "the write-attribution migration landed at/after v23",
|
|
49
|
+
// not "v23 is still current."
|
|
50
|
+
expect(SCHEMA_VERSION).toBeGreaterThanOrEqual(23);
|
|
47
51
|
});
|
|
48
52
|
|
|
49
53
|
it("the four columns exist and are indexed", () => {
|
|
@@ -230,7 +234,11 @@ describe("write-attribution — legacy backfill (v23 migration)", () => {
|
|
|
230
234
|
const ver = (legacy.prepare("SELECT MAX(version) AS v FROM schema_version").get() as {
|
|
231
235
|
v: number;
|
|
232
236
|
}).v;
|
|
233
|
-
|
|
237
|
+
// A v22 vault runs the WHOLE migration chain (v23 write-attribution +
|
|
238
|
+
// whatever landed after — v24's typed-index poison scan, vault#553),
|
|
239
|
+
// ending at the current SCHEMA_VERSION rather than a hardcoded literal
|
|
240
|
+
// that goes stale on every later bump.
|
|
241
|
+
expect(ver).toBe(SCHEMA_VERSION);
|
|
234
242
|
|
|
235
243
|
// Legacy row: attribution columns exist now but are NULL (not fabricated).
|
|
236
244
|
const old = legacy
|
|
@@ -20,9 +20,11 @@
|
|
|
20
20
|
import { describe, it, test, expect, beforeEach } from "bun:test";
|
|
21
21
|
import { Database } from "bun:sqlite";
|
|
22
22
|
import { SqliteStore } from "./store.js";
|
|
23
|
-
import { generateMcpTools } from "./mcp.js";
|
|
23
|
+
import { generateMcpTools, SchemaValidationError } from "./mcp.js";
|
|
24
24
|
import { TransitionConflictError } from "./notes.js";
|
|
25
|
+
import * as noteOps from "./notes.js";
|
|
25
26
|
import { TagFieldConflictError } from "./tag-schemas.js";
|
|
27
|
+
import { initSchema, SCHEMA_VERSION } from "./schema.js";
|
|
26
28
|
|
|
27
29
|
let store: SqliteStore;
|
|
28
30
|
let db: Database;
|
|
@@ -91,16 +93,231 @@ describe("contract: typed indexes — passing (lock in current behavior)", () =>
|
|
|
91
93
|
});
|
|
92
94
|
});
|
|
93
95
|
|
|
94
|
-
describe("contract: typed indexes —
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
96
|
+
describe("contract: typed indexes — Decision A: indexed ⇒ strict writes (#553, flipped from todo)", () => {
|
|
97
|
+
it("a write of a TEXT value to an indexed integer field is REJECTED, not just warned", async () => {
|
|
98
|
+
await store.upsertTagRecord("metric", { fields: { n: { type: "integer", indexed: true } } });
|
|
99
|
+
const create = generateMcpTools(store).find((t) => t.name === "create-note")!;
|
|
100
|
+
|
|
101
|
+
let err: any;
|
|
102
|
+
try {
|
|
103
|
+
await create.execute({ content: "x", tags: ["metric"], metadata: { n: "four" } });
|
|
104
|
+
} catch (e) {
|
|
105
|
+
err = e;
|
|
106
|
+
}
|
|
107
|
+
expect(err).toBeInstanceOf(SchemaValidationError);
|
|
108
|
+
expect(err.violations).toHaveLength(1);
|
|
109
|
+
expect(err.violations[0].field).toBe("n");
|
|
110
|
+
expect(err.violations[0].reason).toBe("type_mismatch");
|
|
111
|
+
expect(err.violations[0].strict).toBe(true);
|
|
112
|
+
// Message names field + expected type + got type.
|
|
113
|
+
expect(err.violations[0].message).toContain("n");
|
|
114
|
+
expect(err.violations[0].message).toContain("integer");
|
|
115
|
+
expect(err.violations[0].message).toContain("string");
|
|
116
|
+
|
|
117
|
+
// Nothing was written — the poisoned row never lands, so it can never
|
|
118
|
+
// sort above a real integer under {gt: 100}-style range queries.
|
|
119
|
+
const all = await store.queryNotes({ tags: ["metric"] });
|
|
120
|
+
expect(all).toHaveLength(0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("rejects the same indexed-type violation on update-note", async () => {
|
|
124
|
+
await store.upsertTagRecord("metric", { fields: { n: { type: "integer", indexed: true } } });
|
|
125
|
+
const create = generateMcpTools(store).find((t) => t.name === "create-note")!;
|
|
126
|
+
const update = generateMcpTools(store).find((t) => t.name === "update-note")!;
|
|
127
|
+
const note = (await create.execute({ content: "x", tags: ["metric"], metadata: { n: 5 } })) as any;
|
|
128
|
+
|
|
129
|
+
await expect(
|
|
130
|
+
update.execute({ id: note.id, metadata: { n: "four" }, if_updated_at: note.updatedAt }),
|
|
131
|
+
).rejects.toThrow(SchemaValidationError);
|
|
132
|
+
|
|
133
|
+
// Untouched — still the original, well-typed value.
|
|
134
|
+
const fresh = await store.getNote(note.id);
|
|
135
|
+
expect((fresh!.metadata as any).n).toBe(5);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("a range query never matches a TEXT value on an indexed integer field (the poisoning is now unreachable)", async () => {
|
|
139
|
+
await store.upsertTagRecord("metric", { fields: { n: { type: "integer", indexed: true } } });
|
|
140
|
+
const create = generateMcpTools(store).find((t) => t.name === "create-note")!;
|
|
141
|
+
await create.execute({ content: "n=5", tags: ["metric"], metadata: { n: 5 } });
|
|
142
|
+
await expect(
|
|
143
|
+
create.execute({ content: "poison", tags: ["metric"], metadata: { n: "four" } }),
|
|
144
|
+
).rejects.toThrow(SchemaValidationError);
|
|
145
|
+
|
|
146
|
+
const gt100 = await store.queryNotes({ tags: ["metric"], metadata: { n: { gt: 100 } } });
|
|
147
|
+
expect(gt100).toHaveLength(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("a type_mismatch on a NON-indexed field stays advisory (unchanged behavior — the escalation is scoped to indexed:true)", async () => {
|
|
151
|
+
await store.upsertTagRecord("metric", { fields: { n: { type: "integer" } } });
|
|
152
|
+
const create = generateMcpTools(store).find((t) => t.name === "create-note")!;
|
|
153
|
+
const note = (await create.execute({ content: "x", tags: ["metric"], metadata: { n: "four" } })) as any;
|
|
154
|
+
// Write succeeded — advisory only.
|
|
155
|
+
expect(note.metadata.n).toBe("four");
|
|
156
|
+
expect(note.validation_status.warnings[0].reason).toBe("type_mismatch");
|
|
157
|
+
expect(note.validation_status.warnings[0].strict).toBeUndefined();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("contract: typed indexes — Decision B: explicit-default-only enum backfill (#553, flipped from todo)", () => {
|
|
162
|
+
it("an unset enum field stays ABSENT — no first-value backfill — so exists:false correctly matches", async () => {
|
|
163
|
+
await store.upsertTagRecord("task", {
|
|
164
|
+
fields: { status: { type: "string", enum: ["queued", "done"], indexed: true } }, // no `default`
|
|
165
|
+
});
|
|
166
|
+
const create = generateMcpTools(store).find((t) => t.name === "create-note")!;
|
|
167
|
+
const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
|
|
168
|
+
|
|
169
|
+
const note = (await create.execute({ content: "x", tags: ["task"] })) as any;
|
|
170
|
+
expect(note.metadata?.status).toBeUndefined();
|
|
171
|
+
const onDisk = await store.getNote(note.id);
|
|
172
|
+
expect((onDisk!.metadata as any)?.status).toBeUndefined();
|
|
173
|
+
|
|
174
|
+
const unset = (await query.execute({
|
|
175
|
+
tag: "task",
|
|
176
|
+
metadata: { status: { exists: false } },
|
|
177
|
+
})) as any[];
|
|
178
|
+
expect(unset.map((n) => n.id)).toContain(note.id);
|
|
179
|
+
|
|
180
|
+
const set = (await query.execute({
|
|
181
|
+
tag: "task",
|
|
182
|
+
metadata: { status: { exists: true } },
|
|
183
|
+
})) as any[];
|
|
184
|
+
expect(set.map((n) => n.id)).not.toContain(note.id);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("an EXPLICIT `default:` backfills as before — exists:true matches it", async () => {
|
|
188
|
+
await store.upsertTagRecord("task", {
|
|
189
|
+
fields: { status: { type: "string", enum: ["queued", "done"], default: "queued", indexed: true } },
|
|
190
|
+
});
|
|
191
|
+
const create = generateMcpTools(store).find((t) => t.name === "create-note")!;
|
|
192
|
+
const query = generateMcpTools(store).find((t) => t.name === "query-notes")!;
|
|
193
|
+
|
|
194
|
+
const note = (await create.execute({ content: "x", tags: ["task"] })) as any;
|
|
195
|
+
expect(note.metadata.status).toBe("queued");
|
|
196
|
+
|
|
197
|
+
const set = (await query.execute({
|
|
198
|
+
tag: "task",
|
|
199
|
+
metadata: { status: { exists: true } },
|
|
200
|
+
})) as any[];
|
|
201
|
+
expect(set.map((n) => n.id)).toContain(note.id);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("a non-conforming `default` is rejected as a tag-schema error, not silently stored", async () => {
|
|
205
|
+
const updateTag = generateMcpTools(store).find((t) => t.name === "update-tag")!;
|
|
206
|
+
let err: any;
|
|
207
|
+
try {
|
|
208
|
+
await updateTag.execute({
|
|
209
|
+
tag: "task",
|
|
210
|
+
fields: { status: { type: "string", enum: ["queued", "done"], default: "bogus" } },
|
|
211
|
+
});
|
|
212
|
+
} catch (e) {
|
|
213
|
+
err = e;
|
|
214
|
+
}
|
|
215
|
+
expect(err).toBeInstanceOf(TagFieldConflictError);
|
|
216
|
+
expect(err.violations.some((v: any) => v.reason === "invalid_default" && v.field === "status")).toBe(true);
|
|
217
|
+
// Nothing persisted.
|
|
218
|
+
expect((await store.getTagRecord("task"))?.fields ?? null).toBeFalsy();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("contract: typed indexes — Decision C: honest type list (#553, flipped from todo)", () => {
|
|
223
|
+
it("the update-tag field-type description clarifies only string/integer/boolean are indexable", () => {
|
|
224
|
+
const updateTag = generateMcpTools(store).find((t) => t.name === "update-tag")!;
|
|
225
|
+
const typeDesc = (updateTag.inputSchema as any).properties.fields.additionalProperties.properties.type.description as string;
|
|
226
|
+
// Honest about the full storage/advisory vocabulary AND the indexable subset.
|
|
227
|
+
expect(typeDesc).toContain("number");
|
|
228
|
+
expect(typeDesc).toContain("Only string/integer/boolean are INDEXABLE");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("declaring indexed:true with an unindexable type (number) is rejected", async () => {
|
|
232
|
+
const updateTag = generateMcpTools(store).find((t) => t.name === "update-tag")!;
|
|
233
|
+
let err: any;
|
|
234
|
+
try {
|
|
235
|
+
await updateTag.execute({ tag: "metric", fields: { score: { type: "number", indexed: true } } });
|
|
236
|
+
} catch (e) {
|
|
237
|
+
err = e;
|
|
238
|
+
}
|
|
239
|
+
expect(err).toBeInstanceOf(TagFieldConflictError);
|
|
240
|
+
expect(err.violations[0].reason).toBe("unsupported_indexed_type");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("declaring a number field WITHOUT indexed:true is accepted (storage/advisory only)", async () => {
|
|
244
|
+
const updateTag = generateMcpTools(store).find((t) => t.name === "update-tag")!;
|
|
245
|
+
const result = await updateTag.execute({ tag: "metric", fields: { score: { type: "number" } } }) as any;
|
|
246
|
+
expect(result.fields.score.type).toBe("number");
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("contract: typed indexes — Decision D: migrateToV24 poison coercion (#553, new)", () => {
|
|
251
|
+
it("coerces a lossless numeric string, leaves a genuinely non-coercible string, and is idempotent on re-run", async () => {
|
|
252
|
+
// Declare the field indexed AFTER notes already exist (real-data-like:
|
|
253
|
+
// a field indexed retroactively on a vault with pre-existing rows).
|
|
254
|
+
const clean = noteOps.createNote(db, "clean", { metadata: { n: 42 } });
|
|
255
|
+
const coercible = noteOps.createNote(db, "coercible", { metadata: { n: "5" } }); // clean numeric string
|
|
256
|
+
const nonCoercible = noteOps.createNote(db, "non-coercible", { metadata: { n: "hello" } });
|
|
257
|
+
const boolish = noteOps.createNote(db, "boolish", { metadata: { flag: "true" } });
|
|
258
|
+
|
|
259
|
+
await store.upsertTagRecord("metric", {
|
|
260
|
+
fields: {
|
|
261
|
+
n: { type: "integer", indexed: true },
|
|
262
|
+
flag: { type: "boolean", indexed: true },
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
noteOps.tagNote(db, clean.id, ["metric"]);
|
|
266
|
+
noteOps.tagNote(db, coercible.id, ["metric"]);
|
|
267
|
+
noteOps.tagNote(db, nonCoercible.id, ["metric"]);
|
|
268
|
+
noteOps.tagNote(db, boolish.id, ["metric"]);
|
|
269
|
+
|
|
270
|
+
// Re-running initSchema (idempotent by construction for every migration
|
|
271
|
+
// step) exercises migrateToV24's coercion pass over the poison just
|
|
272
|
+
// planted via the raw noteOps layer (bypassing the strict write gate —
|
|
273
|
+
// simulating data that predates it, or an operator-side bulk import).
|
|
274
|
+
initSchema(db);
|
|
275
|
+
|
|
276
|
+
const after = (id: string) => (noteOps.getNote(db, id)!.metadata as any);
|
|
277
|
+
expect(after(clean.id).n).toBe(42); // untouched — was already clean
|
|
278
|
+
expect(after(coercible.id).n).toBe(5); // coerced: "5" (string) → 5 (number)
|
|
279
|
+
expect(after(nonCoercible.id).n).toBe("hello"); // LEFT IN PLACE — never deleted or nulled
|
|
280
|
+
expect(after(boolish.id).flag).toBe(true); // coerced: "true" (string) → true (boolean)
|
|
281
|
+
|
|
282
|
+
// Doctor still surfaces the genuinely non-coercible one for operator cleanup.
|
|
283
|
+
const report = await store.doctor();
|
|
284
|
+
const finding = report.findings.find(
|
|
285
|
+
(f) => f.type === "mixed_type_indexed_field" && f.subject === "n",
|
|
286
|
+
);
|
|
287
|
+
expect(finding).toBeDefined();
|
|
288
|
+
expect(finding!.detail).toContain(nonCoercible.id);
|
|
289
|
+
|
|
290
|
+
// Idempotency: a second re-run coerces nothing further and doesn't
|
|
291
|
+
// touch the already-clean/already-coerced/already-left values.
|
|
292
|
+
initSchema(db);
|
|
293
|
+
expect(after(clean.id).n).toBe(42);
|
|
294
|
+
expect(after(coercible.id).n).toBe(5);
|
|
295
|
+
expect(after(nonCoercible.id).n).toBe("hello");
|
|
296
|
+
expect(after(boolish.id).flag).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("coerces a number into a TEXT-indexed field losslessly", async () => {
|
|
300
|
+
const note = noteOps.createNote(db, "x", { metadata: { code: 12345 } });
|
|
301
|
+
await store.upsertTagRecord("item", { fields: { code: { type: "string", indexed: true } } });
|
|
302
|
+
noteOps.tagNote(db, note.id, ["item"]);
|
|
303
|
+
|
|
304
|
+
initSchema(db);
|
|
305
|
+
expect((noteOps.getNote(db, note.id)!.metadata as any).code).toBe("12345");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("leaves array/object values in an indexed field untouched (never coercible, never deleted)", async () => {
|
|
309
|
+
const note = noteOps.createNote(db, "x", { metadata: { n: [1, 2, 3] } });
|
|
310
|
+
await store.upsertTagRecord("metric", { fields: { n: { type: "integer", indexed: true } } });
|
|
311
|
+
noteOps.tagNote(db, note.id, ["metric"]);
|
|
312
|
+
|
|
313
|
+
initSchema(db);
|
|
314
|
+
expect((noteOps.getNote(db, note.id)!.metadata as any).n).toEqual([1, 2, 3]);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("is a no-op on a vault with zero indexed fields (schema_version still advances)", async () => {
|
|
318
|
+
const row = db.prepare("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1").get() as { version: number };
|
|
319
|
+
expect(row.version).toBe(SCHEMA_VERSION);
|
|
320
|
+
});
|
|
104
321
|
});
|
|
105
322
|
|
|
106
323
|
describe("contract: update-tag messaging — #553/#554 (flipped from todo)", () => {
|
package/core/src/core.test.ts
CHANGED
|
@@ -2081,8 +2081,11 @@ describe("MCP tools", async () => {
|
|
|
2081
2081
|
// schema-default-filled field was missing from the returned note even
|
|
2082
2082
|
// though it had just been written to disk.
|
|
2083
2083
|
it("create-note response reflects post-applySchemaDefaults state (vault#316)", async () => {
|
|
2084
|
+
// vault#553 Decision B: backfill is explicit-`default`-only now — declare
|
|
2085
|
+
// one so this test still exercises the post-defaults re-read mechanism
|
|
2086
|
+
// (the thing vault#316 is actually about).
|
|
2084
2087
|
await store.upsertTagSchema("task", {
|
|
2085
|
-
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
2088
|
+
fields: { priority: { type: "string", enum: ["high", "low"], default: "high" } },
|
|
2086
2089
|
});
|
|
2087
2090
|
const tools = generateMcpTools(store);
|
|
2088
2091
|
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
@@ -2093,7 +2096,7 @@ describe("MCP tools", async () => {
|
|
|
2093
2096
|
path: "Inbox/task-1",
|
|
2094
2097
|
tags: ["task"],
|
|
2095
2098
|
}) as any;
|
|
2096
|
-
expect(single.metadata?.priority).toBe("high"); //
|
|
2099
|
+
expect(single.metadata?.priority).toBe("high"); // explicit schema default
|
|
2097
2100
|
// Disk and response agree.
|
|
2098
2101
|
const onDisk = await store.getNoteByPath("Inbox/task-1");
|
|
2099
2102
|
expect((onDisk!.metadata as any)?.priority).toBe("high");
|
|
@@ -3456,7 +3459,7 @@ describe("MCP tools", async () => {
|
|
|
3456
3459
|
expect(links.some((l) => l.relationship === "wikilink")).toBe(true);
|
|
3457
3460
|
});
|
|
3458
3461
|
|
|
3459
|
-
it("create-note with schema tag
|
|
3462
|
+
it("create-note with schema tag leaves undeclared fields ABSENT (vault#553 Decision B — no implicit enum[0]/zero-value backfill)", async () => {
|
|
3460
3463
|
await store.upsertTagSchema("person", {
|
|
3461
3464
|
description: "A person",
|
|
3462
3465
|
fields: {
|
|
@@ -3472,10 +3475,36 @@ describe("MCP tools", async () => {
|
|
|
3472
3475
|
|
|
3473
3476
|
const result = await createNote.execute({ content: "Alice", tags: ["person"] }) as any;
|
|
3474
3477
|
const fresh = await query.execute({ id: result.id }) as any;
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
+
// None of these fields declares a `default` — the pre-0.7.0 behavior
|
|
3479
|
+
// (enum[0] / type zero-value backfill) is retired. "Never set" now stays
|
|
3480
|
+
// genuinely absent instead of masquerading as "explicitly set to X" — in
|
|
3481
|
+
// fact NO field got backfilled, so `metadata` itself stays empty/absent.
|
|
3482
|
+
expect(fresh.metadata?.first_appeared).toBeUndefined();
|
|
3483
|
+
expect(fresh.metadata?.active).toBeUndefined();
|
|
3484
|
+
expect(fresh.metadata?.priority).toBeUndefined();
|
|
3485
|
+
expect(fresh.metadata?.status).toBeUndefined();
|
|
3486
|
+
});
|
|
3487
|
+
|
|
3488
|
+
it("create-note with schema tag applies EXPLICIT `default:` values only (vault#553 Decision B)", async () => {
|
|
3489
|
+
await store.upsertTagSchema("employee", {
|
|
3490
|
+
description: "An employee",
|
|
3491
|
+
fields: {
|
|
3492
|
+
active: { type: "boolean", default: true },
|
|
3493
|
+
priority: { type: "integer", default: 3 },
|
|
3494
|
+
status: { type: "string", enum: ["active", "archived"], default: "active" },
|
|
3495
|
+
nickname: { type: "string" }, // no default — stays absent
|
|
3496
|
+
},
|
|
3497
|
+
});
|
|
3498
|
+
const tools = generateMcpTools(store);
|
|
3499
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
3500
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3501
|
+
|
|
3502
|
+
const result = await createNote.execute({ content: "Bob", tags: ["employee"] }) as any;
|
|
3503
|
+
const fresh = await query.execute({ id: result.id }) as any;
|
|
3504
|
+
expect(fresh.metadata.active).toBe(true);
|
|
3505
|
+
expect(fresh.metadata.priority).toBe(3);
|
|
3478
3506
|
expect(fresh.metadata.status).toBe("active");
|
|
3507
|
+
expect(fresh.metadata.nickname).toBeUndefined();
|
|
3479
3508
|
});
|
|
3480
3509
|
|
|
3481
3510
|
// ---- query-notes input-shape tolerance (vault#214) ----
|
|
@@ -4616,8 +4645,9 @@ describe("update-note if_missing=create (vault#309)", async () => {
|
|
|
4616
4645
|
});
|
|
4617
4646
|
|
|
4618
4647
|
it("create branch applies tag-schema defaults when the new tag declares fields", async () => {
|
|
4648
|
+
// vault#553 Decision B: backfill is explicit-`default`-only.
|
|
4619
4649
|
await store.upsertTagSchema("task", {
|
|
4620
|
-
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
4650
|
+
fields: { priority: { type: "string", enum: ["high", "low"], default: "high" } },
|
|
4621
4651
|
});
|
|
4622
4652
|
const tools = generateMcpTools(store);
|
|
4623
4653
|
const update = tools.find((t) => t.name === "update-note")!;
|
|
@@ -4629,7 +4659,7 @@ describe("update-note if_missing=create (vault#309)", async () => {
|
|
|
4629
4659
|
}) as any;
|
|
4630
4660
|
expect(result.created).toBe(true);
|
|
4631
4661
|
// Schema defaults populated metadata.priority on insert.
|
|
4632
|
-
expect(result.metadata?.priority).
|
|
4662
|
+
expect(result.metadata?.priority).toBe("high");
|
|
4633
4663
|
});
|
|
4634
4664
|
|
|
4635
4665
|
it("create branch surfaces validation warnings just like create-note", async () => {
|
package/core/src/doctor.ts
CHANGED
|
@@ -196,28 +196,58 @@ const EXPECTED_JSON_TYPES: Record<string, Set<string>> = {
|
|
|
196
196
|
TEXT: new Set(["text"]),
|
|
197
197
|
};
|
|
198
198
|
|
|
199
|
+
/** One vault-wide-scan result row from {@link findMixedTypeIndexedFieldNotes}. */
|
|
200
|
+
export interface MixedTypeIndexedFieldRow {
|
|
201
|
+
id: string;
|
|
202
|
+
/** SQLite `json_type()` of the CURRENT `metadata.<field>` value — "text", "integer", "real", "true", "false", "array", "object" (never "null": absent/null keys are excluded upstream). */
|
|
203
|
+
jt: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Vault-wide detector: every note whose `metadata.<field>` JSON type
|
|
208
|
+
* disagrees with `sqliteType` (the field's declared indexed storage class —
|
|
209
|
+
* "INTEGER" or "TEXT"). Returns `[]` for an unknown `sqliteType` or when
|
|
210
|
+
* nothing mismatches. UNSCOPED — no tag-scope filtering (that's the caller's
|
|
211
|
+
* job; see `scanMixedTypeIndexedFields` below for the tag-scoped doctor
|
|
212
|
+
* finding, and `migrateToV24` in `core/src/schema.ts` for the unscoped
|
|
213
|
+
* migration consumer). `json_type(metadata, ?)` returns NULL when the key is
|
|
214
|
+
* absent — those notes simply don't declare the field and aren't a mismatch.
|
|
215
|
+
*
|
|
216
|
+
* Extracted (vault#553 Decision D) so the SAME detection query backs both
|
|
217
|
+
* the `mixed_type_indexed_field` doctor finding AND the `migrateToV24`
|
|
218
|
+
* startup migration's poison scan — one source of truth for "what counts as
|
|
219
|
+
* mismatched," so a fix to one can't silently diverge from the other.
|
|
220
|
+
*/
|
|
221
|
+
export function findMixedTypeIndexedFieldNotes(
|
|
222
|
+
db: Database,
|
|
223
|
+
field: string,
|
|
224
|
+
sqliteType: string,
|
|
225
|
+
): MixedTypeIndexedFieldRow[] {
|
|
226
|
+
const expected = EXPECTED_JSON_TYPES[sqliteType];
|
|
227
|
+
if (!expected) return []; // unknown sqlite_type — nothing to compare against
|
|
228
|
+
const path = `$."${field}"`;
|
|
229
|
+
const rows = db
|
|
230
|
+
.prepare(
|
|
231
|
+
`SELECT id, json_type(metadata, ?) as jt FROM notes WHERE metadata IS NOT NULL AND metadata != '' AND json_valid(metadata)`,
|
|
232
|
+
)
|
|
233
|
+
.all(path) as { id: string; jt: string | null }[];
|
|
234
|
+
return rows.filter((r): r is MixedTypeIndexedFieldRow => r.jt !== null && !expected.has(r.jt));
|
|
235
|
+
}
|
|
236
|
+
|
|
199
237
|
function scanMixedTypeIndexedFields(db: Database, allowedTags: Set<string> | null): DoctorFinding[] {
|
|
200
238
|
const findings: DoctorFinding[] = [];
|
|
201
239
|
const noteInScope = makeNoteInScope(db, allowedTags);
|
|
202
240
|
for (const f of listIndexedFields(db)) {
|
|
203
241
|
if (allowedTags && !f.declarerTags.some((t) => allowedTags.has(t))) continue;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
// notes simply don't declare the field and aren't a mismatch. Only rows
|
|
209
|
-
// where the key IS present get compared.
|
|
210
|
-
const path = `$."${f.field}"`;
|
|
211
|
-
const rows = db
|
|
212
|
-
.prepare(
|
|
213
|
-
`SELECT id, json_type(metadata, ?) as jt FROM notes WHERE metadata IS NOT NULL AND metadata != '' AND json_valid(metadata)`,
|
|
214
|
-
)
|
|
215
|
-
.all(path) as { id: string; jt: string | null }[];
|
|
242
|
+
|
|
243
|
+
const rows = findMixedTypeIndexedFieldNotes(db, f.field, f.sqliteType);
|
|
244
|
+
if (rows.length === 0) continue;
|
|
245
|
+
|
|
216
246
|
// Tag-scope (vault#552 auth fold): the note query above is vault-wide —
|
|
217
247
|
// filter mismatches to IN-SCOPE notes so a scoped caller never sees an
|
|
218
248
|
// out-of-scope note id (nor a count/exemplar reflecting one). If every
|
|
219
249
|
// mismatch is on an out-of-scope note, the finding is dropped entirely.
|
|
220
|
-
const mismatches = rows.filter((r) =>
|
|
250
|
+
const mismatches = rows.filter((r) => noteInScope(r.id));
|
|
221
251
|
if (mismatches.length === 0) continue;
|
|
222
252
|
|
|
223
253
|
// Also generalize the "Declared by" list to in-scope declarers only —
|
package/core/src/mcp.ts
CHANGED
|
@@ -5,7 +5,14 @@ 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 {
|
|
8
|
+
import {
|
|
9
|
+
collectUnknownTagWarnings,
|
|
10
|
+
emptySearchWarning,
|
|
11
|
+
ignoredParamWarning,
|
|
12
|
+
computeSearchDidYouMean,
|
|
13
|
+
searchDidYouMeanWarning,
|
|
14
|
+
type QueryWarning,
|
|
15
|
+
} from "./query-warnings.js";
|
|
9
16
|
import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "./search-query.js";
|
|
10
17
|
import * as linkOps from "./links.js";
|
|
11
18
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
@@ -265,7 +272,9 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
265
272
|
- Cursor mode (\`cursor\` param present — including \`cursor: ""\` to bootstrap): \`{notes: [...], next_cursor}\`. See \`cursor\` below for the bootstrap flow.
|
|
266
273
|
- 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.
|
|
267
274
|
|
|
268
|
-
\`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
|
|
275
|
+
\`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.
|
|
276
|
+
|
|
277
|
+
\`search\` indexes BOTH a note's title (\`path\`) and its \`content\` (vault#551 WS2C, schema v25) — a title match is weighted far above a passing body mention, so a dedicated note on a topic outranks another note that merely references it. Every result carries a \`score\` field (higher = more relevant; only meaningful as a RELATIVE comparison within one result set). Word matching also stems regular English affixes ("firefighter" matches "firefighters", "microbe" matches "microbes") — irregular plurals with a consonant change ("wolf"/"wolves") aren't covered by stemming. A search that returns ZERO results may carry a \`search_did_you_mean\` warning suggesting the closest indexed term when one looks like a likely typo (only unscoped sessions — tag-scoped tokens never see it, since the suggestion is computed vault-wide).`,
|
|
269
278
|
inputSchema: {
|
|
270
279
|
type: "object",
|
|
271
280
|
properties: {
|
|
@@ -322,7 +331,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
322
331
|
search: {
|
|
323
332
|
type: "string",
|
|
324
333
|
description:
|
|
325
|
-
'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.',
|
|
334
|
+
'Full-text search query, matched against BOTH a note\'s title (path) and its content — a title match ranks far above a passing body mention. 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. Matching stems regular affixes ("firefighter"/"firefighters") but not irregular plurals ("wolf"/"wolves"). Results carry a `score` field (higher = more relevant, relative within this result set only). A zero-result search may carry a `search_did_you_mean` warning (unscoped sessions only).',
|
|
326
335
|
},
|
|
327
336
|
search_mode: {
|
|
328
337
|
type: "string",
|
|
@@ -603,6 +612,20 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
603
612
|
mode,
|
|
604
613
|
sort: params.sort as "asc" | "desc" | undefined,
|
|
605
614
|
});
|
|
615
|
+
// Zero-result `did_you_mean` (vault#551 WS2B) — cheap (a bounded
|
|
616
|
+
// FTS5-vocabulary scan) and ONLY computed on the already-rare
|
|
617
|
+
// empty-result path, mirroring `unknown_tag`'s did_you_mean.
|
|
618
|
+
// Scope-unaware by construction (same as `collectUnknownTagWarnings`
|
|
619
|
+
// above) — safe here because `applyTagScopeWrappers`'s
|
|
620
|
+
// `query-notes` wrapper (`src/mcp-tools.ts`) strips the ENTIRE
|
|
621
|
+
// `warnings` array for a tag-scoped session before it reaches the
|
|
622
|
+
// caller, so this never leaks out-of-scope vocabulary to one.
|
|
623
|
+
if (results.length === 0) {
|
|
624
|
+
const suggestion = computeSearchDidYouMean(db, params.search as string);
|
|
625
|
+
if (suggestion) {
|
|
626
|
+
queryWarnings.push(searchDidYouMeanWarning(params.search as string, suggestion));
|
|
627
|
+
}
|
|
628
|
+
}
|
|
606
629
|
}
|
|
607
630
|
} else {
|
|
608
631
|
// --- Structured query ---
|
|
@@ -1588,15 +1611,16 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1588
1611
|
description: { type: "string", description: "Human-readable description of what this tag means" },
|
|
1589
1612
|
fields: {
|
|
1590
1613
|
type: "object",
|
|
1591
|
-
description: 'Metadata fields notes with this tag should have. E.g., { "status": { "type": "string", "enum": ["active", "archived"], "strict": true } }. Constraints are ADVISORY by default (violations surface as validation_status warnings; the write still succeeds). Mark a field `strict: true` to ENFORCE all its constraints — type + enum + required + cardinality flip to hard write rejections (vault#299).',
|
|
1614
|
+
description: 'Metadata fields notes with this tag should have. E.g., { "status": { "type": "string", "enum": ["active", "archived"], "strict": true, "default": "active" } }. Constraints are ADVISORY by default (violations surface as validation_status warnings; the write still succeeds). Mark a field `strict: true` to ENFORCE all its constraints — type + enum + required + cardinality flip to hard write rejections (vault#299). Mark a field `indexed: true` to make it queryable — an indexed field\'s TYPE is ALWAYS enforced (a type-mismatched write is REJECTED, independent of `strict`) because a bad-typed value silently poisons range-query ordering (vault#553).',
|
|
1592
1615
|
additionalProperties: {
|
|
1593
1616
|
type: "object",
|
|
1594
1617
|
properties: {
|
|
1595
|
-
type: { type: "string", description: "Field type: string, boolean, integer, number, array, object" },
|
|
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)." },
|
|
1596
1619
|
description: { type: "string" },
|
|
1597
|
-
enum: { type: "array", items: { type: "string" }, description: "Allowed values (first is default
|
|
1598
|
-
|
|
1599
|
-
|
|
1620
|
+
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
|
+
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." },
|
|
1622
|
+
indexed: { type: "boolean", description: "When true, a generated column + index are maintained on notes.metadata.<field>, making it queryable via metadata operator objects and order_by. Global: all tags declaring the field must agree on both type and indexed. Only string/integer/boolean are indexable. Indexed ⇒ a type-mismatched write is HARD-REJECTED (schema_validation), not just warned — vault#553." },
|
|
1623
|
+
strict: { type: "boolean", description: "vault#299. Default false (advisory). When true, ALL of this field's declared constraints (type + enum + required + cardinality) are ENFORCED — a violating write is rejected with a schema_validation error, not just warned. All-or-nothing per field; free-form fields on a strict tag simply leave strict off. Note: `indexed: true` fields enforce their TYPE constraint regardless of this flag (vault#553)." },
|
|
1600
1624
|
required: { type: "boolean", description: "vault#299. The field must be present + non-null on a note with this tag. Advisory unless `strict: true`." },
|
|
1601
1625
|
cardinality: { type: "string", enum: ["one", "many"], description: "vault#299. 'one' (scalar, default) or 'many' (array). Advisory unless `strict: true`." },
|
|
1602
1626
|
},
|
|
@@ -1953,18 +1977,33 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1953
1977
|
* for any field they omitted. Returns the IDs of the notes whose metadata was
|
|
1954
1978
|
* actually written — callers use this to re-read ONLY the mutated notes (and
|
|
1955
1979
|
* to skip the re-read entirely when nothing changed). The common no-schema /
|
|
1956
|
-
* no-defaults path returns an empty array.
|
|
1980
|
+
* no-declared-defaults path returns an empty array.
|
|
1981
|
+
*
|
|
1982
|
+
* vault#553 Decision B: backfill is EXPLICIT-`default`-only. A field with no
|
|
1983
|
+
* declared `default` is skipped entirely here — it stays genuinely absent on
|
|
1984
|
+
* the note, not silently filled with the first enum value or a type
|
|
1985
|
+
* zero-value (the pre-0.7.0 behavior, which made "never set" and
|
|
1986
|
+
* "explicitly set to the default" indistinguishable and broke `exists:false`).
|
|
1987
|
+
* Exported so `src/routes.ts` (REST create/PATCH) shares this ONE
|
|
1988
|
+
* implementation instead of carrying its own copy — the two had drifted into
|
|
1989
|
+
* a byte-identical duplicate pre-#553; centralizing here means the cloud
|
|
1990
|
+
* runtime (which imports core directly, not `src/routes.ts`) automatically
|
|
1991
|
+
* inherits this behavior with zero handler-side work.
|
|
1957
1992
|
*
|
|
1958
1993
|
* vault#299: this runs AFTER the create write (so AFTER the strict gate) and
|
|
1959
1994
|
* intentionally does NOT re-run `enforceStrict`. Defaults are always
|
|
1960
|
-
* conforming by construction — `
|
|
1961
|
-
*
|
|
1962
|
-
* `
|
|
1963
|
-
*
|
|
1964
|
-
*
|
|
1965
|
-
*
|
|
1995
|
+
* conforming by construction — `store.upsertTagRecord` validates a field's
|
|
1996
|
+
* `default` against its own `type`/`enum` BEFORE the schema can be persisted
|
|
1997
|
+
* (`InvalidFieldDefaultError` / `TagFieldConflictError` reason
|
|
1998
|
+
* `invalid_default`), so a default can never violate type/enum at read time
|
|
1999
|
+
* here. And a `required` strict field is already caught at the pre-write
|
|
2000
|
+
* gate, so a note that would need a default to satisfy `required` never
|
|
2001
|
+
* reaches this filler (the create was rejected first) — declaring a
|
|
2002
|
+
* `default` does NOT satisfy `required`; the caller must still set the field
|
|
2003
|
+
* explicitly. Don't add a defaults path that could inject a violating value
|
|
2004
|
+
* without re-gating.
|
|
1966
2005
|
*/
|
|
1967
|
-
async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<string[]> {
|
|
2006
|
+
export async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<string[]> {
|
|
1968
2007
|
const schemas = tagSchemaOps.getTagSchemaMap(db);
|
|
1969
2008
|
if (Object.keys(schemas).length === 0) return [];
|
|
1970
2009
|
|
|
@@ -1973,9 +2012,10 @@ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[]
|
|
|
1973
2012
|
const schema = schemas[tag];
|
|
1974
2013
|
if (!schema?.fields) continue;
|
|
1975
2014
|
for (const [field, fieldSchema] of Object.entries(schema.fields)) {
|
|
1976
|
-
if (
|
|
1977
|
-
|
|
1978
|
-
|
|
2015
|
+
if (field in defaults) continue; // first tag that declares a REAL default wins
|
|
2016
|
+
const value = defaultForField(fieldSchema);
|
|
2017
|
+
if (value === undefined) continue; // no `default` declared — leave the slot open for a later tag
|
|
2018
|
+
defaults[field] = value;
|
|
1979
2019
|
}
|
|
1980
2020
|
}
|
|
1981
2021
|
if (Object.keys(defaults).length === 0) return [];
|
|
@@ -2001,13 +2041,13 @@ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[]
|
|
|
2001
2041
|
return mutated;
|
|
2002
2042
|
}
|
|
2003
2043
|
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2044
|
+
/**
|
|
2045
|
+
* Resolve a field's backfill value — its declared `default` (vault#553
|
|
2046
|
+
* Decision B), or `undefined` when none was declared (the field stays
|
|
2047
|
+
* absent). Exported alongside `applySchemaDefaults` for `src/routes.ts`.
|
|
2048
|
+
*/
|
|
2049
|
+
export function defaultForField(field: { default?: unknown }): unknown {
|
|
2050
|
+
return field.default;
|
|
2011
2051
|
}
|
|
2012
2052
|
|
|
2013
2053
|
// ---------------------------------------------------------------------------
|