@platforma-sdk/model 1.54.13 → 1.56.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.
Files changed (120) hide show
  1. package/dist/bconfig/normalization.cjs +8 -1
  2. package/dist/bconfig/normalization.cjs.map +1 -1
  3. package/dist/bconfig/normalization.d.ts.map +1 -1
  4. package/dist/bconfig/normalization.js +8 -1
  5. package/dist/bconfig/normalization.js.map +1 -1
  6. package/dist/block_api_v3.d.ts +2 -2
  7. package/dist/block_api_v3.d.ts.map +1 -1
  8. package/dist/block_migrations.cjs +246 -214
  9. package/dist/block_migrations.cjs.map +1 -1
  10. package/dist/block_migrations.d.ts +180 -158
  11. package/dist/block_migrations.d.ts.map +1 -1
  12. package/dist/block_migrations.js +247 -214
  13. package/dist/block_migrations.js.map +1 -1
  14. package/dist/block_model.cjs +85 -35
  15. package/dist/block_model.cjs.map +1 -1
  16. package/dist/block_model.d.ts +66 -38
  17. package/dist/block_model.d.ts.map +1 -1
  18. package/dist/block_model.js +86 -36
  19. package/dist/block_model.js.map +1 -1
  20. package/dist/{builder.cjs → block_model_legacy.cjs} +2 -2
  21. package/dist/block_model_legacy.cjs.map +1 -0
  22. package/dist/{builder.d.ts → block_model_legacy.d.ts} +1 -1
  23. package/dist/block_model_legacy.d.ts.map +1 -0
  24. package/dist/{builder.js → block_model_legacy.js} +2 -2
  25. package/dist/block_model_legacy.js.map +1 -0
  26. package/dist/block_state_patch.d.ts +11 -1
  27. package/dist/block_state_patch.d.ts.map +1 -1
  28. package/dist/block_storage.cjs +126 -109
  29. package/dist/block_storage.cjs.map +1 -1
  30. package/dist/block_storage.d.ts +109 -112
  31. package/dist/block_storage.d.ts.map +1 -1
  32. package/dist/block_storage.js +126 -101
  33. package/dist/block_storage.js.map +1 -1
  34. package/dist/block_storage_callbacks.cjs +227 -0
  35. package/dist/block_storage_callbacks.cjs.map +1 -0
  36. package/dist/block_storage_callbacks.d.ts +113 -0
  37. package/dist/block_storage_callbacks.d.ts.map +1 -0
  38. package/dist/block_storage_callbacks.js +218 -0
  39. package/dist/block_storage_callbacks.js.map +1 -0
  40. package/dist/block_storage_facade.cjs +104 -0
  41. package/dist/block_storage_facade.cjs.map +1 -0
  42. package/dist/block_storage_facade.d.ts +168 -0
  43. package/dist/block_storage_facade.d.ts.map +1 -0
  44. package/dist/block_storage_facade.js +99 -0
  45. package/dist/block_storage_facade.js.map +1 -0
  46. package/dist/index.cjs +13 -14
  47. package/dist/index.cjs.map +1 -1
  48. package/dist/index.d.ts +8 -3
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +6 -4
  51. package/dist/index.js.map +1 -1
  52. package/dist/package.json.cjs +1 -1
  53. package/dist/package.json.js +1 -1
  54. package/dist/platforma.d.ts +11 -4
  55. package/dist/platforma.d.ts.map +1 -1
  56. package/dist/plugin_model.cjs +171 -0
  57. package/dist/plugin_model.cjs.map +1 -0
  58. package/dist/plugin_model.d.ts +162 -0
  59. package/dist/plugin_model.d.ts.map +1 -0
  60. package/dist/plugin_model.js +169 -0
  61. package/dist/plugin_model.js.map +1 -0
  62. package/dist/render/api.cjs +20 -21
  63. package/dist/render/api.cjs.map +1 -1
  64. package/dist/render/api.d.ts +8 -8
  65. package/dist/render/api.d.ts.map +1 -1
  66. package/dist/render/api.js +20 -21
  67. package/dist/render/api.js.map +1 -1
  68. package/dist/render/internal.cjs.map +1 -1
  69. package/dist/render/internal.d.ts +2 -1
  70. package/dist/render/internal.d.ts.map +1 -1
  71. package/dist/render/internal.js.map +1 -1
  72. package/dist/version.cjs +4 -0
  73. package/dist/version.cjs.map +1 -1
  74. package/dist/version.d.ts +4 -0
  75. package/dist/version.d.ts.map +1 -1
  76. package/dist/version.js +4 -1
  77. package/dist/version.js.map +1 -1
  78. package/package.json +5 -5
  79. package/src/bconfig/normalization.ts +8 -1
  80. package/src/block_api_v3.ts +2 -2
  81. package/src/block_migrations.test.ts +141 -171
  82. package/src/block_migrations.ts +300 -285
  83. package/src/block_model.ts +205 -95
  84. package/src/{builder.ts → block_model_legacy.ts} +1 -1
  85. package/src/block_state_patch.ts +13 -1
  86. package/src/block_storage.test.ts +283 -95
  87. package/src/block_storage.ts +199 -188
  88. package/src/block_storage_callbacks.ts +326 -0
  89. package/src/block_storage_facade.ts +199 -0
  90. package/src/index.ts +7 -3
  91. package/src/platforma.ts +26 -7
  92. package/src/plugin_model.test.ts +168 -0
  93. package/src/plugin_model.ts +242 -0
  94. package/src/render/api.ts +26 -24
  95. package/src/render/internal.ts +3 -1
  96. package/src/typing.test.ts +1 -1
  97. package/src/version.ts +8 -0
  98. package/dist/block_storage_vm.cjs +0 -262
  99. package/dist/block_storage_vm.cjs.map +0 -1
  100. package/dist/block_storage_vm.d.ts +0 -59
  101. package/dist/block_storage_vm.d.ts.map +0 -1
  102. package/dist/block_storage_vm.js +0 -258
  103. package/dist/block_storage_vm.js.map +0 -1
  104. package/dist/branding.d.ts +0 -7
  105. package/dist/branding.d.ts.map +0 -1
  106. package/dist/builder.cjs.map +0 -1
  107. package/dist/builder.d.ts.map +0 -1
  108. package/dist/builder.js.map +0 -1
  109. package/dist/sdk_info.cjs +0 -10
  110. package/dist/sdk_info.cjs.map +0 -1
  111. package/dist/sdk_info.d.ts +0 -5
  112. package/dist/sdk_info.d.ts.map +0 -1
  113. package/dist/sdk_info.js +0 -8
  114. package/dist/sdk_info.js.map +0 -1
  115. package/dist/unionize.d.ts +0 -12
  116. package/dist/unionize.d.ts.map +0 -1
  117. package/src/block_storage_vm.ts +0 -346
  118. package/src/branding.ts +0 -4
  119. package/src/sdk_info.ts +0 -9
  120. package/src/unionize.ts +0 -12
