@palantir/pack.schema 0.5.0 → 0.6.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.
@@ -1 +1 @@
1
- {"mappings":"AAgBA,cAAc,uBAAuB;AAErC,cAAc,WAAW,WAAyB,UAAU,qBAAqB;AAEjF,cAAc,KAAK,YAAY;;AAI/B,YAAY,iBAAiB;;AAG7B,YAAY,OAAO,UAAU,aAAa;AAE1C,YAAY,cAAc,UAAU,gBACjC,WAAW,KAAI,EAAE,WAAW,eAAe,KAAK,aAAa,KAC1D,EAAE,WAAW,gBAAgB,KAAK,cAAc;AAItD,iBAAiB,cAAc,UAAU,eAAe,OAAO;CAE7D;QAAe;EAAkB,UAAU;GACzC,MAAM,GACN,MAAM,IACL,cAAc,OAAO,KAAK,KAAI;CACjC,SAAS,UAAU;;AAGrB,iBAAiB,aAAa,UAAU,eAAe;CAErD,iBAAiB,kBACf,MAAM,GACN,eAAe,kBACd,aAAa,OAAO,KAAK,KAAI;CAChC,SAAS,SAAS;;AAmEpB,OAAO,iBAAS;OACR,UAAU;OACV,UAAU;EAEhB,QAAQ,GACR,YAAY,QAAQ,cAAc,OAAO,IACxC,IAAI","names":[],"sources":["../../src/defineMigration.ts"],"version":3,"file":"defineMigration.d.ts"}
1
+ {"mappings":"AAgBA,cAAc,uBAAuB;AAErC,cAAc,WAAW,WAAyB,UAAU,qBAAqB;AAEjF,cAAc,KAAK,YAAY;;AAI/B,YAAY,iBAAiB;;AAG7B,YAAY,OAAO,UAAU,aAAa;AAE1C,YAAY,cAAc,UAAU,gBACjC,WAAW,KAAI,EAAE,WAAW,eAAe,KAAK,aAAa,KAC1D,EAAE,WAAW,gBAAgB,KAAK,cAAc;;;;;;;;KAWjD,+CAKD,cACA;gBAAiB;;;;;;;;;;AAUrB,iBAAiB,oBAAoB,aAAa,yBAAyB;UAChE,aAAa,oBAAoB;UACjC,UAAU;;;;;;AAOrB,iBAAiB,qBAAqB;UAC3B,UAAU;;AAGrB,YAAY,eACR,oBAAoB,2BACpB;AAuCJ,iBAAiB,cAAc,UAAU,eAAe,OAAO;CAE7D;QAAe;EAAkB,UAAU;GACzC,MAAM,GACN,MAAM,GACN,UAAU,eACT,cAAc,OAAO,KAAK,KAAI;CACjC,YAAY,gBAAgB,YAC1B,MAAM,IACL,cAAc,KAAK,GAAG;CACzB,SAAS,UAAU;;AAGrB,iBAAiB,aAAa,UAAU,eAAe;CAErD,iBAAiB,kBACf,MAAM,GACN,eAAe,kBACd,aAAa,OAAO,KAAK,KAAI;CAChC,SAAS,SAAS;;;AAwIpB,YAAY,gBAAgB,eAE1B,eAAe,oBAAoB;AAGrC,iBAAiB,gBAAgB,UAAU,WAAW;UAC3C,QAAQ;UACR,WAAW;;;;;;;;;;;;;;AAetB,OAAO,iBAAS;OACR,UAAU;OACV,UAAU;EAEhB,QAAQ,GACR,YAAY,QAAQ,cAAc,OAAO,IACxC,gBAAgB,IAAI;AAqCvB,OAAO,iBAAS;OACR,UAAU;OACV,UAAU;EAEhB,QAAQ,GACR,YAAY,QAAQ,cAAc,OAAO,IACxC,IAAI","names":[],"sources":["../../src/defineMigration.ts"],"version":3,"file":"defineMigration.d.ts"}
@@ -7,7 +7,6 @@ export interface InitialSchema<T extends ModelDefs = ModelDefs> {
7
7
  }
8
8
  export interface FieldMigration {
9
9
  readonly derivedFrom: readonly string[];
10
- readonly forward: (oldFields: Record<string, unknown>) => unknown;
11
10
  }
12
11
  export type VersionMigrations = Record<string, Record<string, FieldMigration>>;
