@openparachute/vault 0.6.4-rc.3 → 0.6.4-rc.5

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.
@@ -5,6 +5,7 @@ import { generateMcpTools } from "./mcp.js";
5
5
  import { initSchema } from "./schema.js";
6
6
  import { decodeCursor } from "./cursor.js";
7
7
  import { traverseLinks } from "./links.js";
8
+ import * as indexedFieldOps from "./indexed-fields.js";
8
9
 
9
10
  let store: SqliteStore;
10
11
  let db: Database;
@@ -2169,6 +2170,50 @@ describe("MCP tools", async () => {
2169
2170
  expect(result.extension).toBe("csv");
2170
2171
  });
2171
2172
 
2173
+ // ---- Metadata key-deletion via null tombstone (vault#478 / #479) ---------
2174
+
2175
+ it("update-note deletes a metadata key when its value is null (RFC 7386)", async () => {
2176
+ const note = await store.createNote("m", {
2177
+ metadata: { keep: "yes", drop: "old", other: 1 },
2178
+ });
2179
+ const tools = generateMcpTools(store);
2180
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2181
+ await updateNote.execute({ id: note.id, metadata: { drop: null }, force: true });
2182
+
2183
+ const fresh = await store.getNote(note.id);
2184
+ // The deleted key is GONE — not a literal JSON null.
2185
+ expect(fresh!.metadata).not.toHaveProperty("drop");
2186
+ // Other keys are untouched.
2187
+ expect(fresh!.metadata).toMatchObject({ keep: "yes", other: 1 });
2188
+ });
2189
+
2190
+ it("update-note metadata key-rename in one call (new key set, old key deleted)", async () => {
2191
+ const note = await store.createNote("m", {
2192
+ metadata: { "old-key": "v", stable: true },
2193
+ });
2194
+ const tools = generateMcpTools(store);
2195
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2196
+ await updateNote.execute({
2197
+ id: note.id,
2198
+ metadata: { new_key: "v", "old-key": null },
2199
+ force: true,
2200
+ });
2201
+
2202
+ const fresh = await store.getNote(note.id);
2203
+ expect(fresh!.metadata).not.toHaveProperty("old-key");
2204
+ expect(fresh!.metadata).toMatchObject({ new_key: "v", stable: true });
2205
+ });
2206
+
2207
+ it("deleting an absent metadata key is a no-op (round-trips cleanly)", async () => {
2208
+ const note = await store.createNote("m", { metadata: { a: 1 } });
2209
+ const tools = generateMcpTools(store);
2210
+ const updateNote = tools.find((t) => t.name === "update-note")!;
2211
+ await updateNote.execute({ id: note.id, metadata: { ghost: null }, force: true });
2212
+ const fresh = await store.getNote(note.id);
2213
+ expect(fresh!.metadata).toEqual({ a: 1 });
2214
+ expect(fresh!.metadata).not.toHaveProperty("ghost");
2215
+ });
2216
+
2172
2217
  it("query-notes filters by extension (vault#328)", async () => {
2173
2218
  await store.createNote("md note", { path: "a" });
2174
2219
  await store.createNote("csv note", { path: "b", extension: "csv" });
@@ -5301,6 +5346,87 @@ describe("tag record API (patterns/tag-data-model.md)", async () => {
5301
5346
  expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
5302
5347
  expect(await store.getTagRecord("voice")).toBeNull();
5303
5348
  });
5349
+
5350
+ // ---- Indexed-field atomicity (vault#478) ----------------------------------
5351
+
5352
+ it("upsertTagRecord with a bad indexed-field name throws IndexedFieldError and leaves the schema unchanged", async () => {
5353
+ // kebab-case field name violates [A-Za-z0-9_] / no-leading-digit. The
5354
+ // declaration must NOT persist, and no orphan/lying index may be created.
5355
+ await expect(
5356
+ store.upsertTagRecord("meeting", {
5357
+ description: "meetings",
5358
+ fields: { "meeting-type": { type: "string", indexed: true } },
5359
+ }),
5360
+ ).rejects.toThrow(indexedFieldOps.IndexedFieldError);
5361
+
5362
+ // Schema is untouched — the tag row may not even exist, and definitely
5363
+ // doesn't claim the poisoned field.
5364
+ const record = await store.getTagRecord("meeting");
5365
+ expect(record?.fields?.["meeting-type"]).toBeUndefined();
5366
+
5367
+ // No backing index row was created for the bad field.
5368
+ expect(indexedFieldOps.getIndexedField(db, "meeting-type")).toBeNull();
5369
+ // And the generated column doesn't exist either.
5370
+ const cols = (db.prepare("PRAGMA table_xinfo(notes)").all() as { name: string }[]).map(
5371
+ (c) => c.name,
5372
+ );
5373
+ expect(cols).not.toContain("meta_meeting-type");
5374
+ });
5375
+
5376
+ it("upsertTagRecord with an unindexable type throws and leaves the schema unchanged", async () => {
5377
+ await expect(
5378
+ store.upsertTagRecord("meeting", {
5379
+ fields: { attendees: { type: "json", indexed: true } },
5380
+ }),
5381
+ ).rejects.toThrow(indexedFieldOps.IndexedFieldError);
5382
+ const record = await store.getTagRecord("meeting");
5383
+ expect(record?.fields?.attendees).toBeUndefined();
5384
+ expect(indexedFieldOps.getIndexedField(db, "attendees")).toBeNull();
5385
+ });
5386
+
5387
+ it("a bad indexed field does NOT corrupt a prior valid declaration on the same tag", async () => {
5388
+ // First, a clean valid indexed field.
5389
+ await store.upsertTagRecord("meeting", {
5390
+ fields: { held_on: { type: "string", indexed: true } },
5391
+ });
5392
+ expect(indexedFieldOps.getIndexedField(db, "held_on")?.declarerTags).toContain("meeting");
5393
+
5394
+ // Now an update that adds a poisoned field. The whole write must roll
5395
+ // back — the prior valid field survives untouched.
5396
+ await expect(
5397
+ store.upsertTagRecord("meeting", {
5398
+ fields: {
5399
+ held_on: { type: "string", indexed: true },
5400
+ "bad-field": { type: "string", indexed: true },
5401
+ },
5402
+ }),
5403
+ ).rejects.toThrow(indexedFieldOps.IndexedFieldError);
5404
+
5405
+ const record = await store.getTagRecord("meeting");
5406
+ expect(record?.fields?.held_on?.indexed).toBe(true);
5407
+ expect(record?.fields?.["bad-field"]).toBeUndefined();
5408
+ expect(indexedFieldOps.getIndexedField(db, "held_on")?.declarerTags).toContain("meeting");
5409
+ expect(indexedFieldOps.getIndexedField(db, "bad-field")).toBeNull();
5410
+ });
5411
+
5412
+ it("upsertTagRecord with fields:null releases indexed fields (transaction commits the release path)", async () => {
5413
+ // Declare an indexed field, then clear all fields — the release path runs
5414
+ // inside the same transaction as the persist. It must commit cleanly:
5415
+ // the row, generated column, and index all drop.
5416
+ await store.upsertTagRecord("meeting", {
5417
+ fields: { held_on: { type: "string", indexed: true } },
5418
+ });
5419
+ expect(indexedFieldOps.getIndexedField(db, "held_on")?.declarerTags).toContain("meeting");
5420
+
5421
+ await store.upsertTagRecord("meeting", { fields: null });
5422
+ const record = await store.getTagRecord("meeting");
5423
+ expect(record?.fields).toBeUndefined();
5424
+ expect(indexedFieldOps.getIndexedField(db, "held_on")).toBeNull();
5425
+ const cols = (db.prepare("PRAGMA table_xinfo(notes)").all() as { name: string }[]).map(
5426
+ (c) => c.name,
5427
+ );
5428
+ expect(cols).not.toContain("meta_held_on");
5429
+ });
5304
5430
  });
