@platforma-sdk/model 1.54.13 → 1.55.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/dist/bconfig/normalization.cjs +8 -1
- package/dist/bconfig/normalization.cjs.map +1 -1
- package/dist/bconfig/normalization.d.ts.map +1 -1
- package/dist/bconfig/normalization.js +8 -1
- package/dist/bconfig/normalization.js.map +1 -1
- package/dist/block_api_v3.d.ts +2 -2
- package/dist/block_api_v3.d.ts.map +1 -1
- package/dist/block_migrations.cjs +246 -214
- package/dist/block_migrations.cjs.map +1 -1
- package/dist/block_migrations.d.ts +180 -158
- package/dist/block_migrations.d.ts.map +1 -1
- package/dist/block_migrations.js +247 -214
- package/dist/block_migrations.js.map +1 -1
- package/dist/block_model.cjs +85 -35
- package/dist/block_model.cjs.map +1 -1
- package/dist/block_model.d.ts +66 -38
- package/dist/block_model.d.ts.map +1 -1
- package/dist/block_model.js +86 -36
- package/dist/block_model.js.map +1 -1
- package/dist/{builder.cjs → block_model_legacy.cjs} +2 -2
- package/dist/block_model_legacy.cjs.map +1 -0
- package/dist/{builder.d.ts → block_model_legacy.d.ts} +1 -1
- package/dist/block_model_legacy.d.ts.map +1 -0
- package/dist/{builder.js → block_model_legacy.js} +2 -2
- package/dist/block_model_legacy.js.map +1 -0
- package/dist/block_state_patch.d.ts +11 -1
- package/dist/block_state_patch.d.ts.map +1 -1
- package/dist/block_storage.cjs +126 -109
- package/dist/block_storage.cjs.map +1 -1
- package/dist/block_storage.d.ts +109 -112
- package/dist/block_storage.d.ts.map +1 -1
- package/dist/block_storage.js +126 -101
- package/dist/block_storage.js.map +1 -1
- package/dist/block_storage_callbacks.cjs +227 -0
- package/dist/block_storage_callbacks.cjs.map +1 -0
- package/dist/block_storage_callbacks.d.ts +113 -0
- package/dist/block_storage_callbacks.d.ts.map +1 -0
- package/dist/block_storage_callbacks.js +218 -0
- package/dist/block_storage_callbacks.js.map +1 -0
- package/dist/block_storage_facade.cjs +104 -0
- package/dist/block_storage_facade.cjs.map +1 -0
- package/dist/block_storage_facade.d.ts +168 -0
- package/dist/block_storage_facade.d.ts.map +1 -0
- package/dist/block_storage_facade.js +99 -0
- package/dist/block_storage_facade.js.map +1 -0
- package/dist/index.cjs +13 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/package.json.cjs +1 -1
- package/dist/package.json.js +1 -1
- package/dist/platforma.d.ts +11 -4
- package/dist/platforma.d.ts.map +1 -1
- package/dist/plugin_model.cjs +171 -0
- package/dist/plugin_model.cjs.map +1 -0
- package/dist/plugin_model.d.ts +162 -0
- package/dist/plugin_model.d.ts.map +1 -0
- package/dist/plugin_model.js +169 -0
- package/dist/plugin_model.js.map +1 -0
- package/dist/render/api.cjs +20 -21
- package/dist/render/api.cjs.map +1 -1
- package/dist/render/api.d.ts +8 -8
- package/dist/render/api.d.ts.map +1 -1
- package/dist/render/api.js +20 -21
- package/dist/render/api.js.map +1 -1
- package/dist/render/internal.cjs.map +1 -1
- package/dist/render/internal.d.ts +1 -1
- package/dist/render/internal.d.ts.map +1 -1
- package/dist/render/internal.js.map +1 -1
- package/dist/version.cjs +4 -0
- package/dist/version.cjs.map +1 -1
- package/dist/version.d.ts +4 -0
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +4 -1
- package/dist/version.js.map +1 -1
- package/package.json +5 -5
- package/src/bconfig/normalization.ts +8 -1
- package/src/block_api_v3.ts +2 -2
- package/src/block_migrations.test.ts +141 -171
- package/src/block_migrations.ts +300 -285
- package/src/block_model.ts +205 -95
- package/src/{builder.ts → block_model_legacy.ts} +1 -1
- package/src/block_state_patch.ts +13 -1
- package/src/block_storage.test.ts +283 -95
- package/src/block_storage.ts +199 -188
- package/src/block_storage_callbacks.ts +326 -0
- package/src/block_storage_facade.ts +199 -0
- package/src/index.ts +7 -3
- package/src/platforma.ts +26 -7
- package/src/plugin_model.test.ts +168 -0
- package/src/plugin_model.ts +242 -0
- package/src/render/api.ts +26 -24
- package/src/render/internal.ts +1 -1
- package/src/typing.test.ts +1 -1
- package/src/version.ts +8 -0
- package/dist/block_storage_vm.cjs +0 -262
- package/dist/block_storage_vm.cjs.map +0 -1
- package/dist/block_storage_vm.d.ts +0 -59
- package/dist/block_storage_vm.d.ts.map +0 -1
- package/dist/block_storage_vm.js +0 -258
- package/dist/block_storage_vm.js.map +0 -1
- package/dist/branding.d.ts +0 -7
- package/dist/branding.d.ts.map +0 -1
- package/dist/builder.cjs.map +0 -1
- package/dist/builder.d.ts.map +0 -1
- package/dist/builder.js.map +0 -1
- package/dist/sdk_info.cjs +0 -10
- package/dist/sdk_info.cjs.map +0 -1
- package/dist/sdk_info.d.ts +0 -5
- package/dist/sdk_info.d.ts.map +0 -1
- package/dist/sdk_info.js +0 -8
- package/dist/sdk_info.js.map +0 -1
- package/dist/unionize.d.ts +0 -12
- package/dist/unionize.d.ts.map +0 -1
- package/src/block_storage_vm.ts +0 -346
- package/src/branding.ts +0 -4
- package/src/sdk_info.ts +0 -9
- package/src/unionize.ts +0 -12
package/src/block_migrations.ts
CHANGED
|
@@ -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?:
|
|
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
|
-
*
|
|
107
|
-
*
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
this.versionChain
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
*
|
|
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<
|
|
149
|
-
|
|
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
|
|
159
|
-
|
|
134
|
+
initialDataFn: initialData,
|
|
135
|
+
...this.recoverState(),
|
|
160
136
|
});
|
|
161
137
|
}
|
|
162
138
|
}
|
|
163
139
|
|
|
164
140
|
/**
|
|
165
|
-
*
|
|
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
|
-
*
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
198
|
-
this.migrationSteps = steps;
|
|
212
|
+
super({ versionChain, steps });
|
|
199
213
|
}
|
|
200
214
|
|
|
201
215
|
/**
|
|
202
|
-
* Add a migration step
|
|
216
|
+
* Add a migration step transforming data from the current version to the next.
|
|
203
217
|
*
|
|
204
|
-
*
|
|
205
|
-
* -
|
|
206
|
-
*
|
|
207
|
-
*
|
|
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(
|
|
224
|
+
* .migrate<BlockDataV2>("v2", (v1) => ({ ...v1, labels: [] }))
|
|
217
225
|
*/
|
|
218
|
-
migrate<
|
|
219
|
-
nextVersion:
|
|
220
|
-
fn: DataMigrateFn<
|
|
221
|
-
): DataModelMigrationChain<
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
246
|
-
*
|
|
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
|
|
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
|
|
252
|
-
* @returns Builder with
|
|
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
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
*
|
|
268
|
+
* Handle legacy V1 model state ({ args, uiState }) when upgrading a block from
|
|
269
|
+
* BlockModel V1 to BlockModelV3.
|
|
274
270
|
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
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
|
-
*
|
|
279
|
-
*
|
|
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
|
-
*
|
|
282
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
return
|
|
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
|
-
|
|
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
|
-
* @
|
|
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
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
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
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
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
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
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
|
|
365
|
+
export class DataModelBuilder {
|
|
326
366
|
/**
|
|
327
|
-
* Start
|
|
367
|
+
* Start the migration chain with the given initial data type and version key.
|
|
328
368
|
*
|
|
329
|
-
* @typeParam
|
|
330
|
-
* @param initialVersion -
|
|
331
|
-
* @returns Migration chain builder
|
|
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<
|
|
339
|
-
|
|
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
|
|
382
|
+
* Use `new DataModelBuilder()` to create a DataModel.
|
|
358
383
|
*
|
|
359
|
-
* **Simple (no migrations):**
|
|
360
384
|
* @example
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
* .
|
|
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 ===
|
|
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
|
-
* .
|
|
394
|
+
* .migrate<V3>("v3", (v2) => ({ ...v2, description: "" }))
|
|
395
|
+
* .init(() => ({ count: 0, label: "", description: "" }));
|
|
393
396
|
*/
|
|
394
397
|
export class DataModel<State> {
|
|
395
|
-
|
|
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:
|
|
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
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
477
|
-
* - If version is
|
|
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.
|
|
488
|
-
return { version: this.
|
|
506
|
+
if (fromVersion === this.latestVersion) {
|
|
507
|
+
return { version: this.latestVersion, data: data as State };
|
|
489
508
|
}
|
|
490
509
|
|
|
491
|
-
const startIndex = this.
|
|
492
|
-
if (startIndex
|
|
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.
|
|
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
|
}
|