@openparachute/vault 0.6.4-rc.2 → 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/README.md +38 -1
- package/core/src/enforced-writes.test.ts +533 -0
- package/core/src/mcp.ts +183 -4
- package/core/src/migrate-tag-field.test.ts +471 -0
- package/core/src/migrate-tag-field.ts +638 -0
- package/core/src/notes.ts +128 -4
- package/core/src/query-operators.ts +117 -0
- package/core/src/schema-defaults.ts +162 -9
- package/core/src/tag-schemas.ts +12 -0
- package/core/src/triggers-store.ts +6 -0
- package/core/src/types.ts +2 -11
- package/core/src/vault-projection.ts +19 -0
- package/package.json +1 -1
- package/src/cli.ts +164 -3
- package/src/config.ts +11 -0
- package/src/mcp-http.ts +27 -0
- package/src/mcp-tools.ts +15 -3
- package/src/routes.ts +133 -3
- package/src/routing.ts +10 -2
- package/src/scopes.test.ts +24 -0
- package/src/scopes.ts +63 -0
- package/src/triggers-api.ts +24 -0
- package/src/triggers.test.ts +33 -0
- package/src/triggers.ts +17 -0
|
@@ -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
|
+
});
|