@palantir/pack.schema 0.5.0 → 0.7.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.
@@ -33,12 +33,93 @@ export type SchemaBuilder<T extends ModelDefs> = {
33
33
  : never;
34
34
  };
35
35
 
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.
58
+ */
59
+ export interface UpgradeFieldOptions<TOld extends Record<string, unknown>> {
60
+ readonly derivedFrom: ReadonlyArray<keyof TOld & string>;
61
+ readonly default?: JsonValue;
62
+ }
63
+
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
+ }
112
+
36
113
  export interface RecordBuilder<T extends Record<string, Type>> {
37
114
  // TODO: builders should support arg types and resolveModels to refs
38
115
  addField<const K extends string, V extends Type>(
39
116
  name: K,
40
117
  type: V,
118
+ options?: FieldOptions,
41
119
  ): RecordBuilder<T & { [k in K]: V }>;
120
+ removeField<K extends keyof T & string>(
121
+ name: K,
122
+ ): RecordBuilder<Omit<T, K>>;
42
123
  build(): RecordDef<T>;
43
124
  }
44
125
 
@@ -84,60 +165,177 @@ class UnionBuilderImpl<S extends UnionVariants> implements UnionBuilder<S> {
84
165
  }
85
166
  }
86
167
 
168
+ /**
169
+ * Non-enumerable carrier property attached to a `RecordDef` by
170
+ * `RecordBuilderImpl.build()` when the builder has collected one or more
171
+ * `FieldOptions`. `applyMigration` reads this and then deletes the property,
172
+ * so the `RecordDef` it returns is structurally clean (no extra keys, no
173
+ * symbol-keyed metadata).
174
+ */
175
+ const UPGRADE_OPTIONS_CARRIER = "__pack_upgrade_options__";
176
+
87
177
  class RecordBuilderImpl<T extends RecordFields> implements RecordBuilder<T> {
88
178
  private readonly name: string;
89
179
  private readonly fields: T;
180
+ private readonly upgradeOptions: ReadonlyMap<string, FieldOptions>;
90
181
 
91
- constructor(initialRecordDef: RecordDef<T>) {
182
+ constructor(
183
+ initialRecordDef: RecordDef<T>,
184
+ upgradeOptions?: ReadonlyMap<string, FieldOptions>,
185
+ ) {
92
186
  this.name = initialRecordDef.name;
93
187
  this.fields = { ...initialRecordDef.fields };
188
+ // Upgrade options only flow forward within a single migration callback —
189
+ // they are NOT inherited from the prior version's RecordDef, because each
190
+ // version's upgrades describe a specific version transition.
191
+ this.upgradeOptions = new Map<string, FieldOptions>(upgradeOptions ?? []);
94
192
  }
95
193
 
96
194
  // TODO: resolve field value from arg instead
97
195
  addField<const K extends string, const V extends Type>(
98
196
  name: K,
99
197
  value: V,
198
+ options?: FieldOptions,
100
199
  ): RecordBuilder<T & Record<K, V>> {
101
- return new RecordBuilderImpl({
102
- type: ModelDefType.RECORD,
103
- name: this.name,
104
- fields: { ...this.fields, [name]: value },
105
- docs: "",
106
- });
200
+ if (options != null && "default" in options && options.default !== undefined) {
201
+ assertJsonDefault(options.default, `addField("${name}").default`);
202
+ }
203
+ const next = new Map(this.upgradeOptions);
204
+ if (options != null) {
205
+ next.set(name, options);
206
+ }
207
+ return new RecordBuilderImpl(
208
+ {
209
+ type: ModelDefType.RECORD,
210
+ name: this.name,
211
+ fields: { ...this.fields, [name]: value },
212
+ docs: "",
213
+ },
214
+ next,
215
+ );
216
+ }
217
+
218
+ removeField<K extends keyof T & string>(
219
+ 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);
224
+ return new RecordBuilderImpl(
225
+ {
226
+ type: ModelDefType.RECORD,
227
+ name: this.name,
228
+ fields: rest as RecordFields,
229
+ docs: "",
230
+ },
231
+ next,
232
+ ) as unknown as RecordBuilder<Omit<T, K>>;
107
233
  }
234
+
108
235
  build(): RecordDef<T> {
109
- return {
236
+ const result: RecordDef<T> = {
110
237
  type: ModelDefType.RECORD,
111
238
  name: this.name,
112
239
  fields: this.fields,
113
240
  docs: "",
114
241
  };
242
+ if (this.upgradeOptions.size > 0) {
243
+ Object.defineProperty(result, UPGRADE_OPTIONS_CARRIER, {
244
+ value: this.upgradeOptions,
245
+ enumerable: false,
246
+ configurable: true,
247
+ writable: false,
248
+ });
249
+ }
250
+ return result;
115
251
  }
116
252
  }
117
253
 
118
- export function defineMigration<
254
+ function makeBuilders<T extends ModelDefs>(models: T): SchemaBuilder<T> {
255
+ const builders = {} as SchemaBuilder<T>;
256
+ for (const key in models) {
257
+ const v = models[key];
258
+ if (isRecordDef(v)) {
259
+ builders[key] = new RecordBuilderImpl(v) as unknown as SchemaBuilder<T>[typeof key];
260
+ } else if (isUnionDef(v)) {
261
+ builders[key] = new UnionBuilderImpl(v) as unknown as SchemaBuilder<T>[typeof key];
262
+ }
263
+ }
264
+ return builders;
265
+ }
266
+
267
+ /** Per-model field migrations: `[modelKey][fieldName]` → upgrade options. */
268
+ export type FieldUpgrades = Record<
269
+ string,
270
+ Record<string, UpgradeFieldOptions<Record<string, unknown>>>
271
+ >;
272
+
273
+ export interface MigrationResult<M extends ModelDefs> {
274
+ readonly models: M;
275
+ readonly upgrades?: FieldUpgrades;
276
+ }
277
+
278
+ /**
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.
283
+ *
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.
289
+ */
290
+ export function applyMigration<
119
291
  const T extends ModelDefs,
120
292
  const S extends ModelDefs,
121
293
  >(
122
294
  models: T,
123
295
  migration: (schema: SchemaBuilder<T>) => S,
124
- ): T & S {
125
- const builders = {} as SchemaBuilder<T>;
126
-
127
- for (const key in models) {
128
- const v = models[key];
296
+ ): MigrationResult<T & S> {
297
+ const builders = makeBuilders(models);
298
+ const merged = { ...models, ...migration(builders) } as T & S;
129
299
 
130
- if (isRecordDef(v)) {
131
- const recordBuilder = new RecordBuilderImpl(v);
132
- builders[key] = recordBuilder as unknown as SchemaBuilder<T>[typeof key];
133
- } else if (isUnionDef(v)) {
134
- const unionBuilder = new UnionBuilderImpl(v);
135
- builders[key] = unionBuilder as unknown as SchemaBuilder<T>[typeof key];
300
+ const upgrades: FieldUpgrades = {};
301
+ for (const [modelKey, def] of Object.entries(merged)) {
302
+ if (!isRecordDef(def)) continue;
303
+ const carrier = def as unknown as Record<string, unknown>;
304
+ const opts = carrier[UPGRADE_OPTIONS_CARRIER] as
305
+ | ReadonlyMap<string, FieldOptions>
306
+ | 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) {
319
+ fieldEntries[fieldName] = options;
320
+ }
321
+ }
322
+ if (Object.keys(fieldEntries).length > 0) {
323
+ upgrades[modelKey] = fieldEntries;
136
324
  }
137
325
  }
138
326
 
139
327
  return {
140
- ...models,
141
- ...migration(builders),
328
+ models: merged,
329
+ upgrades: Object.keys(upgrades).length > 0 ? upgrades : undefined,
142
330
  };
143
331
  }
332
+
333
+ export function defineMigration<
334
+ const T extends ModelDefs,
335
+ const S extends ModelDefs,
336
+ >(
337
+ models: T,
338
+ migration: (schema: SchemaBuilder<T>) => S,
339
+ ): T & S {
340
+ return applyMigration(models, migration).models;
341
+ }
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { SchemaBuilder } from "./defineMigration.js";
18
- import { defineMigration } from "./defineMigration.js";
18
+ import { applyMigration } from "./defineMigration.js";
19
19
  import type { ModelDefs } from "./defs.js";
20
20
 
21
21
  export interface InitialSchema<T extends ModelDefs = ModelDefs> {
@@ -26,7 +26,6 @@ export interface InitialSchema<T extends ModelDefs = ModelDefs> {
26
26
 
27
27
  export interface FieldMigration {
28
28
  readonly derivedFrom: readonly string[];
29
- readonly forward: (oldFields: Record<string, unknown>) => unknown;
30
29
  }
31
30
 
32
31
  export type VersionMigrations = Record<string, Record<string, FieldMigration>>;
@@ -67,6 +66,57 @@ export interface SchemaVersionBuilder<T extends ModelDefs> {
67
66
  build(): VersionedSchema<T>;
68
67
  }
69
68
 
69
+ /**
70
+ * Merge two `VersionMigrations` records. Per-model field migrations from
71
+ * `override` take precedence over `base` for the same field, but the maps are
72
+ * combined at both the model and field level so explicit `withMigrations`
73
+ * calls can supplement (not just replace) the sugar from `addField` options.
74
+ */
75
+ function mergeMigrations(
76
+ base: VersionMigrations | undefined,
77
+ override: VersionMigrations | undefined,
78
+ ): VersionMigrations | undefined {
79
+ if (base == null) return override;
80
+ if (override == null) return base;
81
+ const result: VersionMigrations = { ...base };
82
+ for (const [modelKey, fields] of Object.entries(override)) {
83
+ result[modelKey] = { ...result[modelKey], ...fields };
84
+ }
85
+ return result;
86
+ }
87
+
88
+ /**
89
+ * Drop migration entries that no longer correspond to a field on a record in
90
+ * `models`. Required because `mergeMigrations` is monotonic — a later
91
+ * `addSchemaUpdate` that removes or retypes a field would otherwise leave a
92
+ * stale entry in the accumulator. Entries are dropped when:
93
+ * - the model key is no longer present,
94
+ * - the model is no longer a record (e.g. replaced by a union), or
95
+ * - the field name is no longer one of the record's fields.
96
+ */
97
+ function pruneMigrations(
98
+ migrations: VersionMigrations | undefined,
99
+ models: ModelDefs,
100
+ ): VersionMigrations | undefined {
101
+ if (migrations == null) return undefined;
102
+ const result: VersionMigrations = {};
103
+ for (const [modelKey, fields] of Object.entries(migrations)) {
104
+ const def = models[modelKey];
105
+ if (def == null || def.type !== "record") continue;
106
+ const recordFields = def.fields;
107
+ const kept: Record<string, FieldMigration> = {};
108
+ for (const [fieldName, migration] of Object.entries(fields)) {
109
+ if (fieldName in recordFields) {
110
+ kept[fieldName] = migration;
111
+ }
112
+ }
113
+ if (Object.keys(kept).length > 0) {
114
+ result[modelKey] = kept;
115
+ }
116
+ }
117
+ return Object.keys(result).length > 0 ? result : undefined;
118
+ }
119
+
70
120
  class SchemaVersionBuilderImpl<T extends ModelDefs> implements SchemaVersionBuilder<T> {
71
121
  private readonly models: T;
72
122
  private readonly version: number;
@@ -88,12 +138,23 @@ class SchemaVersionBuilderImpl<T extends ModelDefs> implements SchemaVersionBuil
88
138
  addSchemaUpdate<S extends ModelDefs>(
89
139
  update: SchemaUpdate<T, S>,
90
140
  ): SchemaVersionBuilder<T & S> {
91
- const merged = defineMigration(this.models, update.migration);
92
- return new SchemaVersionBuilderImpl(merged, this.version, this.previous, this._migrations);
141
+ // applyMigration returns both the merged models and any sugar-form
142
+ // upgrades collected via `addField(name, type, { derivedFrom, forward })`.
143
+ const { models, upgrades } = applyMigration(this.models, update.migration);
144
+ const merged = mergeMigrations(
145
+ this._migrations,
146
+ upgrades as VersionMigrations | undefined,
147
+ );
148
+ // Reconcile against the new models: a later update that removes or
149
+ // retypes a field must drop the prior step's stale migration entry.
150
+ const pruned = pruneMigrations(merged, models);
151
+ return new SchemaVersionBuilderImpl(models, this.version, this.previous, pruned);
93
152
  }
94
153
 
95
154
  withMigrations(migrations: VersionMigrations): SchemaVersionBuilder<T> {
96
- return new SchemaVersionBuilderImpl(this.models, this.version, this.previous, migrations);
155
+ const merged = mergeMigrations(this._migrations, migrations);
156
+ const pruned = pruneMigrations(merged, this.models);
157
+ return new SchemaVersionBuilderImpl(this.models, this.version, this.previous, pruned);
97
158
  }
98
159
 
99
160
  build(): VersionedSchema<T> {