@platforma-sdk/model 1.57.2 → 1.58.1

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 (94) hide show
  1. package/dist/block_migrations.cjs +60 -77
  2. package/dist/block_migrations.cjs.map +1 -1
  3. package/dist/block_migrations.d.ts +35 -32
  4. package/dist/block_migrations.d.ts.map +1 -1
  5. package/dist/block_migrations.js +60 -78
  6. package/dist/block_migrations.js.map +1 -1
  7. package/dist/block_model.cjs +18 -14
  8. package/dist/block_model.cjs.map +1 -1
  9. package/dist/block_model.d.ts +5 -5
  10. package/dist/block_model.d.ts.map +1 -1
  11. package/dist/block_model.js +18 -14
  12. package/dist/block_model.js.map +1 -1
  13. package/dist/block_model_legacy.cjs +1 -0
  14. package/dist/block_model_legacy.cjs.map +1 -1
  15. package/dist/block_model_legacy.d.ts.map +1 -1
  16. package/dist/block_model_legacy.js +1 -0
  17. package/dist/block_model_legacy.js.map +1 -1
  18. package/dist/block_storage.cjs +24 -20
  19. package/dist/block_storage.cjs.map +1 -1
  20. package/dist/block_storage.d.ts +20 -16
  21. package/dist/block_storage.d.ts.map +1 -1
  22. package/dist/block_storage.js +24 -20
  23. package/dist/block_storage.js.map +1 -1
  24. package/dist/block_storage_callbacks.cjs +4 -3
  25. package/dist/block_storage_callbacks.cjs.map +1 -1
  26. package/dist/block_storage_callbacks.d.ts +7 -5
  27. package/dist/block_storage_callbacks.d.ts.map +1 -1
  28. package/dist/block_storage_callbacks.js +4 -3
  29. package/dist/block_storage_callbacks.js.map +1 -1
  30. package/dist/components/PFrameForGraphs.cjs +0 -117
  31. package/dist/components/PFrameForGraphs.cjs.map +1 -1
  32. package/dist/components/PFrameForGraphs.d.ts +3 -5
  33. package/dist/components/PFrameForGraphs.d.ts.map +1 -1
  34. package/dist/components/PFrameForGraphs.js +2 -117
  35. package/dist/components/PFrameForGraphs.js.map +1 -1
  36. package/dist/index.cjs +8 -5
  37. package/dist/index.cjs.map +1 -1
  38. package/dist/index.d.ts +4 -3
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +5 -3
  41. package/dist/index.js.map +1 -1
  42. package/dist/package.json.cjs +1 -1
  43. package/dist/package.json.js +1 -1
  44. package/dist/pframe_utils/axes.cjs +131 -0
  45. package/dist/pframe_utils/axes.cjs.map +1 -0
  46. package/dist/pframe_utils/axes.d.ts +15 -0
  47. package/dist/pframe_utils/axes.d.ts.map +1 -0
  48. package/dist/pframe_utils/axes.js +128 -0
  49. package/dist/pframe_utils/axes.js.map +1 -0
  50. package/dist/pframe_utils/columns.cjs +4 -8
  51. package/dist/pframe_utils/columns.cjs.map +1 -1
  52. package/dist/pframe_utils/columns.js +1 -5
  53. package/dist/pframe_utils/columns.js.map +1 -1
  54. package/dist/pframe_utils/index.cjs +0 -3
  55. package/dist/pframe_utils/index.cjs.map +1 -1
  56. package/dist/pframe_utils/index.js +0 -3
  57. package/dist/pframe_utils/index.js.map +1 -1
  58. package/dist/platforma.d.ts +12 -2
  59. package/dist/platforma.d.ts.map +1 -1
  60. package/dist/plugin_handle.cjs +29 -0
  61. package/dist/plugin_handle.cjs.map +1 -0
  62. package/dist/plugin_handle.d.ts +51 -0
  63. package/dist/plugin_handle.d.ts.map +1 -0
  64. package/dist/plugin_handle.js +25 -0
  65. package/dist/plugin_handle.js.map +1 -0
  66. package/dist/plugin_model.cjs +29 -29
  67. package/dist/plugin_model.cjs.map +1 -1
  68. package/dist/plugin_model.d.ts +43 -35
  69. package/dist/plugin_model.d.ts.map +1 -1
  70. package/dist/plugin_model.js +29 -29
  71. package/dist/plugin_model.js.map +1 -1
  72. package/dist/render/api.cjs +9 -5
  73. package/dist/render/api.cjs.map +1 -1
  74. package/dist/render/api.d.ts +11 -5
  75. package/dist/render/api.d.ts.map +1 -1
  76. package/dist/render/api.js +9 -5
  77. package/dist/render/api.js.map +1 -1
  78. package/package.json +6 -6
  79. package/src/block_migrations.test.ts +109 -12
  80. package/src/block_migrations.ts +63 -87
  81. package/src/block_model.ts +34 -20
  82. package/src/block_model_legacy.ts +1 -0
  83. package/src/block_storage.test.ts +11 -10
  84. package/src/block_storage.ts +40 -32
  85. package/src/block_storage_callbacks.ts +12 -10
  86. package/src/components/PFrameForGraphs.ts +4 -167
  87. package/src/index.ts +24 -2
  88. package/src/pframe_utils/axes.ts +175 -0
  89. package/src/pframe_utils/columns.ts +2 -2
  90. package/src/platforma.ts +17 -2
  91. package/src/plugin_handle.ts +85 -0
  92. package/src/plugin_model.test.ts +2 -2
  93. package/src/plugin_model.ts +120 -58
  94. package/src/render/api.ts +21 -11
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { DataModelBuilder, defaultRecover, makeDataVersioned } from "./block_migrations";
3
+ import { DATA_MODEL_LEGACY_VERSION } from "./block_storage";
3
4
 