@@ -1,46 +1,8 @@
1
- import { tryRegisterCallback } from "./internal";
2
- import { createBlockStorage } from "./block_storage";
3
-
4
1
  export type DataVersionKey = string;
5
- export type DataVersionMap = Record<string, unknown>;
6
2
  export type DataMigrateFn<From, To> = (prev: Readonly<From>) => To;
7
3
  export type DataCreateFn<T> = () => T;
8
4
  export type DataRecoverFn<T> = (version: DataVersionKey, data: unknown) => T;
9
5
 
10
- /**
11
- * Helper to define version keys with literal type inference and runtime validation.
12
- * - Validates that all version values are unique
13
- * - Validates that no version value is empty
14
- * - Eliminates need for `as const` assertion
15
- *
16
- * @throws Error if duplicate or empty version values are found
17
- *
18
- * @example
19
- * const Version = defineDataVersions({
20
- * Initial: 'v1',
21
- * AddedLabels: 'v2',
22
- * });
23
- *
24
- * type VersionedData = {
25
- * [Version.Initial]: DataV1;
26
- * [Version.AddedLabels]: DataV2;
27
- * };
28
- */
29
- export function defineDataVersions<const T extends Record<string, string>>(versions: T): T {
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] === "");
33
- if (emptyKeys.length > 0) {
34
- throw new Error(`Version values must be non-empty strings (empty: ${emptyKeys.join(", ")})`);
35
- }
36
- const unique = new Set(values);
37
- if (unique.size !== values.length) {
38
- const duplicates = values.filter((v, i) => values.indexOf(v) !== i);
39
- throw new Error(`Duplicate version values: ${[...new Set(duplicates)].join(", ")}`);
40
- }
41
- return versions;
42
- }
43
-
44
6
  /** Versioned data wrapper for persistence */
