@openparachute/vault 0.7.0-rc.4 → 0.7.0-rc.6
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-taxonomy.test.ts +193 -17
- package/core/src/contract-typed-index.test.ts +228 -11
- package/core/src/core.test.ts +67 -14
- package/core/src/doctor-scope.test.ts +117 -0
- package/core/src/doctor.ts +360 -0
- package/core/src/mcp.ts +164 -28
- package/core/src/notes.ts +98 -24
- package/core/src/portable-md.test.ts +107 -0
- package/core/src/portable-md.ts +51 -5
- package/core/src/schema-defaults.ts +48 -7
- package/core/src/schema.ts +147 -2
- package/core/src/seed-packs.ts +20 -13
- package/core/src/store.ts +35 -3
- package/core/src/tag-hierarchy.ts +66 -0
- package/core/src/tag-schemas.ts +166 -6
- package/core/src/types.ts +23 -1
- package/package.json +1 -1
- package/src/mcp-http.ts +13 -0
- package/src/mcp-tools.ts +90 -5
- package/src/mirror-import.ts +5 -0
- package/src/onboarding-seed.test.ts +5 -2
- package/src/routes.ts +91 -52
- package/src/routing.ts +29 -0
- package/src/tag-integrity-mcp.test.ts +288 -0
- package/src/tag-integrity-scope.test.ts +97 -0
- package/src/tag-scope.ts +43 -0
- package/src/vault.test.ts +58 -35
|
@@ -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
|
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
* flipped to real assertions in a later wave. See #552 for the full write-up.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { describe, it,
|
|
12
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
13
13
|
import { Database } from "bun:sqlite";
|
|
14
14
|
import { SqliteStore } from "./store.js";
|
|
15
|
+
import { generateMcpTools } from "./mcp.js";
|
|
16
|
+
import { ParentCycleError } from "./tag-schemas.js";
|
|
17
|
+
import { declareField } from "./indexed-fields.js";
|
|
15
18
|
|
|
16
19
|
let store: SqliteStore;
|
|
17
20
|
let db: Database;
|
|
@@ -100,20 +103,193 @@ describe("contract: taxonomy — passing (lock in current behavior)", () => {
|
|
|
100
103
|
});
|
|
101
104
|
});
|
|
102
105
|
|
|
103
|
-
describe("contract: taxonomy —
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
106
|
+
describe("contract: taxonomy — #552 (flipped from test.todo)", () => {
|
|
107
|
+
it("rename-tag is exposed as an MCP tool and cascades parent_names — the gardener's exact bug no longer happens", async () => {
|
|
108
|
+
// Root tag, a child that declares it as a parent (the parent_names
|
|
109
|
+
// surface the gardener's rename left orphaned), and a note tagged with
|
|
110
|
+
// the CHILD only (proves subtype expansion still finds it post-rename).
|
|
111
|
+
await store.upsertTagRecord("proj", { description: "root project tag" });
|
|
112
|
+
await store.upsertTagRecord("special", { parent_names: ["proj"] });
|
|
113
|
+
const childTagged = await store.createNote("filed under the child tag", { tags: ["special"] });
|
|
114
|
+
|
|
115
|
+
const tools = generateMcpTools(store);
|
|
116
|
+
const renameTag = tools.find((t) => t.name === "rename-tag");
|
|
117
|
+
expect(renameTag).toBeDefined();
|
|
118
|
+
expect(renameTag!.requiredVerb).toBe("write");
|
|
119
|
+
|
|
120
|
+
const result = await renameTag!.execute({ old_name: "proj", new_name: "initiative" }) as any;
|
|
121
|
+
expect("error" in result).toBe(false);
|
|
122
|
+
expect(result.parent_refs_updated).toBeGreaterThanOrEqual(1);
|
|
123
|
+
|
|
124
|
+
// The exact gardener's bug: "special"'s parent_names must follow the
|
|
125
|
+
// rename, not keep pointing at the now-dead "proj".
|
|
126
|
+
const special = await store.getTagRecord("special");
|
|
127
|
+
expect(special?.parent_names).toEqual(["initiative"]);
|
|
128
|
+
|
|
129
|
+
// The renamed-away tag is NOT a live query surface anymore.
|
|
130
|
+
const projRecord = await store.getTagRecord("proj");
|
|
131
|
+
expect(projRecord).toBeNull();
|
|
132
|
+
|
|
133
|
+
// The NEW tag doesn't silently miss child-tagged notes (the gardener's
|
|
134
|
+
// second symptom): subtype expansion of "initiative" still reaches the
|
|
135
|
+
// note tagged only with the child "special".
|
|
136
|
+
const viaExpansion = await store.queryNotes({ tags: ["initiative"] });
|
|
137
|
+
expect(viaExpansion.map((n) => n.id)).toContain(childTagged.id);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("rename-tag MCP tool reports target_exists (not a silent overwrite) and tag_not_found for a missing source", async () => {
|
|
141
|
+
await store.upsertTagRecord("a", {});
|
|
142
|
+
await store.upsertTagRecord("b", {});
|
|
143
|
+
const tools = generateMcpTools(store);
|
|
144
|
+
const renameTag = tools.find((t) => t.name === "rename-tag")!;
|
|
145
|
+
|
|
146
|
+
let caught: any;
|
|
147
|
+
try {
|
|
148
|
+
await renameTag.execute({ old_name: "a", new_name: "b" });
|
|
149
|
+
} catch (e) { caught = e; }
|
|
150
|
+
expect(caught?.error_type).toBe("target_exists");
|
|
151
|
+
expect(caught?.conflicting).toContain("b");
|
|
152
|
+
|
|
153
|
+
caught = undefined;
|
|
154
|
+
try {
|
|
155
|
+
await renameTag.execute({ old_name: "nonexistent", new_name: "whatever" });
|
|
156
|
+
} catch (e) { caught = e; }
|
|
157
|
+
expect(caught?.error_type).toBe("tag_not_found");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("merge-tags is exposed as an MCP tool and merges N sources into one target", async () => {
|
|
161
|
+
await store.upsertTagRecord("draft", {});
|
|
162
|
+
await store.upsertTagRecord("wip", {});
|
|
163
|
+
const draftNote = await store.createNote("draft note", { tags: ["draft"] });
|
|
164
|
+
const wipNote = await store.createNote("wip note", { tags: ["wip"] });
|
|
165
|
+
|
|
166
|
+
const tools = generateMcpTools(store);
|
|
167
|
+
const mergeTags = tools.find((t) => t.name === "merge-tags");
|
|
168
|
+
expect(mergeTags).toBeDefined();
|
|
169
|
+
expect(mergeTags!.requiredVerb).toBe("write");
|
|
170
|
+
|
|
171
|
+
const result = await mergeTags!.execute({ sources: ["draft", "wip"], target: "active" }) as any;
|
|
172
|
+
expect(result.target).toBe("active");
|
|
173
|
+
expect(result.merged.draft).toBe(1);
|
|
174
|
+
expect(result.merged.wip).toBe(1);
|
|
175
|
+
|
|
176
|
+
const draftAfter = await store.getNote(draftNote.id);
|
|
177
|
+
const wipAfter = await store.getNote(wipNote.id);
|
|
178
|
+
expect(draftAfter!.tags).toEqual(["active"]);
|
|
179
|
+
expect(wipAfter!.tags).toEqual(["active"]);
|
|
180
|
+
expect(await store.getTagRecord("draft")).toBeNull();
|
|
181
|
+
expect(await store.getTagRecord("wip")).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("delete-tag refuses a tag still referenced in another tag's parent_names, then cascade/detach both proceed and strip the reference", async () => {
|
|
185
|
+
await store.upsertTagRecord("proj", { description: "root" });
|
|
186
|
+
await store.upsertTagRecord("child", { parent_names: ["proj"] });
|
|
187
|
+
|
|
188
|
+
const tools = generateMcpTools(store);
|
|
189
|
+
const deleteTag = tools.find((t) => t.name === "delete-tag")!;
|
|
190
|
+
|
|
191
|
+
// Default (no flag): refused, nothing mutated.
|
|
192
|
+
const refused = await deleteTag.execute({ tag: "proj" }) as any;
|
|
193
|
+
expect(refused.error).toBe("tag_referenced_as_parent");
|
|
194
|
+
expect(refused.referencing_tags).toEqual(["child"]);
|
|
195
|
+
expect(await store.getTagRecord("proj")).not.toBeNull();
|
|
196
|
+
expect((await store.getTagRecord("child"))?.parent_names).toEqual(["proj"]);
|
|
197
|
+
|
|
198
|
+
// cascade: true proceeds and strips the stale reference.
|
|
199
|
+
const cascaded = await deleteTag.execute({ tag: "proj", cascade: true }) as any;
|
|
200
|
+
expect(cascaded.deleted).toBe(true);
|
|
201
|
+
expect(cascaded.parent_refs_detached).toBe(1);
|
|
202
|
+
expect(await store.getTagRecord("proj")).toBeNull();
|
|
203
|
+
expect((await store.getTagRecord("child"))?.parent_names ?? null).toBeFalsy();
|
|
204
|
+
|
|
205
|
+
// detach is a synonym — same repair, exercised on a second fixture.
|
|
206
|
+
await store.upsertTagRecord("proj2", { description: "root2" });
|
|
207
|
+
await store.upsertTagRecord("child2", { parent_names: ["proj2"] });
|
|
208
|
+
const detached = await deleteTag.execute({ tag: "proj2", detach: true }) as any;
|
|
209
|
+
expect(detached.deleted).toBe(true);
|
|
210
|
+
expect(detached.parent_refs_detached).toBe(1);
|
|
211
|
+
expect((await store.getTagRecord("child2"))?.parent_names ?? null).toBeFalsy();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("a parent_names cycle (direct A→B→A, and bare self-parent) is rejected at write time with error_type parent_cycle", async () => {
|
|
215
|
+
await store.upsertTagRecord("B", {});
|
|
216
|
+
await store.upsertTagRecord("A", { parent_names: ["B"] });
|
|
217
|
+
|
|
218
|
+
let caught: unknown;
|
|
219
|
+
try {
|
|
220
|
+
await store.upsertTagRecord("B", { parent_names: ["A"] });
|
|
221
|
+
} catch (e) { caught = e; }
|
|
222
|
+
expect(caught).toBeInstanceOf(ParentCycleError);
|
|
223
|
+
const cycleErr = caught as ParentCycleError;
|
|
224
|
+
expect(cycleErr.error_type).toBe("parent_cycle");
|
|
225
|
+
expect(cycleErr.tag).toBe("B");
|
|
226
|
+
expect(cycleErr.cycle).toContain("A");
|
|
227
|
+
expect(cycleErr.cycle).toContain("B");
|
|
228
|
+
// Nothing persisted — B's parent_names is untouched by the rejected write.
|
|
229
|
+
expect((await store.getTagRecord("B"))?.parent_names ?? null).toBeFalsy();
|
|
230
|
+
|
|
231
|
+
// Bare self-parent: also caught (getTagDescendants always includes the
|
|
232
|
+
// tag itself).
|
|
233
|
+
let selfCaught: unknown;
|
|
234
|
+
try {
|
|
235
|
+
await store.upsertTagRecord("C", { parent_names: ["C"] });
|
|
236
|
+
} catch (e) { selfCaught = e; }
|
|
237
|
+
expect(selfCaught).toBeInstanceOf(ParentCycleError);
|
|
238
|
+
expect((selfCaught as ParentCycleError).cycle).toEqual(["C", "C"]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("doctor reports each finding type on a seeded-broken fixture, and stays clean on a healthy one", async () => {
|
|
242
|
+
// 1. dangling_parent_name — "ghost-parent" is never created as a tag row.
|
|
243
|
+
await store.upsertTagRecord("orphan-child", { parent_names: ["ghost-parent"] });
|
|
244
|
+
|
|
245
|
+
// 2. parent_names_cycle — the write-time guard blocks NEW cycles, so
|
|
246
|
+
// simulate pre-existing/pre-guard cyclic data with a direct DB write
|
|
247
|
+
// (same technique core.test.ts's cycle-tolerance tests use).
|
|
248
|
+
await store.upsertTagRecord("cyc-a", {});
|
|
249
|
+
await store.upsertTagRecord("cyc-b", { parent_names: ["cyc-a"] });
|
|
250
|
+
db.prepare("UPDATE tags SET parent_names = ? WHERE name = ?").run(JSON.stringify(["cyc-b"]), "cyc-a");
|
|
251
|
+
|
|
252
|
+
// 3. mixed_type_indexed_field — "n" is indexed INTEGER, but this note's
|
|
253
|
+
// metadata.n is a JSON string. Not `strict`, so the write succeeds with
|
|
254
|
+
// just an advisory warning — landing exactly the mismatched value.
|
|
255
|
+
await store.upsertTagRecord("metric", { fields: { n: { type: "integer", indexed: true } } });
|
|
256
|
+
await store.createNote("bad metric", { tags: ["metric"], metadata: { n: "not-a-number" } });
|
|
257
|
+
|
|
258
|
+
// 4. orphaned_indexed_field_declarer — declare directly via the
|
|
259
|
+
// indexed-fields API, then drop the tag row WITHOUT releasing (the
|
|
260
|
+
// pre-fix gitcoin state prune-schema's own tests seed identically).
|
|
261
|
+
declareField(db, "legacy_status", "TEXT", "ghost-declarer");
|
|
262
|
+
|
|
263
|
+
// 5. dead_tag_metadata_reference (heuristic) — "epic" holds tag-shaped
|
|
264
|
+
// values elsewhere (a live tag), so a value that ISN'T a live tag is
|
|
265
|
+
// the drift signal.
|
|
266
|
+
await store.upsertTagRecord("launch-v2", {});
|
|
267
|
+
await store.createNote("current epic", { tags: ["launch-v2"], metadata: { epic: "launch-v2" } });
|
|
268
|
+
await store.createNote("stale epic reference", { tags: ["launch-v2"], metadata: { epic: "launch-v1" } });
|
|
269
|
+
|
|
270
|
+
const report = await store.doctor();
|
|
271
|
+
const byType = new Map(report.findings.map((f) => [f.type, f]));
|
|
272
|
+
|
|
273
|
+
expect(byType.get("dangling_parent_name")?.subject).toBe("orphan-child");
|
|
274
|
+
expect(byType.get("parent_names_cycle")).toBeDefined();
|
|
275
|
+
expect(byType.get("mixed_type_indexed_field")?.subject).toBe("n");
|
|
276
|
+
expect(byType.get("mixed_type_indexed_field")?.severity).toBe("error");
|
|
277
|
+
expect(byType.get("orphaned_indexed_field_declarer")?.subject).toBe("legacy_status");
|
|
278
|
+
const driftFinding = byType.get("dead_tag_metadata_reference");
|
|
279
|
+
expect(driftFinding?.subject).toBe("metadata.epic");
|
|
280
|
+
expect(driftFinding?.heuristic).toBe(true);
|
|
281
|
+
expect(driftFinding?.detail).toContain("launch-v1");
|
|
282
|
+
|
|
283
|
+
// Every finding type from the fixture is present — five distinct types.
|
|
284
|
+
expect(byType.size).toBe(5);
|
|
285
|
+
|
|
286
|
+
// A fresh, healthy vault reports clean.
|
|
287
|
+
const cleanDb = new Database(":memory:");
|
|
288
|
+
const cleanStore = new SqliteStore(cleanDb);
|
|
289
|
+
await cleanStore.upsertTagRecord("healthy", { description: "nothing wrong here" });
|
|
290
|
+
await cleanStore.createNote("fine", { tags: ["healthy"] });
|
|
291
|
+
const cleanReport = await cleanStore.doctor();
|
|
292
|
+
expect(cleanReport.findings).toEqual([]);
|
|
293
|
+
expect(cleanReport.summary).toContain("clean");
|
|
294
|
+
});
|
|
119
295
|
});
|
|
@@ -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)", () => {
|