@palantir/pack.schema 0.4.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.
- package/.turbo/turbo-lint.log +7 -4
- 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 +13 -0
- package/build/browser/index.js +162 -22
- package/build/browser/index.js.map +1 -1
- package/build/cjs/index.cjs +163 -22
- package/build/cjs/index.cjs.map +1 -1
- package/build/cjs/index.d.cts +52 -3
- package/build/esm/index.js +162 -22
- package/build/esm/index.js.map +1 -1
- package/build/types/__tests__/addFieldOptions.test.d.ts +1 -0
- package/build/types/__tests__/addFieldOptions.test.d.ts.map +1 -0
- package/build/types/defineMigration.d.ts +55 -1
- package/build/types/defineMigration.d.ts.map +1 -1
- package/build/types/defineSchemaUpdate.d.ts +0 -1
- package/build/types/defineSchemaUpdate.d.ts.map +1 -1
- package/build/types/primitives.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/addFieldOptions.test.ts +577 -0
- package/src/__tests__/primitives.test.ts +11 -24
- package/src/defineMigration.ts +220 -22
- package/src/defineSchemaUpdate.ts +66 -5
- package/src/primitives.ts +19 -8
|
@@ -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
|
+
});
|
|
@@ -65,15 +65,8 @@ describe("primitives", () => {
|
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
-
it("should
|
|
69
|
-
|
|
70
|
-
expect(nestedArray).toEqual({
|
|
71
|
-
type: "array",
|
|
72
|
-
items: {
|
|
73
|
-
type: "array",
|
|
74
|
-
items: { type: "string" },
|
|
75
|
-
},
|
|
76
|
-
});
|
|
68
|
+
it("should reject nested arrays", () => {
|
|
69
|
+
expect(() => P.Array(P.Array(P.String))).toThrow(/Nested arrays/);
|
|
77
70
|
});
|
|
78
71
|
|
|
79
72
|
it("should create arrays of optional types", () => {
|
|
@@ -124,15 +117,8 @@ describe("primitives", () => {
|
|
|
124
117
|
});
|
|
125
118
|
});
|
|
126
119
|
|
|
127
|
-
it("should
|
|
128
|
-
|
|
129
|
-
expect(doubleOptional).toEqual({
|
|
130
|
-
type: "optional",
|
|
131
|
-
item: {
|
|
132
|
-
type: "optional",
|
|
133
|
-
item: { type: "string" },
|
|
134
|
-
},
|
|
135
|
-
});
|
|
120
|
+
it("should reject nested optionals", () => {
|
|
121
|
+
expect(() => P.Optional(P.Optional(P.String))).toThrow(/Nested optionals/);
|
|
136
122
|
});
|
|
137
123
|
|
|
138
124
|
it("should create an optional boolean", () => {
|
|
@@ -167,23 +153,24 @@ describe("primitives", () => {
|
|
|
167
153
|
});
|
|
168
154
|
|
|
169
155
|
describe("Type combinations", () => {
|
|
170
|
-
it("should create
|
|
171
|
-
const complexType = P.Optional(P.Array(P.Optional(P.
|
|
156
|
+
it("should create optional array of optional strings", () => {
|
|
157
|
+
const complexType = P.Optional(P.Array(P.Optional(P.String)));
|
|
172
158
|
expect(complexType).toEqual({
|
|
173
159
|
type: "optional",
|
|
174
160
|
item: {
|
|
175
161
|
type: "array",
|
|
176
162
|
items: {
|
|
177
163
|
type: "optional",
|
|
178
|
-
item: {
|
|
179
|
-
type: "array",
|
|
180
|
-
items: { type: "string" },
|
|
181
|
-
},
|
|
164
|
+
item: { type: "string" },
|
|
182
165
|
},
|
|
183
166
|
},
|
|
184
167
|
});
|
|
185
168
|
});
|
|
186
169
|
|
|
170
|
+
it("should reject nested arrays even when inner array is wrapped in optional", () => {
|
|
171
|
+
expect(() => P.Array(P.Optional(P.Array(P.String)))).toThrow(/Nested arrays/);
|
|
172
|
+
});
|
|
173
|
+
|
|
187
174
|
it("should maintain immutability of primitive types", () => {
|
|
188
175
|
const string1 = P.String;
|
|
189
176
|
const string2 = P.String;
|