@platforma-sdk/model 1.53.1 → 1.53.3

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.
@@ -1,5 +1,5 @@
1
1
  import { tryRegisterCallback } from './internal';
2
- import { createBlockStorage, DATA_MODEL_DEFAULT_VERSION } from './block_storage';
2
+ import { createBlockStorage } from './block_storage';
3
3
 
4
4
  export type DataVersionKey = string;
5
5
  export type DataVersionMap = Record<string, unknown>;
@@ -10,9 +10,10 @@ export type DataRecoverFn<T> = (version: DataVersionKey, data: unknown) => T;
10
10
  /**
11
11
  * Helper to define version keys with literal type inference and runtime validation.
12
12
  * - Validates that all version values are unique
13
+ * - Validates that no version value is empty
13
14
  * - Eliminates need for `as const` assertion
14
15
  *
15
- * @throws Error if duplicate version values are found
16
+ * @throws Error if duplicate or empty version values are found
16
17
  *
17
18
  * @example
18
19
  * const Version = defineDataVersions({
@@ -26,8 +27,9 @@ export type DataRecoverFn<T> = (version: DataVersionKey, data: unknown) => T;
26
27
  * };
27
28
  */
28
29
  export function defineDataVersions<const T extends Record<string, string>>(versions: T): T {
29
- const values = Object.values(versions);
30
- const emptyKeys = Object.keys(versions).filter((key) => versions[key] === '');
30
+ const values = Object.values(versions) as (string & keyof T)[];
31
+ const keys = Object.keys(versions) as (keyof T)[];
32
+ const emptyKeys = keys.filter((key) => versions[key] === '');
31
33
  if (emptyKeys.length > 0) {
32
34
  throw new Error(`Version values must be non-empty strings (empty: ${emptyKeys.join(', ')})`);
33
35
  }
@@ -73,76 +75,278 @@ type MigrationStep = {
73
75
  migrate: (data: unknown) => unknown;
74
76
  };
75
77
 
76
- /** Default recover function for unknown versions */
78
+ /**
79
+ * Default recover function for unknown versions.
80
+ * Use as fallback at the end of custom recover functions.
81
+ *
82
+ * @example
83
+ * .recover((version, data) => {
84
+ * if (version === 'legacy') {
85
+ * return transformLegacyData(data);
86
+ * }
87
+ * return defaultRecover(version, data);
88
+ * })
89
+ */
77
90
  export const defaultRecover: DataRecoverFn<never> = (version, _data) => {
78
91
  throw new DataUnrecoverableError(version);
79
92
  };
80
93
 
81
- /** Internal builder for chaining migrations */
82
- class DataModelBuilder<
94
+ /** Symbol for internal builder creation method */
95
+ const FROM_BUILDER = Symbol('fromBuilder');
96
+
97
+ /** Internal state passed from builder to DataModel */
98
+ type BuilderState<S> = {
99
+ versionChain: DataVersionKey[];
100
+ steps: MigrationStep[];
101
+ initialDataFn: () => S;
102
+ recoverFn?: DataRecoverFn<S>;
103
+ };
104
+
105
+ /**
106
+ * Final builder state after recover() is called.
107
+ * Only allows calling create() to finalize the DataModel.
108
+ *
109
+ * @typeParam VersionedData - Map of version keys to their data types
110
+ * @typeParam CurrentVersion - The current (final) version in the chain
111
+ * @internal
112
+ */
113
+ class DataModelBuilderWithRecover<
83
114
  VersionedData extends DataVersionMap,
84
115
  CurrentVersion extends keyof VersionedData & string,
85
116
  > {
86
117
  private readonly versionChain: DataVersionKey[];
87
118
  private readonly migrationSteps: MigrationStep[];
88
- private readonly recoverFn?: DataRecoverFn<VersionedData[CurrentVersion]>;
119
+ private readonly recoverFn: DataRecoverFn<VersionedData[CurrentVersion]>;
89
120
 
90
- private constructor(
91
- versionChain: DataVersionKey[],
92
- steps: MigrationStep[] = [],
93
- recoverFn?: DataRecoverFn<VersionedData[CurrentVersion]>,
94
- ) {
121
+ /** @internal */
122
+ constructor({
123
+ versionChain,
124
+ steps,
125
+ recoverFn,
126
+ }: {
127
+ versionChain: DataVersionKey[];
128
+ steps: MigrationStep[];
129
+ recoverFn: DataRecoverFn<VersionedData[CurrentVersion]>;
130
+ }) {
95
131
  this.versionChain = versionChain;
96
132
  this.migrationSteps = steps;
97
133
  this.recoverFn = recoverFn;
98
134
  }
99
135
 
100
- /** Start a migration chain from an initial version */
101
- static from<
102
- VersionedData extends DataVersionMap,
103
- InitialVersion extends keyof VersionedData & string = keyof VersionedData & string,
104
- >(initialVersion: InitialVersion): DataModelBuilder<VersionedData, InitialVersion> {
105
- return new DataModelBuilder<VersionedData, InitialVersion>([initialVersion]);
136
+ /**
137
+ * Finalize the DataModel with initial data factory.
138
+ *
139
+ * The initial data factory is called when creating new blocks or when
140
+ * migration/recovery fails and data must be reset.
141
+ *
142
+ * @param initialData - Factory function returning initial state (must exactly match CurrentVersion's data type)
143
+ * @returns Finalized DataModel instance
144
+ *
145
+ * @example
146
+ * .init(() => ({ numbers: [], labels: [], description: '' }))
147
+ */
148
+ init<S extends VersionedData[CurrentVersion]>(
149
+ initialData: DataCreateFn<S>,
150
+ // Compile-time check: S must have exactly the same keys as VersionedData[CurrentVersion]
151
+ ..._noExtraKeys: Exclude<keyof S, keyof VersionedData[CurrentVersion]> extends never
152
+ ? []
153
+ : [never]
154
+ ): DataModel<VersionedData[CurrentVersion]> {
155
+ return DataModel[FROM_BUILDER]<VersionedData[CurrentVersion]>({
156
+ versionChain: this.versionChain,
157
+ steps: this.migrationSteps,
158
+ initialDataFn: initialData as DataCreateFn<VersionedData[CurrentVersion]>,
159
+ recoverFn: this.recoverFn,
160
+ });
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Internal builder for constructing DataModel with type-safe migration chains.
166
+ *
167
+ * Tracks the current version through the generic type system, ensuring:
168
+ * - Migration functions receive correctly typed input
169
+ * - Migration functions must return the correct output type
170
+ * - Version keys must exist in the VersionedData map
171
+ * - All versions must be covered before calling init()
172
+ *
173
+ * @typeParam VersionedData - Map of version keys to their data types
174
+ * @typeParam CurrentVersion - The current version in the migration chain
175
+ * @typeParam RemainingVersions - Versions not yet covered by migrations
176
+ * @internal
177
+ */
178
+ class DataModelMigrationChain<
179
+ VersionedData extends DataVersionMap,
180
+ CurrentVersion extends keyof VersionedData & string,
181
+ RemainingVersions extends keyof VersionedData & string = Exclude<
182
+ keyof VersionedData & string,
183
+ CurrentVersion
184
+ >,
185
+ > {
186
+ private readonly versionChain: DataVersionKey[];
187
+ private readonly migrationSteps: MigrationStep[];
188
+
189
+ /** @internal */
190
+ constructor({
191
+ versionChain,
192
+ steps = [],
193
+ }: {
194
+ versionChain: DataVersionKey[];
195
+ steps?: MigrationStep[];
196
+ }) {
197
+ this.versionChain = versionChain;
198
+ this.migrationSteps = steps;
106
199
  }
107
200
 
108
- /** Add a migration step to the target version */
109
- migrate<NextVersion extends keyof VersionedData & string>(
201
+ /**
202
+ * Add a migration step to transform data from current version to next version.
203
+ *
204
+ * Migration functions:
205
+ * - Receive data typed as the current version's data type (readonly)
206
+ * - Must return data matching the target version's data type
207
+ * - Should be pure functions (no side effects)
208
+ * - May throw errors (will result in data reset with warning)
209
+ *
210
+ * @typeParam NextVersion - The target version key (must be in RemainingVersions)
211
+ * @param nextVersion - The version key to migrate to
212
+ * @param fn - Migration function transforming current data to next version
213
+ * @returns Builder with updated current version
214
+ *
215
+ * @example
216
+ * .migrate(Version.V2, (data) => ({ ...data, labels: [] }))
217
+ */
218
+ migrate<NextVersion extends RemainingVersions>(
110
219
  nextVersion: NextVersion,
111
220
  fn: DataMigrateFn<VersionedData[CurrentVersion], VersionedData[NextVersion]>,
112
- ): DataModelBuilder<VersionedData, NextVersion> {
221
+ ): DataModelMigrationChain<VersionedData, NextVersion, Exclude<RemainingVersions, NextVersion>> {
113
222
  if (this.versionChain.includes(nextVersion)) {
114
223
  throw new Error(`Duplicate version '${nextVersion}' in migration chain`);
115
224
  }
116
225
  const fromVersion = this.versionChain[this.versionChain.length - 1];
117
- const step: MigrationStep = { fromVersion, toVersion: nextVersion, migrate: fn as (data: unknown) => unknown };
118
- return new DataModelBuilder<VersionedData, NextVersion>(
119
- [...this.versionChain, nextVersion],
120
- [...this.migrationSteps, step],
121
- );
226
+ const step: MigrationStep = {
227
+ fromVersion,
228
+ toVersion: nextVersion,
229
+ migrate: fn as (data: unknown) => unknown,
230
+ };
231
+ return new DataModelMigrationChain<
232
+ VersionedData,
233
+ NextVersion,
234
+ Exclude<RemainingVersions, NextVersion>
235
+ >({
236
+ versionChain: [...this.versionChain, nextVersion],
237
+ steps: [...this.migrationSteps, step],
238
+ });
122
239
  }
123
240
 
124
- /** Set recovery handler for unknown or unsupported versions */
241
+ /**
242
+ * Set a recovery handler for unknown or legacy versions.
243
+ *
244
+ * The recover function is called when data has a version not in the migration chain.
245
+ * It should either:
246
+ * - Transform the data to the current version's format and return it
247
+ * - Call `defaultRecover(version, data)` to signal unrecoverable data
248
+ *
249
+ * Can only be called once. After calling, only `init()` is available.
250
+ *
251
+ * @param fn - Recovery function that transforms unknown data or throws
252
+ * @returns Builder with only init() method available
253
+ *
254
+ * @example
255
+ * .recover((version, data) => {
256
+ * if (version === 'legacy' && isLegacyFormat(data)) {
257
+ * return transformLegacy(data);
258
+ * }
259
+ * return defaultRecover(version, data);
260
+ * })
261
+ */
125
262
  recover(
126
263
  fn: DataRecoverFn<VersionedData[CurrentVersion]>,
127
- ): DataModelBuilder<VersionedData, CurrentVersion> {
128
- return new DataModelBuilder<VersionedData, CurrentVersion>(
129
- [...this.versionChain],
130
- [...this.migrationSteps],
131
- fn,
132
- );
264
+ ): DataModelBuilderWithRecover<VersionedData, CurrentVersion> {
265
+ return new DataModelBuilderWithRecover<VersionedData, CurrentVersion>({
266
+ versionChain: [...this.versionChain],
267
+ steps: [...this.migrationSteps],
268
+ recoverFn: fn,
269
+ });
133
270
  }
134
271
 
135
- /** Finalize with initial data, creating the DataModel */
136
- create<S extends VersionedData[CurrentVersion]>(
272
+ /**
273
+ * Finalize the DataModel with initial data factory.
274
+ *
275
+ * Can only be called when all versions in VersionedData have been covered
276
+ * by the migration chain (RemainingVersions is empty).
277
+ *
278
+ * The initial data factory is called when creating new blocks or when
279
+ * migration/recovery fails and data must be reset.
280
+ *
281
+ * @param initialData - Factory function returning initial state (must exactly match CurrentVersion's data type)
282
+ * @returns Finalized DataModel instance
283
+ *
284
+ * @example
285
+ * .init(() => ({ numbers: [], labels: [], description: '' }))
286
+ */
287
+ init<S extends VersionedData[CurrentVersion]>(
288
+ // Compile-time check: RemainingVersions must be empty (all versions covered)
289
+ this: DataModelMigrationChain<VersionedData, CurrentVersion, never>,
137
290
  initialData: DataCreateFn<S>,
138
- ..._: [VersionedData[CurrentVersion]] extends [S] ? [] : [never]
139
- ): DataModel<S> {
140
- return DataModel._fromBuilder<S>(
141
- this.versionChain,
142
- this.migrationSteps,
143
- initialData,
144
- this.recoverFn as DataRecoverFn<S> | undefined,
145
- );
291
+ // Compile-time check: S must have exactly the same keys as VersionedData[CurrentVersion]
292
+ ..._noExtraKeys: Exclude<keyof S, keyof VersionedData[CurrentVersion]> extends never
293
+ ? []
294
+ : [never]
295
+ ): DataModel<VersionedData[CurrentVersion]> {
296
+ return DataModel[FROM_BUILDER]<VersionedData[CurrentVersion]>({
297
+ versionChain: this.versionChain,
298
+ steps: this.migrationSteps,
299
+ initialDataFn: initialData as DataCreateFn<VersionedData[CurrentVersion]>,
300
+ });
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Builder entry point for creating DataModel with type-safe migrations.
306
+ *
307
+ * @typeParam VersionedData - Map of version keys to their data types
308
+ *
309
+ * @example
310
+ * const Version = defineDataVersions({
311
+ * V1: 'v1',
312
+ * V2: 'v2',
313
+ * });
314
+ *
315
+ * type VersionedData = {
316
+ * [Version.V1]: { count: number };
317
+ * [Version.V2]: { count: number; label: string };
318
+ * };
319
+ *
320
+ * const dataModel = new DataModelBuilder<VersionedData>()
321
+ * .from(Version.V1)
322
+ * .migrate(Version.V2, (data) => ({ ...data, label: '' }))
323
+ * .init(() => ({ count: 0, label: '' }));
324
+ */
325
+ export class DataModelBuilder<VersionedData extends DataVersionMap> {
326
+ /**
327
+ * Start a migration chain from an initial version.
328
+ *
329
+ * @typeParam InitialVersion - The starting version key (inferred from argument)
330
+ * @param initialVersion - The version key to start from
331
+ * @returns Migration chain builder for adding migrations
332
+ *
333
+ * @example
334
+ * new DataModelBuilder<VersionedData>()
335
+ * .from(Version.V1)
336
+ * .migrate(Version.V2, (data) => ({ ...data, newField: '' }))
337
+ */
338
+ from<InitialVersion extends keyof VersionedData & string>(
339
+ initialVersion: InitialVersion,
340
+ ): DataModelMigrationChain<
341
+ VersionedData,
342
+ InitialVersion,
343
+ Exclude<keyof VersionedData & string, InitialVersion>
344
+ > {
345
+ return new DataModelMigrationChain<
346
+ VersionedData,
347
+ InitialVersion,
348
+ Exclude<keyof VersionedData & string, InitialVersion>
349
+ >({ versionChain: [initialVersion] });
146
350
  }
147
351
  }
148
352
 
@@ -150,16 +354,21 @@ class DataModelBuilder<
150
354
  * DataModel defines the block's data structure, initial values, and migrations.
151
355
  * Used by BlockModelV3 to manage data state.
152
356
  *
357
+ * Use `new DataModelBuilder<VersionedData>()` to create a DataModel:
358
+ *
359
+ * **Simple (no migrations):**
153
360
  * @example
154
- * // Simple data model (no migrations)
155
- * const dataModel = DataModel.create<BlockData>(() => ({
156
- * numbers: [],
157
- * labels: [],
158
- * }));
361
+ * const Version = defineDataVersions({ V1: DATA_MODEL_DEFAULT_VERSION });
362
+ * type VersionedData = { [Version.V1]: BlockData };
363
+ *
364
+ * const dataModel = new DataModelBuilder<VersionedData>()
365
+ * .from(Version.V1)
366
+ * .init(() => ({ numbers: [], labels: [] }));
159
367
  *
160
- * // Data model with migrations
368
+ * **With migrations:**
369
+ * @example
161
370
  * const Version = defineDataVersions({
162
- * V1: 'v1',
371
+ * V1: DATA_MODEL_DEFAULT_VERSION,
163
372
  * V2: 'v2',
164
373
  * V3: 'v3',
165
374
  * });
@@ -170,8 +379,8 @@ class DataModelBuilder<
170
379
  * [Version.V3]: { numbers: number[]; labels: string[]; description: string };
171
380
  * };
172
381
  *
173
- * const dataModel = DataModel
174
- * .from<VersionedData>(Version.V1)
382
+ * const dataModel = new DataModelBuilder<VersionedData>()
383
+ * .from(Version.V1)
175
384
  * .migrate(Version.V2, (data) => ({ ...data, labels: [] }))
176
385
  * .migrate(Version.V3, (data) => ({ ...data, description: '' }))
177
386
  * .recover((version, data) => {
@@ -180,7 +389,7 @@ class DataModelBuilder<
180
389
  * }
181
390
  * return defaultRecover(version, data);
182
391
  * })
183
- * .create(() => ({ numbers: [], labels: [], description: '' }));
392
+ * .init(() => ({ numbers: [], labels: [], description: '' }));
184
393
  */
185
394
  export class DataModel<State> {
186
395
  private readonly versionChain: DataVersionKey[];
@@ -188,62 +397,60 @@ export class DataModel<State> {
188
397
  private readonly initialDataFn: () => State;
189
398
  private readonly recoverFn: DataRecoverFn<State>;
190
399
 
191
- private constructor(
192
- versionChain: DataVersionKey[],
193
- steps: MigrationStep[],
194
- initialData: () => State,
195
- recover: DataRecoverFn<State> = defaultRecover as DataRecoverFn<State>,
196
- ) {
400
+ private constructor({
401
+ versionChain,
402
+ steps,
403
+ initialDataFn,
404
+ recoverFn = defaultRecover as DataRecoverFn<State>,
405
+ }: {
406
+ versionChain: DataVersionKey[];
407
+ steps: MigrationStep[];
408
+ initialDataFn: () => State;
409
+ recoverFn?: DataRecoverFn<State>;
410
+ }) {
197
411
  if (versionChain.length === 0) {
198
412
  throw new Error('DataModel requires at least one version key');
199
413
  }
200
414
  this.versionChain = versionChain;
201
415
  this.steps = steps;
202
- this.initialDataFn = initialData;
203
- this.recoverFn = recover;
204
- }
205
-
206
- /** Start a migration chain from an initial type */
207
- static from<
208
- VersionedData extends DataVersionMap,
209
- InitialVersion extends keyof VersionedData & string = keyof VersionedData & string,
210
- >(initialVersion: InitialVersion): DataModelBuilder<VersionedData, InitialVersion> {
211
- return DataModelBuilder.from<VersionedData, InitialVersion>(initialVersion);
212
- }
213
-
214
- /** Create a data model with just initial data (no migrations) */
215
- static create<S>(initialData: () => S, version: DataVersionKey = DATA_MODEL_DEFAULT_VERSION): DataModel<S> {
216
- return new DataModel<S>([version], [], initialData);
416
+ this.initialDataFn = initialDataFn;
417
+ this.recoverFn = recoverFn;
217
418
  }
218
419
 
219
- /** Create from builder (internal use) */
220
- static _fromBuilder<S>(
221
- versionChain: DataVersionKey[],
222
- steps: MigrationStep[],
223
- initialData: () => S,
224
- recover?: DataRecoverFn<S>,
225
- ): DataModel<S> {
226
- return new DataModel<S>(versionChain, steps, initialData, recover);
420
+ /**
421
+ * Internal method for creating DataModel from builder.
422
+ * Uses Symbol key to prevent external access.
423
+ * @internal
424
+ */
425
+ static [FROM_BUILDER]<S>(state: BuilderState<S>): DataModel<S> {
426
+ return new DataModel<S>(state);
227
427
  }
228
428
 
229
429
  /**
230
- * Latest version key.
430
+ * The latest (current) version key in the migration chain.
231
431
  */
232
432
  get version(): DataVersionKey {
233
433
  return this.versionChain[this.versionChain.length - 1];
234
434
  }
235
435
 
236
- /** Number of migration steps */
436
+ /**
437
+ * Number of migration steps defined.
438
+ */
237
439
  get migrationCount(): number {
238
440
  return this.steps.length;
239
441
  }
240
442
 
241
- /** Get initial data */
443
+ /**
444
+ * Get a fresh copy of the initial data.
445
+ */
242
446
  initialData(): State {
243
447
  return this.initialDataFn();
244
448
  }
245
449
 
246
- /** Get default data wrapped with current version */
450
+ /**
451
+ * Get initial data wrapped with current version.
452
+ * Used when creating new blocks or resetting to defaults.
453
+ */
247
454
  getDefaultData(): DataVersioned<State> {
248
455
  return makeDataVersioned(this.version, this.initialDataFn());
249
456
  }
@@ -265,8 +472,14 @@ export class DataModel<State> {
265
472
 
266
473
  /**
267
474
  * Migrate versioned data from any version to the latest.
268
- * Applies only the migrations needed (skips already-applied ones).
269
- * If a migration fails, returns default data with a warning.
475
+ *
476
+ * - If data is already at latest version, returns as-is
477
+ * - If version is in chain, applies needed migrations
478
+ * - If version is unknown, calls recover function
479
+ * - If migration/recovery fails, returns default data with warning
480
+ *
481
+ * @param versioned - Data with version tag
482
+ * @returns Migration result with data at latest version
270
483
  */
271
484
  migrate(versioned: DataVersioned<unknown>): DataMigrationResult<State> {
272
485
  const { version: fromVersion, data } = versioned;
@@ -301,14 +514,13 @@ export class DataModel<State> {
301
514
  * Register callbacks for use in the VM.
302
515
  * Called by BlockModelV3.create() to set up internal callbacks.
303
516
  *
304
- * All callbacks are prefixed with `__pl_` to indicate internal SDK use:
305
- * - `__pl_data_initial`: returns initial data for new blocks
306
- * - `__pl_data_migrate`: migrates versioned data from any version to latest
307
- * - `__pl_storage_initial`: returns initial BlockStorage as JSON string
517
+ * @internal
308
518
  */
309
519
  registerCallbacks(): void {
310
520
  tryRegisterCallback('__pl_data_initial', () => this.initialDataFn());
311
- tryRegisterCallback('__pl_data_migrate', (versioned: DataVersioned<unknown>) => this.migrate(versioned));
521
+ tryRegisterCallback('__pl_data_upgrade', (versioned: DataVersioned<unknown>) =>
522
+ this.migrate(versioned),
523
+ );
312
524
  tryRegisterCallback('__pl_storage_initial', () => {
313
525
  const { version, data } = this.getDefaultData();
314
526
  const storage = createBlockStorage(data, version);