45
7
  export type DataVersioned<T> = {
46
8
  version: DataVersionKey;
@@ -94,98 +56,151 @@ export const defaultRecover: DataRecoverFn<never> = (version, _data) => {
94
56
  /** Symbol for internal builder creation method */
95
57
  const FROM_BUILDER = Symbol("fromBuilder");
96
58
 
59
+ /** Legacy V1 model state shape: { args, uiState } */
60
+ export type LegacyV1State<Args, UiState> = { args: Args; uiState: UiState };
61
+
97
62
  /** Internal state passed from builder to DataModel */
98
63
  type BuilderState<S> = {
99
64
  versionChain: DataVersionKey[];
100
65
  steps: MigrationStep[];
101
66
  initialDataFn: () => S;
102
- recoverFn?: DataRecoverFn<S>;
67
+ recoverFn?: (version: DataVersionKey, data: unknown) => unknown;
68
+ /** Index of the first step to run after recovery. Equals the number of steps
69
+ * present at the time recover() was called. */
70
+ recoverFromIndex?: number;
71
+ /** Transforms legacy V1 model data ({ args, uiState }) at the initial version. */
72
+ upgradeLegacyFn?: (data: unknown) => unknown;
73
+ };
74
+
75
+ type RecoverState = {
76
+ recoverFn?: (version: DataVersionKey, data: unknown) => unknown;
77
+ recoverFromIndex?: number;
78
+ upgradeLegacyFn?: (data: unknown) => unknown;
103
79
  };
104
80
 
105
81
  /**
106
- * Final builder state after recover() is called.
107
- * Only allows calling create() to finalize the DataModel.
82
+ * Abstract base for both migration chain types.
83
+ * Holds shared state, buildStep() helper, and init().
84
+ * migrate() cannot be shared due to a TypeScript limitation: when the base class
85
+ * migrate() return type is abstract, subclasses cannot narrow it without losing type safety.
86
+ * Each subclass therefore owns its migrate() with the correct concrete return type.
108
87
  *
109
- * @typeParam VersionedData - Map of version keys to their data types
110
- * @typeParam CurrentVersion - The current (final) version in the chain
111
88
  * @internal
112
89
  */
113
- class DataModelBuilderWithRecover<
114
- VersionedData extends DataVersionMap,
115
- CurrentVersion extends keyof VersionedData & string,
116
- > {
117
- private readonly versionChain: DataVersionKey[];
118
- private readonly migrationSteps: MigrationStep[];
119
- private readonly recoverFn: DataRecoverFn<VersionedData[CurrentVersion]>;
90
+ abstract class MigrationChainBase<Current> {
91
+ protected readonly versionChain: DataVersionKey[];
92
+ protected readonly migrationSteps: MigrationStep[];
120
93
 
121
- /** @internal */
122
- constructor({
123
- versionChain,
124
- steps,
125
- recoverFn,
126
- }: {
127
- versionChain: DataVersionKey[];
128
- steps: MigrationStep[];
129
- recoverFn: DataRecoverFn<VersionedData[CurrentVersion]>;
130
- }) {
131
- this.versionChain = versionChain;
132
- this.migrationSteps = steps;
133
- this.recoverFn = recoverFn;
94
+ protected constructor(state: { versionChain: DataVersionKey[]; steps: MigrationStep[] }) {
95
+ this.versionChain = state.versionChain;
96
+ this.migrationSteps = state.steps;
97
+ }
98
+
99
+ /** Appends a migration step and returns the new versionChain and steps arrays. */
100
+ protected buildStep<Next>(
101
+ nextVersion: string,
102
+ fn: DataMigrateFn<Current, Next>,
103
+ ): { versionChain: DataVersionKey[]; steps: MigrationStep[] } {
104
+ if (this.versionChain.includes(nextVersion)) {
105
+ throw new Error(`Duplicate version '${nextVersion}' in migration chain`);
106
+ }
107
+ const fromVersion = this.versionChain[this.versionChain.length - 1];
108
+ const step: MigrationStep = {
109
+ fromVersion,
110
+ toVersion: nextVersion,
111
+ migrate: fn as (data: unknown) => unknown,
112
+ };
113
+ return {
114
+ versionChain: [...this.versionChain, nextVersion],
115
+ steps: [...this.migrationSteps, step],
116
+ };
117
+ }
118
+
119
+ /** Returns recover-specific fields for DataModel construction. Overridden by WithRecover. */
120
+ protected recoverState(): RecoverState {
121
+ return {};
134
122
  }
135
123
 
136
124
  /**
137
125
  * Finalize the DataModel with initial data factory.
138
126
  *
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)
127
+ * @param initialData - Factory function returning the initial state
143
128
  * @returns Finalized DataModel instance
144
- *
145
- * @example
146
- * .init(() => ({ numbers: [], labels: [], description: '' }))
147
129
  */
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]>({
130
+ init(initialData: DataCreateFn<Current>): DataModel<Current> {
131
+ return DataModel[FROM_BUILDER]<Current>({
156
132
  versionChain: this.versionChain,
157
133
  steps: this.migrationSteps,
158
- initialDataFn: initialData as DataCreateFn<VersionedData[CurrentVersion]>,
159
- recoverFn: this.recoverFn,
134
+ initialDataFn: initialData,
135
+ ...this.recoverState(),
160
136
  });
161
137
  }
162
138
  }
163
139
 
164
140
  /**
165
- * Internal builder for constructing DataModel with type-safe migration chains.
141
+ * Migration chain after recover() or upgradeLegacy() has been called.
142
+ * Further migrate() calls are allowed; recover() and upgradeLegacy() are not
143
+ * (enforced by type — no such methods on this class).
166
144
  *
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
145
+ * @typeParam Current - Data type at the current point in the chain
176
146
  * @internal
177
147
  */
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[];
148
+ class DataModelMigrationChainWithRecover<Current> extends MigrationChainBase<Current> {
149
+ private readonly recoverFn?: (version: DataVersionKey, data: unknown) => unknown;
150
+ private readonly recoverFromIndex?: number;
151
+ private readonly upgradeLegacyFn?: (data: unknown) => unknown;
188
152
 
153
+ /** @internal */
154
+ constructor(state: {
155
+ versionChain: DataVersionKey[];
156
+ steps: MigrationStep[];
157
+ recoverFn?: (version: DataVersionKey, data: unknown) => unknown;
158
+ recoverFromIndex?: number;
159
+ upgradeLegacyFn?: (data: unknown) => unknown;
160
+ }) {
161
+ super(state);
162
+ this.recoverFn = state.recoverFn;
163
+ this.recoverFromIndex = state.recoverFromIndex;
164
+ this.upgradeLegacyFn = state.upgradeLegacyFn;
165
+ }
166
+
167
+ protected override recoverState(): RecoverState {
168
+ return {
169
+ recoverFn: this.recoverFn,
170
+ recoverFromIndex: this.recoverFromIndex,
171
+ upgradeLegacyFn: this.upgradeLegacyFn,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Add a migration step. Same semantics as on the base chain.
177
+ * recover() and upgradeLegacy() are not available — one has already been called.
178
+ */
179
+ migrate<Next>(
180
+ nextVersion: string,
181
+ fn: DataMigrateFn<Current, Next>,
182
+ ): DataModelMigrationChainWithRecover<Next> {
183
+ const { versionChain, steps } = this.buildStep(nextVersion, fn);
184
+ return new DataModelMigrationChainWithRecover<Next>({
185
+ versionChain,
186
+ steps,
187
+ recoverFn: this.recoverFn,
188
+ recoverFromIndex: this.recoverFromIndex,
189
+ upgradeLegacyFn: this.upgradeLegacyFn,
190
+ });
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Migration chain builder.
196
+ * Each migrate() call advances the current data type. recover() can be called once
197
+ * at any point — it removes itself from the returned chain so it cannot be called again.
198
+ * Duplicate version keys throw at runtime.
199
+ *
200
+ * @typeParam Current - Data type at the current point in the migration chain
201
+ * @internal
202
+ */
203
+ class DataModelMigrationChain<Current> extends MigrationChainBase<Current> {
189
204
  /** @internal */
190
205
  constructor({
191
206
  versionChain,
@@ -194,109 +209,109 @@ class DataModelMigrationChain<
194
209
  versionChain: DataVersionKey[];
195
210
  steps?: MigrationStep[];
196
211
  }) {
197
- this.versionChain = versionChain;
198
- this.migrationSteps = steps;
212
+ super({ versionChain, steps });
199
213
  }
200
214
 
201
215
  /**
202
- * Add a migration step to transform data from current version to next version.
216
+ * Add a migration step transforming data from the current version to the next.
203
217
  *
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
218
+ * @typeParam Next - Data type of the next version
219
+ * @param nextVersion - Version key to migrate to (must be unique in the chain)
220
+ * @param fn - Migration function
221
+ * @returns Builder with the next version as current
214
222
  *
215
223
  * @example
216
- * .migrate(Version.V2, (data) => ({ ...data, labels: [] }))
224
+ * .migrate<BlockDataV2>("v2", (v1) => ({ ...v1, labels: [] }))
217
225
  */
218
- migrate<NextVersion extends RemainingVersions>(
219
- nextVersion: NextVersion,
220
- fn: DataMigrateFn<VersionedData[CurrentVersion], VersionedData[NextVersion]>,
221
- ): DataModelMigrationChain<VersionedData, NextVersion, Exclude<RemainingVersions, NextVersion>> {
222
- if (this.versionChain.includes(nextVersion)) {
223
- throw new Error(`Duplicate version '${nextVersion}' in migration chain`);
224
- }
225
- const fromVersion = this.versionChain[this.versionChain.length - 1];
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
- });
226
+ migrate<Next>(
227
+ nextVersion: string,
228
+ fn: DataMigrateFn<Current, Next>,
229
+ ): DataModelMigrationChain<Next> {
230
+ const { versionChain, steps } = this.buildStep(nextVersion, fn);
231
+ return new DataModelMigrationChain<Next>({ versionChain, steps });
239
232
  }
240
233
 
241
234
  /**
242
235
  * Set a recovery handler for unknown or legacy versions.
243
236
  *
244
237
  * 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
238
+ * It must return data of the type at this point in the chain (Current). Any migrate()
239
+ * steps added after recover() will then run on the recovered data.
248
240
  *
249
- * Can only be called once. After calling, only `init()` is available.
241
+ * Can only be called once the returned chain has no recover() method.
242
+ * Mutually exclusive with upgradeLegacy().
250
243
  *
251
- * @param fn - Recovery function that transforms unknown data or throws
252
- * @returns Builder with only init() method available
244
+ * @param fn - Recovery function returning Current (the type at this chain position)
245
+ * @returns Builder with migrate() and init() but without recover() or upgradeLegacy()
253
246
  *
254
247
  * @example
255
- * .recover((version, data) => {
256
- * if (version === 'legacy' && isLegacyFormat(data)) {
257
- * return transformLegacy(data);
258
- * }
259
- * return defaultRecover(version, data);
260
- * })
248
+ * // Recover between migrations — recovered data goes through v3 migration
249
+ * new DataModelBuilder<V1>("v1")
250
+ * .migrate<V2>("v2", (v1) => ({ ...v1, label: "" }))
251
+ * .recover((version, data) => {
252
+ * if (version === 'legacy') return transformLegacy(data); // returns V2
253
+ * return defaultRecover(version, data);
254
+ * })
255
+ * .migrate<V3>("v3", (v2) => ({ ...v2, description: "" }))
256
+ * .init(() => ({ count: 0, label: "", description: "" }));
261
257
  */
262
- recover(
263
- fn: DataRecoverFn<VersionedData[CurrentVersion]>,
264
- ): DataModelBuilderWithRecover<VersionedData, CurrentVersion> {
265
- return new DataModelBuilderWithRecover<VersionedData, CurrentVersion>({
266
- versionChain: [...this.versionChain],
267
- steps: [...this.migrationSteps],
268
- recoverFn: fn,
258
+ recover(fn: DataRecoverFn<Current>): DataModelMigrationChainWithRecover<Current> {
259
+ return new DataModelMigrationChainWithRecover<Current>({
260
+ versionChain: this.versionChain,
261
+ steps: this.migrationSteps,
262
+ recoverFn: fn as (version: DataVersionKey, data: unknown) => unknown,
263
+ recoverFromIndex: this.migrationSteps.length,
269
264
  });
270
265
  }
271
266
 
272
267
  /**
273
- * Finalize the DataModel with initial data factory.
268
+ * Handle legacy V1 model state ({ args, uiState }) when upgrading a block from
269
+ * BlockModel V1 to BlockModelV3.
274
270
  *
275
- * Can only be called when all versions in VersionedData have been covered
276
- * by the migration chain (RemainingVersions is empty).
271
+ * When a V1 block is upgraded, its stored state `{ args, uiState }` arrives at the
272
+ * initial version (DATA_MODEL_DEFAULT_VERSION) in the migration chain. This method
273
+ * detects the legacy shape and transforms it to the current chain type using the
274
+ * provided typed callback. Non-legacy data passes through unchanged.
277
275
  *
278
- * The initial data factory is called when creating new blocks or when
279
- * migration/recovery fails and data must be reset.
276
+ * Should be called right after `.from()` (before any `.migrate()` calls), since legacy
277
+ * data always arrives at the initial version. Any `.migrate()` steps added after
278
+ * `upgradeLegacy()` will run on the transformed result.
280
279
  *
281
- * @param initialData - Factory function returning initial state (must exactly match CurrentVersion's data type)
282
- * @returns Finalized DataModel instance
280
+ * Can only be called once the returned chain has no upgradeLegacy() method.
281
+ * Mutually exclusive with recover().
282
+ *
283
+ * @typeParam Args - Type of the legacy block args
284
+ * @typeParam UiState - Type of the legacy block uiState
285
+ * @param fn - Typed transform from { args, uiState } to Current
286
+ * @returns Builder with migrate() and init() but without recover() or upgradeLegacy()
283
287
  *
284
288
  * @example
285
- * .init(() => ({ numbers: [], labels: [], description: '' }))
289
+ * type OldArgs = { inputFile: string; threshold: number };
290
+ * type OldUiState = { selectedTab: string };
291
+ * type BlockData = { inputFile: string; threshold: number; selectedTab: string };
292
+ *
293
+ * const dataModel = new DataModelBuilder()
294
+ * .from<BlockData>(DATA_MODEL_DEFAULT_VERSION)
295
+ * .upgradeLegacy<OldArgs, OldUiState>(({ args, uiState }) => ({
296
+ * inputFile: args.inputFile,
297
+ * threshold: args.threshold,
298
+ * selectedTab: uiState.selectedTab,
299
+ * }))
300
+ * .init(() => ({ inputFile: '', threshold: 0, selectedTab: 'main' }));
286
301
  */
287
- init<S extends VersionedData[CurrentVersion]>(
288
- // Compile-time check: RemainingVersions must be empty (all versions covered)
289
- this: DataModelMigrationChain<VersionedData, CurrentVersion, never>,
290
- initialData: DataCreateFn<S>,
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]>({
302
+ upgradeLegacy<Args, UiState = unknown>(
303
+ fn: (legacy: LegacyV1State<Args, UiState>) => Current,
304
+ ): DataModelMigrationChainWithRecover<Current> {
305
+ const wrappedFn = (data: unknown): unknown => {
306
+ if (data !== null && typeof data === "object" && "args" in data) {
307
+ return fn(data as LegacyV1State<Args, UiState>);
308
+ }
309
+ return data;
310
+ };
311
+ return new DataModelMigrationChainWithRecover<Current>({
297
312
  versionChain: this.versionChain,
298
313
  steps: this.migrationSteps,
299
- initialDataFn: initialData as DataCreateFn<VersionedData[CurrentVersion]>,
314
+ upgradeLegacyFn: wrappedFn,
300
315
  });
301
316
  }
302
317
  }
@@ -304,49 +319,59 @@ class DataModelMigrationChain<
304
319
  /**
305
320
  * Builder entry point for creating DataModel with type-safe migrations.
306
321
  *
307
- * @typeParam VersionedData - Map of version keys to their data types
322
+ * @example
323
+ * // Simple (no migrations):
324
+ * const dataModel = new DataModelBuilder()
325
+ * .from<BlockData>(DATA_MODEL_DEFAULT_VERSION)
326
+ * .init(() => ({ numbers: [] }));
327
+ *
328
+ * @example
329
+ * // With migrations:
330
+ * const dataModel = new DataModelBuilder()
331
+ * .from<BlockDataV1>(DATA_MODEL_DEFAULT_VERSION)
332
+ * .migrate<BlockDataV2>("v2", (v1) => ({ ...v1, labels: [] }))
333
+ * .migrate<BlockDataV3>("v3", (v2) => ({ ...v2, description: '' }))
334
+ * .init(() => ({ numbers: [], labels: [], description: '' }));
308
335
  *
309
336
  * @example
310
- * const Version = defineDataVersions({
311
- * V1: 'v1',
312
- * V2: 'v2',
313
- * });
337
+ * // With recover() between migrations — recovered data goes through remaining migrations:
338
+ * const dataModelChain = new DataModelBuilder()
339
+ * .from<BlockDataV1>(DATA_MODEL_DEFAULT_VERSION)
340
+ * .migrate<BlockDataV2>("v2", (v1) => ({ ...v1, labels: [] }));
314
341
  *
315
- * type VersionedData = {
316
- * [Version.V1]: { count: number };
317
- * [Version.V2]: { count: number; label: string };
318
- * };
342
+ * // recover() placed before the v3 migration: recovered data goes through v3
343
+ * const dataModel = dataModelChain
344
+ * .recover((version, data) => {
345
+ * if (version === 'legacy' && isLegacyData(data)) return transformLegacy(data); // returns V2
346
+ * return defaultRecover(version, data);
347
+ * })
348
+ * .migrate<BlockDataV3>("v3", (v2) => ({ ...v2, description: '' }))
349
+ * .init(() => ({ numbers: [], labels: [], description: '' }));
319
350
  *
320
- * const dataModel = new DataModelBuilder<VersionedData>()
321
- * .from(Version.V1)
322
- * .migrate(Version.V2, (data) => ({ ...data, label: '' }))
323
- * .init(() => ({ count: 0, label: '' }));
351
+ * @example
352
+ * // With upgradeLegacy() — typed upgrade from BlockModel V1 state:
353
+ * type OldArgs = { inputFile: string };
354
+ * type OldUiState = { selectedTab: string };
355
+ * type BlockData = { inputFile: string; selectedTab: string };
356
+ *
357
+ * const dataModel = new DataModelBuilder()
358
+ * .from<BlockData>(DATA_MODEL_DEFAULT_VERSION)
359
+ * .upgradeLegacy<OldArgs, OldUiState>(({ args, uiState }) => ({
360
+ * inputFile: args.inputFile,
361
+ * selectedTab: uiState.selectedTab,
362
+ * }))
363
+ * .init(() => ({ inputFile: '', selectedTab: 'main' }));
324
364
  */
325
- export class DataModelBuilder<VersionedData extends DataVersionMap> {
365
+ export class DataModelBuilder {
326
366
  /**
327
- * Start a migration chain from an initial version.
367
+ * Start the migration chain with the given initial data type and version key.
328
368
  *
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: '' }))
369
+ * @typeParam T - Data type for the initial version
370
+ * @param initialVersion - Version key string (e.g. DATA_MODEL_DEFAULT_VERSION or "v1")
371
+ * @returns Migration chain builder
337
372
  */
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] });
373
+ from<T>(initialVersion: string): DataModelMigrationChain<T> {
374
+ return new DataModelMigrationChain<T>({ versionChain: [initialVersion] });
350
375
  }
351
376
  }
352
377
 
@@ -354,67 +379,51 @@ export class DataModelBuilder<VersionedData extends DataVersionMap> {
354
379
  * DataModel defines the block's data structure, initial values, and migrations.
355
380
  * Used by BlockModelV3 to manage data state.
356
381
  *
357
- * Use `new DataModelBuilder<VersionedData>()` to create a DataModel:
382
+ * Use `new DataModelBuilder()` to create a DataModel.
358
383
  *
359
- * **Simple (no migrations):**
360
384
  * @example
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: [] }));
367
- *
368
- * **With migrations:**
369
- * @example
370
- * const Version = defineDataVersions({
371
- * V1: DATA_MODEL_DEFAULT_VERSION,
372
- * V2: 'v2',
373
- * V3: 'v3',
374
- * });
375
- *
376
- * type VersionedData = {
377
- * [Version.V1]: { numbers: number[] };
378
- * [Version.V2]: { numbers: number[]; labels: string[] };
379
- * [Version.V3]: { numbers: number[]; labels: string[]; description: string };
380
- * };
381
- *
382
- * const dataModel = new DataModelBuilder<VersionedData>()
383
- * .from(Version.V1)
384
- * .migrate(Version.V2, (data) => ({ ...data, labels: [] }))
385
- * .migrate(Version.V3, (data) => ({ ...data, description: '' }))
385
+ * // With recover() between migrations:
386
+ * // Recovered data (V2) goes through the v2→v3 migration automatically.
387
+ * const dataModel = new DataModelBuilder()
388
+ * .from<V1>(DATA_MODEL_DEFAULT_VERSION)
389
+ * .migrate<V2>("v2", (v1) => ({ ...v1, label: "" }))
386
390
  * .recover((version, data) => {
387
- * if (version === 'legacy' && typeof data === 'object' && data !== null && 'numbers' in data) {
388
- * return { numbers: (data as { numbers: number[] }).numbers, labels: [], description: '' };
389
- * }
391
+ * if (version === "legacy") return transformLegacy(data); // returns V2
390
392
  * return defaultRecover(version, data);
391
393
  * })
392
- * .init(() => ({ numbers: [], labels: [], description: '' }));
394
+ * .migrate<V3>("v3", (v2) => ({ ...v2, description: "" }))
395
+ * .init(() => ({ count: 0, label: "", description: "" }));
393
396
  */
394
397
  export class DataModel<State> {
395
- private readonly versionChain: DataVersionKey[];
398
+ /** Latest version key — O(1) access for the common "already current" check. */
399
+ private readonly latestVersion: DataVersionKey;
400
+ /** Maps each known version key to the index of the first step to run from it. O(1) lookup. */
401
+ private readonly stepsByFromVersion: ReadonlyMap<DataVersionKey, number>;
396
402
  private readonly steps: MigrationStep[];
397
403
  private readonly initialDataFn: () => State;
398
- private readonly recoverFn: DataRecoverFn<State>;
404
+ private readonly recoverFn: (version: DataVersionKey, data: unknown) => unknown;
405
+ private readonly recoverFromIndex: number;
406
+ /** Transforms legacy V1 model data at the initial version before running migrations. */
407
+ private readonly upgradeLegacyFn?: (data: unknown) => unknown;
399
408
 
400
409
  private constructor({
401
410
  versionChain,
402
411
  steps,
403
412
  initialDataFn,
404
- recoverFn = defaultRecover as DataRecoverFn<State>,
405
- }: {
406
- versionChain: DataVersionKey[];
407
- steps: MigrationStep[];
408
- initialDataFn: () => State;
409
- recoverFn?: DataRecoverFn<State>;
410
- }) {
413
+ recoverFn = defaultRecover,
414
+ recoverFromIndex,
415
+ upgradeLegacyFn,
416
+ }: BuilderState<State>) {
411
417
  if (versionChain.length === 0) {
412
418
  throw new Error("DataModel requires at least one version key");
413
419
  }
414
- this.versionChain = versionChain;
420
+ this.latestVersion = versionChain[versionChain.length - 1];
421
+ this.stepsByFromVersion = new Map(versionChain.map((v, i) => [v, i]));
415
422
  this.steps = steps;
416
423
  this.initialDataFn = initialDataFn;
417
424
  this.recoverFn = recoverFn;
425
+ this.recoverFromIndex = recoverFromIndex ?? steps.length;
426
+ this.upgradeLegacyFn = upgradeLegacyFn;
418
427
  }
419
428
 
420
429
  /**
@@ -430,14 +439,7 @@ export class DataModel<State> {
430
439
  * The latest (current) version key in the migration chain.
431
440
  */
432
441
  get version(): DataVersionKey {
433
- return this.versionChain[this.versionChain.length - 1];
434
- }
435
-
436
- /**
437
- * Number of migration steps defined.
438
- */
439
- get migrationCount(): number {
440
- return this.steps.length;
442
+ return this.latestVersion;
441
443
  }
442
444
 
443
445
  /**
@@ -452,12 +454,14 @@ export class DataModel<State> {
452
454
  * Used when creating new blocks or resetting to defaults.
453
455
  */
454
456
  getDefaultData(): DataVersioned<State> {
455
- return makeDataVersioned(this.version, this.initialDataFn());
457
+ return makeDataVersioned(this.latestVersion, this.initialDataFn());
456
458
  }
457
459
 
458
460
  private recoverFrom(data: unknown, version: DataVersionKey): DataMigrationResult<State> {
461
+ // Step 1: call the recover function to get data at the recover point
462
+ let currentData: unknown;
459
463
  try {
460
- return { version: this.version, data: this.recoverFn(version, data) };
464
+ currentData = this.recoverFn(version, data);
461
465
  } catch (error) {
462
466
  if (isDataUnrecoverableError(error)) {
463
467
  return { ...this.getDefaultData(), warning: error.message };
@@ -468,14 +472,29 @@ export class DataModel<State> {
468
472
  warning: `Recover failed for version '${version}': ${errorMessage}`,
469
473
  };
470
474
  }
475
+
476
+ // Step 2: run any migrations that were added after recover() in the chain
477
+ for (let i = this.recoverFromIndex; i < this.steps.length; i++) {
478
+ const step = this.steps[i];
479
+ try {
480
+ currentData = step.migrate(currentData);
481
+ } catch (error) {
482
+ const errorMessage = error instanceof Error ? error.message : String(error);
483
+ return {
484
+ ...this.getDefaultData(),
485
+ warning: `Migration ${step.fromVersion}→${step.toVersion} failed: ${errorMessage}`,
486
+ };
487
+ }
488
+ }
489
+
490
+ return { version: this.latestVersion, data: currentData as State };
471
491
  }
472
492
 
473
493
  /**
474
494
  * Migrate versioned data from any version to the latest.
475
495
  *
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
496
+ * - If version is in chain, applies needed migrations (O(1) lookup)
497
+ * - If version is unknown, calls recover function then runs remaining migrations
479
498
  * - If migration/recovery fails, returns default data with warning
480
499
  *
481
500
  * @param versioned - Data with version tag
@@ -484,16 +503,30 @@ export class DataModel<State> {
484
503
  migrate(versioned: DataVersioned<unknown>): DataMigrationResult<State> {
485
504
  const { version: fromVersion, data } = versioned;
486
505
 
487
- if (fromVersion === this.version) {
488
- return { version: this.version, data: data as State };
506
+ if (fromVersion === this.latestVersion) {
507
+ return { version: this.latestVersion, data: data as State };
489
508
  }
490
509
 
491
- const startIndex = this.versionChain.indexOf(fromVersion);
492
- if (startIndex < 0) {
510
+ const startIndex = this.stepsByFromVersion.get(fromVersion);
511
+ if (startIndex === undefined) {
493
512
  return this.recoverFrom(data, fromVersion);
494
513
  }
495
514
 
496
515
  let currentData: unknown = data;
516
+
517
+ // Legacy V1 upgrade: detect and transform { args, uiState } at the initial version
518
+ if (startIndex === 0 && this.upgradeLegacyFn) {
519
+ try {
520
+ currentData = this.upgradeLegacyFn(currentData);
521
+ } catch (error) {
522
+ const errorMessage = error instanceof Error ? error.message : String(error);
523
+ return {
524
+ ...this.getDefaultData(),
525
+ warning: `Legacy upgrade failed: ${errorMessage}`,
526
+ };
527
+ }
528
+ }
529
+
497
530
  for (let i = startIndex; i < this.steps.length; i++) {
498
531
  const step = this.steps[i];
499
532
  try {
@@ -507,24 +540,6 @@ export class DataModel<State> {
507
540
  }
508
541
  }
509
542
 
510
- return { version: this.version, data: currentData as State };
511
- }
512
-
513
- /**
514
- * Register callbacks for use in the VM.
515
- * Called by BlockModelV3.create() to set up internal callbacks.
516
- *
517
- * @internal
518
- */
519
- registerCallbacks(): void {
520
- tryRegisterCallback("__pl_data_initial", () => this.initialDataFn());
521
- tryRegisterCallback("__pl_data_upgrade", (versioned: DataVersioned<unknown>) =>
522
- this.migrate(versioned),
523
- );
524
- tryRegisterCallback("__pl_storage_initial", () => {
525
- const { version, data } = this.getDefaultData();
526
- const storage = createBlockStorage(data, version);
527
- return JSON.stringify(storage);
528
- });
543
+ return { version: this.latestVersion, data: currentData as State };
529
544
  }
530
545
  }