@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.
- package/.turbo/turbo-lint.log +7 -4
- package/.turbo/turbo-transpileBrowser.log +1 -2
- package/.turbo/turbo-transpileCjs.log +1 -2
- package/.turbo/turbo-transpileEsm.log +1 -2
- package/.turbo/turbo-transpileTypes.log +1 -1
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +12 -0
- package/build/browser/index.js +142 -15
- package/build/browser/index.js.map +1 -1
- package/build/cjs/index.cjs +143 -15
- package/build/cjs/index.cjs.map +1 -1
- package/build/cjs/index.d.cts +52 -3
- package/build/esm/index.js +142 -15
- 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/package.json +1 -1
- package/src/__tests__/addFieldOptions.test.ts +577 -0
- package/src/defineMigration.ts +220 -22
- package/src/defineSchemaUpdate.ts +66 -5
package/src/defineMigration.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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 {
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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> {
|