13
12
  export interface VersionedSchema<T extends ModelDefs = ModelDefs> {
@@ -1 +1 @@
1
- {"mappings":"AAgBA,cAAc,qBAAqB;AAEnC,cAAc,iBAAiB;AAE/B,iBAAiB,cAAc,UAAU,YAAY,WAAW;UACrD,MAAM;UACN,SAAS;UACT,QAAQ;;AAGnB,iBAAiB,eAAe;UACrB;UACA,UAAU,WAAW;;AAGhC,YAAY,oBAAoB,eAAe,eAAe;AAE9D,iBAAiB,gBAAgB,UAAU,YAAY,WAAW;UACvD,MAAM;UACN,QAAQ;UACR;UACA,UAAU;UACV,aAAa;;AAGxB,YAAY,iBAAiB,UAAU,YAAY,aAC/C,cAAc,KACd,gBAAgB;AAEpB,OAAO,iBAAS,aAAa,UAAU,WAAW,QAAQ,IAAI,cAAc;AAI5E,iBAAiB;CAAa,UAAU;CAAW,UAAU;EAAW;UAC7D;UACA,YAAY,QAAQ,cAAc,OAAO;;AAGpD,OAAO,iBAAS;CAAmB,UAAU;CAAW,UAAU;EAChE,cACA,YAAY,QAAQ,cAAc,OAAO,IACxC,aAAa,GAAG;AAInB,iBAAiB,qBAAqB,UAAU,WAAW;CACzD,gBAAgB,UAAU,WACxB,QAAQ,aAAa,GAAG,KACvB,qBAAqB,IAAI;CAC5B,eAAe,YAAY,oBAAoB,qBAAqB;CACpE,SAAS,gBAAgB;;AA2C3B,OAAO,iBAAS,WAAW,UAAU,WACnC,UAAU,iBAAiB,KAC1B,qBAAqB","names":[],"sources":["../../src/defineSchemaUpdate.ts"],"version":3,"file":"defineSchemaUpdate.d.ts"}
1
+ {"mappings":"AAgBA,cAAc,qBAAqB;AAEnC,cAAc,iBAAiB;AAE/B,iBAAiB,cAAc,UAAU,YAAY,WAAW;UACrD,MAAM;UACN,SAAS;UACT,QAAQ;;AAGnB,iBAAiB,eAAe;UACrB;;AAGX,YAAY,oBAAoB,eAAe,eAAe;AAE9D,iBAAiB,gBAAgB,UAAU,YAAY,WAAW;UACvD,MAAM;UACN,QAAQ;UACR;UACA,UAAU;UACV,aAAa;;AAGxB,YAAY,iBAAiB,UAAU,YAAY,aAC/C,cAAc,KACd,gBAAgB;AAEpB,OAAO,iBAAS,aAAa,UAAU,WAAW,QAAQ,IAAI,cAAc;AAI5E,iBAAiB;CAAa,UAAU;CAAW,UAAU;EAAW;UAC7D;UACA,YAAY,QAAQ,cAAc,OAAO;;AAGpD,OAAO,iBAAS;CAAmB,UAAU;CAAW,UAAU;EAChE,cACA,YAAY,QAAQ,cAAc,OAAO,IACxC,aAAa,GAAG;AAInB,iBAAiB,qBAAqB,UAAU,WAAW;CACzD,gBAAgB,UAAU,WACxB,QAAQ,aAAa,GAAG,KACvB,qBAAqB,IAAI;CAC5B,eAAe,YAAY,oBAAoB,qBAAqB;CACpE,SAAS,gBAAgB;;AAyG3B,OAAO,iBAAS,WAAW,UAAU,WACnC,UAAU,iBAAiB,KAC1B,qBAAqB","names":[],"sources":["../../src/defineSchemaUpdate.ts"],"version":3,"file":"defineSchemaUpdate.d.ts"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palantir/pack.schema",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "API package for PACK schema functionality",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -0,0 +1,577 @@
1
+ /*
2
+ * Copyright 2026 Palantir Technologies, Inc. All rights reserved.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import { describe, expect, it } from "vitest";
18
+ import type { SchemaBuilder } from "../defineMigration.js";
19
+ import { applyMigration, defineMigration } from "../defineMigration.js";
20
+ import { defineRecord } from "../defineRecord.js";
21
+ import { defineSchema, defineSchemaUpdate, nextSchema } from "../defineSchemaUpdate.js";
22
+ import { defineUnion } from "../defineUnion.js";
23
+ import type { RecordDef } from "../defs.js";
24
+ import * as P from "../primitives.js";
25
+
26
+ describe("addField with upgrade options", () => {
27
+ const v1 = defineSchema({
28
+ ShapeBox: defineRecord("ShapeBox", {
29
+ docs: "A box",
30
+ fields: {
31
+ x: P.Double,
32
+ y: P.Double,
33
+ color: P.Optional(P.String),
34
+ },
35
+ }),
36
+ });
37
+
38
+ it("propagates derivedFrom to versioned upgrades", () => {
39
+ const colorSplit = defineSchemaUpdate(
40
+ "colorSplit",
41
+ (schema: SchemaBuilder<typeof v1.models>) => ({
42
+ ShapeBox: schema.ShapeBox
43
+ .addField("fillColor", P.Optional(P.String), {
44
+ derivedFrom: ["color"],
45
+ })
46
+ .addField("strokeColor", P.Optional(P.String), {
47
+ derivedFrom: ["color"],
48
+ })
49
+ .removeField("color")
50
+ .build(),
51
+ }),
52
+ );
53
+
54
+ const v2 = nextSchema(v1).addSchemaUpdate(colorSplit).build();
55
+
56
+ expect(v2.migrations).toBeDefined();
57
+ expect(v2.migrations?.ShapeBox?.fillColor?.derivedFrom).toEqual(["color"]);
58
+ expect(v2.migrations?.ShapeBox?.strokeColor?.derivedFrom).toEqual(["color"]);
59
+ expect(Object.keys(v2.migrations?.ShapeBox?.fillColor ?? {})).toEqual(["derivedFrom"]);
60
+ });
61
+
62
+ it("removeField strips the field from the resulting record fields", () => {
63
+ const colorSplit = defineSchemaUpdate(
64
+ "colorSplit",
65
+ (schema: SchemaBuilder<typeof v1.models>) => ({
66
+ ShapeBox: schema.ShapeBox
67
+ .addField("fillColor", P.Optional(P.String), {
68
+ derivedFrom: ["color"],
69
+ })
70
+ .removeField("color")
71
+ .build(),
72
+ }),
73
+ );
74
+
75
+ const v2 = nextSchema(v1).addSchemaUpdate(colorSplit).build();
76
+ const shapeBox = v2.models.ShapeBox;
77
+ expect(Object.keys(shapeBox.fields).sort()).toEqual([
78
+ "fillColor",
79
+ "x",
80
+ "y",
81
+ ]);
82
+ });
83
+
84
+ it("explicit withMigrations is merged with addField sugar", () => {
85
+ const update = defineSchemaUpdate("update", (schema: SchemaBuilder<typeof v1.models>) => ({
86
+ ShapeBox: schema.ShapeBox
87
+ .addField("fillColor", P.Optional(P.String), {
88
+ derivedFrom: ["color"],
89
+ })
90
+ .build(),
91
+ }));
92
+
93
+ const v2 = nextSchema(v1)
94
+ .addSchemaUpdate(update)
95
+ .withMigrations({
96
+ ShapeBox: {
97
+ fillColor: {
98
+ derivedFrom: ["color"],
99
+ },
100
+ },
101
+ })
102
+ .build();
103
+
104
+ expect(v2.migrations?.ShapeBox?.fillColor?.derivedFrom).toEqual(["color"]);
105
+ });
106
+
107
+ it("withMigrations supplements (does not erase) sugar from a different field", () => {
108
+ const update = defineSchemaUpdate("update", (schema: SchemaBuilder<typeof v1.models>) => ({
109
+ ShapeBox: schema.ShapeBox
110
+ .addField("fillColor", P.Optional(P.String), {
111
+ derivedFrom: ["color"],
112
+ })
113
+ .addField("opacity", P.Optional(P.Double))
114
+ .build(),
115
+ }));
116
+
117
+ const v2 = nextSchema(v1)
118
+ .addSchemaUpdate(update)
119
+ .withMigrations({
120
+ ShapeBox: {
121
+ opacity: {
122
+ derivedFrom: [],
123
+ },
124
+ },
125
+ })
126
+ .build();
127
+
128
+ expect(v2.migrations?.ShapeBox?.fillColor?.derivedFrom).toEqual(["color"]);
129
+ expect(v2.migrations?.ShapeBox?.opacity?.derivedFrom).toEqual([]);
130
+ });
131
+
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
+ it("addField without options stays sugar-free", () => {
144
+ const update = defineSchemaUpdate("update", (schema: SchemaBuilder<typeof v1.models>) => ({
145
+ ShapeBox: schema.ShapeBox
146
+ .addField("z", P.Double)
147
+ .build(),
148
+ }));
149
+
150
+ const v2 = nextSchema(v1).addSchemaUpdate(update).build();
151
+ expect(v2.migrations).toBeUndefined();
152
+ });
153
+
154
+ it("removeField drops any upgrade option staged for that field name", () => {
155
+ const update = defineSchemaUpdate("update", (schema: SchemaBuilder<typeof v1.models>) => ({
156
+ ShapeBox: schema.ShapeBox
157
+ .addField("temp", P.Optional(P.String), {
158
+ derivedFrom: ["color"],
159
+ })
160
+ .removeField("temp")
161
+ .build(),
162
+ }));
163
+
164
+ const v2 = nextSchema(v1).addSchemaUpdate(update).build();
165
+ expect(v2.migrations).toBeUndefined();
166
+ expect((v2.models.ShapeBox.fields as Record<string, unknown>).temp).toBeUndefined();
167
+ });
168
+
169
+ it("subsequent addSchemaUpdate does not re-collect options from the prior step", () => {
170
+ const step1 = defineSchemaUpdate(
171
+ "step1",
172
+ (schema: SchemaBuilder<typeof v1.models>) => ({
173
+ ShapeBox: schema.ShapeBox
174
+ .addField("fillColor", P.Optional(P.String), {
175
+ derivedFrom: ["color"],
176
+ })
177
+ .build(),
178
+ }),
179
+ );
180
+
181
+ const step2 = defineSchemaUpdate(
182
+ "step2",
183
+ (
184
+ schema: SchemaBuilder<
185
+ typeof v1.models & { ShapeBox: typeof v1.models.ShapeBox }
186
+ >,
187
+ ) => ({
188
+ ShapeBox: schema.ShapeBox.addField("z", P.Double).build(),
189
+ }),
190
+ );
191
+
192
+ const v2 = nextSchema(v1).addSchemaUpdate(step1).addSchemaUpdate(step2).build();
193
+
194
+ expect(v2.migrations?.ShapeBox?.fillColor?.derivedFrom).toEqual(["color"]);
195
+ expect(Object.keys(v2.migrations?.ShapeBox ?? {})).toEqual(["fillColor"]);
196
+ expect((v2.migrations?.ShapeBox as Record<string, unknown> | undefined)?.z).toBeUndefined();
197
+ });
198
+
199
+ it("does not propagate sugar across nextSchema boundaries", () => {
200
+ const step1 = defineSchemaUpdate(
201
+ "step1",
202
+ (schema: SchemaBuilder<typeof v1.models>) => ({
203
+ ShapeBox: schema.ShapeBox
204
+ .addField("fillColor", P.Optional(P.String), {
205
+ derivedFrom: ["color"],
206
+ })
207
+ .build(),
208
+ }),
209
+ );
210
+
211
+ const v2 = nextSchema(v1).addSchemaUpdate(step1).build();
212
+ expect(v2.migrations?.ShapeBox?.fillColor).toBeDefined();
213
+
214
+ const step2 = defineSchemaUpdate(
215
+ "step2",
216
+ (schema: SchemaBuilder<typeof v2.models>) => ({
217
+ ShapeBox: schema.ShapeBox.addField("z", P.Double).build(),
218
+ }),
219
+ );
220
+ const v3 = nextSchema(v2).addSchemaUpdate(step2).build();
221
+
222
+ expect(v3.migrations).toBeUndefined();
223
+ });
224
+
225
+ it("a migration that renames a model keys the upgrade by the output name", () => {
226
+ const rename = defineSchemaUpdate(
227
+ "rename",
228
+ (schema: SchemaBuilder<typeof v1.models>) => ({
229
+ // The user reassigns the built def to a different output key.
230
+ Box: schema.ShapeBox
231
+ .addField("fillColor", P.Optional(P.String), {
232
+ derivedFrom: ["color"],
233
+ })
234
+ .build(),
235
+ }),
236
+ );
237
+
238
+ const v2 = nextSchema(v1).addSchemaUpdate(rename).build();
239
+ expect(v2.migrations?.Box?.fillColor?.derivedFrom).toEqual(["color"]);
240
+ expect(v2.migrations?.ShapeBox).toBeUndefined();
241
+ });
242
+
243
+ it("RecordDef carries no symbol-keyed metadata after a migration", () => {
244
+ const { models } = applyMigration(v1.models, schema => ({
245
+ ShapeBox: schema.ShapeBox
246
+ .addField("fillColor", P.Optional(P.String), {
247
+ derivedFrom: ["color"],
248
+ })
249
+ .build(),
250
+ }));
251
+
252
+ expect(Object.getOwnPropertySymbols(models.ShapeBox)).toEqual([]);
253
+ const json = JSON.parse(JSON.stringify(models.ShapeBox)) as Record<string, unknown>;
254
+ expect(Object.keys(json).sort()).toEqual(["docs", "fields", "name", "type"]);
255
+ });
256
+
257
+ it("applyMigration returns undefined upgrades when none are collected", () => {
258
+ const result = applyMigration(v1.models, schema => ({
259
+ ShapeBox: schema.ShapeBox.addField("z", P.Double).build(),
260
+ }));
261
+ expect(result.upgrades).toBeUndefined();
262
+ });
263
+
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
+ it("defineMigration drops upgrade options silently (lower-level entry point)", () => {
274
+ // The public defineMigration returns just T & S; it does not surface
275
+ // upgrades. Callers that need upgrades use applyMigration or addSchemaUpdate.
276
+ const merged = defineMigration(v1.models, schema => ({
277
+ ShapeBox: schema.ShapeBox
278
+ .addField("fillColor", P.Optional(P.String), {
279
+ derivedFrom: ["color"],
280
+ })
281
+ .build(),
282
+ }));
283
+
284
+ expect(merged.ShapeBox.fields.fillColor).toEqual({
285
+ type: "optional",
286
+ item: { type: "string" },
287
+ });
288
+ expect(Object.getOwnPropertySymbols(merged.ShapeBox)).toEqual([]);
289
+ });
290
+
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
+ it("withMigrations silently drops entries for fields the model no longer has", () => {
388
+ const v2 = nextSchema(v1)
389
+ .withMigrations({
390
+ ShapeBox: {
391
+ ghost: {
392
+ derivedFrom: ["color"],
393
+ },
394
+ },
395
+ })
396
+ .build();
397
+
398
+ expect(v2.migrations).toBeUndefined();
399
+ });
400
+
401
+ it("retyping a record into a union drops the prior step's migrations", () => {
402
+ const Circle = defineRecord("Circle", {
403
+ docs: "circle",
404
+ fields: { radius: P.Double },
405
+ });
406
+ const Square = defineRecord("Square", {
407
+ docs: "square",
408
+ fields: { side: P.Double },
409
+ });
410
+
411
+ const addSugar = defineSchemaUpdate(
412
+ "addSugar",
413
+ (schema: SchemaBuilder<typeof v1.models>) => ({
414
+ ShapeBox: schema.ShapeBox
415
+ .addField("fillColor", P.Optional(P.String), {
416
+ derivedFrom: ["color"],
417
+ })
418
+ .build(),
419
+ }),
420
+ );
421
+
422
+ const retype = defineSchemaUpdate(
423
+ "retype",
424
+ (
425
+ _schema: SchemaBuilder<
426
+ typeof v1.models & { ShapeBox: typeof v1.models.ShapeBox }
427
+ >,
428
+ ) => ({
429
+ Circle,
430
+ Square,
431
+ ShapeBox: defineUnion("ShapeBox", {
432
+ docs: "now a union",
433
+ variants: { Circle, Square },
434
+ }),
435
+ }),
436
+ );
437
+
438
+ const v2 = nextSchema(v1).addSchemaUpdate(addSugar).addSchemaUpdate(retype).build();
439
+
440
+ // The merged TS type for the `ShapeBox` key intersects a `RecordDef` with
441
+ // a `UnionDef`, which collapses to `never` at the type level even though
442
+ // the runtime override produces the union. Cast for the runtime check.
443
+ expect((v2.models.ShapeBox as unknown as { type: string }).type).toBe("union");
444
+ expect(v2.migrations).toBeUndefined();
445
+ });
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
+ });