@openparachute/vault 0.6.4-rc.3 → 0.6.4-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/migrate-tag-field.test.ts +471 -0
- package/core/src/migrate-tag-field.ts +638 -0
- package/package.json +1 -1
- package/src/cli.ts +164 -3
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* migrate-tag-field (vault#314) — evolve a tag-schema field's type / values
|
|
3
|
+
* across the existing notes that carry a tag, so a schema tightening (declare a
|
|
4
|
+
* `strict` enum, rename an enum value, retype a field, set a default for a
|
|
5
|
+
* now-required field) can bring the back-catalog into conformance in one
|
|
6
|
+
* operation instead of editing notes by hand.
|
|
7
|
+
*
|
|
8
|
+
* This is the companion to enforced writes (vault#299 / MW2): declare `strict`
|
|
9
|
+
* → find the non-conforming notes → migrate them. The migration is the EXACT
|
|
10
|
+
* use case the migrate-bypass scope (`vault:migrate`) exists for — it rewrites
|
|
11
|
+
* notes through `strict` enforcement and logs the bypass for audit.
|
|
12
|
+
*
|
|
13
|
+
* Design (issue #314):
|
|
14
|
+
* - Walk every note carrying `tag` (descendants included — `store.queryNotes`
|
|
15
|
+
* already expands the hierarchy), apply a `transform` to the named
|
|
16
|
+
* metadata `field`, write back.
|
|
17
|
+
* - **Dry-run by default.** The default returns a plan (count + sample of
|
|
18
|
+
* would-change rows) WITHOUT mutating. `apply: true` is the explicit
|
|
19
|
+
* opt-in that writes. A migration is destructive across many notes, so the
|
|
20
|
+
* preview is first-class.
|
|
21
|
+
* - **Bypass + attribution.** Each rewrite runs `enforceStrictWrite` with
|
|
22
|
+
* `bypass: true` so a `strict:true` field that the back-catalog violates
|
|
23
|
+
* doesn't block the migration, and every bypassed write is logged
|
|
24
|
+
* (`onBypass`). Writes carry `actor`/`via` (MW1) — the migration is the
|
|
25
|
+
* `last_updated_*` author; `created_*` is set-once and never touched.
|
|
26
|
+
* - **Surgical.** Only the named `field` on matching notes is touched; all
|
|
27
|
+
* other metadata is preserved verbatim. A `rename` transform moves the
|
|
28
|
+
* value to the new key and drops the old.
|
|
29
|
+
* - **Clean failure.** An unconvertible retype (`to_int` on `"banana"`)
|
|
30
|
+
* fails that note cleanly — it's reported as `errored`, never partially
|
|
31
|
+
* written. By default a single error aborts the whole apply BEFORE any
|
|
32
|
+
* write (atomic preflight); `continueOnError: true` switches to
|
|
33
|
+
* best-effort, writing the convertible notes and reporting the rest.
|
|
34
|
+
* - **Idempotent.** Re-running an apply once the catalog conforms is a no-op
|
|
35
|
+
* (no note is in the would-change set the second time).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { Store, Note } from "./types.js";
|
|
39
|
+
import type { ValidationWarning } from "./schema-defaults.js";
|
|
40
|
+
import { enforceStrictWrite } from "./mcp.js";
|
|
41
|
+
|
|
42
|
+
// Re-export for callers (tests, server layer) that work with the bypass
|
|
43
|
+
// logger's violation list without importing schema-defaults directly.
|
|
44
|
+
export type { ValidationWarning } from "./schema-defaults.js";
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Transforms
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The transform set (issue #314 §Proposal — the built-in set; no expression
|
|
52
|
+
* language). Each names a concrete schema-evolution move:
|
|
53
|
+
*
|
|
54
|
+
* - `rename` — move the field's value to a NEW metadata key, dropping the
|
|
55
|
+
* old key. The schema-rename move (`kpi` → `metric`). `to` is
|
|
56
|
+
* the new field name. Notes missing the field are untouched.
|
|
57
|
+
* - `remap` — rewrite the field's VALUE per a value→value map. The enum
|
|
58
|
+
* value-rename move (`old` → `new`). Values not in the map are
|
|
59
|
+
* left as-is. Pass `default` to also fill missing/null values.
|
|
60
|
+
* - `set_default` — set the field to `value` on notes where it's missing,
|
|
61
|
+
* null, or (when `onlyInvalid` + a validator) currently
|
|
62
|
+
* invalid. Notes that already carry a valid value are
|
|
63
|
+
* untouched. The "default for a now-required field" move.
|
|
64
|
+
* - `to_string` / `to_int` / `to_number` / `to_boolean` — retype the field's
|
|
65
|
+
* value. Conversions are conservative (see `coerce*`); an
|
|
66
|
+
* unconvertible value is an error for that note, not a
|
|
67
|
+
* silent drop. The "changed a field's type" move.
|
|
68
|
+
*/
|
|
69
|
+
export type FieldTransform =
|
|
70
|
+
| { kind: "rename"; to: string }
|
|
71
|
+
| { kind: "remap"; map: Record<string, unknown>; default?: unknown }
|
|
72
|
+
| { kind: "set_default"; value: unknown; onlyInvalid?: boolean }
|
|
73
|
+
| { kind: "to_string" }
|
|
74
|
+
| { kind: "to_int" }
|
|
75
|
+
| { kind: "to_number" }
|
|
76
|
+
| { kind: "to_boolean" };
|
|
77
|
+
|
|
78
|
+
/** A transform raised this when a value can't be converted (clean per-note failure). */
|
|
79
|
+
export class TransformError extends Error {
|
|
80
|
+
code = "TRANSFORM_ERROR" as const;
|
|
81
|
+
constructor(message: string) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = "TransformError";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const PRESENT = Symbol("present"); // sentinel: field exists with this value
|
|
88
|
+
const ABSENT = Symbol("absent"); // sentinel: field is missing entirely
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Outcome of applying a transform to one note's field. `change` describes the
|
|
92
|
+
* metadata edit:
|
|
93
|
+
* - `null` → no change (note left untouched).
|
|
94
|
+
* - `{ setField, value }` → set `setField` to `value`.
|
|
95
|
+
* - `{ setField, value, dropField }` → set the new key, drop the old (rename).
|
|
96
|
+
*/
|
|
97
|
+
interface TransformResult {
|
|
98
|
+
/** Field name to write (the new key for rename, else the original field). */
|
|
99
|
+
setField: string;
|
|
100
|
+
/** Value to write. */
|
|
101
|
+
value: unknown;
|
|
102
|
+
/** Field name to remove (rename only — the original key). */
|
|
103
|
+
dropField?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Apply a transform to the (possibly-absent) current value of `field`. Returns
|
|
108
|
+
* the metadata edit to make, or `null` for "no change." Throws `TransformError`
|
|
109
|
+
* on an unconvertible retype.
|
|
110
|
+
*
|
|
111
|
+
* `present` distinguishes "field is missing" from "field is present and null/
|
|
112
|
+
* undefined" — `set_default` fills both, but `rename`/`remap`/retype only act
|
|
113
|
+
* on a present field (you can't rename a key that isn't there).
|
|
114
|
+
*/
|
|
115
|
+
export function applyTransform(
|
|
116
|
+
field: string,
|
|
117
|
+
current: unknown,
|
|
118
|
+
present: boolean,
|
|
119
|
+
transform: FieldTransform,
|
|
120
|
+
isValid?: (v: unknown) => boolean,
|
|
121
|
+
): TransformResult | null {
|
|
122
|
+
switch (transform.kind) {
|
|
123
|
+
case "rename": {
|
|
124
|
+
if (!present) return null; // nothing to move
|
|
125
|
+
if (transform.to === field) return null; // no-op rename
|
|
126
|
+
return { setField: transform.to, value: current, dropField: field };
|
|
127
|
+
}
|
|
128
|
+
case "remap": {
|
|
129
|
+
// Fill missing/null when a `default` is provided…
|
|
130
|
+
if (!present || current === null || current === undefined) {
|
|
131
|
+
if ("default" in transform) {
|
|
132
|
+
return changeIfDifferent(field, current, transform.default, present);
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
// …else remap the value if it's a key in the map (string-keyed lookup).
|
|
137
|
+
const key = typeof current === "string" ? current : String(current);
|
|
138
|
+
if (Object.prototype.hasOwnProperty.call(transform.map, key)) {
|
|
139
|
+
return changeIfDifferent(field, current, transform.map[key], present);
|
|
140
|
+
}
|
|
141
|
+
return null; // value not in the map — leave as-is
|
|
142
|
+
}
|
|
143
|
+
case "set_default": {
|
|
144
|
+
const missing = !present || current === null || current === undefined;
|
|
145
|
+
if (missing) {
|
|
146
|
+
return changeIfDifferent(field, current, transform.value, present);
|
|
147
|
+
}
|
|
148
|
+
// Present + non-null. Overwrite only when `onlyInvalid` and the value
|
|
149
|
+
// currently fails validation (e.g. an enum value that's no longer legal).
|
|
150
|
+
if (transform.onlyInvalid && isValid && !isValid(current)) {
|
|
151
|
+
return changeIfDifferent(field, current, transform.value, present);
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
case "to_string": {
|
|
156
|
+
if (!present || current === null || current === undefined) return null;
|
|
157
|
+
return changeIfDifferent(field, current, coerceString(current), present);
|
|
158
|
+
}
|
|
159
|
+
case "to_int": {
|
|
160
|
+
if (!present || current === null || current === undefined) return null;
|
|
161
|
+
return changeIfDifferent(field, current, coerceInt(field, current), present);
|
|
162
|
+
}
|
|
163
|
+
case "to_number": {
|
|
164
|
+
if (!present || current === null || current === undefined) return null;
|
|
165
|
+
return changeIfDifferent(field, current, coerceNumber(field, current), present);
|
|
166
|
+
}
|
|
167
|
+
case "to_boolean": {
|
|
168
|
+
if (!present || current === null || current === undefined) return null;
|
|
169
|
+
return changeIfDifferent(field, current, coerceBoolean(field, current), present);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Return a `TransformResult` only when the new value actually differs from the
|
|
176
|
+
* current one (idempotency at the value level — a remap to the same value, or a
|
|
177
|
+
* retype of an already-correctly-typed value, is a no-op). `present === false`
|
|
178
|
+
* always counts as a change when we're writing a value (the field is being
|
|
179
|
+
* added).
|
|
180
|
+
*/
|
|
181
|
+
function changeIfDifferent(
|
|
182
|
+
field: string,
|
|
183
|
+
current: unknown,
|
|
184
|
+
next: unknown,
|
|
185
|
+
present: boolean,
|
|
186
|
+
): TransformResult | null {
|
|
187
|
+
if (present && Object.is(current, next)) return null;
|
|
188
|
+
// Deep-equal escape hatch for non-primitive equal values (rare; remap to an
|
|
189
|
+
// equal object/array). JSON compare is sufficient — metadata is JSON.
|
|
190
|
+
if (present && typeof current === typeof next && typeof current === "object") {
|
|
191
|
+
try {
|
|
192
|
+
if (JSON.stringify(current) === JSON.stringify(next)) return null;
|
|
193
|
+
} catch {
|
|
194
|
+
/* fall through — treat as a change */
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { setField: field, value: next };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function coerceString(v: unknown): string {
|
|
201
|
+
if (typeof v === "string") return v;
|
|
202
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
203
|
+
// Objects/arrays → JSON, which is the least-surprising stringification for
|
|
204
|
+
// metadata and round-trips back through `to_*` cleanly.
|
|
205
|
+
return JSON.stringify(v);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function coerceInt(field: string, v: unknown): number {
|
|
209
|
+
if (typeof v === "number") {
|
|
210
|
+
if (!Number.isFinite(v)) throw new TransformError(`'${field}': ${v} is not a finite number`);
|
|
211
|
+
if (!Number.isInteger(v)) {
|
|
212
|
+
throw new TransformError(`'${field}': ${v} has a fractional part — cannot convert to integer`);
|
|
213
|
+
}
|
|
214
|
+
return v;
|
|
215
|
+
}
|
|
216
|
+
if (typeof v === "boolean") return v ? 1 : 0;
|
|
217
|
+
if (typeof v === "string") {
|
|
218
|
+
const t = v.trim();
|
|
219
|
+
// Strict integer parse — reject "5px", "5.5", "" and other junk that
|
|
220
|
+
// Number() would silently coerce or NaN.
|
|
221
|
+
if (!/^[+-]?\d+$/.test(t)) {
|
|
222
|
+
throw new TransformError(`'${field}': "${v}" is not an integer`);
|
|
223
|
+
}
|
|
224
|
+
const n = Number(t);
|
|
225
|
+
if (!Number.isSafeInteger(n)) {
|
|
226
|
+
throw new TransformError(`'${field}': "${v}" is out of safe-integer range`);
|
|
227
|
+
}
|
|
228
|
+
return n;
|
|
229
|
+
}
|
|
230
|
+
throw new TransformError(`'${field}': cannot convert ${typeof v} to integer`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function coerceNumber(field: string, v: unknown): number {
|
|
234
|
+
if (typeof v === "number") {
|
|
235
|
+
if (!Number.isFinite(v)) throw new TransformError(`'${field}': ${v} is not a finite number`);
|
|
236
|
+
return v;
|
|
237
|
+
}
|
|
238
|
+
if (typeof v === "boolean") return v ? 1 : 0;
|
|
239
|
+
if (typeof v === "string") {
|
|
240
|
+
const t = v.trim();
|
|
241
|
+
if (t === "" || !Number.isFinite(Number(t))) {
|
|
242
|
+
throw new TransformError(`'${field}': "${v}" is not a number`);
|
|
243
|
+
}
|
|
244
|
+
return Number(t);
|
|
245
|
+
}
|
|
246
|
+
throw new TransformError(`'${field}': cannot convert ${typeof v} to number`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function coerceBoolean(field: string, v: unknown): boolean {
|
|
250
|
+
if (typeof v === "boolean") return v;
|
|
251
|
+
if (typeof v === "number") {
|
|
252
|
+
if (v === 1) return true;
|
|
253
|
+
if (v === 0) return false;
|
|
254
|
+
throw new TransformError(`'${field}': ${v} is not a boolean (expected 0 or 1)`);
|
|
255
|
+
}
|
|
256
|
+
if (typeof v === "string") {
|
|
257
|
+
const t = v.trim().toLowerCase();
|
|
258
|
+
if (t === "true" || t === "yes" || t === "1") return true;
|
|
259
|
+
if (t === "false" || t === "no" || t === "0") return false;
|
|
260
|
+
throw new TransformError(`'${field}': "${v}" is not a boolean (expected true/false/yes/no/1/0)`);
|
|
261
|
+
}
|
|
262
|
+
throw new TransformError(`'${field}': cannot convert ${typeof v} to boolean`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Migration
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
export interface MigrateTagFieldOpts {
|
|
270
|
+
/** The tag whose notes carry the field. Hierarchy descendants are included. */
|
|
271
|
+
tag: string;
|
|
272
|
+
/** The metadata field to evolve. */
|
|
273
|
+
field: string;
|
|
274
|
+
/** The transform to apply. */
|
|
275
|
+
transform: FieldTransform;
|
|
276
|
+
/**
|
|
277
|
+
* Dry-run by default. `apply: true` is the explicit opt-in that writes —
|
|
278
|
+
* everything else returns a plan and mutates NOTHING.
|
|
279
|
+
*/
|
|
280
|
+
apply?: boolean;
|
|
281
|
+
/**
|
|
282
|
+
* Write-attribution (MW1) for the rewrites. The migration is the
|
|
283
|
+
* `last_updated_*` author; `created_*` is never touched. Defaults to
|
|
284
|
+
* `{ actor: "migrate", via: "migrate" }` so a migration is always
|
|
285
|
+
* distinguishable in the attribution columns even when the caller omits it.
|
|
286
|
+
*/
|
|
287
|
+
actor?: string | null;
|
|
288
|
+
via?: string | null;
|
|
289
|
+
/**
|
|
290
|
+
* How many would-change rows to include in `sample`. Default 10. The full
|
|
291
|
+
* count is always reported regardless.
|
|
292
|
+
*/
|
|
293
|
+
sampleSize?: number;
|
|
294
|
+
/**
|
|
295
|
+
* Error policy on an unconvertible retype:
|
|
296
|
+
* - default (`false`) — ATOMIC: any error aborts the whole apply BEFORE a
|
|
297
|
+
* single write, so the migration is all-or-nothing. The plan lists every
|
|
298
|
+
* errored note so the operator can fix the data first.
|
|
299
|
+
* - `true` — best-effort: write every convertible note, report the rest.
|
|
300
|
+
*/
|
|
301
|
+
continueOnError?: boolean;
|
|
302
|
+
/**
|
|
303
|
+
* Bypass-audit logger (MW2). Invoked once per write that bypassed a
|
|
304
|
+
* `strict:true` field constraint, with the would-be violations + attribution.
|
|
305
|
+
* The server layer passes `logStrictBypass`; core stays log-sink-agnostic.
|
|
306
|
+
* Omitted → bypassed writes still happen (the migration must), just unlogged.
|
|
307
|
+
*/
|
|
308
|
+
onStrictBypass?: (info: {
|
|
309
|
+
actor: string | null;
|
|
310
|
+
via: string | null;
|
|
311
|
+
path?: string | null;
|
|
312
|
+
tags?: string[];
|
|
313
|
+
violations: ValidationWarning[];
|
|
314
|
+
}) => void;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** A single planned/applied note change. */
|
|
318
|
+
export interface MigrateChange {
|
|
319
|
+
id: string;
|
|
320
|
+
path: string | null;
|
|
321
|
+
/** The value of the field before the transform (the original key for rename). */
|
|
322
|
+
before: unknown;
|
|
323
|
+
/** The value written (under `field`, or under the new key for rename). */
|
|
324
|
+
after: unknown;
|
|
325
|
+
/** Set for rename: the new key the value moved to. */
|
|
326
|
+
renamedTo?: string;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** A note that couldn't be transformed (unconvertible retype). */
|
|
330
|
+
export interface MigrateErroredNote {
|
|
331
|
+
id: string;
|
|
332
|
+
path: string | null;
|
|
333
|
+
before: unknown;
|
|
334
|
+
error: string;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export interface MigrateTagFieldResult {
|
|
338
|
+
tag: string;
|
|
339
|
+
field: string;
|
|
340
|
+
transform: FieldTransform;
|
|
341
|
+
/** True when this run wrote to the vault; false for a dry-run. */
|
|
342
|
+
applied: boolean;
|
|
343
|
+
/** Notes carrying the tag that were scanned. */
|
|
344
|
+
scanned: number;
|
|
345
|
+
/** Notes that would change (dry-run) or did change (apply). */
|
|
346
|
+
changed: number;
|
|
347
|
+
/** Notes left untouched (already conformant / field absent / value not in map). */
|
|
348
|
+
unchanged: number;
|
|
349
|
+
/** Notes whose transform failed (unconvertible). */
|
|
350
|
+
errored: number;
|
|
351
|
+
/** A bounded preview of the changes (up to `sampleSize`). */
|
|
352
|
+
sample: MigrateChange[];
|
|
353
|
+
/** Every errored note (not bounded — the operator needs the full list to fix data). */
|
|
354
|
+
errors: MigrateErroredNote[];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Run a tag-field migration. Dry-run by default; `apply: true` writes.
|
|
359
|
+
*
|
|
360
|
+
* The flow is preflight-then-write:
|
|
361
|
+
* 1. Query every note carrying `tag`.
|
|
362
|
+
* 2. For each, compute the transform result. Collect changes + errors.
|
|
363
|
+
* 3. If NOT `apply` → return the plan (nothing written).
|
|
364
|
+
* 4. If `apply` and there are errors and NOT `continueOnError` → return the
|
|
365
|
+
* plan WITHOUT writing (atomic abort — the operator fixes the data first).
|
|
366
|
+
* 5. Otherwise write each change via `store.updateNote`, running
|
|
367
|
+
* `enforceStrictWrite(bypass:true)` first so a `strict` violation logs
|
|
368
|
+
* rather than blocks.
|
|
369
|
+
*/
|
|
370
|
+
export async function migrateTagField(
|
|
371
|
+
store: Store,
|
|
372
|
+
opts: MigrateTagFieldOpts,
|
|
373
|
+
): Promise<MigrateTagFieldResult> {
|
|
374
|
+
const { tag, field, transform } = opts;
|
|
375
|
+
const sampleSize = opts.sampleSize ?? 10;
|
|
376
|
+
const actor = opts.actor ?? "migrate";
|
|
377
|
+
const via = opts.via ?? "migrate";
|
|
378
|
+
|
|
379
|
+
// Validate the transform shape up front so a bad arg fails before any scan.
|
|
380
|
+
validateTransform(field, transform);
|
|
381
|
+
|
|
382
|
+
// A validator the `set_default { onlyInvalid }` path uses to decide whether
|
|
383
|
+
// a present value is "invalid" — driven by the tag's resolved schema for the
|
|
384
|
+
// field (enum / type), so `onlyInvalid` means "the current value violates the
|
|
385
|
+
// declared schema." Absent schema → never invalid (so onlyInvalid is a no-op,
|
|
386
|
+
// which is the safe reading: with nothing to validate against, don't clobber).
|
|
387
|
+
// Only built for the transform that uses it; other transforms never call it.
|
|
388
|
+
const isValid =
|
|
389
|
+
transform.kind === "set_default" && transform.onlyInvalid
|
|
390
|
+
? makeFieldValidator(store, tag, field)
|
|
391
|
+
: undefined;
|
|
392
|
+
|
|
393
|
+
const notes = await collectTaggedNotes(store, tag);
|
|
394
|
+
|
|
395
|
+
const changes: MigrateChange[] = [];
|
|
396
|
+
const errors: MigrateErroredNote[] = [];
|
|
397
|
+
// Parallel list of (note, result) for the write phase — keeps the transform
|
|
398
|
+
// pass and the write pass from diverging on which notes change.
|
|
399
|
+
const pending: { note: Note; result: TransformResult }[] = [];
|
|
400
|
+
|
|
401
|
+
for (const note of notes) {
|
|
402
|
+
const meta = (note.metadata ?? {}) as Record<string, unknown>;
|
|
403
|
+
const present = Object.prototype.hasOwnProperty.call(meta, field);
|
|
404
|
+
const current = present ? meta[field] : undefined;
|
|
405
|
+
let result: TransformResult | null;
|
|
406
|
+
try {
|
|
407
|
+
result = applyTransform(field, current, present, transform, isValid);
|
|
408
|
+
} catch (err) {
|
|
409
|
+
errors.push({
|
|
410
|
+
id: note.id,
|
|
411
|
+
path: note.path ?? null,
|
|
412
|
+
before: current,
|
|
413
|
+
error: err instanceof Error ? err.message : String(err),
|
|
414
|
+
});
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (result === null) continue; // unchanged
|
|
418
|
+
pending.push({ note, result });
|
|
419
|
+
if (changes.length < sampleSize) {
|
|
420
|
+
changes.push({
|
|
421
|
+
id: note.id,
|
|
422
|
+
path: note.path ?? null,
|
|
423
|
+
before: current,
|
|
424
|
+
after: result.value,
|
|
425
|
+
...(result.dropField ? { renamedTo: result.setField } : {}),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const baseResult = {
|
|
431
|
+
tag,
|
|
432
|
+
field,
|
|
433
|
+
transform,
|
|
434
|
+
scanned: notes.length,
|
|
435
|
+
changed: pending.length,
|
|
436
|
+
unchanged: notes.length - pending.length - errors.length,
|
|
437
|
+
errored: errors.length,
|
|
438
|
+
sample: changes,
|
|
439
|
+
errors,
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// Dry-run, or an atomic abort because the preflight found errors.
|
|
443
|
+
if (!opts.apply) {
|
|
444
|
+
return { ...baseResult, applied: false };
|
|
445
|
+
}
|
|
446
|
+
if (errors.length > 0 && !opts.continueOnError) {
|
|
447
|
+
// Atomic: refuse to write any note when some can't be converted, so the
|
|
448
|
+
// migration is all-or-nothing. The caller sees the full error list.
|
|
449
|
+
return { ...baseResult, applied: false };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Write phase.
|
|
453
|
+
for (const { note, result } of pending) {
|
|
454
|
+
const nextMeta: Record<string, unknown> = { ...(note.metadata ?? {}) };
|
|
455
|
+
nextMeta[result.setField] = result.value;
|
|
456
|
+
if (result.dropField && result.dropField !== result.setField) {
|
|
457
|
+
delete nextMeta[result.dropField];
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Run strict enforcement with bypass ON (MW2) through the SAME gate the
|
|
461
|
+
// MCP/REST write transports use, so the migration can't drift from the
|
|
462
|
+
// canonical enforcement contract (e.g. when audit-log #300 lands and
|
|
463
|
+
// `onBypass` grows a DB write). The migration must write THROUGH a
|
|
464
|
+
// `strict:true` field the back-catalog violates — bypass skips the throw,
|
|
465
|
+
// and `onBypass` logs the waived violations with attribution for audit.
|
|
466
|
+
enforceStrictWrite(
|
|
467
|
+
store,
|
|
468
|
+
{ path: note.path, tags: note.tags, metadata: nextMeta },
|
|
469
|
+
{
|
|
470
|
+
bypass: true,
|
|
471
|
+
onBypass: opts.onStrictBypass
|
|
472
|
+
? (violations) =>
|
|
473
|
+
opts.onStrictBypass!({
|
|
474
|
+
actor,
|
|
475
|
+
via,
|
|
476
|
+
path: note.path ?? null,
|
|
477
|
+
tags: note.tags,
|
|
478
|
+
violations,
|
|
479
|
+
})
|
|
480
|
+
: undefined,
|
|
481
|
+
},
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
await store.updateNote(note.id, { metadata: nextMeta, actor, via });
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return { ...baseResult, applied: true };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Make a predicate "does this value satisfy the tag's declared schema for
|
|
492
|
+
* `field`?" from the live schema config. Used by `set_default { onlyInvalid }`.
|
|
493
|
+
* When no schema declares the field, every value is "valid" (no constraint to
|
|
494
|
+
* fail), so `onlyInvalid` won't overwrite anything — the conservative choice.
|
|
495
|
+
*/
|
|
496
|
+
function makeFieldValidator(
|
|
497
|
+
store: Store,
|
|
498
|
+
tag: string,
|
|
499
|
+
field: string,
|
|
500
|
+
): (v: unknown) => boolean {
|
|
501
|
+
return (v: unknown): boolean => {
|
|
502
|
+
// Build a synthetic single-field note and ask the resolver whether THIS
|
|
503
|
+
// field has a strict-or-advisory violation. Any violation on the field →
|
|
504
|
+
// invalid.
|
|
505
|
+
const status = store.validateNoteAgainstSchemas({
|
|
506
|
+
tags: [tag],
|
|
507
|
+
metadata: { [field]: v },
|
|
508
|
+
});
|
|
509
|
+
if (!status) return true;
|
|
510
|
+
return !status.warnings.some(
|
|
511
|
+
(w) => w.field === field && w.reason !== "schema_conflict" && w.reason !== "missing_required",
|
|
512
|
+
);
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Pull every note carrying `tag` (hierarchy-expanded by the store). */
|
|
517
|
+
async function collectTaggedNotes(store: Store, tag: string): Promise<Note[]> {
|
|
518
|
+
// The migration must see EVERY tagged note, not a default page. Use a large
|
|
519
|
+
// limit — the same approach `syncAllWikilinks` takes for a whole-vault sweep.
|
|
520
|
+
return store.queryNotes({ tags: [tag], limit: 1_000_000 });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
// CLI transform-spec parsing
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Parse a CLI `--transform <spec>` string into a `FieldTransform`. The built-in
|
|
529
|
+
* spec grammar (issue #314 — "just a built-in set", no expression language):
|
|
530
|
+
*
|
|
531
|
+
* rename:<newkey> → { kind: "rename", to }
|
|
532
|
+
* remap:<old>=<new>[,...] → { kind: "remap", map }
|
|
533
|
+
* set-default:<value> → { kind: "set_default", value } (value JSON-parsed, else string)
|
|
534
|
+
* set-default-invalid:<v> → { kind: "set_default", value, onlyInvalid: true }
|
|
535
|
+
* to_string|to_int|to_number|to_boolean → the matching retype
|
|
536
|
+
*
|
|
537
|
+
* `remap` values and `set-default` values are parsed as JSON when they parse
|
|
538
|
+
* (so `set-default:0`, `set-default:true`, `remap:a=1`), falling back to the
|
|
539
|
+
* literal string otherwise (so `set-default:draft`, `remap:wip=published`).
|
|
540
|
+
*
|
|
541
|
+
* Throws `TransformError` on an unrecognized or malformed spec.
|
|
542
|
+
*/
|
|
543
|
+
export function parseTransformSpec(spec: string): FieldTransform {
|
|
544
|
+
const trimmed = spec.trim();
|
|
545
|
+
|
|
546
|
+
if (trimmed === "to_string") return { kind: "to_string" };
|
|
547
|
+
if (trimmed === "to_int") return { kind: "to_int" };
|
|
548
|
+
if (trimmed === "to_number") return { kind: "to_number" };
|
|
549
|
+
if (trimmed === "to_boolean") return { kind: "to_boolean" };
|
|
550
|
+
|
|
551
|
+
const colon = trimmed.indexOf(":");
|
|
552
|
+
if (colon === -1) {
|
|
553
|
+
throw new TransformError(
|
|
554
|
+
`unrecognized transform "${spec}" (expected one of: rename:<key>, remap:<old>=<new>, set-default:<value>, set-default-invalid:<value>, to_string, to_int, to_number, to_boolean)`,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
const head = trimmed.slice(0, colon);
|
|
558
|
+
const rest = trimmed.slice(colon + 1);
|
|
559
|
+
|
|
560
|
+
switch (head) {
|
|
561
|
+
case "rename": {
|
|
562
|
+
if (!rest) throw new TransformError("rename: missing new key — use rename:<newkey>");
|
|
563
|
+
return { kind: "rename", to: rest };
|
|
564
|
+
}
|
|
565
|
+
case "remap": {
|
|
566
|
+
if (!rest) throw new TransformError("remap: missing pairs — use remap:<old>=<new>[,<old>=<new>]");
|
|
567
|
+
const map: Record<string, unknown> = {};
|
|
568
|
+
for (const pair of rest.split(",")) {
|
|
569
|
+
const eq = pair.indexOf("=");
|
|
570
|
+
if (eq === -1) {
|
|
571
|
+
throw new TransformError(`remap: "${pair}" is not an <old>=<new> pair`);
|
|
572
|
+
}
|
|
573
|
+
const oldVal = pair.slice(0, eq);
|
|
574
|
+
const newVal = pair.slice(eq + 1);
|
|
575
|
+
if (!oldVal) throw new TransformError(`remap: empty <old> in "${pair}"`);
|
|
576
|
+
map[oldVal] = parseScalar(newVal);
|
|
577
|
+
}
|
|
578
|
+
return { kind: "remap", map };
|
|
579
|
+
}
|
|
580
|
+
case "set-default": {
|
|
581
|
+
return { kind: "set_default", value: parseScalar(rest) };
|
|
582
|
+
}
|
|
583
|
+
case "set-default-invalid": {
|
|
584
|
+
return { kind: "set_default", value: parseScalar(rest), onlyInvalid: true };
|
|
585
|
+
}
|
|
586
|
+
default:
|
|
587
|
+
throw new TransformError(`unknown transform "${head}" in "${spec}"`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Parse a CLI scalar: JSON when it parses to a scalar, else the literal string. */
|
|
592
|
+
function parseScalar(raw: string): unknown {
|
|
593
|
+
if (raw === "") return "";
|
|
594
|
+
try {
|
|
595
|
+
const parsed = JSON.parse(raw);
|
|
596
|
+
// Only accept JSON scalars (string/number/boolean/null) — an unquoted word
|
|
597
|
+
// like `draft` throws and falls through to the literal-string branch.
|
|
598
|
+
if (
|
|
599
|
+
parsed === null ||
|
|
600
|
+
typeof parsed === "string" ||
|
|
601
|
+
typeof parsed === "number" ||
|
|
602
|
+
typeof parsed === "boolean"
|
|
603
|
+
) {
|
|
604
|
+
return parsed;
|
|
605
|
+
}
|
|
606
|
+
} catch {
|
|
607
|
+
/* not JSON — treat as a literal string */
|
|
608
|
+
}
|
|
609
|
+
return raw;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Reject a structurally-invalid transform before any DB work. */
|
|
613
|
+
function validateTransform(field: string, transform: FieldTransform): void {
|
|
614
|
+
switch (transform.kind) {
|
|
615
|
+
case "rename":
|
|
616
|
+
if (typeof transform.to !== "string" || transform.to.length === 0) {
|
|
617
|
+
throw new TransformError("rename: `to` (new field name) is required");
|
|
618
|
+
}
|
|
619
|
+
break;
|
|
620
|
+
case "remap":
|
|
621
|
+
if (!transform.map || typeof transform.map !== "object" || Array.isArray(transform.map)) {
|
|
622
|
+
throw new TransformError("remap: `map` (value→value object) is required");
|
|
623
|
+
}
|
|
624
|
+
break;
|
|
625
|
+
case "set_default":
|
|
626
|
+
if (!("value" in transform)) {
|
|
627
|
+
throw new TransformError("set_default: `value` is required");
|
|
628
|
+
}
|
|
629
|
+
break;
|
|
630
|
+
case "to_string":
|
|
631
|
+
case "to_int":
|
|
632
|
+
case "to_number":
|
|
633
|
+
case "to_boolean":
|
|
634
|
+
break;
|
|
635
|
+
default:
|
|
636
|
+
throw new TransformError(`unknown transform: ${(transform as { kind: string }).kind}`);
|
|
637
|
+
}
|
|
638
|
+
}
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -3345,11 +3345,17 @@ async function cmdExport(args: string[]) {
|
|
|
3345
3345
|
*/
|
|
3346
3346
|
async function cmdSchema(args: string[] = []) {
|
|
3347
3347
|
const sub = args[0];
|
|
3348
|
+
if (sub === "migrate-field") {
|
|
3349
|
+
await cmdSchemaMigrateField(args.slice(1));
|
|
3350
|
+
return;
|
|
3351
|
+
}
|
|
3348
3352
|
if (sub !== "prune") {
|
|
3349
|
-
console.error("Usage: parachute-vault schema prune
|
|
3353
|
+
console.error("Usage: parachute-vault schema <prune|migrate-field> ...");
|
|
3350
3354
|
console.error("\nSubcommands:");
|
|
3351
|
-
console.error(" prune
|
|
3352
|
-
console.error("
|
|
3355
|
+
console.error(" prune Drop orphaned indexed-field columns whose declaring tags are gone.");
|
|
3356
|
+
console.error(" Dry-run by default (--dry-run is an explicit alias); pass --apply (or --yes) to execute.");
|
|
3357
|
+
console.error(" migrate-field Evolve a tag-schema field across existing notes (rename/remap/set-default/retype).");
|
|
3358
|
+
console.error(" Dry-run by default; pass --apply (or --yes) to write. See `schema migrate-field --help`.");
|
|
3353
3359
|
process.exit(1);
|
|
3354
3360
|
}
|
|
3355
3361
|
|
|
@@ -3416,6 +3422,154 @@ async function cmdSchema(args: string[] = []) {
|
|
|
3416
3422
|
}
|
|
3417
3423
|
}
|
|
3418
3424
|
|
|
3425
|
+
/**
|
|
3426
|
+
* `parachute-vault schema migrate-field <tag> <field> --transform <spec>` —
|
|
3427
|
+
* evolve a tag-schema field's type / values across the existing notes that
|
|
3428
|
+
* carry the tag (vault#314). Dry-run by default — prints the count + a sample
|
|
3429
|
+
* of what WOULD change and writes nothing; `--apply` (alias `--yes`) executes.
|
|
3430
|
+
*
|
|
3431
|
+
* Transform specs (the built-in set — issue #314):
|
|
3432
|
+
* rename:<newkey> move the field's value to <newkey>, drop the old key
|
|
3433
|
+
* remap:<old>=<new>[,…] rewrite enum-style values (repeatable / comma-sep)
|
|
3434
|
+
* set-default:<value> fill the field where missing/null
|
|
3435
|
+
* set-default-invalid:<v> set <v> where missing/null OR currently schema-invalid
|
|
3436
|
+
* to_string | to_int | to_number | to_boolean retype the value
|
|
3437
|
+
*
|
|
3438
|
+
* The migration writes through `strict` enforcement (the migrate-bypass path,
|
|
3439
|
+
* vault#299) so a freshly-declared strict field that the back-catalog violates
|
|
3440
|
+
* doesn't block the rewrite, and every bypassed write is logged. Rewrites are
|
|
3441
|
+
* attributed `migrate` (last_updated_*); created_* is preserved.
|
|
3442
|
+
*
|
|
3443
|
+
* Atomic by default: if any note's value can't be converted (e.g. `to_int` on
|
|
3444
|
+
* "banana"), the apply aborts BEFORE writing anything and lists the offenders.
|
|
3445
|
+
* Pass `--continue-on-error` for best-effort (write the convertible, skip the rest).
|
|
3446
|
+
*/
|
|
3447
|
+
async function cmdSchemaMigrateField(args: string[]) {
|
|
3448
|
+
const usageLine =
|
|
3449
|
+
"Usage: parachute-vault schema migrate-field <tag> <field> --transform <spec> [--vault <name>] [--apply|--yes] [--continue-on-error]";
|
|
3450
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
3451
|
+
console.log(usageLine);
|
|
3452
|
+
console.log("\nTransform specs:");
|
|
3453
|
+
console.log(" rename:<newkey> Move the field's value to <newkey>, drop the old key.");
|
|
3454
|
+
console.log(" remap:<old>=<new>[,...] Rewrite enum-style values (repeatable, comma-separated).");
|
|
3455
|
+
console.log(" set-default:<value> Set <value> on notes where the field is missing/null.");
|
|
3456
|
+
console.log(" set-default-invalid:<v> Set <v> where missing/null OR the current value is schema-invalid.");
|
|
3457
|
+
console.log(" to_string | to_int | to_number | to_boolean Retype the field's value.");
|
|
3458
|
+
console.log("\nDry-run by default — prints the plan. Pass --apply (or --yes) to write.");
|
|
3459
|
+
return;
|
|
3460
|
+
}
|
|
3461
|
+
|
|
3462
|
+
let vaultName = "default";
|
|
3463
|
+
let apply = false;
|
|
3464
|
+
let continueOnError = false;
|
|
3465
|
+
let transformSpec: string | undefined;
|
|
3466
|
+
const positionals: string[] = [];
|
|
3467
|
+
for (let i = 0; i < args.length; i++) {
|
|
3468
|
+
const arg = args[i]!;
|
|
3469
|
+
if (arg === "--vault") {
|
|
3470
|
+
const v = args[++i];
|
|
3471
|
+
if (!v) { console.error("--vault requires a value."); process.exit(1); }
|
|
3472
|
+
vaultName = v;
|
|
3473
|
+
} else if (arg === "--transform") {
|
|
3474
|
+
const v = args[++i];
|
|
3475
|
+
if (!v) { console.error("--transform requires a value."); process.exit(1); }
|
|
3476
|
+
transformSpec = v;
|
|
3477
|
+
} else if (arg === "--apply" || arg === "--yes") {
|
|
3478
|
+
apply = true;
|
|
3479
|
+
} else if (arg === "--dry-run") {
|
|
3480
|
+
// default; accept as an explicit affirmative
|
|
3481
|
+
} else if (arg === "--continue-on-error") {
|
|
3482
|
+
continueOnError = true;
|
|
3483
|
+
} else if (arg.startsWith("--")) {
|
|
3484
|
+
console.error(`Unknown flag for \`schema migrate-field\`: ${arg}`);
|
|
3485
|
+
process.exit(1);
|
|
3486
|
+
} else {
|
|
3487
|
+
positionals.push(arg);
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
const [tag, field] = positionals;
|
|
3492
|
+
if (!tag || !field || !transformSpec) {
|
|
3493
|
+
console.error(usageLine);
|
|
3494
|
+
console.error("\n <tag> <field> and --transform <spec> are all required.");
|
|
3495
|
+
console.error(" Run `schema migrate-field --help` for the transform spec list.");
|
|
3496
|
+
process.exit(1);
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
const { parseTransformSpec } = await import("../core/src/migrate-tag-field.ts");
|
|
3500
|
+
let transform;
|
|
3501
|
+
try {
|
|
3502
|
+
transform = parseTransformSpec(transformSpec);
|
|
3503
|
+
} catch (err) {
|
|
3504
|
+
console.error(`Invalid --transform "${transformSpec}": ${(err as Error).message}`);
|
|
3505
|
+
process.exit(1);
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3508
|
+
const config = readVaultConfig(vaultName);
|
|
3509
|
+
if (!config) {
|
|
3510
|
+
console.error(`Vault "${vaultName}" not found.`);
|
|
3511
|
+
process.exit(1);
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
const { getVaultStore } = await import("./vault-store.ts");
|
|
3515
|
+
const { migrateTagField } = await import("../core/src/migrate-tag-field.ts");
|
|
3516
|
+
const { logStrictBypass } = await import("./scopes.ts");
|
|
3517
|
+
const store = getVaultStore(vaultName);
|
|
3518
|
+
|
|
3519
|
+
const result = await migrateTagField(store, {
|
|
3520
|
+
tag,
|
|
3521
|
+
field,
|
|
3522
|
+
transform: transform!,
|
|
3523
|
+
apply,
|
|
3524
|
+
continueOnError,
|
|
3525
|
+
// The CLI is the operator path — it bypasses strict enforcement by nature
|
|
3526
|
+
// (writes go through the store, not the scope-gated transport). Log every
|
|
3527
|
+
// waived violation so the bypass is auditable, exactly as MW2 intends.
|
|
3528
|
+
onStrictBypass: (info) => logStrictBypass(info),
|
|
3529
|
+
});
|
|
3530
|
+
|
|
3531
|
+
const verb = result.applied ? "Migrated" : "Would migrate";
|
|
3532
|
+
console.log(
|
|
3533
|
+
`${verb} field "${result.field}" on #${result.tag} in vault "${vaultName}"${result.applied ? "" : " (dry-run)"}:`,
|
|
3534
|
+
);
|
|
3535
|
+
console.log(
|
|
3536
|
+
` scanned ${result.scanned} · ${result.applied ? "changed" : "would change"} ${result.changed} · unchanged ${result.unchanged} · errored ${result.errored}`,
|
|
3537
|
+
);
|
|
3538
|
+
|
|
3539
|
+
if (result.sample.length > 0) {
|
|
3540
|
+
console.log(`\n ${result.applied ? "Changes" : "Sample of changes"} (${result.sample.length}${result.changed > result.sample.length ? ` of ${result.changed}` : ""}):`);
|
|
3541
|
+
for (const c of result.sample) {
|
|
3542
|
+
const ref = c.path ?? c.id;
|
|
3543
|
+
if (c.renamedTo) {
|
|
3544
|
+
console.log(` ${ref}: ${field} → ${c.renamedTo} = ${JSON.stringify(c.after)}`);
|
|
3545
|
+
} else {
|
|
3546
|
+
console.log(` ${ref}: ${JSON.stringify(c.before)} → ${JSON.stringify(c.after)}`);
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
if (result.errors.length > 0) {
|
|
3552
|
+
console.log(`\n Unconvertible (${result.errors.length}):`);
|
|
3553
|
+
for (const e of result.errors) {
|
|
3554
|
+
console.log(` ${e.path ?? e.id}: ${e.error}`);
|
|
3555
|
+
}
|
|
3556
|
+
if (apply && !result.applied) {
|
|
3557
|
+
console.log(
|
|
3558
|
+
"\n Aborted without writing — some notes can't be converted (atomic by default).",
|
|
3559
|
+
);
|
|
3560
|
+
console.log(" Fix the data, or re-run with --continue-on-error to migrate the rest.");
|
|
3561
|
+
process.exit(1);
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
if (!apply && result.changed > 0) {
|
|
3566
|
+
console.log("\n Re-run with --apply (or --yes) to write these changes.");
|
|
3567
|
+
}
|
|
3568
|
+
if (result.changed === 0 && result.errored === 0) {
|
|
3569
|
+
console.log("\n Nothing to migrate — the back-catalog already conforms.");
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3419
3573
|
// ---------------------------------------------------------------------------
|
|
3420
3574
|
// Export-watch glue. The git-shell + commit-message logic lives in
|
|
3421
3575
|
// `./export-watch.ts` for unit-testability; cli.ts just wires it in.
|
|
@@ -3961,6 +4115,13 @@ Schema maintenance:
|
|
|
3961
4115
|
parachute-vault schema prune --apply Execute the prune (alias: --yes). Co-declared
|
|
3962
4116
|
fields keep their column; a drop loses only
|
|
3963
4117
|
the index (data lives in notes.metadata).
|
|
4118
|
+
parachute-vault schema migrate-field <tag> <field> Evolve a tag-schema field across existing
|
|
4119
|
+
--transform <spec> notes (rename:<key>, remap:<old>=<new>,
|
|
4120
|
+
set-default:<v>, to_string/to_int/to_number/
|
|
4121
|
+
to_boolean). Dry-run by default — prints the
|
|
4122
|
+
plan; pass --apply (or --yes) to write.
|
|
4123
|
+
Writes through strict enforcement (migrate
|
|
4124
|
+
bypass). See \`schema migrate-field --help\`.
|
|
3964
4125
|
|
|
3965
4126
|
── Advanced / standalone ──────────────────────────────────────────────
|
|
3966
4127
|
|