@palantir/pack.schema 0.7.0 → 0.9.0
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/.turbo/turbo-lint.log +6 -6
- package/.turbo/turbo-transpileBrowser.log +1 -1
- package/.turbo/turbo-transpileCjs.log +1 -1
- package/.turbo/turbo-transpileEsm.log +1 -1
- package/.turbo/turbo-transpileTypes.log +1 -1
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +24 -0
- package/build/browser/index.js +78 -61
- package/build/browser/index.js.map +1 -1
- package/build/cjs/index.cjs +78 -61
- package/build/cjs/index.cjs.map +1 -1
- package/build/cjs/index.d.cts +31 -36
- package/build/esm/index.js +78 -61
- package/build/esm/index.js.map +1 -1
- package/build/types/defineMigration.d.ts +24 -36
- package/build/types/defineMigration.d.ts.map +1 -1
- package/build/types/defineSchemaUpdate.d.ts +6 -0
- package/build/types/defineSchemaUpdate.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/addFieldOptions.test.ts +27 -256
- package/src/defineMigration.ts +87 -105
- package/src/defineSchemaUpdate.ts +78 -23
|
@@ -20,7 +20,6 @@ import { applyMigration, defineMigration } from "../defineMigration.js";
|
|
|
20
20
|
import { defineRecord } from "../defineRecord.js";
|
|
21
21
|
import { defineSchema, defineSchemaUpdate, nextSchema } from "../defineSchemaUpdate.js";
|
|
22
22
|
import { defineUnion } from "../defineUnion.js";
|
|
23
|
-
import type { RecordDef } from "../defs.js";
|
|
24
23
|
import * as P from "../primitives.js";
|
|
25
24
|
|
|
26
25
|
describe("addField with upgrade options", () => {
|
|
@@ -46,7 +45,7 @@ describe("addField with upgrade options", () => {
|
|
|
46
45
|
.addField("strokeColor", P.Optional(P.String), {
|
|
47
46
|
derivedFrom: ["color"],
|
|
48
47
|
})
|
|
49
|
-
.
|
|
48
|
+
.deprecateField("color")
|
|
50
49
|
.build(),
|
|
51
50
|
}),
|
|
52
51
|
);
|
|
@@ -59,7 +58,7 @@ describe("addField with upgrade options", () => {
|
|
|
59
58
|
expect(Object.keys(v2.migrations?.ShapeBox?.fillColor ?? {})).toEqual(["derivedFrom"]);
|
|
60
59
|
});
|
|
61
60
|
|
|
62
|
-
it("
|
|
61
|
+
it("deprecateField keeps the field and records the deprecation", () => {
|
|
63
62
|
const colorSplit = defineSchemaUpdate(
|
|
64
63
|
"colorSplit",
|
|
65
64
|
(schema: SchemaBuilder<typeof v1.models>) => ({
|
|
@@ -67,7 +66,7 @@ describe("addField with upgrade options", () => {
|
|
|
67
66
|
.addField("fillColor", P.Optional(P.String), {
|
|
68
67
|
derivedFrom: ["color"],
|
|
69
68
|
})
|
|
70
|
-
.
|
|
69
|
+
.deprecateField("color", "use fillColor/strokeColor")
|
|
71
70
|
.build(),
|
|
72
71
|
}),
|
|
73
72
|
);
|
|
@@ -75,10 +74,29 @@ describe("addField with upgrade options", () => {
|
|
|
75
74
|
const v2 = nextSchema(v1).addSchemaUpdate(colorSplit).build();
|
|
76
75
|
const shapeBox = v2.models.ShapeBox;
|
|
77
76
|
expect(Object.keys(shapeBox.fields).sort()).toEqual([
|
|
77
|
+
"color",
|
|
78
78
|
"fillColor",
|
|
79
79
|
"x",
|
|
80
80
|
"y",
|
|
81
81
|
]);
|
|
82
|
+
expect(v2.deprecations?.ShapeBox?.color).toEqual({
|
|
83
|
+
fromVersion: 2,
|
|
84
|
+
message: "use fillColor/strokeColor",
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("deprecateField without a message records the version only", () => {
|
|
89
|
+
const update = defineSchemaUpdate(
|
|
90
|
+
"deprecateColor",
|
|
91
|
+
(schema: SchemaBuilder<typeof v1.models>) => ({
|
|
92
|
+
ShapeBox: schema.ShapeBox.deprecateField("color").build(),
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const v2 = nextSchema(v1).addSchemaUpdate(update).build();
|
|
97
|
+
expect(v2.deprecations?.ShapeBox?.color).toEqual({ fromVersion: 2 });
|
|
98
|
+
// Field remains present.
|
|
99
|
+
expect((v2.models.ShapeBox.fields as Record<string, unknown>).color).toBeDefined();
|
|
82
100
|
});
|
|
83
101
|
|
|
84
102
|
it("explicit withMigrations is merged with addField sugar", () => {
|
|
@@ -129,17 +147,6 @@ describe("addField with upgrade options", () => {
|
|
|
129
147
|
expect(v2.migrations?.ShapeBox?.opacity?.derivedFrom).toEqual([]);
|
|
130
148
|
});
|
|
131
149
|
|
|
132
|
-
it("AdditiveFieldOptions { default } is accepted but not added to upgrades", () => {
|
|
133
|
-
const update = defineSchemaUpdate("update", (schema: SchemaBuilder<typeof v1.models>) => ({
|
|
134
|
-
ShapeBox: schema.ShapeBox
|
|
135
|
-
.addField("opacity", P.Optional(P.Double), { default: 1.0 })
|
|
136
|
-
.build(),
|
|
137
|
-
}));
|
|
138
|
-
|
|
139
|
-
const v2 = nextSchema(v1).addSchemaUpdate(update).build();
|
|
140
|
-
expect(v2.migrations?.ShapeBox?.opacity).toBeUndefined();
|
|
141
|
-
});
|
|
142
|
-
|
|
143
150
|
it("addField without options stays sugar-free", () => {
|
|
144
151
|
const update = defineSchemaUpdate("update", (schema: SchemaBuilder<typeof v1.models>) => ({
|
|
145
152
|
ShapeBox: schema.ShapeBox
|
|
@@ -151,19 +158,19 @@ describe("addField with upgrade options", () => {
|
|
|
151
158
|
expect(v2.migrations).toBeUndefined();
|
|
152
159
|
});
|
|
153
160
|
|
|
154
|
-
it("
|
|
161
|
+
it("deprecateField on an existing field does not affect a new field's upgrade option", () => {
|
|
155
162
|
const update = defineSchemaUpdate("update", (schema: SchemaBuilder<typeof v1.models>) => ({
|
|
156
163
|
ShapeBox: schema.ShapeBox
|
|
157
|
-
.addField("
|
|
164
|
+
.addField("fillColor", P.Optional(P.String), {
|
|
158
165
|
derivedFrom: ["color"],
|
|
159
166
|
})
|
|
160
|
-
.
|
|
167
|
+
.deprecateField("color")
|
|
161
168
|
.build(),
|
|
162
169
|
}));
|
|
163
170
|
|
|
164
171
|
const v2 = nextSchema(v1).addSchemaUpdate(update).build();
|
|
165
|
-
expect(v2.migrations).
|
|
166
|
-
expect(
|
|
172
|
+
expect(v2.migrations?.ShapeBox?.fillColor?.derivedFrom).toEqual(["color"]);
|
|
173
|
+
expect(v2.deprecations?.ShapeBox?.color).toBeDefined();
|
|
167
174
|
});
|
|
168
175
|
|
|
169
176
|
it("subsequent addSchemaUpdate does not re-collect options from the prior step", () => {
|
|
@@ -261,15 +268,6 @@ describe("addField with upgrade options", () => {
|
|
|
261
268
|
expect(result.upgrades).toBeUndefined();
|
|
262
269
|
});
|
|
263
270
|
|
|
264
|
-
it("applyMigration filters out AdditiveFieldOptions from upgrades", () => {
|
|
265
|
-
const result = applyMigration(v1.models, schema => ({
|
|
266
|
-
ShapeBox: schema.ShapeBox
|
|
267
|
-
.addField("opacity", P.Optional(P.Double), { default: 0.5 })
|
|
268
|
-
.build(),
|
|
269
|
-
}));
|
|
270
|
-
expect(result.upgrades).toBeUndefined();
|
|
271
|
-
});
|
|
272
|
-
|
|
273
271
|
it("defineMigration drops upgrade options silently (lower-level entry point)", () => {
|
|
274
272
|
// The public defineMigration returns just T & S; it does not surface
|
|
275
273
|
// upgrades. Callers that need upgrades use applyMigration or addSchemaUpdate.
|
|
@@ -288,102 +286,6 @@ describe("addField with upgrade options", () => {
|
|
|
288
286
|
expect(Object.getOwnPropertySymbols(merged.ShapeBox)).toEqual([]);
|
|
289
287
|
});
|
|
290
288
|
|
|
291
|
-
// Type of v1.models after a prior step adds an optional-string `temp` field
|
|
292
|
-
// to ShapeBox. Used to type the input of a later step that calls
|
|
293
|
-
// `removeField("temp")` — without this, `temp` is not statically known to
|
|
294
|
-
// be in the field set and the call would fail to type-check.
|
|
295
|
-
type V1WithTemp = typeof v1.models & {
|
|
296
|
-
ShapeBox: RecordDef<
|
|
297
|
-
typeof v1.models.ShapeBox["fields"] & { readonly temp: P.Optional<P.String> }
|
|
298
|
-
>;
|
|
299
|
-
};
|
|
300
|
-
|
|
301
|
-
it("a later addSchemaUpdate that removes a field also drops its migration", () => {
|
|
302
|
-
const addTemp = defineSchemaUpdate(
|
|
303
|
-
"addTemp",
|
|
304
|
-
(schema: SchemaBuilder<typeof v1.models>) => ({
|
|
305
|
-
ShapeBox: schema.ShapeBox
|
|
306
|
-
.addField("temp", P.Optional(P.String), {
|
|
307
|
-
derivedFrom: ["color"],
|
|
308
|
-
})
|
|
309
|
-
.build(),
|
|
310
|
-
}),
|
|
311
|
-
);
|
|
312
|
-
|
|
313
|
-
const removeTemp = defineSchemaUpdate(
|
|
314
|
-
"removeTemp",
|
|
315
|
-
(schema: SchemaBuilder<V1WithTemp>) => ({
|
|
316
|
-
ShapeBox: schema.ShapeBox.removeField("temp").build(),
|
|
317
|
-
}),
|
|
318
|
-
);
|
|
319
|
-
|
|
320
|
-
const v2 = nextSchema(v1).addSchemaUpdate(addTemp).addSchemaUpdate(removeTemp).build();
|
|
321
|
-
|
|
322
|
-
expect((v2.models.ShapeBox.fields as Record<string, unknown>).temp).toBeUndefined();
|
|
323
|
-
expect(v2.migrations).toBeUndefined();
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
it("a later update may remove one upgraded field while adding another", () => {
|
|
327
|
-
const addTwo = defineSchemaUpdate(
|
|
328
|
-
"addTwo",
|
|
329
|
-
(schema: SchemaBuilder<typeof v1.models>) => ({
|
|
330
|
-
ShapeBox: schema.ShapeBox
|
|
331
|
-
.addField("temp", P.Optional(P.String), {
|
|
332
|
-
derivedFrom: ["color"],
|
|
333
|
-
})
|
|
334
|
-
.addField("fillColor", P.Optional(P.String), {
|
|
335
|
-
derivedFrom: ["color"],
|
|
336
|
-
})
|
|
337
|
-
.build(),
|
|
338
|
-
}),
|
|
339
|
-
);
|
|
340
|
-
|
|
341
|
-
const removeTemp = defineSchemaUpdate(
|
|
342
|
-
"removeTemp",
|
|
343
|
-
(schema: SchemaBuilder<V1WithTemp>) => ({
|
|
344
|
-
ShapeBox: schema.ShapeBox.removeField("temp").build(),
|
|
345
|
-
}),
|
|
346
|
-
);
|
|
347
|
-
|
|
348
|
-
const v2 = nextSchema(v1).addSchemaUpdate(addTwo).addSchemaUpdate(removeTemp).build();
|
|
349
|
-
|
|
350
|
-
expect(Object.keys(v2.migrations?.ShapeBox ?? {})).toEqual(["fillColor"]);
|
|
351
|
-
expect((v2.migrations?.ShapeBox as Record<string, unknown> | undefined)?.temp).toBeUndefined();
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
it("withMigrations entries are pruned when a later update removes the field", () => {
|
|
355
|
-
const noop = defineSchemaUpdate(
|
|
356
|
-
"noop",
|
|
357
|
-
(schema: SchemaBuilder<typeof v1.models>) => ({
|
|
358
|
-
ShapeBox: schema.ShapeBox
|
|
359
|
-
.addField("temp", P.Optional(P.String))
|
|
360
|
-
.build(),
|
|
361
|
-
}),
|
|
362
|
-
);
|
|
363
|
-
|
|
364
|
-
const removeTemp = defineSchemaUpdate(
|
|
365
|
-
"removeTemp",
|
|
366
|
-
(schema: SchemaBuilder<V1WithTemp>) => ({
|
|
367
|
-
ShapeBox: schema.ShapeBox.removeField("temp").build(),
|
|
368
|
-
}),
|
|
369
|
-
);
|
|
370
|
-
|
|
371
|
-
const v2 = nextSchema(v1)
|
|
372
|
-
.addSchemaUpdate(noop)
|
|
373
|
-
.withMigrations({
|
|
374
|
-
ShapeBox: {
|
|
375
|
-
temp: {
|
|
376
|
-
derivedFrom: ["color"],
|
|
377
|
-
},
|
|
378
|
-
},
|
|
379
|
-
})
|
|
380
|
-
.addSchemaUpdate(removeTemp)
|
|
381
|
-
.build();
|
|
382
|
-
|
|
383
|
-
expect((v2.models.ShapeBox.fields as Record<string, unknown>).temp).toBeUndefined();
|
|
384
|
-
expect(v2.migrations).toBeUndefined();
|
|
385
|
-
});
|
|
386
|
-
|
|
387
289
|
it("withMigrations silently drops entries for fields the model no longer has", () => {
|
|
388
290
|
const v2 = nextSchema(v1)
|
|
389
291
|
.withMigrations({
|
|
@@ -443,135 +345,4 @@ describe("addField with upgrade options", () => {
|
|
|
443
345
|
expect((v2.models.ShapeBox as unknown as { type: string }).type).toBe("union");
|
|
444
346
|
expect(v2.migrations).toBeUndefined();
|
|
445
347
|
});
|
|
446
|
-
|
|
447
|
-
it("UpgradeFieldOptions { derivedFrom, default } captures default in upgrades", () => {
|
|
448
|
-
// When a derived field also supplies a literal-JSON `default`, the option
|
|
449
|
-
// object flows into `upgrades` verbatim — the runtime upgrade function
|
|
450
|
-
// may consult `default` when no source data is present.
|
|
451
|
-
const update = defineSchemaUpdate("update", (schema: SchemaBuilder<typeof v1.models>) => ({
|
|
452
|
-
ShapeBox: schema.ShapeBox
|
|
453
|
-
.addField("fillColor", P.Optional(P.String), {
|
|
454
|
-
derivedFrom: ["color"],
|
|
455
|
-
default: "transparent",
|
|
456
|
-
})
|
|
457
|
-
.build(),
|
|
458
|
-
}));
|
|
459
|
-
|
|
460
|
-
const v2 = nextSchema(v1).addSchemaUpdate(update).build();
|
|
461
|
-
expect(v2.migrations?.ShapeBox?.fillColor).toEqual({
|
|
462
|
-
derivedFrom: ["color"],
|
|
463
|
-
default: "transparent",
|
|
464
|
-
});
|
|
465
|
-
});
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
// Exercises `assertJsonDefault` via the public `addField()` entry point.
|
|
469
|
-
// `assertJsonDefault` is not exported, so we drive it through the builder.
|
|
470
|
-
// Each rejection case asserts both that the call throws and that the path
|
|
471
|
-
// reported in the error message includes the field name (and, where
|
|
472
|
-
// applicable, a nested key/index) — that path is the user-facing breadcrumb
|
|
473
|
-
// when a default value is malformed.
|
|
474
|
-
describe("addField default value validation (assertJsonDefault)", () => {
|
|
475
|
-
const v1 = defineSchema({
|
|
476
|
-
R: defineRecord("R", { docs: "r", fields: { x: P.Double } }),
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
/**
|
|
480
|
-
* Helper that builds a one-field migration adding `extra` with the given
|
|
481
|
-
* `default`. Returns the `addField` invocation as a thunk so callers can
|
|
482
|
-
* either invoke it (success case) or assert it throws (rejection case).
|
|
483
|
-
*/
|
|
484
|
-
function withDefault(value: unknown): () => unknown {
|
|
485
|
-
return () =>
|
|
486
|
-
defineMigration(v1.models, schema => ({
|
|
487
|
-
R: schema.R
|
|
488
|
-
.addField("extra", P.Optional(P.String), { default: value as never })
|
|
489
|
-
.build(),
|
|
490
|
-
}));
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
describe("accepts literal JSON", () => {
|
|
494
|
-
it.each([
|
|
495
|
-
["string", "hello"],
|
|
496
|
-
["number", 42],
|
|
497
|
-
["zero", 0],
|
|
498
|
-
["negative number", -1.5],
|
|
499
|
-
["true", true],
|
|
500
|
-
["false", false],
|
|
501
|
-
["null", null],
|
|
502
|
-
["empty object", {}],
|
|
503
|
-
["empty array", []],
|
|
504
|
-
["nested plain object", { a: 1, b: { c: "x", d: [1, 2, null] } }],
|
|
505
|
-
["nested array of plain objects", [{ x: 1 }, { x: 2 }]],
|
|
506
|
-
])("%s", (_label, value) => {
|
|
507
|
-
expect(withDefault(value)).not.toThrow();
|
|
508
|
-
});
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
describe("rejects non-JSON values", () => {
|
|
512
|
-
it("undefined at the top level", () => {
|
|
513
|
-
// Note: `{ default: undefined }` is structurally the same as `{}` so
|
|
514
|
-
// it bypasses validation; the real top-level case is `default: NaN`
|
|
515
|
-
// surfacing as undefined-after-stringify. We test undefined-via-nesting
|
|
516
|
-
// below where it actually triggers the validator.
|
|
517
|
-
expect(withDefault(() => undefined)).toThrow(/function is not valid JSON/);
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
it("nested undefined inside an object", () => {
|
|
521
|
-
expect(withDefault({ a: 1, b: undefined })).toThrow(
|
|
522
|
-
/addField\("extra"\)\.default\.b: undefined is not valid JSON/,
|
|
523
|
-
);
|
|
524
|
-
});
|
|
525
|
-
|
|
526
|
-
it("nested undefined inside an array", () => {
|
|
527
|
-
expect(withDefault([1, undefined, 3])).toThrow(
|
|
528
|
-
/addField\("extra"\)\.default\[1\]: undefined is not valid JSON/,
|
|
529
|
-
);
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
it("function", () => {
|
|
533
|
-
expect(withDefault(() => 1)).toThrow(/function is not valid JSON/);
|
|
534
|
-
});
|
|
535
|
-
|
|
536
|
-
it("symbol", () => {
|
|
537
|
-
expect(withDefault(Symbol("s"))).toThrow(/symbol is not valid JSON/);
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
it("bigint", () => {
|
|
541
|
-
expect(withDefault(BigInt(1))).toThrow(/bigint is not valid JSON/);
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
it("Date instance (non-plain object)", () => {
|
|
545
|
-
expect(withDefault(new Date(0))).toThrow(/Date is not valid JSON/);
|
|
546
|
-
});
|
|
547
|
-
|
|
548
|
-
it("RegExp instance (non-plain object)", () => {
|
|
549
|
-
expect(withDefault(/foo/)).toThrow(/RegExp is not valid JSON/);
|
|
550
|
-
});
|
|
551
|
-
|
|
552
|
-
it("class instance (non-plain object)", () => {
|
|
553
|
-
class Widget {
|
|
554
|
-
readonly id = 1;
|
|
555
|
-
}
|
|
556
|
-
expect(withDefault(new Widget())).toThrow(/Widget is not valid JSON/);
|
|
557
|
-
});
|
|
558
|
-
|
|
559
|
-
it("Map (non-plain object)", () => {
|
|
560
|
-
expect(withDefault(new Map())).toThrow(/Map is not valid JSON/);
|
|
561
|
-
});
|
|
562
|
-
|
|
563
|
-
it("symbol-keyed property on a plain object", () => {
|
|
564
|
-
const obj: Record<string | symbol, unknown> = { a: 1 };
|
|
565
|
-
obj[Symbol("k")] = 2;
|
|
566
|
-
expect(withDefault(obj)).toThrow(
|
|
567
|
-
/symbol-keyed properties are not valid JSON/,
|
|
568
|
-
);
|
|
569
|
-
});
|
|
570
|
-
|
|
571
|
-
it("nested non-plain object reports the path", () => {
|
|
572
|
-
expect(withDefault({ inner: { date: new Date(0) } })).toThrow(
|
|
573
|
-
/addField\("extra"\)\.default\.inner\.date: Date is not valid JSON/,
|
|
574
|
-
);
|
|
575
|
-
});
|
|
576
|
-
});
|
|
577
348
|
});
|
package/src/defineMigration.ts
CHANGED
|
@@ -34,81 +34,19 @@ export type SchemaBuilder<T extends ModelDefs> = {
|
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
| string
|
|
45
|
-
| number
|
|
46
|
-
| boolean
|
|
47
|
-
| null
|
|
48
|
-
| JsonValue[]
|
|
49
|
-
| { [key: string]: JsonValue };
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Upgrade options for a field whose value is derived from prior-version fields.
|
|
53
|
-
* Supplied as the third argument to `addField`; surfaced by `applyMigration`
|
|
54
|
-
* as part of `MigrationResult.upgrades`. Declares the structural dependency
|
|
55
|
-
* (`derivedFrom`) and an optional literal JSON `default` used when no source
|
|
56
|
-
* data exists. The runtime forward function is supplied separately via the
|
|
57
|
-
* generated `DocumentModel(...)` factory's `UpgradeFns` parameter.
|
|
37
|
+
* Upgrade options for a field added in this version. Supplied as the third
|
|
38
|
+
* argument to `addField`; surfaced by `applyMigration` as part of
|
|
39
|
+
* `MigrationResult.upgrades`. Declares the structural dependency
|
|
40
|
+
* (`derivedFrom`) the runtime lens uses to decide when to invoke the
|
|
41
|
+
* upgrade function. The function itself — derived or constant — is supplied
|
|
42
|
+
* at boot via the generated `DocumentModel(...)` factory's `UpgradeFns`
|
|
43
|
+
* parameter.
|
|
58
44
|
*/
|
|
59
45
|
export interface UpgradeFieldOptions<TOld extends Record<string, unknown>> {
|
|
60
46
|
readonly derivedFrom: ReadonlyArray<keyof TOld & string>;
|
|
61
|
-
readonly default?: JsonValue;
|
|
62
47
|
}
|
|
63
48
|
|
|
64
|
-
|
|
65
|
-
* Options for a purely additive field. `default` is literal JSON only;
|
|
66
|
-
* validated at `addField()` call time.
|
|
67
|
-
*/
|
|
68
|
-
export interface AdditiveFieldOptions {
|
|
69
|
-
readonly default?: JsonValue;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export type FieldOptions =
|
|
73
|
-
| UpgradeFieldOptions<Record<string, unknown>>
|
|
74
|
-
| AdditiveFieldOptions;
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Recursively assert `value` is representable as literal JSON. Rejects `Date`,
|
|
78
|
-
* `RegExp`, functions, class instances, symbol-keyed properties, `undefined`
|
|
79
|
-
* (anywhere — top-level or nested), `symbol`, and `bigint`. Throws on the
|
|
80
|
-
* first offending value, naming the path for debuggability.
|
|
81
|
-
*/
|
|
82
|
-
function assertJsonDefault(value: unknown, path: string): void {
|
|
83
|
-
// eslint-disable-next-line eqeqeq -- need to distinguish null (valid JSON) from undefined (not).
|
|
84
|
-
if (value === null) return;
|
|
85
|
-
const t = typeof value;
|
|
86
|
-
if (t === "undefined") {
|
|
87
|
-
throw new Error(`Invalid JSON default at ${path}: undefined is not valid JSON`);
|
|
88
|
-
}
|
|
89
|
-
if (t === "string" || t === "number" || t === "boolean") return;
|
|
90
|
-
if (t === "function" || t === "symbol" || t === "bigint") {
|
|
91
|
-
throw new Error(`Invalid JSON default at ${path}: ${t} is not valid JSON`);
|
|
92
|
-
}
|
|
93
|
-
if (Array.isArray(value)) {
|
|
94
|
-
for (let i = 0; i < value.length; i++) {
|
|
95
|
-
assertJsonDefault(value[i], `${path}[${i}]`);
|
|
96
|
-
}
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
const proto = Object.getPrototypeOf(value);
|
|
100
|
-
if (proto !== Object.prototype && proto != null) {
|
|
101
|
-
const ctorName = (value as { constructor?: { name?: string } }).constructor?.name
|
|
102
|
-
?? "non-plain object";
|
|
103
|
-
throw new Error(`Invalid JSON default at ${path}: ${ctorName} is not valid JSON`);
|
|
104
|
-
}
|
|
105
|
-
if (Object.getOwnPropertySymbols(value as object).length > 0) {
|
|
106
|
-
throw new Error(`Invalid JSON default at ${path}: symbol-keyed properties are not valid JSON`);
|
|
107
|
-
}
|
|
108
|
-
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
109
|
-
assertJsonDefault(v, `${path}.${k}`);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
49
|
+
export type FieldOptions = UpgradeFieldOptions<Record<string, unknown>>;
|
|
112
50
|
|
|
113
51
|
export interface RecordBuilder<T extends Record<string, Type>> {
|
|
114
52
|
// TODO: builders should support arg types and resolveModels to refs
|
|
@@ -117,9 +55,10 @@ export interface RecordBuilder<T extends Record<string, Type>> {
|
|
|
117
55
|
type: V,
|
|
118
56
|
options?: FieldOptions,
|
|
119
57
|
): RecordBuilder<T & { [k in K]: V }>;
|
|
120
|
-
|
|
58
|
+
deprecateField<K extends keyof T & string>(
|
|
121
59
|
name: K,
|
|
122
|
-
|
|
60
|
+
message?: string,
|
|
61
|
+
): RecordBuilder<T>;
|
|
123
62
|
build(): RecordDef<T>;
|
|
124
63
|
}
|
|
125
64
|
|
|
@@ -174,14 +113,29 @@ class UnionBuilderImpl<S extends UnionVariants> implements UnionBuilder<S> {
|
|
|
174
113
|
*/
|
|
175
114
|
const UPGRADE_OPTIONS_CARRIER = "__pack_upgrade_options__";
|
|
176
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Non-enumerable carrier property attached to a `RecordDef` by
|
|
118
|
+
* `RecordBuilderImpl.build()` when the builder has collected one or more
|
|
119
|
+
* `deprecateField` calls. `applyMigration` reads this and then deletes the
|
|
120
|
+
* property, so the returned `RecordDef` is structurally clean. Maps field name
|
|
121
|
+
* → its deprecation info.
|
|
122
|
+
*/
|
|
123
|
+
const DEPRECATED_FIELDS_CARRIER = "__pack_deprecated_fields__";
|
|
124
|
+
|
|
125
|
+
export interface FieldDeprecation {
|
|
126
|
+
readonly message?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
177
129
|
class RecordBuilderImpl<T extends RecordFields> implements RecordBuilder<T> {
|
|
178
130
|
private readonly name: string;
|
|
179
131
|
private readonly fields: T;
|
|
180
132
|
private readonly upgradeOptions: ReadonlyMap<string, FieldOptions>;
|
|
133
|
+
private readonly deprecatedFields: ReadonlyMap<string, FieldDeprecation>;
|
|
181
134
|
|
|
182
135
|
constructor(
|
|
183
136
|
initialRecordDef: RecordDef<T>,
|
|
184
137
|
upgradeOptions?: ReadonlyMap<string, FieldOptions>,
|
|
138
|
+
deprecatedFields?: ReadonlyMap<string, FieldDeprecation>,
|
|
185
139
|
) {
|
|
186
140
|
this.name = initialRecordDef.name;
|
|
187
141
|
this.fields = { ...initialRecordDef.fields };
|
|
@@ -189,6 +143,7 @@ class RecordBuilderImpl<T extends RecordFields> implements RecordBuilder<T> {
|
|
|
189
143
|
// they are NOT inherited from the prior version's RecordDef, because each
|
|
190
144
|
// version's upgrades describe a specific version transition.
|
|
191
145
|
this.upgradeOptions = new Map<string, FieldOptions>(upgradeOptions ?? []);
|
|
146
|
+
this.deprecatedFields = new Map<string, FieldDeprecation>(deprecatedFields ?? []);
|
|
192
147
|
}
|
|
193
148
|
|
|
194
149
|
// TODO: resolve field value from arg instead
|
|
@@ -197,9 +152,6 @@ class RecordBuilderImpl<T extends RecordFields> implements RecordBuilder<T> {
|
|
|
197
152
|
value: V,
|
|
198
153
|
options?: FieldOptions,
|
|
199
154
|
): RecordBuilder<T & Record<K, V>> {
|
|
200
|
-
if (options != null && "default" in options && options.default !== undefined) {
|
|
201
|
-
assertJsonDefault(options.default, `addField("${name}").default`);
|
|
202
|
-
}
|
|
203
155
|
const next = new Map(this.upgradeOptions);
|
|
204
156
|
if (options != null) {
|
|
205
157
|
next.set(name, options);
|
|
@@ -212,24 +164,26 @@ class RecordBuilderImpl<T extends RecordFields> implements RecordBuilder<T> {
|
|
|
212
164
|
docs: "",
|
|
213
165
|
},
|
|
214
166
|
next,
|
|
167
|
+
this.deprecatedFields,
|
|
215
168
|
);
|
|
216
169
|
}
|
|
217
170
|
|
|
218
|
-
|
|
171
|
+
deprecateField<K extends keyof T & string>(
|
|
219
172
|
name: K,
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const next = new Map(this.
|
|
223
|
-
next.
|
|
173
|
+
message?: string,
|
|
174
|
+
): RecordBuilder<T> {
|
|
175
|
+
const next = new Map(this.deprecatedFields);
|
|
176
|
+
next.set(name, { message });
|
|
224
177
|
return new RecordBuilderImpl(
|
|
225
178
|
{
|
|
226
179
|
type: ModelDefType.RECORD,
|
|
227
180
|
name: this.name,
|
|
228
|
-
fields:
|
|
181
|
+
fields: { ...this.fields },
|
|
229
182
|
docs: "",
|
|
230
183
|
},
|
|
184
|
+
this.upgradeOptions,
|
|
231
185
|
next,
|
|
232
|
-
)
|
|
186
|
+
);
|
|
233
187
|
}
|
|
234
188
|
|
|
235
189
|
build(): RecordDef<T> {
|
|
@@ -247,6 +201,14 @@ class RecordBuilderImpl<T extends RecordFields> implements RecordBuilder<T> {
|
|
|
247
201
|
writable: false,
|
|
248
202
|
});
|
|
249
203
|
}
|
|
204
|
+
if (this.deprecatedFields.size > 0) {
|
|
205
|
+
Object.defineProperty(result, DEPRECATED_FIELDS_CARRIER, {
|
|
206
|
+
value: this.deprecatedFields,
|
|
207
|
+
enumerable: false,
|
|
208
|
+
configurable: true,
|
|
209
|
+
writable: false,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
250
212
|
return result;
|
|
251
213
|
}
|
|
252
214
|
}
|
|
@@ -270,22 +232,29 @@ export type FieldUpgrades = Record<
|
|
|
270
232
|
Record<string, UpgradeFieldOptions<Record<string, unknown>>>
|
|
271
233
|
>;
|
|
272
234
|
|
|
235
|
+
export type FieldDeprecations = Record<
|
|
236
|
+
string,
|
|
237
|
+
Record<string, FieldDeprecation>
|
|
238
|
+
>;
|
|
239
|
+
|
|
273
240
|
export interface MigrationResult<M extends ModelDefs> {
|
|
274
241
|
readonly models: M;
|
|
275
242
|
readonly upgrades?: FieldUpgrades;
|
|
243
|
+
readonly deprecations?: FieldDeprecations;
|
|
276
244
|
}
|
|
277
245
|
|
|
278
246
|
/**
|
|
279
|
-
* Lower-level entry point that returns
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
247
|
+
* Lower-level entry point that returns the merged models plus the per-field
|
|
248
|
+
* metadata collected during the migration callback: `UpgradeFieldOptions` from
|
|
249
|
+
* `addField(name, type, options)` and deprecations from `deprecateField(name)`.
|
|
250
|
+
* Used by `addSchemaUpdate` to fold sugar-form upgrades into a
|
|
251
|
+
* `VersionedSchema`'s migrations map and deprecations into its deprecations map.
|
|
283
252
|
*
|
|
284
|
-
* Each `RecordBuilderImpl.build()` attaches its collected options
|
|
285
|
-
* produced `RecordDef` via
|
|
286
|
-
* walks the merged result, extracts
|
|
287
|
-
* output key (so renames are handled), and strips the
|
|
288
|
-
* `RecordDef`s are structurally clean.
|
|
253
|
+
* Each `RecordBuilderImpl.build()` attaches its collected upgrade options and
|
|
254
|
+
* deprecations to the produced `RecordDef` via two non-enumerable carrier
|
|
255
|
+
* properties; this function walks the merged result, extracts both keyed by the
|
|
256
|
+
* user's chosen output key (so renames are handled), and strips the carriers so
|
|
257
|
+
* the returned `RecordDef`s are structurally clean.
|
|
289
258
|
*/
|
|
290
259
|
export function applyMigration<
|
|
291
260
|
const T extends ModelDefs,
|
|
@@ -298,35 +267,48 @@ export function applyMigration<
|
|
|
298
267
|
const merged = { ...models, ...migration(builders) } as T & S;
|
|
299
268
|
|
|
300
269
|
const upgrades: FieldUpgrades = {};
|
|
270
|
+
const deprecations: FieldDeprecations = {};
|
|
301
271
|
for (const [modelKey, def] of Object.entries(merged)) {
|
|
302
272
|
if (!isRecordDef(def)) continue;
|
|
303
273
|
const carrier = def as unknown as Record<string, unknown>;
|
|
304
274
|
const opts = carrier[UPGRADE_OPTIONS_CARRIER] as
|
|
305
275
|
| ReadonlyMap<string, FieldOptions>
|
|
306
276
|
| undefined;
|
|
307
|
-
if (opts
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
// Only `UpgradeFieldOptions` (which declare `derivedFrom`) flow into the
|
|
316
|
-
// upgrades map; `AdditiveFieldOptions` carry only a literal `default`
|
|
317
|
-
// and have no read-time lens effect.
|
|
318
|
-
if ("derivedFrom" in options) {
|
|
277
|
+
if (opts != null) {
|
|
278
|
+
// Strip the carrier so the returned RecordDef is structurally clean.
|
|
279
|
+
delete carrier[UPGRADE_OPTIONS_CARRIER];
|
|
280
|
+
const fieldEntries: Record<
|
|
281
|
+
string,
|
|
282
|
+
UpgradeFieldOptions<Record<string, unknown>>
|
|
283
|
+
> = {};
|
|
284
|
+
for (const [fieldName, options] of opts) {
|
|
319
285
|
fieldEntries[fieldName] = options;
|
|
320
286
|
}
|
|
287
|
+
if (Object.keys(fieldEntries).length > 0) {
|
|
288
|
+
upgrades[modelKey] = fieldEntries;
|
|
289
|
+
}
|
|
321
290
|
}
|
|
322
|
-
|
|
323
|
-
|
|
291
|
+
|
|
292
|
+
const deprecated = carrier[DEPRECATED_FIELDS_CARRIER] as
|
|
293
|
+
| ReadonlyMap<string, FieldDeprecation>
|
|
294
|
+
| undefined;
|
|
295
|
+
if (deprecated != null) {
|
|
296
|
+
// Strip the carrier so the returned RecordDef is structurally clean.
|
|
297
|
+
delete carrier[DEPRECATED_FIELDS_CARRIER];
|
|
298
|
+
const fieldEntries: Record<string, FieldDeprecation> = {};
|
|
299
|
+
for (const [fieldName, info] of deprecated) {
|
|
300
|
+
fieldEntries[fieldName] = info;
|
|
301
|
+
}
|
|
302
|
+
if (Object.keys(fieldEntries).length > 0) {
|
|
303
|
+
deprecations[modelKey] = fieldEntries;
|
|
304
|
+
}
|
|
324
305
|
}
|
|
325
306
|
}
|
|
326
307
|
|
|
327
308
|
return {
|
|
328
309
|
models: merged,
|
|
329
310
|
upgrades: Object.keys(upgrades).length > 0 ? upgrades : undefined,
|
|
311
|
+
deprecations: Object.keys(deprecations).length > 0 ? deprecations : undefined,
|
|
330
312
|
};
|
|
331
313
|
}
|
|
332
314
|
|