4
5
  describe("makeDataVersioned", () => {
5
6
  it("creates correct DataVersioned shape", () => {
@@ -18,7 +19,6 @@ describe("DataModel migrations", () => {
18
19
  const result = dataModel.migrate(makeDataVersioned("legacy", { count: 42 }));
19
20
  expect(result.version).toBe("v2");
20
21
  expect(result.data).toStrictEqual({ count: 0, label: "" });
21
- expect(result.warning).toBe(`Unknown version 'legacy'`);
22
22
  });
23
23
 
24
24
  it("throws at build time on duplicate version key", () => {
@@ -33,7 +33,7 @@ describe("DataModel migrations", () => {
33
33
  ).toThrow("Duplicate version 'v1' in migration chain");
34
34
  });
35
35
 
36
- it("returns initial data on migration failure", () => {
36
+ it("throws on migration failure", () => {
37
37
  const dataModel = new DataModelBuilder()
38
38
  .from<{ numbers: number[] }>("v1")
39
39
  .migrate<{ numbers: number[]; label: string }>("v2", (v1) => {
@@ -42,10 +42,9 @@ describe("DataModel migrations", () => {
42
42
  })
43
43
  .init(() => ({ numbers: [], label: "" }));
44
44
 
45
- const result = dataModel.migrate(makeDataVersioned("v1", { numbers: [666] }));
46
- expect(result.version).toBe("v2");
47
- expect(result.data).toStrictEqual({ numbers: [], label: "" });
48
- expect(result.warning).toBe(`Migration v1→v2 failed: Forbidden number`);
45
+ expect(() => dataModel.migrate(makeDataVersioned("v1", { numbers: [666] }))).toThrow(
46
+ "Forbidden number",
47
+ );
49
48
  });
50
49
 
51
50
  describe("recover()", () => {
@@ -70,7 +69,6 @@ describe("DataModel migrations", () => {
70
69
  const result = dataModel.migrate(makeDataVersioned("legacy", { count: 5 }));
71
70
  expect(result.version).toBe("v2");
72
71
  expect(result.data).toStrictEqual({ count: 5, label: "default" });
73
- expect(result.warning).toBeUndefined();
74
72
  });
75
73
 
76
74
  it("recover() between migrations — recovered data goes through subsequent migrations", () => {
@@ -97,7 +95,6 @@ describe("DataModel migrations", () => {
97
95
  const result = dataModel.migrate(makeDataVersioned("legacy", { count: 7 }));
98
96
  expect(result.version).toBe("v3");
99
97
  expect(result.data).toStrictEqual({ count: 7, label: "recovered", description: "added" });
100
- expect(result.warning).toBeUndefined();
101
98
  });
102
99
 
103
100
  it("recover() at the end of chain — recovered data is the final type", () => {
@@ -122,10 +119,9 @@ describe("DataModel migrations", () => {
122
119
  const result = dataModel.migrate(makeDataVersioned("legacy", { count: 9 }));
123
120
  expect(result.version).toBe("v2");
124
121
  expect(result.data).toStrictEqual({ count: 9, label: "recovered" });
125
- expect(result.warning).toBeUndefined();
126
122
  });
127
123
 
128
- it("recover() delegates to defaultRecover for truly unknown versions", () => {
124
+ it("recover() delegates to defaultRecover for truly unknown versions — resets to initial data", () => {
129
125
  const dataModel = new DataModelBuilder()
130
126
  .from<{ count: number }>("v1")
131
127
  .migrate<{ count: number; label: string }>("v2", (v1) => ({ ...v1, label: "" }))
@@ -135,7 +131,6 @@ describe("DataModel migrations", () => {
135
131
  const result = dataModel.migrate(makeDataVersioned("unknown", { count: 7 }));
136
132
  expect(result.version).toBe("v2");
137
133
  expect(result.data).toStrictEqual({ count: 0, label: "" });
138
- expect(result.warning).toBe(`Unknown version 'unknown'`);
139
134
  });
140
135
 
141
136
  it("migration failure after recover() resets to initial data", () => {
@@ -164,7 +159,6 @@ describe("DataModel migrations", () => {
164
159
  const result = dataModel.migrate(makeDataVersioned("legacy", { count: 7 }));
165
160
  expect(result.version).toBe("v3");
166
161
  expect(result.data).toStrictEqual({ count: 0, label: "", description: "" });
167
- expect(result.warning).toBe("Migration v2→v3 failed: v3 failed");
168
162
  });
169
163
 
170
164
  it("recover() cannot be called twice — enforced by type (no recover() on WithRecover)", () => {
@@ -172,4 +166,107 @@ describe("DataModel migrations", () => {
172
166
  // Verified by the absence of recover() in DataModelMigrationChainWithRecover.
173
167
  });
174
168
  });
169
+
170
+ describe("upgradeLegacy()", () => {
171
+ type LegacyArgs = { inputFile: string; threshold: number };
172
+ type LegacyUiState = { selectedTab: string };
173
+ type BlockData = { inputFile: string; threshold: number; selectedTab: string };
174
+
175
+ it("upgrades legacy { args, uiState } data with custom initial version", () => {
176
+ const dataModel = new DataModelBuilder()
177
+ .from<BlockData>("v1")
178
+ .upgradeLegacy<LegacyArgs, LegacyUiState>(({ args, uiState }) => ({
179
+ inputFile: args.inputFile,
180
+ threshold: args.threshold,
181
+ selectedTab: uiState.selectedTab,
182
+ }))
183
+ .init(() => ({ inputFile: "", threshold: 0, selectedTab: "main" }));
184
+
185
+ // Legacy data arrives at DATA_MODEL_LEGACY_VERSION (how normalizeBlockStorage wraps it)
186
+ const result = dataModel.migrate(
187
+ makeDataVersioned(DATA_MODEL_LEGACY_VERSION, {
188
+ args: { inputFile: "test.fa", threshold: 5 },
189
+ uiState: { selectedTab: "results" },
190
+ }),
191
+ );
192
+ expect(result.version).toBe("v1");
193
+ expect(result.data).toStrictEqual({
194
+ inputFile: "test.fa",
195
+ threshold: 5,
196
+ selectedTab: "results",
197
+ });
198
+ });
199
+
200
+ it("passes through non-legacy data at custom initial version unchanged", () => {
201
+ const dataModel = new DataModelBuilder()
202
+ .from<BlockData>("v1")
203
+ .upgradeLegacy<LegacyArgs, LegacyUiState>(({ args, uiState }) => ({
204
+ inputFile: args.inputFile,
205
+ threshold: args.threshold,
206
+ selectedTab: uiState.selectedTab,
207
+ }))
208
+ .init(() => ({ inputFile: "", threshold: 0, selectedTab: "main" }));
209
+
210
+ // Non-legacy data at the user's version passes through unchanged
211
+ const result = dataModel.migrate(
212
+ makeDataVersioned("v1", {
213
+ inputFile: "existing.fa",
214
+ threshold: 10,
215
+ selectedTab: "overview",
216
+ }),
217
+ );
218
+ expect(result.version).toBe("v1");
219
+ expect(result.data).toStrictEqual({
220
+ inputFile: "existing.fa",
221
+ threshold: 10,
222
+ selectedTab: "overview",
223
+ });
224
+ });
225
+
226
+ it("upgrades legacy data and runs subsequent migrations", () => {
227
+ type BlockDataV2 = BlockData & { description: string };
228
+
229
+ const dataModel = new DataModelBuilder()
230
+ .from<BlockData>("v1")
231
+ .upgradeLegacy<LegacyArgs, LegacyUiState>(({ args, uiState }) => ({
232
+ inputFile: args.inputFile,
233
+ threshold: args.threshold,
234
+ selectedTab: uiState.selectedTab,
235
+ }))
236
+ .migrate<BlockDataV2>("v2", (v1) => ({ ...v1, description: "auto" }))
237
+ .init(() => ({ inputFile: "", threshold: 0, selectedTab: "main", description: "" }));
238
+
239
+ const result = dataModel.migrate(
240
+ makeDataVersioned(DATA_MODEL_LEGACY_VERSION, {
241
+ args: { inputFile: "test.fa", threshold: 3 },
242
+ uiState: { selectedTab: "tab1" },
243
+ }),
244
+ );
245
+ expect(result.version).toBe("v2");
246
+ expect(result.data).toStrictEqual({
247
+ inputFile: "test.fa",
248
+ threshold: 3,
249
+ selectedTab: "tab1",
250
+ description: "auto",
251
+ });
252
+ });
253
+
254
+ it("throws when legacy upgrade fails", () => {
255
+ const dataModel = new DataModelBuilder()
256
+ .from<BlockData>("v1")
257
+ .upgradeLegacy<LegacyArgs, LegacyUiState>(() => {
258
+ throw new Error("bad legacy data");
259
+ })
260
+ .init(() => ({ inputFile: "", threshold: 0, selectedTab: "main" }));
261
+
262
+ expect(() =>
263
+ dataModel.migrate(
264
+ makeDataVersioned(DATA_MODEL_LEGACY_VERSION, {
265
+ args: { inputFile: "test.fa", threshold: 5 },
266
+ uiState: { selectedTab: "results" },
267
+ }),
268
+ ),
269
+ ).toThrow("bad legacy data");
270
+ });
271
+ });
175
272
  });
@@ -1,3 +1,5 @@
1
+ import { DATA_MODEL_LEGACY_VERSION } from "./block_storage";
2
+
1
3
  export type DataVersionKey = string;
2
4
  export type DataMigrateFn<From, To> = (prev: Readonly<From>) => To;
3
5
  export type DataCreateFn<T> = () => T;
@@ -14,10 +16,13 @@ export function makeDataVersioned<T>(version: DataVersionKey, data: T): DataVers
14
16
  return { version, data };
15
17
  }
16
18
 
17
- /** Result of migration operation, may include warning if migration failed */
18
- export type DataMigrationResult<T> = DataVersioned<T> & {
19
- warning?: string;
20
- };
19
+ /** Thrown when a migration step fails. */
20
+ export class DataMigrationError extends Error {
21
+ name = "DataMigrationError";
22
+ constructor(message: string) {
23
+ super(message);
24
+ }
25
+ }
21
26
 
22
27
  /** Thrown by recover() to signal unrecoverable data. */
23
28
  export class DataUnrecoverableError extends Error {
@@ -68,14 +73,11 @@ type BuilderState<S> = {
68
73
  /** Index of the first step to run after recovery. Equals the number of steps
69
74
  * present at the time recover() was called. */
70
75
  recoverFromIndex?: number;
71
- /** Transforms legacy V1 model data ({ args, uiState }) at the initial version. */
72
- upgradeLegacyFn?: (data: unknown) => unknown;
73
76
  };
74
77
 
75
78
  type RecoverState = {
76
79
  recoverFn?: (version: DataVersionKey, data: unknown) => unknown;
77
80
  recoverFromIndex?: number;
78
- upgradeLegacyFn?: (data: unknown) => unknown;
79
81
  };
80
82
 
81
83
  /**
@@ -148,7 +150,6 @@ abstract class MigrationChainBase<Current> {
148
150
  class DataModelMigrationChainWithRecover<Current> extends MigrationChainBase<Current> {
149
151
  private readonly recoverFn?: (version: DataVersionKey, data: unknown) => unknown;
150
152
  private readonly recoverFromIndex?: number;
151
- private readonly upgradeLegacyFn?: (data: unknown) => unknown;
152
153
 
153
154
  /** @internal */
154
155
  constructor(state: {
@@ -156,19 +157,16 @@ class DataModelMigrationChainWithRecover<Current> extends MigrationChainBase<Cur
156
157
  steps: MigrationStep[];
157
158
  recoverFn?: (version: DataVersionKey, data: unknown) => unknown;
158
159
  recoverFromIndex?: number;
159
- upgradeLegacyFn?: (data: unknown) => unknown;
160
160
  }) {
161
161
  super(state);
162
162
  this.recoverFn = state.recoverFn;
163
163
  this.recoverFromIndex = state.recoverFromIndex;
164
- this.upgradeLegacyFn = state.upgradeLegacyFn;
165
164
  }
166
165
 
167
166
  protected override recoverState(): RecoverState {
168
167
  return {
169
168
  recoverFn: this.recoverFn,
170
169
  recoverFromIndex: this.recoverFromIndex,
171
- upgradeLegacyFn: this.upgradeLegacyFn,
172
170
  };
173
171
  }
174
172
 
@@ -186,7 +184,6 @@ class DataModelMigrationChainWithRecover<Current> extends MigrationChainBase<Cur
186
184
  steps,
187
185
  recoverFn: this.recoverFn,
188
186
  recoverFromIndex: this.recoverFromIndex,
189
- upgradeLegacyFn: this.upgradeLegacyFn,
190
187
  });
191
188
  }
192
189
  }
@@ -239,10 +236,9 @@ class DataModelMigrationChain<Current> extends MigrationChainBase<Current> {
239
236
  * steps added after recover() will then run on the recovered data.
240
237
  *
241
238
  * Can only be called once — the returned chain has no recover() method.
242
- * Mutually exclusive with upgradeLegacy().
243
239
  *
244
240
  * @param fn - Recovery function returning Current (the type at this chain position)
245
- * @returns Builder with migrate() and init() but without recover() or upgradeLegacy()
241
+ * @returns Builder with migrate() and init() but without recover()
246
242
  *
247
243
  * @example
248
244
  * // Recover between migrations — recovered data goes through v3 migration
@@ -263,19 +259,28 @@ class DataModelMigrationChain<Current> extends MigrationChainBase<Current> {
263
259
  recoverFromIndex: this.migrationSteps.length,
264
260
  });
265
261
  }
262
+ }
266
263
 
264
+ /**
265
+ * Initial migration chain returned by `.from()`.
266
+ * Extends DataModelMigrationChain with `upgradeLegacy()` — available only before
267
+ * any `.migrate()` calls, since legacy data always arrives at the initial version.
268
+ *
269
+ * @typeParam Current - Data type at the initial version
270
+ * @internal
271
+ */
272
+ class DataModelInitialChain<Current> extends DataModelMigrationChain<Current> {
267
273
  /**
268
274
  * Handle legacy V1 model state ({ args, uiState }) when upgrading a block from
269
275
  * BlockModel V1 to BlockModelV3.
270
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
+ * When a V1 block is upgraded, its stored state `{ args, uiState }` is normalized
278
+ * to the internal default version. This method inserts a migration step from that
279
+ * internal version to the version specified in `.from()`, using the provided typed
280
+ * callback to transform the legacy shape. Non-legacy data passes through unchanged.
275
281
  *
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.
282
+ * Must be called right after `.from()` not available after `.migrate()` calls.
283
+ * Any `.migrate()` steps added after `upgradeLegacy()` will run on the transformed result.
279
284
  *
280
285
  * Can only be called once — the returned chain has no upgradeLegacy() method.
281
286
  * Mutually exclusive with recover().
@@ -291,7 +296,7 @@ class DataModelMigrationChain<Current> extends MigrationChainBase<Current> {
291
296
  * type BlockData = { inputFile: string; threshold: number; selectedTab: string };
292
297
  *
293
298
  * const dataModel = new DataModelBuilder()
294
- * .from<BlockData>(DATA_MODEL_DEFAULT_VERSION)
299
+ * .from<BlockData>("v1")
295
300
  * .upgradeLegacy<OldArgs, OldUiState>(({ args, uiState }) => ({
296
301
  * inputFile: args.inputFile,
297
302
  * threshold: args.threshold,
@@ -308,10 +313,18 @@ class DataModelMigrationChain<Current> extends MigrationChainBase<Current> {
308
313
  }
309
314
  return data;
310
315
  };
316
+
317
+ // Insert DATA_MODEL_LEGACY_VERSION as the true first version
318
+ // with a migration step that transforms legacy data to the user's initial version.
319
+ const initialVersion = this.versionChain[0];
320
+ const step: MigrationStep = {
321
+ fromVersion: DATA_MODEL_LEGACY_VERSION,
322
+ toVersion: initialVersion,
323
+ migrate: wrappedFn,
324
+ };
311
325
  return new DataModelMigrationChainWithRecover<Current>({
312
- versionChain: this.versionChain,
313
- steps: this.migrationSteps,
314
- upgradeLegacyFn: wrappedFn,
326
+ versionChain: [DATA_MODEL_LEGACY_VERSION, ...this.versionChain],
327
+ steps: [step, ...this.migrationSteps],
315
328
  });
316
329
  }
317
330
  }
@@ -322,13 +335,13 @@ class DataModelMigrationChain<Current> extends MigrationChainBase<Current> {
322
335
  * @example
323
336
  * // Simple (no migrations):
324
337
  * const dataModel = new DataModelBuilder()
325
- * .from<BlockData>(DATA_MODEL_DEFAULT_VERSION)
338
+ * .from<BlockData>("v1")
326
339
  * .init(() => ({ numbers: [] }));
327
340
  *
328
341
  * @example
329
342
  * // With migrations:
330
343
  * const dataModel = new DataModelBuilder()
331
- * .from<BlockDataV1>(DATA_MODEL_DEFAULT_VERSION)
344
+ * .from<BlockDataV1>("v1")
332
345
  * .migrate<BlockDataV2>("v2", (v1) => ({ ...v1, labels: [] }))
333
346
  * .migrate<BlockDataV3>("v3", (v2) => ({ ...v2, description: '' }))
334
347
  * .init(() => ({ numbers: [], labels: [], description: '' }));
@@ -336,7 +349,7 @@ class DataModelMigrationChain<Current> extends MigrationChainBase<Current> {
336
349
  * @example
337
350
  * // With recover() between migrations — recovered data goes through remaining migrations:
338
351
  * const dataModelChain = new DataModelBuilder()
339
- * .from<BlockDataV1>(DATA_MODEL_DEFAULT_VERSION)
352
+ * .from<BlockDataV1>("v1")
340
353
  * .migrate<BlockDataV2>("v2", (v1) => ({ ...v1, labels: [] }));
341
354
  *
342
355
  * // recover() placed before the v3 migration: recovered data goes through v3
@@ -355,7 +368,7 @@ class DataModelMigrationChain<Current> extends MigrationChainBase<Current> {
355
368
  * type BlockData = { inputFile: string; selectedTab: string };
356
369
  *
357
370
  * const dataModel = new DataModelBuilder()
358
- * .from<BlockData>(DATA_MODEL_DEFAULT_VERSION)
371
+ * .from<BlockData>("v1")
359
372
  * .upgradeLegacy<OldArgs, OldUiState>(({ args, uiState }) => ({
360
373
  * inputFile: args.inputFile,
361
374
  * selectedTab: uiState.selectedTab,
@@ -367,11 +380,11 @@ export class DataModelBuilder {
367
380
  * Start the migration chain with the given initial data type and version key.
368
381
  *
369
382
  * @typeParam T - Data type for the initial version
370
- * @param initialVersion - Version key string (e.g. DATA_MODEL_DEFAULT_VERSION or "v1")
383
+ * @param initialVersion - Version key string (e.g. "v1")
371
384
  * @returns Migration chain builder
372
385
  */
373
- from<T>(initialVersion: string): DataModelMigrationChain<T> {
374
- return new DataModelMigrationChain<T>({ versionChain: [initialVersion] });
386
+ from<T>(initialVersion: string): DataModelInitialChain<T> {
387
+ return new DataModelInitialChain<T>({ versionChain: [initialVersion] });
375
388
  }
376
389
  }
377
390
 
@@ -385,7 +398,7 @@ export class DataModelBuilder {
385
398
  * // With recover() between migrations:
386
399
  * // Recovered data (V2) goes through the v2→v3 migration automatically.
387
400
  * const dataModel = new DataModelBuilder()
388
- * .from<V1>(DATA_MODEL_DEFAULT_VERSION)
401
+ * .from<V1>("v1")
389
402
  * .migrate<V2>("v2", (v1) => ({ ...v1, label: "" }))
390
403
  * .recover((version, data) => {
391
404
  * if (version === "legacy") return transformLegacy(data); // returns V2
@@ -403,8 +416,6 @@ export class DataModel<State> {
403
416
  private readonly initialDataFn: () => State;
404
417
  private readonly recoverFn: (version: DataVersionKey, data: unknown) => unknown;
405
418
  private readonly recoverFromIndex: number;
406
- /** Transforms legacy V1 model data at the initial version before running migrations. */
407
- private readonly upgradeLegacyFn?: (data: unknown) => unknown;
408
419
 
409
420
  private constructor({
410
421
  versionChain,
@@ -412,7 +423,6 @@ export class DataModel<State> {
412
423
  initialDataFn,
413
424
  recoverFn = defaultRecover,
414
425
  recoverFromIndex,
415
- upgradeLegacyFn,
416
426
  }: BuilderState<State>) {
417
427
  if (versionChain.length === 0) {
418
428
  throw new Error("DataModel requires at least one version key");
@@ -423,7 +433,6 @@ export class DataModel<State> {
423
433
  this.initialDataFn = initialDataFn;
424
434
  this.recoverFn = recoverFn;
425
435
  this.recoverFromIndex = recoverFromIndex ?? steps.length;
426
- this.upgradeLegacyFn = upgradeLegacyFn;
427
436
  }
428
437
 
429
438
  /**
@@ -457,34 +466,15 @@ export class DataModel<State> {
457
466
  return makeDataVersioned(this.latestVersion, this.initialDataFn());
458
467
  }
459
468
 
460
- private recoverFrom(data: unknown, version: DataVersionKey): DataMigrationResult<State> {
469
+ private recoverFrom(data: unknown, version: DataVersionKey): DataVersioned<State> {
461
470
  // Step 1: call the recover function to get data at the recover point
462
- let currentData: unknown;
463
- try {
464
- currentData = this.recoverFn(version, data);
465
- } catch (error) {
466
- if (isDataUnrecoverableError(error)) {
467
- return { ...this.getDefaultData(), warning: error.message };
468
- }
469
- const errorMessage = error instanceof Error ? error.message : String(error);
470
- return {
471
- ...this.getDefaultData(),
472
- warning: `Recover failed for version '${version}': ${errorMessage}`,
473
- };
474
- }
471
+ // Let errors (including DataUnrecoverableError) propagate to the caller.
472
+ let currentData: unknown = this.recoverFn(version, data);
475
473
 
476
474
  // Step 2: run any migrations that were added after recover() in the chain
477
475
  for (let i = this.recoverFromIndex; i < this.steps.length; i++) {
478
476
  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
- }
477
+ currentData = step.migrate(currentData);
488
478
  }
489
479
 
490
480
  return { version: this.latestVersion, data: currentData as State };
@@ -494,13 +484,14 @@ export class DataModel<State> {
494
484
  * Migrate versioned data from any version to the latest.
495
485
  *
496
486
  * - If version is in chain, applies needed migrations (O(1) lookup)
497
- * - If version is unknown, calls recover function then runs remaining migrations
498
- * - If migration/recovery fails, returns default data with warning
487
+ * - If version is unknown, attempts recovery; falls back to initial data
488
+ * - If a migration step fails, throws so the caller can preserve original data
499
489
  *
500
490
  * @param versioned - Data with version tag
501
- * @returns Migration result with data at latest version
491
+ * @returns Migrated data at the latest version
492
+ * @throws If a migration step from a known version fails
502
493
  */
503
- migrate(versioned: DataVersioned<unknown>): DataMigrationResult<State> {
494
+ migrate(versioned: DataVersioned<unknown>): DataVersioned<State> {
504
495
  const { version: fromVersion, data } = versioned;
505
496
 
506
497
  if (fromVersion === this.latestVersion) {
@@ -509,35 +500,20 @@ export class DataModel<State> {
509
500
 
510
501
  const startIndex = this.stepsByFromVersion.get(fromVersion);
511
502
  if (startIndex === undefined) {
512
- return this.recoverFrom(data, fromVersion);
513
- }
514
-
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
503
  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
- };
504
+ return this.recoverFrom(data, fromVersion);
505
+ } catch {
506
+ // Recovery failed (unknown version, recover fn threw, or post-recover
507
+ // migration failed) — reset to initial data rather than blocking the update.
508
+ return this.getDefaultData();
527
509
  }
528
510
  }
529
511
 
512
+ let currentData: unknown = data;
513
+
530
514
  for (let i = startIndex; i < this.steps.length; i++) {
531
515
  const step = this.steps[i];
532
- try {
533
- currentData = step.migrate(currentData);
534
- } catch (error) {
535
- const errorMessage = error instanceof Error ? error.message : String(error);
536
- return {
537
- ...this.getDefaultData(),
538
- warning: `Migration ${step.fromVersion}→${step.toVersion} failed: ${errorMessage}`,
539
- };
540
- }
516
+ currentData = step.migrate(currentData);
541
517
  }
542
518
 
543
519
  return { version: this.latestVersion, data: currentData as State };
@@ -6,12 +6,14 @@ import type {
6
6
  BlockCodeKnownFeatureFlags,
7
7
  BlockConfigContainer,
8
8
  } from "@milaboratories/pl-model-common";
9
+ import { mergeFeatureFlags } from "@milaboratories/pl-model-common";
9
10
  import { getPlatformaInstance, isInUI, createAndRegisterRenderLambda } from "./internal";
10
11
  import type { DataModel } from "./block_migrations";
11
12
  import type { PlatformaV3 } from "./platforma";
12
13
  import type { InferRenderFunctionReturn, RenderFunction } from "./render";
13
14
  import { BlockRenderCtx, PluginRenderCtx } from "./render";
14
- import type { PluginModel } from "./plugin_model";
15
+ import type { PluginData, PluginModel, PluginOutputs, PluginParams } from "./plugin_model";
16
+ import { type PluginHandle, pluginOutputKey } from "./plugin_handle";
15
17
  import type { RenderCtxBase } from "./render";
16
18
  import { PlatformaSDKVersion } from "./version";
17
19
  import {
@@ -59,7 +61,11 @@ type ParamsInputErased = Record<string, (ctx: RenderCtxBase) => unknown>;
59
61
  * Registered plugin: model + param derivation lambdas.
60
62
  * Type parameters are carried by PluginModel generic.
61
63
  */
62
- export type PluginInstance<Data = unknown, Params = unknown, Outputs = unknown> = {
64
+ export type PluginInstance<
65
+ Data extends PluginData = PluginData,
66
+ Params extends PluginParams = undefined,
67
+ Outputs extends PluginOutputs = PluginOutputs,
68
+ > = {
63
69
  readonly model: PluginModel<Data, Params, Outputs>;
64
70
  readonly inputs: ParamsInputErased;
65
71
  };
@@ -108,7 +114,7 @@ export class BlockModelV3<
108
114
  *
109
115
  * @example
110
116
  * const dataModel = new DataModelBuilder()
111
- * .from<BlockData>(DATA_MODEL_DEFAULT_VERSION)
117
+ * .from<BlockData>("v1")
112
118
  * .init(() => ({ numbers: [], labels: [] }));
113
119
  *
114
120
  * BlockModelV3.create(dataModel)
@@ -125,7 +131,6 @@ export class BlockModelV3<
125
131
  renderingMode: "Heavy",
126
132
  dataModel,
127
133
  outputs: {},
128
- // Register default sections callback (returns empty array)
129
134
  sections: createAndRegisterRenderLambda({ handle: "sections", lambda: () => [] }, true),
130
135
  title: undefined,
131
136
  subtitle: undefined,
@@ -372,23 +377,28 @@ export class BlockModelV3<
372
377
  * sourceId: (ctx) => ctx.data.selectedSource,
373
378
  * })
374
379
  */
375
- public plugin<const PluginId extends string, PluginData, PluginParams, PluginOutputs>(
380
+ public plugin<
381
+ const PluginId extends string,
382
+ PData extends PluginData,
383
+ PParams extends PluginParams,
384
+ POutputs extends PluginOutputs,
385
+ >(
376
386
  pluginId: PluginId,
377
- plugin: PluginModel<PluginData, PluginParams, PluginOutputs>,
378
- params?: ParamsInput<PluginParams, Args, Data>,
387
+ plugin: PluginModel<PData, PParams, POutputs>,
388
+ params?: ParamsInput<PParams, Args, Data>,
379
389
  ): BlockModelV3<
380
390
  Args,
381
391
  OutputsCfg,
382
392
  Data,
383
393
  Href,
384
- Plugins & { [K in PluginId]: PluginInstance<PluginData, PluginParams, PluginOutputs> }
394
+ Plugins & { [K in PluginId]: PluginInstance<PData, PParams, POutputs> }
385
395
  > {
386
396
  // Validate pluginId uniqueness
387
397
  if (pluginId in this.config.plugins) {
388
398
  throw new Error(`Plugin '${pluginId}' already registered`);
389
399
  }
390
400
 
391
- const instance: PluginInstance<PluginData, PluginParams, PluginOutputs> = {
401
+ const instance: PluginInstance<PData, PParams, POutputs> = {
392
402
  model: plugin,
393
403
  inputs: (params ?? {}) as ParamsInputErased,
394
404
  };
@@ -399,6 +409,7 @@ export class BlockModelV3<
399
409
  ...this.config.plugins,
400
410
  [pluginId]: instance,
401
411
  },
412
+ featureFlags: mergeFeatureFlags(this.config.featureFlags, plugin.featureFlags ?? {}),
402
413
  });
403
414
  }
404
415
 
@@ -423,15 +434,16 @@ export class BlockModelV3<
423
434
  // Build plugin registry
424
435
  const { plugins } = this.config;
425
436
  const pluginRegistry: Record<string, PluginName> = {};
426
- for (const [pluginId, { model }] of Object.entries(plugins)) {
427
- pluginRegistry[pluginId] = model.name;
437
+ const pluginHandles = Object.keys(plugins) as PluginHandle[];
438
+ for (const handle of pluginHandles) {
439
+ pluginRegistry[handle] = plugins[handle].model.name;
428
440
  }
429
441
 
430
442
  const { dataModel, argsFunction, prerunArgsFunction } = this.config;
431
443
 
432
- function getPlugin(pluginId: string): PluginInstance {
433
- const plugin = plugins[pluginId];
434
- if (!plugin) throw new Error(`Plugin model not found for '${pluginId}'`);
444
+ function getPlugin(handle: PluginHandle): PluginInstance {
445
+ const plugin = plugins[handle];
446
+ if (!plugin) throw new Error(`Plugin model not found for '${handle}'`);
435
447
  return plugin;
436
448
  }
437
449
 
@@ -443,14 +455,14 @@ export class BlockModelV3<
443
455
  migrateStorage(currentStorageJson, {
444
456
  migrateBlockData: (v) => dataModel.migrate(v),
445
457
  getPluginRegistry: () => pluginRegistry,
446
- migratePluginData: (pluginId, v) => getPlugin(pluginId).model.dataModel.migrate(v),
447
- createPluginData: (pluginId) => getPlugin(pluginId).model.dataModel.getDefaultData(),
458
+ migratePluginData: (handle, v) => getPlugin(handle).model.dataModel.migrate(v),
459
+ createPluginData: (handle) => getPlugin(handle).model.dataModel.getDefaultData(),
448
460
  }),
449
461
  [BlockStorageFacadeCallbacks.StorageInitial]: () =>
450
462
  createInitialStorage({
451
463
  getDefaultBlockData: () => dataModel.getDefaultData(),
452
464
  getPluginRegistry: () => pluginRegistry,
453
- createPluginData: (pluginId) => getPlugin(pluginId).model.dataModel.getDefaultData(),
465
+ createPluginData: (handle) => getPlugin(handle).model.dataModel.getDefaultData(),
454
466
  }),
455
467
  [BlockStorageFacadeCallbacks.ArgsDerive]: (storageJson) =>
456
468
  deriveArgsFromStorage(storageJson, argsFunction),
@@ -460,7 +472,8 @@ export class BlockModelV3<
460
472
 
461
473
  // Register plugin input and output lambdas
462
474
  const pluginOutputs: Record<string, ConfigRenderLambda> = {};
463
- for (const [pluginId, { model, inputs }] of Object.entries(plugins)) {
475
+ for (const handle of pluginHandles) {
476
+ const { model, inputs } = plugins[handle];
464
477
  // Wrap plugin param lambdas: close over BlockRenderCtx creation
465
478
  const wrappedInputs: Record<string, () => unknown> = {};
466
479
  for (const [paramKey, paramFn] of Object.entries(inputs)) {
@@ -470,10 +483,10 @@ export class BlockModelV3<
470
483
  // Register plugin outputs (in config pack, evaluated by middle layer)
471
484
  const outputs = model.outputs as Record<string, (ctx: PluginRenderCtx) => unknown>;
472
485
  for (const [outputKey, outputFn] of Object.entries(outputs)) {
473
- const key = `plugin-output#${pluginId}#${outputKey}`;
486
+ const key = pluginOutputKey(handle, outputKey);
474
487
  pluginOutputs[key] = createAndRegisterRenderLambda({
475
488
  handle: key,
476
- lambda: () => outputFn(new PluginRenderCtx(pluginId, wrappedInputs)),
489
+ lambda: () => outputFn(new PluginRenderCtx(handle, wrappedInputs)),
477
490
  });
478
491
  }
479
492
  }
@@ -528,6 +541,7 @@ export class BlockModelV3<
528
541
  },
529
542
  ]),
530
543
  ),
544
+ pluginIds: pluginHandles,
531
545
  },
532
546
  } as any;
533
547
  }