5305
5431
 
5306
5432
  // ---------------------------------------------------------------------------
package/core/src/mcp.ts CHANGED
@@ -830,7 +830,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
830
830
  },
831
831
  path: { type: "string", description: "New path" },
832
832
  extension: { type: "string", description: "Change the note's file extension (vault#328). Allowed but caller-owned — you're responsible for content validity if you switch a non-empty note's extension. Lowercase alphanumeric, 1–16 chars; \"parachute\" prefix reserved." },
833
- metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale)" },
833
+ metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale). A value of `null` deletes that key (RFC 7386 merge-patch) — e.g. `{\"new_key\": \"v\", \"old_key\": null}` renames in one call. Omitting a key preserves its existing value." },
834
834
  created_at: { type: "string", description: "New created_at timestamp" },
835
835
  if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since. Required unless `force: true` is set or the call is `append`/`prepend`-only." },
836
836
  force: { type: "boolean", description: "Waive the *requirement to supply* `if_updated_at` and run the update unconditionally. Use only for bulk migrations or scripted writes where concurrency is known-safe. Note: this does not override an `if_updated_at` you actually pass — if you supply both, the precondition still applies and a mismatch returns a conflict error." },
@@ -1194,9 +1194,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1194
1194
  updates.extension = validateExtension(item.extension);
