@openparachute/vault 0.6.3 → 0.6.4-rc.10

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.
Files changed (51) hide show
  1. package/README.md +46 -11
  2. package/core/src/attribution.test.ts +273 -0
  3. package/core/src/core.test.ts +126 -0
  4. package/core/src/cursor.ts +8 -0
  5. package/core/src/enforced-writes.test.ts +533 -0
  6. package/core/src/mcp.ts +235 -9
  7. package/core/src/migrate-tag-field.test.ts +471 -0
  8. package/core/src/migrate-tag-field.ts +638 -0
  9. package/core/src/notes.ts +280 -7
  10. package/core/src/query-operators.ts +117 -0
  11. package/core/src/schema-defaults.ts +162 -9
  12. package/core/src/schema.ts +61 -2
  13. package/core/src/store.ts +51 -19
  14. package/core/src/tag-schemas.ts +12 -0
  15. package/core/src/triggers-store.ts +6 -0
  16. package/core/src/types.ts +35 -14
  17. package/core/src/vault-projection.ts +19 -0
  18. package/package.json +1 -1
  19. package/src/admin-spa.test.ts +18 -5
  20. package/src/admin-spa.ts +24 -3
  21. package/src/attribution-threading.test.ts +350 -0
  22. package/src/auth.ts +82 -4
  23. package/src/cli.ts +345 -9
  24. package/src/config.ts +11 -0
  25. package/src/first-boot-create.test.ts +155 -0
  26. package/src/import-daemon-busy.test.ts +8 -17
  27. package/src/mcp-http.ts +27 -0
  28. package/src/mcp-tools.ts +31 -4
  29. package/src/mirror-credentials.test.ts +47 -0
  30. package/src/mirror-credentials.ts +33 -0
  31. package/src/mirror-history.test.ts +426 -0
  32. package/src/mirror-manager.ts +202 -22
  33. package/src/mirror-routes.test.ts +78 -0
  34. package/src/mirror-routes.ts +195 -1
  35. package/src/module-config.ts +46 -80
  36. package/src/routes.ts +209 -41
  37. package/src/routing.test.ts +115 -25
  38. package/src/routing.ts +64 -2
  39. package/src/scale.bench.test.ts +82 -0
  40. package/src/scopes.test.ts +24 -0
  41. package/src/scopes.ts +63 -0
  42. package/src/self-register.test.ts +5 -5
  43. package/src/self-register.ts +8 -3
  44. package/src/server.ts +58 -38
  45. package/src/subscribe.test.ts +23 -2
  46. package/src/test-support/spawn.ts +85 -0
  47. package/src/triggers-api.ts +24 -0
  48. package/src/triggers.test.ts +33 -0
  49. package/src/triggers.ts +17 -0
  50. package/src/vault-remove.test.ts +4 -5
  51. package/src/vault.test.ts +188 -17
@@ -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
+ }