@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.
@@ -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
- .removeField("color")
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("removeField strips the field from the resulting record fields", () => {
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
- .removeField("color")
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("removeField drops any upgrade option staged for that field name", () => {
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("temp", P.Optional(P.String), {
164
+ .addField("fillColor", P.Optional(P.String), {
158
165
  derivedFrom: ["color"],
159
166
  })
160
- .removeField("temp")
167
+ .deprecateField("color")
161
168
  .build(),
162
169
  }));
163
170
 
164
171
  const v2 = nextSchema(v1).addSchemaUpdate(update).build();
165
- expect(v2.migrations).toBeUndefined();
166
- expect((v2.models.ShapeBox.fields as Record<string, unknown>).temp).toBeUndefined();
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
  });
@@ -34,81 +34,19 @@ export type SchemaBuilder<T extends ModelDefs> = {
34
34
  };
35
35
 
36
36
  /**
37
- * Recursive literal-JSON type. Constrains schema-time `default` values to
38
- * data that JSON-serializes losslessly — no `Date`, `RegExp`, functions, class
39
- * instances, or `undefined`. Mirrors the `JsonValue` exported by
40
- * `@palantir/pack.document-schema.model-types`; kept local here to avoid a
41
- * package dependency from `pack.schema`.
42
- */
43
- type JsonValue =
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
- removeField<K extends keyof T & string>(
58
+ deprecateField<K extends keyof T & string>(
121
59
  name: K,
122
- ): RecordBuilder<Omit<T, K>>;
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
- removeField<K extends keyof T & string>(
171
+ deprecateField<K extends keyof T & string>(
219
172
  name: K,
220
- ): RecordBuilder<Omit<T, K>> {
221
- const { [name]: _removed, ...rest } = this.fields;
222
- const next = new Map(this.upgradeOptions);
223
- next.delete(name);
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: rest as RecordFields,
181
+ fields: { ...this.fields },
229
182
  docs: "",
230
183
  },
184
+ this.upgradeOptions,
231
185
  next,
232
- ) as unknown as RecordBuilder<Omit<T, K>>;
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 both the merged models AND any
280
- * `UpgradeFieldOptions` collected via `addField(name, type, options)` during
281
- * the migration callback. Used by `addSchemaUpdate` to fold sugar-form
282
- * upgrades into a `VersionedSchema`'s migrations map.
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 to the
285
- * produced `RecordDef` via a non-enumerable carrier property; this function
286
- * walks the merged result, extracts the options keyed by the user's chosen
287
- * output key (so renames are handled), and strips the carrier so the returned
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 == null) continue;
308
- // Strip the carrier so the returned RecordDef is structurally clean.
309
- delete carrier[UPGRADE_OPTIONS_CARRIER];
310
- const fieldEntries: Record<
311
- string,
312
- UpgradeFieldOptions<Record<string, unknown>>
313
- > = {};
314
- for (const [fieldName, options] of opts) {
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
- if (Object.keys(fieldEntries).length > 0) {
323
- upgrades[modelKey] = fieldEntries;
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