1195
1195
  }
1196
1196
  if (item.metadata !== undefined) {
1197
- // Merge metadata (don't replace wholesale)
1198
- const existing = (note.metadata as Record<string, unknown>) ?? {};
1199
- updates.metadata = { ...existing, ...(item.metadata as Record<string, unknown>) };
1197
+ // Merge metadata (RFC 7386: keys are merged, incoming `null`
1198
+ // removes the key rather than persisting a literal null —
1199
+ // vault#478/#479). Mirrors the REST PATCH path.
1200
+ updates.metadata = noteOps.mergeMetadata(
1201
+ note.metadata as Record<string, unknown> | null | undefined,
1202
+ item.metadata as Record<string, unknown>,
1203
+ );
1200
1204
  }
1201
1205
  if (item.created_at !== undefined) updates.created_at = item.created_at;
1202
1206
  if (item.if_updated_at !== undefined) updates.if_updated_at = item.if_updated_at as string;
@@ -0,0 +1,471 @@
1
+ /**
2
+ * migrate-tag-field (vault#314) — evolve a tag-schema field across existing
3
+ * notes carrying a tag.
4
+ *
5
+ * Coverage (per the brief):
6
+ * - dry-run reports the correct would-change set without mutating
7
+ * - apply performs remap / set-default / retype correctly
8
+ * - notes without the tag/field are untouched
9
+ * - an unconvertible retype fails cleanly (clear error, no partial corruption)
10
+ * - the migration writes through a strict-schema field (bypass) + the bypass
11
+ * is logged/attributed
12
+ * - idempotency (re-running apply is a no-op once conformant)
13
+ * - the CLI transform-spec parser
14
+ */
15
+
16
+ import { describe, it, expect, beforeEach } from "bun:test";
17
+ import { Database } from "bun:sqlite";
18
+ import { SqliteStore } from "./store.js";
19
+ import {
20
+ migrateTagField,
21
+ applyTransform,
22
+ parseTransformSpec,
23
+ TransformError,
24
+ type ValidationWarning,
25
+ } from "./migrate-tag-field.js";
26
+
27
+ let store: SqliteStore;
28
+ let db: Database;
29
+
30
+ beforeEach(() => {
31
+ db = new Database(":memory:");
32
+ store = new SqliteStore(db);
33
+ });
34
+
35
+ async function getMeta(id: string): Promise<Record<string, unknown>> {
36
+ const note = await store.getNote(id);
37
+ return (note?.metadata ?? {}) as Record<string, unknown>;
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Dry-run vs apply
42
+ // ---------------------------------------------------------------------------
43
+
44
+ describe("dry-run reports the would-change set without mutating", () => {
45
+ it("counts and samples changes but writes nothing", async () => {
46
+ const a = await store.createNote("a", { tags: ["kpi"], metadata: { unit: "wip" } });
47
+ const b = await store.createNote("b", { tags: ["kpi"], metadata: { unit: "wip" } });
48
+ const c = await store.createNote("c", { tags: ["kpi"], metadata: { unit: "done" } });
49
+
50
+ const result = await migrateTagField(store, {
51
+ tag: "kpi",
52
+ field: "unit",
53
+ transform: { kind: "remap", map: { wip: "in_progress" } },
54
+ // apply omitted → dry-run
55
+ });
56
+
57
+ expect(result.applied).toBe(false);
58
+ expect(result.scanned).toBe(3);
59
+ expect(result.changed).toBe(2);
60
+ expect(result.unchanged).toBe(1);
61
+ expect(result.errored).toBe(0);
62
+ expect(result.sample).toHaveLength(2);
63
+
64
+ // Nothing written.
65
+ expect((await getMeta(a.id)).unit).toBe("wip");
66
+ expect((await getMeta(b.id)).unit).toBe("wip");
67
+ expect((await getMeta(c.id)).unit).toBe("done");
68
+ });
69
+ });
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // apply: remap / set-default / retype
73
+ // ---------------------------------------------------------------------------
74
+
75
+ describe("apply performs the transform correctly", () => {
76
+ it("remaps an enum value across matching notes only", async () => {
77
+ const a = await store.createNote("a", { tags: ["kpi"], metadata: { state: "wip", other: 1 } });
78
+ const b = await store.createNote("b", { tags: ["kpi"], metadata: { state: "shipped" } });
79
+
80
+ const result = await migrateTagField(store, {
81
+ tag: "kpi",
82
+ field: "state",
83
+ transform: { kind: "remap", map: { wip: "in_progress" } },
84
+ apply: true,
85
+ });
86
+
87
+ expect(result.applied).toBe(true);
88
+ expect(result.changed).toBe(1);
89
+ expect((await getMeta(a.id)).state).toBe("in_progress");
90
+ expect((await getMeta(a.id)).other).toBe(1); // unrelated field preserved
91
+ expect((await getMeta(b.id)).state).toBe("shipped"); // value not in map — untouched
92
+ });
93
+
94
+ it("remap with a default fills missing/null AND remaps present values", async () => {
95
+ const missing = await store.createNote("missing", { tags: ["kpi"], metadata: {} });
96
+ const old = await store.createNote("old", { tags: ["kpi"], metadata: { state: "wip" } });
97
+ const kept = await store.createNote("kept", { tags: ["kpi"], metadata: { state: "shipped" } });
98
+
99
+ const result = await migrateTagField(store, {
100
+ tag: "kpi",
101
+ field: "state",
102
+ transform: { kind: "remap", map: { wip: "in_progress" }, default: "unknown" },
103
+ apply: true,
104
+ });
105
+
106
+ expect(result.changed).toBe(2); // missing (filled) + old (remapped)
107
+ expect((await getMeta(missing.id)).state).toBe("unknown"); // default filled
108
+ expect((await getMeta(old.id)).state).toBe("in_progress"); // remapped
109
+ expect((await getMeta(kept.id)).state).toBe("shipped"); // not in map — kept
110
+ });
111
+
112
+ it("set_default onlyInvalid is a no-op when no schema declares the field", async () => {
113
+ // No upsertTagRecord here — nothing to validate against, so onlyInvalid
114
+ // must NOT clobber an existing value (the conservative reading).
115
+ const a = await store.createNote("a", { tags: ["freeform"], metadata: { x: "anything" } });
116
+ const result = await migrateTagField(store, {
117
+ tag: "freeform",
118
+ field: "x",
119
+ transform: { kind: "set_default", value: "default", onlyInvalid: true },
120
+ apply: true,
121
+ });
122
+ expect(result.changed).toBe(0);
123
+ expect((await getMeta(a.id)).x).toBe("anything");
124
+ });
125
+
126
+ it("sets a default on notes missing the field", async () => {
127
+ const a = await store.createNote("a", { tags: ["task"], metadata: { title: "t" } });
128
+ const b = await store.createNote("b", { tags: ["task"], metadata: { title: "u", priority: "high" } });
129
+
130
+ const result = await migrateTagField(store, {
131
+ tag: "task",
132
+ field: "priority",
133
+ transform: { kind: "set_default", value: "normal" },
134
+ apply: true,
135
+ });
136
+
137
+ expect(result.changed).toBe(1);
138
+ expect((await getMeta(a.id)).priority).toBe("normal"); // filled
139
+ expect((await getMeta(b.id)).priority).toBe("high"); // already present — untouched
140
+ });
141
+
142
+ it("retypes string → int and renames a key", async () => {
143
+ const a = await store.createNote("a", { tags: ["kpi"], metadata: { count: "5" } });
144
+
145
+ const retype = await migrateTagField(store, {
146
+ tag: "kpi",
147
+ field: "count",
148
+ transform: { kind: "to_int" },
149
+ apply: true,
150
+ });
151
+ expect(retype.changed).toBe(1);
152
+ expect((await getMeta(a.id)).count).toBe(5);
153
+
154
+ const rename = await migrateTagField(store, {
155
+ tag: "kpi",
156
+ field: "count",
157
+ transform: { kind: "rename", to: "total" },
158
+ apply: true,
159
+ });
160
+ expect(rename.changed).toBe(1);
161
+ const meta = await getMeta(a.id);
162
+ expect(meta.total).toBe(5);
163
+ expect("count" in meta).toBe(false); // old key dropped
164
+ });
165
+
166
+ it("preserves created attribution; migration is the last_updated actor", async () => {
167
+ const a = await store.createNote("a", {
168
+ tags: ["kpi"],
169
+ metadata: { state: "wip" },
170
+ actor: "alice",
171
+ via: "mcp",
172
+ });
173
+
174
+ await migrateTagField(store, {
175
+ tag: "kpi",
176
+ field: "state",
177
+ transform: { kind: "remap", map: { wip: "done" } },
178
+ apply: true,
179
+ actor: "migrate-bot",
180
+ via: "migrate",
181
+ });
182
+
183
+ const row = db.prepare("SELECT created_by, last_updated_by, last_updated_via FROM notes WHERE id = ?").get(a.id) as {
184
+ created_by: string | null;
185
+ last_updated_by: string | null;
186
+ last_updated_via: string | null;
187
+ };
188
+ expect(row.created_by).toBe("alice"); // set-once, untouched
189
+ expect(row.last_updated_by).toBe("migrate-bot");
190
+ expect(row.last_updated_via).toBe("migrate");
191
+ });
192
+ });
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Scope: notes without the tag/field are untouched
196
+ // ---------------------------------------------------------------------------
197
+
198
+ describe("notes without the tag/field are untouched", () => {
199
+ it("ignores notes carrying a different tag", async () => {
200
+ const tagged = await store.createNote("a", { tags: ["kpi"], metadata: { state: "wip" } });
201
+ const other = await store.createNote("b", { tags: ["note"], metadata: { state: "wip" } });
202
+
203
+ const result = await migrateTagField(store, {
204
+ tag: "kpi",
205
+ field: "state",
206
+ transform: { kind: "remap", map: { wip: "done" } },
207
+ apply: true,
208
+ });
209
+
210
+ expect(result.scanned).toBe(1); // only the kpi note
211
+ expect((await getMeta(tagged.id)).state).toBe("done");
212
+ expect((await getMeta(other.id)).state).toBe("wip"); // other tag — never seen
213
+ });
214
+
215
+ it("leaves the metadata blob byte-identical when nothing matches", async () => {
216
+ const a = await store.createNote("a", { tags: ["kpi"], metadata: { state: "shipped", x: 1 } });
217
+ const before = JSON.stringify(await getMeta(a.id));
218
+
219
+ const result = await migrateTagField(store, {
220
+ tag: "kpi",
221
+ field: "state",
222
+ transform: { kind: "remap", map: { wip: "done" } }, // "shipped" not in map
223
+ apply: true,
224
+ });
225
+
226
+ expect(result.changed).toBe(0);
227
+ expect(JSON.stringify(await getMeta(a.id))).toBe(before);
228
+ });
229
+ });
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Clean failure on unconvertible retype
233
+ // ---------------------------------------------------------------------------
234
+
235
+ describe("unconvertible retype fails cleanly with no partial corruption", () => {
236
+ it("atomically aborts the apply when any note can't convert", async () => {
237
+ const good = await store.createNote("good", { tags: ["kpi"], metadata: { count: "5" } });
238
+ const bad = await store.createNote("bad", { tags: ["kpi"], metadata: { count: "banana" } });
239
+
240
+ const result = await migrateTagField(store, {
241
+ tag: "kpi",
242
+ field: "count",
243
+ transform: { kind: "to_int" },
244
+ apply: true,
245
+ });
246
+
247
+ // Atomic: nothing written because one note errored.
248
+ expect(result.applied).toBe(false);
249
+ expect(result.errored).toBe(1);
250
+ expect(result.errors[0]!.error).toContain("banana");
251
+ expect((await getMeta(good.id)).count).toBe("5"); // NOT converted — atomic abort
252
+ expect((await getMeta(bad.id)).count).toBe("banana");
253
+ });
254
+
255
+ it("continue-on-error writes the convertible notes and reports the rest", async () => {
256
+ const good = await store.createNote("good", { tags: ["kpi"], metadata: { count: "5" } });
257
+ const bad = await store.createNote("bad", { tags: ["kpi"], metadata: { count: "banana" } });
258
+
259
+ const result = await migrateTagField(store, {
260
+ tag: "kpi",
261
+ field: "count",
262
+ transform: { kind: "to_int" },
263
+ apply: true,
264
+ continueOnError: true,
265
+ });
266
+
267
+ expect(result.applied).toBe(true);
268
+ expect(result.changed).toBe(1);
269
+ expect(result.errored).toBe(1);
270
+ expect((await getMeta(good.id)).count).toBe(5); // written
271
+ expect((await getMeta(bad.id)).count).toBe("banana"); // skipped, not corrupted
272
+ });
273
+ });
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Strict-schema bypass + logging
277
+ // ---------------------------------------------------------------------------
278
+
279
+ describe("migration writes through a strict-schema field (bypass) and logs it", () => {
280
+ beforeEach(async () => {
281
+ // Declare a strict enum AFTER the back-catalog exists, so the existing
282
+ // value is non-conforming — the exact migrate-bypass use case.
283
+ await store.upsertTagRecord("kpi", {
284
+ fields: {
285
+ state: { type: "string", enum: ["in_progress", "shipped"], strict: true },
286
+ },
287
+ });
288
+ });
289
+
290
+ it("remaps a non-conforming value through strict enforcement and logs the bypass", async () => {
291
+ const a = await store.createNote("a", { tags: ["kpi"], metadata: { state: "wip" } });
292
+
293
+ const bypassLogs: {
294
+ actor: string | null;
295
+ via: string | null;
296
+ violations: ValidationWarning[];
297
+ }[] = [];
298
+
299
+ const result = await migrateTagField(store, {
300
+ tag: "kpi",
301
+ field: "state",
302
+ // Map the illegal "wip" to a legal enum value — but the intermediate
303
+ // write still goes through a strict field, so the bypass path is exercised
304
+ // and the would-be violation (the original "wip") is what's being fixed.
305
+ transform: { kind: "remap", map: { wip: "in_progress" } },
306
+ apply: true,
307
+ actor: "migrate-bot",
308
+ via: "migrate",
309
+ onStrictBypass: (info) => bypassLogs.push(info),
310
+ });
311
+
312
+ expect(result.applied).toBe(true);
313
+ expect((await getMeta(a.id)).state).toBe("in_progress");
314
+ // The final value conforms, so no violation on the WRITTEN shape — the
315
+ // remap fixes the data. Confirm the written note passes strict validation.
316
+ const status = store.validateNoteAgainstSchemas({
317
+ tags: ["kpi"],
318
+ metadata: await getMeta(a.id),
319
+ });
320
+ expect(status!.warnings.filter((w) => w.strict)).toHaveLength(0);
321
+ });
322
+
323
+ it("logs + attributes the bypass when the written value still violates strict", async () => {
324
+ // set-default to a value NOT in the enum: the write itself stays
325
+ // non-conforming, so the bypass logger MUST fire (we waived enforcement).
326
+ const a = await store.createNote("a", { tags: ["kpi"], metadata: {} });
327
+
328
+ const bypassLogs: {
329
+ actor: string | null;
330
+ via: string | null;
331
+ path?: string | null;
332
+ violations: ValidationWarning[];
333
+ }[] = [];
334
+
335
+ const result = await migrateTagField(store, {
336
+ tag: "kpi",
337
+ field: "state",
338
+ transform: { kind: "set_default", value: "legacy" }, // not in enum
339
+ apply: true,
340
+ actor: "migrate-bot",
341
+ via: "migrate",
342
+ onStrictBypass: (info) => bypassLogs.push(info),
343
+ });
344
+
345
+ expect(result.applied).toBe(true);
346
+ expect((await getMeta(a.id)).state).toBe("legacy"); // written despite strict
347
+ expect(bypassLogs).toHaveLength(1);
348
+ expect(bypassLogs[0]!.actor).toBe("migrate-bot");
349
+ expect(bypassLogs[0]!.via).toBe("migrate");
350
+ expect(bypassLogs[0]!.violations.some((v) => v.field === "state" && v.reason === "enum_mismatch")).toBe(true);
351
+ });
352
+
353
+ it("set-default-invalid only overwrites values that fail the schema", async () => {
354
+ const legal = await store.createNote("legal", { tags: ["kpi"], metadata: { state: "shipped" } });
355
+ const illegal = await store.createNote("illegal", { tags: ["kpi"], metadata: { state: "wip" } });
356
+ const missing = await store.createNote("missing", { tags: ["kpi"], metadata: {} });
357
+
358
+ const result = await migrateTagField(store, {
359
+ tag: "kpi",
360
+ field: "state",
361
+ transform: { kind: "set_default", value: "in_progress", onlyInvalid: true },
362
+ apply: true,
363
+ onStrictBypass: () => {},
364
+ });
365
+
366
+ expect(result.changed).toBe(2); // illegal + missing
367
+ expect((await getMeta(legal.id)).state).toBe("shipped"); // valid — kept
368
+ expect((await getMeta(illegal.id)).state).toBe("in_progress"); // invalid — fixed
369
+ expect((await getMeta(missing.id)).state).toBe("in_progress"); // missing — filled
370
+ });
371
+ });
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Idempotency
375
+ // ---------------------------------------------------------------------------
376
+
377
+ describe("idempotency — re-running apply is a no-op once conformant", () => {
378
+ it("second apply changes nothing", async () => {
379
+ await store.createNote("a", { tags: ["kpi"], metadata: { state: "wip" } });
380
+
381
+ const first = await migrateTagField(store, {
382
+ tag: "kpi",
383
+ field: "state",
384
+ transform: { kind: "remap", map: { wip: "in_progress" } },
385
+ apply: true,
386
+ });
387
+ expect(first.changed).toBe(1);
388
+
389
+ const second = await migrateTagField(store, {
390
+ tag: "kpi",
391
+ field: "state",
392
+ transform: { kind: "remap", map: { wip: "in_progress" } },
393
+ apply: true,
394
+ });
395
+ expect(second.changed).toBe(0);
396
+ expect(second.scanned).toBe(1);
397
+ });
398
+
399
+ it("retype is idempotent — re-running on an already-int value is a no-op", async () => {
400
+ await store.createNote("a", { tags: ["kpi"], metadata: { count: "5" } });
401
+ const first = await migrateTagField(store, {
402
+ tag: "kpi", field: "count", transform: { kind: "to_int" }, apply: true,
403
+ });
404
+ expect(first.changed).toBe(1);
405
+ const second = await migrateTagField(store, {
406
+ tag: "kpi", field: "count", transform: { kind: "to_int" }, apply: true,
407
+ });
408
+ expect(second.changed).toBe(0);
409
+ });
410
+ });
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Unit: applyTransform + coercion edge cases
414
+ // ---------------------------------------------------------------------------
415
+
416
+ describe("applyTransform unit", () => {
417
+ it("rename of an absent field is a no-op", () => {
418
+ expect(applyTransform("x", undefined, false, { kind: "rename", to: "y" })).toBeNull();
419
+ });
420
+ it("to_int rejects a fractional number", () => {
421
+ expect(() => applyTransform("x", 5.5, true, { kind: "to_int" })).toThrow(TransformError);
422
+ });
423
+ it("to_boolean parses yes/no strings", () => {
424
+ expect(applyTransform("x", "yes", true, { kind: "to_boolean" })!.value).toBe(true);
425
+ expect(applyTransform("x", "no", true, { kind: "to_boolean" })!.value).toBe(false);
426
+ });
427
+ it("to_string of an already-string value is a no-op (idempotent)", () => {
428
+ expect(applyTransform("x", "hi", true, { kind: "to_string" })).toBeNull();
429
+ });
430
+ });
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // Unit: parseTransformSpec
434
+ // ---------------------------------------------------------------------------
435
+
436
+ describe("parseTransformSpec", () => {
437
+ it("parses the retype keywords", () => {
438
+ expect(parseTransformSpec("to_string")).toEqual({ kind: "to_string" });
439
+ expect(parseTransformSpec("to_int")).toEqual({ kind: "to_int" });
440
+ expect(parseTransformSpec("to_number")).toEqual({ kind: "to_number" });
441
+ expect(parseTransformSpec("to_boolean")).toEqual({ kind: "to_boolean" });
442
+ });
443
+ it("parses rename", () => {
444
+ expect(parseTransformSpec("rename:metric")).toEqual({ kind: "rename", to: "metric" });
445
+ });
446
+ it("parses remap with multiple pairs and JSON scalars", () => {
447
+ expect(parseTransformSpec("remap:wip=in_progress,done=shipped")).toEqual({
448
+ kind: "remap",
449
+ map: { wip: "in_progress", done: "shipped" },
450
+ });
451
+ expect(parseTransformSpec("remap:a=1,b=true")).toEqual({
452
+ kind: "remap",
453
+ map: { a: 1, b: true },
454
+ });
455
+ });
456
+ it("parses set-default and set-default-invalid with scalar coercion", () => {
457
+ expect(parseTransformSpec("set-default:normal")).toEqual({ kind: "set_default", value: "normal" });
458
+ expect(parseTransformSpec("set-default:0")).toEqual({ kind: "set_default", value: 0 });
459
+ expect(parseTransformSpec("set-default-invalid:in_progress")).toEqual({
460
+ kind: "set_default",
461
+ value: "in_progress",
462
+ onlyInvalid: true,
463
+ });
464
+ });
465
+ it("throws on a malformed or unknown spec", () => {
466
+ expect(() => parseTransformSpec("bogus")).toThrow(TransformError);
467
+ expect(() => parseTransformSpec("rename:")).toThrow(TransformError);
468
+ expect(() => parseTransformSpec("remap:nopair")).toThrow(TransformError);
469
+ expect(() => parseTransformSpec("frobnicate:x")).toThrow(TransformError);
470
+ });
471
+ });