@palantir/pack.schema 0.30.0 → 0.32.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.
@@ -43,6 +43,7 @@ describe("defineSchemaUpdate", () => {
43
43
  const schemaV2 = nextSchema(schemaV1).addSchemaUpdate(addAge).build();
44
44
 
45
45
  expect(schemaV2.models.Person).toBe(schemaV1.models.Person);
46
+ expect(schemaV2.models.PersonV2.docs).toBe("A person");
46
47
  expect(schemaV2.models.PersonV2.fields.name).toEqual({ type: "string" });
47
48
  expect(schemaV2.models.PersonV2.fields.age).toEqual({ type: "double" });
48
49
  expect(schemaV2.version).toBe(2);
@@ -53,6 +54,50 @@ describe("defineSchemaUpdate", () => {
53
54
  assertTypeEquals<typeof schemaV2.models.PersonV2.fields.age, P.Double>();
54
55
  });
55
56
 
57
+ it("should preserve record documentation through field additions and deprecations", () => {
58
+ const schemaV1 = defineSchema({
59
+ Person: defineRecord("Person", {
60
+ docs: "A person",
61
+ fields: { name: P.String },
62
+ }),
63
+ });
64
+
65
+ const schemaV2 = nextSchema(schemaV1)
66
+ .addSchemaUpdate(defineSchemaUpdate("addDisplayName", schema => ({
67
+ Person: schema.Person
68
+ .addField("displayName", P.String, { derivedFrom: ["name"] })
69
+ .deprecateField("name", "Use displayName instead.")
70
+ .build(),
71
+ })))
72
+ .build();
73
+
74
+ expect(schemaV2.models.Person.docs).toBe("A person");
75
+ expect(schemaV2.models.Person.fields).toEqual({ name: P.String, displayName: P.String });
76
+ expect(schemaV2.migrations?.Person?.displayName).toEqual({ derivedFrom: ["name"] });
77
+ expect(schemaV2.deprecations?.Person?.name).toEqual({
78
+ fromVersion: 2,
79
+ message: "Use displayName instead.",
80
+ });
81
+ expect(schemaV1.models.Person.fields).toEqual({ name: P.String });
82
+ });
83
+
84
+ it("should preserve a record when building without changing fields", () => {
85
+ const schemaV1 = defineSchema({
86
+ Person: defineRecord("Person", {
87
+ docs: "A person",
88
+ fields: { name: P.String },
89
+ }),
90
+ });
91
+
92
+ const schemaV2 = nextSchema(schemaV1)
93
+ .addSchemaUpdate(defineSchemaUpdate("rebuildPerson", schema => ({
94
+ Person: schema.Person.build(),
95
+ })))
96
+ .build();
97
+
98
+ expect(schemaV2.models.Person).toEqual(schemaV1.models.Person);
99
+ });
100
+
56
101
  it("should compose multiple schema updates in one version", () => {
57
102
  const schemaV1 = defineSchema({
58
103
  ShapeBox: defineRecord("ShapeBox", {
@@ -153,6 +198,7 @@ describe("defineSchemaUpdate", () => {
153
198
  const schemaV3 = nextSchema(schemaV2).addSchemaUpdate(addTags).build();
154
199
 
155
200
  expect(schemaV3.version).toBe(3);
201
+ expect(schemaV3.models.Item.docs).toBe("An item");
156
202
  expect(schemaV3.models.Item.fields.name).toEqual({ type: "string" });
157
203
  expect(schemaV3.models.Item.fields.description).toEqual({
158
204
  type: "optional",
@@ -287,6 +333,8 @@ describe("defineSchemaUpdate", () => {
287
333
 
288
334
  const schemaV2 = nextSchema(schemaV1).addSchemaUpdate(addTriangle).build();
289
335
 
336
+ expect(schemaV2.models.Shape.discriminant).toBe("type");
337
+ expect(schemaV2.models.Shape.docs).toBe("A shape");
290
338
  expect(schemaV2.models.Shape.variants.circle).toEqual({
291
339
  type: "ref",
292
340
  name: "Circle",
@@ -305,6 +353,115 @@ describe("defineSchemaUpdate", () => {
305
353
  >();
306
354
  });
307
355
 
356
+ it("should preserve union metadata through chained variant additions", () => {
357
+ const Circle = defineRecord("Circle", {
358
+ docs: "A circle",
359
+ fields: { radius: P.Double },
360
+ });
361
+ const Rectangle = defineRecord("Rectangle", {
362
+ docs: "A rectangle",
363
+ fields: { width: P.Double, height: P.Double },
364
+ });
365
+ const Triangle = defineRecord("Triangle", {
366
+ docs: "A triangle",
367
+ fields: { base: P.Double, height: P.Double },
368
+ });
369
+ const schemaV1 = defineSchema({
370
+ Circle,
371
+ Rectangle,
372
+ Triangle,
373
+ Shape: defineUnion("Shape", {
374
+ discriminant: "kind",
375
+ docs: "A shape",
376
+ variants: { circle: Circle },
377
+ }),
378
+ });
379
+
380
+ const schemaV2 = nextSchema(schemaV1)
381
+ .addSchemaUpdate(defineSchemaUpdate("addShapes", schema => ({
382
+ Shape: schema.Shape.addVariant("rectangle", Rectangle)
383
+ .addVariant("triangle", Triangle)
384
+ .build(),
385
+ })))
386
+ .build();
387
+
388
+ expect(schemaV2.models.Shape).toEqual({
389
+ ...schemaV1.models.Shape,
390
+ variants: {
391
+ circle: { type: "ref", name: "Circle", refType: "record" },
392
+ rectangle: { type: "ref", name: "Rectangle", refType: "record" },
393
+ triangle: { type: "ref", name: "Triangle", refType: "record" },
394
+ },
395
+ });
396
+ expect(schemaV1.models.Shape.variants).toEqual({
397
+ circle: { type: "ref", name: "Circle", refType: "record" },
398
+ });
399
+
400
+ const schemaV3 = nextSchema(schemaV2)
401
+ .addSchemaUpdate(defineSchemaUpdate("rebuildShape", schema => ({
402
+ Shape: schema.Shape.build(),
403
+ })))
404
+ .build();
405
+
406
+ expect(schemaV3.models.Shape).toEqual(schemaV2.models.Shape);
407
+ });
408
+
409
+ it("should preserve a union when building without adding variants", () => {
410
+ const Circle = defineRecord("Circle", {
411
+ docs: "A circle",
412
+ fields: { radius: P.Double },
413
+ });
414
+ const schemaV1 = defineSchema({
415
+ Circle,
416
+ Shape: defineUnion("Shape", {
417
+ discriminant: "kind",
418
+ docs: "A shape",
419
+ variants: { circle: Circle },
420
+ }),
421
+ });
422
+
423
+ const schemaV2 = nextSchema(schemaV1)
424
+ .addSchemaUpdate(defineSchemaUpdate("rebuildShape", schema => ({
425
+ Shape: schema.Shape.build(),
426
+ })))
427
+ .build();
428
+
429
+ expect(schemaV2.models.Shape).toEqual(schemaV1.models.Shape);
430
+ });
431
+
432
+ it.each(["circle", "rectangle"] as const)("should reject duplicate variant %s", variantName => {
433
+ const Circle = defineRecord("Circle", {
434
+ docs: "A circle",
435
+ fields: { radius: P.Double },
436
+ });
437
+ const Rectangle = defineRecord("Rectangle", {
438
+ docs: "A rectangle",
439
+ fields: { width: P.Double, height: P.Double },
440
+ });
441
+ const schemaV1 = defineSchema({
442
+ Circle,
443
+ Rectangle,
444
+ Shape: defineUnion("Shape", {
445
+ docs: "A shape",
446
+ variants: { circle: Circle },
447
+ }),
448
+ });
449
+
450
+ expect(() =>
451
+ nextSchema(schemaV1)
452
+ .addSchemaUpdate(defineSchemaUpdate("addShapes", schema => ({
453
+ Shape: schema.Shape.addVariant("rectangle", Rectangle)
454
+ // @ts-expect-error Existing variant names cannot be added again.
455
+ .addVariant(variantName, Rectangle)
456
+ .build(),
457
+ })))
458
+ .build()
459
+ ).toThrow(`Variant "${variantName}" already exists in union "Shape".`);
460
+ expect(schemaV1.models.Shape.variants).toEqual({
461
+ circle: { type: "ref", name: "Circle", refType: "record" },
462
+ });
463
+ });
464
+
308
465
  it("should handle mixed record and union updates in one version", () => {
309
466
  const PersonRecord = defineRecord("Person", {
310
467
  docs: "A person",
@@ -268,3 +268,23 @@ describe("defineUnion", () => {
268
268
  });
269
269
  });
270
270
  });
271
+
272
+ describe("reserved union variant names", () => {
273
+ const Circle = defineRecord("Circle", { docs: "A circle", fields: { radius: P.Double } });
274
+
275
+ it.each(["unknown", "Unknown", "_unknown"])("should reject a variant named %s", variantName => {
276
+ expect(() =>
277
+ defineUnion("Shape", { docs: "A shape", variants: { circle: Circle, [variantName]: Circle } })
278
+ ).toThrow(`Variant "${variantName}" in union "Shape" uses a reserved name.`);
279
+ });
280
+
281
+ it("should reject rawValue as the discriminant", () => {
282
+ expect(() =>
283
+ defineUnion("Shape", {
284
+ discriminant: "rawValue",
285
+ docs: "A shape",
286
+ variants: { circle: Circle },
287
+ })
288
+ ).toThrow("Union \"Shape\" can't use \"rawValue\" as its discriminant.");
289
+ });
290
+ });
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { UnionVariantArg } from "./defineUnion.js";
18
- import { resolveUnionVariantArg } from "./defineUnion.js";
18
+ import { assertVariantNameAllowed, resolveUnionVariantArg } from "./defineUnion.js";
19
19
  import type { ModelDefs, RecordDef, RecordFields, UnionDef, UnionVariants } from "./defs.js";
20
20
  import { ModelDefType } from "./defs.js";
21
21
  import type { Ref, Type } from "./primitives.js";
@@ -83,9 +83,9 @@ export interface RecordBuilder<T extends Record<string, Type>> {
83
83
  /** Evolves a union within a {@link SchemaUpdate}. Obtain one from the {@link SchemaBuilder} and finish with `build()`. */
84
84
  export interface UnionBuilder<S extends UnionVariants> {
85
85
  // TODO: builders should support arg types and resolveModels to refs
86
- /** Add a variant, referencing a record (or a `Ref` to one). */
86
+ /** Add a new variant, referencing a record (or a `Ref` to one). */
87
87
  addVariant<const K extends string>(
88
- name: K,
88
+ name: string extends keyof S ? K : Exclude<K, keyof S>,
89
89
  modelDefOrRef: UnionVariantArg,
90
90
  ): UnionBuilder<S & { [k in K]: Ref }>;
91
91
  /** Produce the updated {@link UnionDef}. */
@@ -95,32 +95,40 @@ export interface UnionBuilder<S extends UnionVariants> {
95
95
  class UnionBuilderImpl<S extends UnionVariants> implements UnionBuilder<S> {
96
96
  private readonly name: string;
97
97
  private readonly docs: string | undefined;
98
+ private readonly discriminant: string;
98
99
  private readonly variants: S;
99
- constructor({ name, docs, variants }: UnionDef<S>) {
100
+ constructor({ name, docs, discriminant, variants }: UnionDef<S>) {
100
101
  this.name = name;
101
102
  this.docs = docs;
103
+ this.discriminant = discriminant;
102
104
  this.variants = { ...variants };
103
105
  }
104
106
  addVariant<const K extends string>(
105
- variantName: K,
107
+ variantName: string extends keyof S ? K : Exclude<K, keyof S>,
106
108
  modelDefOrRef: UnionVariantArg,
107
109
  ): UnionBuilder<S & Record<K, Ref>> {
110
+ if (Object.hasOwn(this.variants, variantName)) {
111
+ throw new Error(`Variant "${variantName}" already exists in union "${this.name}".`);
112
+ }
113
+ assertVariantNameAllowed(variantName, this.name);
108
114
  const ref: Ref = resolveUnionVariantArg(modelDefOrRef);
109
115
  const newVariants = { ...this.variants, [variantName]: ref };
110
116
 
111
117
  return new UnionBuilderImpl({
112
118
  type: ModelDefType.UNION,
113
119
  name: this.name,
120
+ docs: this.docs,
114
121
  variants: newVariants,
115
- discriminant: "type",
122
+ discriminant: this.discriminant,
116
123
  });
117
124
  }
118
125
  build(): UnionDef<S> {
119
126
  return {
120
127
  type: ModelDefType.UNION,
121
128
  variants: this.variants,
122
- discriminant: "type",
129
+ discriminant: this.discriminant,
123
130
  name: this.name,
131
+ docs: this.docs,
124
132
  };
125
133
  }
126
134
  }
@@ -149,6 +157,7 @@ export interface FieldDeprecation {
149
157
 
150
158
  class RecordBuilderImpl<T extends RecordFields> implements RecordBuilder<T> {
151
159
  private readonly name: string;
160
+ private readonly docs: string | undefined;
152
161
  private readonly fields: T;
153
162
  private readonly upgradeOptions: ReadonlyMap<string, FieldOptions>;
154
163
  private readonly deprecatedFields: ReadonlyMap<string, FieldDeprecation>;
@@ -159,6 +168,7 @@ class RecordBuilderImpl<T extends RecordFields> implements RecordBuilder<T> {
159
168
  deprecatedFields?: ReadonlyMap<string, FieldDeprecation>,
160
169
  ) {
161
170
  this.name = initialRecordDef.name;
171
+ this.docs = initialRecordDef.docs;
162
172
  this.fields = { ...initialRecordDef.fields };
163
173
  // Upgrade options only flow forward within a single migration callback —
164
174
  // they are NOT inherited from the prior version's RecordDef, because each
@@ -182,7 +192,7 @@ class RecordBuilderImpl<T extends RecordFields> implements RecordBuilder<T> {
182
192
  type: ModelDefType.RECORD,
183
193
  name: this.name,
184
194
  fields: { ...this.fields, [name]: value },
185
- docs: "",
195
+ docs: this.docs,
186
196
  },
187
197
  next,
188
198
  this.deprecatedFields,
@@ -200,7 +210,7 @@ class RecordBuilderImpl<T extends RecordFields> implements RecordBuilder<T> {
200
210
  type: ModelDefType.RECORD,
201
211
  name: this.name,
202
212
  fields: { ...this.fields },
203
- docs: "",
213
+ docs: this.docs,
204
214
  },
205
215
  this.upgradeOptions,
206
216
  next,
@@ -212,7 +222,7 @@ class RecordBuilderImpl<T extends RecordFields> implements RecordBuilder<T> {
212
222
  type: ModelDefType.RECORD,
213
223
  name: this.name,
214
224
  fields: this.fields,
215
- docs: "",
225
+ docs: this.docs,
216
226
  };
217
227
  if (this.upgradeOptions.size > 0) {
218
228
  Object.defineProperty(result, UPGRADE_OPTIONS_CARRIER, {
@@ -61,9 +61,11 @@ export function defineUnion<const TVariants extends UnionVariantArgs>(
61
61
  variants,
62
62
  }: UnionArgs<TVariants>,
63
63
  ): UnionDef<ResolvedUnionVariants<TVariants>> {
64
- const entries = Object.entries(variants).map(
65
- ([key, value]) => [key, resolveUnionVariantArg(value)] as const,
66
- );
64
+ assertDiscriminantAllowed(discriminant, name);
65
+ const entries = Object.entries(variants).map(([key, value]) => {
66
+ assertVariantNameAllowed(key, name);
67
+ return [key, resolveUnionVariantArg(value)] as const;
68
+ });
67
69
 
68
70
  const resolvedVariants = Object.fromEntries(entries) as ResolvedUnionVariants<TVariants>;
69
71
 
@@ -80,3 +82,38 @@ export function resolveUnionVariantArg(arg: UnionVariantArg): Ref {
80
82
  const resolved = typeof arg === "function" ? arg() : arg;
81
83
  return resolved.type === TypeKind.REF ? resolved : modelToRef(resolved);
82
84
  }
85
+
86
+ /**
87
+ * The name a variant gets in generated code: kebab-case or snake_case to PascalCase,
88
+ * e.g. "my-box" -> "MyBox", "_unknown" -> "Unknown".
89
+ */
90
+ export function formatVariantName(variantName: string): string {
91
+ return variantName
92
+ .split(/[-_]/)
93
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
94
+ .join("");
95
+ }
96
+
97
+ /**
98
+ * Throw if a variant uses a reserved name. Every generated union has an Unknown case for variants
99
+ * an SDK doesn't know: its discriminant is "unknown" and its generated names end in "Unknown", so
100
+ * no variant may produce either (e.g. "unknown", "Unknown", "_unknown").
101
+ */
102
+ export function assertVariantNameAllowed(variantName: string, unionName: string): void {
103
+ if (formatVariantName(variantName) === "Unknown") {
104
+ throw new Error(
105
+ `Variant "${variantName}" in union "${unionName}" uses a reserved name. Every union has a`
106
+ + " generated Unknown case for variants an SDK doesn't know; choose another name.",
107
+ );
108
+ }
109
+ }
110
+
111
+ /** Throw if a discriminant uses `rawValue`, the field the Unknown case keeps the stored value in. */
112
+ function assertDiscriminantAllowed(discriminant: string, unionName: string): void {
113
+ if (discriminant === "rawValue") {
114
+ throw new Error(
115
+ `Union "${unionName}" can't use "rawValue" as its discriminant. The generated Unknown case`
116
+ + " stores the original value in rawValue; choose another name.",
117
+ );
118
+ }
119
+ }