@platforma-sdk/model 1.52.7 → 1.53.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.
- package/dist/block_migrations.cjs +145 -45
- package/dist/block_migrations.cjs.map +1 -1
- package/dist/block_migrations.d.ts +88 -26
- package/dist/block_migrations.d.ts.map +1 -1
- package/dist/block_migrations.js +142 -47
- package/dist/block_migrations.js.map +1 -1
- package/dist/block_storage.cjs +13 -6
- package/dist/block_storage.cjs.map +1 -1
- package/dist/block_storage.d.ts +16 -11
- package/dist/block_storage.d.ts.map +1 -1
- package/dist/block_storage.js +13 -7
- package/dist/block_storage.js.map +1 -1
- package/dist/block_storage_vm.cjs +15 -14
- package/dist/block_storage_vm.cjs.map +1 -1
- package/dist/block_storage_vm.d.ts +1 -1
- package/dist/block_storage_vm.d.ts.map +1 -1
- package/dist/block_storage_vm.js +16 -15
- package/dist/block_storage_vm.js.map +1 -1
- package/dist/components/PlMultiSequenceAlignment.cjs.map +1 -1
- package/dist/components/PlMultiSequenceAlignment.d.ts +5 -3
- package/dist/components/PlMultiSequenceAlignment.d.ts.map +1 -1
- package/dist/components/PlMultiSequenceAlignment.js.map +1 -1
- package/dist/index.cjs +6 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/package.json.cjs +1 -1
- package/dist/package.json.js +1 -1
- package/package.json +7 -7
- package/src/block_migrations.test.ts +170 -0
- package/src/block_migrations.ts +207 -62
- package/src/block_storage.test.ts +23 -22
- package/src/block_storage.ts +23 -16
- package/src/block_storage_vm.ts +21 -19
- package/src/components/PlMultiSequenceAlignment.ts +12 -8
- package/src/index.ts +8 -1
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
DataModel,
|
|
4
|
+
defaultRecover,
|
|
5
|
+
defineDataVersions,
|
|
6
|
+
makeDataVersioned,
|
|
7
|
+
} from './block_migrations';
|
|
8
|
+
|
|
9
|
+
describe('defineDataVersions', () => {
|
|
10
|
+
it('throws on duplicate version values', () => {
|
|
11
|
+
expect(() =>
|
|
12
|
+
defineDataVersions({
|
|
13
|
+
V1: 'v1',
|
|
14
|
+
V2: 'v1', // duplicate!
|
|
15
|
+
}),
|
|
16
|
+
).toThrow('Duplicate version values: v1');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('throws on empty version values', () => {
|
|
20
|
+
expect(() =>
|
|
21
|
+
defineDataVersions({
|
|
22
|
+
V1: 'v1',
|
|
23
|
+
V2: '', // empty!
|
|
24
|
+
}),
|
|
25
|
+
).toThrow('Version values must be non-empty strings (empty: V2)');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('allows unique version values', () => {
|
|
29
|
+
const versions = defineDataVersions({
|
|
30
|
+
V1: 'v1',
|
|
31
|
+
V2: 'v2',
|
|
32
|
+
});
|
|
33
|
+
expect(versions.V1).toBe('v1');
|
|
34
|
+
expect(versions.V2).toBe('v2');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('makeDataVersioned', () => {
|
|
39
|
+
it('creates correct DataVersioned shape', () => {
|
|
40
|
+
const versioned = makeDataVersioned('v1', { count: 42 });
|
|
41
|
+
expect(versioned).toStrictEqual({ version: 'v1', data: { count: 42 } });
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('DataModel migrations', () => {
|
|
46
|
+
it('resets to initial data on unknown version', () => {
|
|
47
|
+
const Version = {
|
|
48
|
+
V1: 'v1',
|
|
49
|
+
V2: 'v2',
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
type VersionedData = {
|
|
53
|
+
[Version.V1]: { count: number };
|
|
54
|
+
[Version.V2]: { count: number; label: string };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const dataModel = DataModel
|
|
58
|
+
.from<VersionedData>(Version.V1)
|
|
59
|
+
.migrate(Version.V2, (v1) => ({ ...v1, label: '' }))
|
|
60
|
+
.create(() => ({ count: 0, label: '' }));
|
|
61
|
+
|
|
62
|
+
const result = dataModel.migrate(makeDataVersioned('legacy', { count: 42 }));
|
|
63
|
+
expect(result.version).toBe('v2');
|
|
64
|
+
expect(result.data).toStrictEqual({ count: 0, label: '' });
|
|
65
|
+
expect(result.warning).toBe(`Unknown version 'legacy'`);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('uses recover() for unknown versions', () => {
|
|
69
|
+
const Version = {
|
|
70
|
+
V1: 'v1',
|
|
71
|
+
V2: 'v2',
|
|
72
|
+
} as const;
|
|
73
|
+
|
|
74
|
+
type VersionedData = {
|
|
75
|
+
[Version.V1]: { count: number };
|
|
76
|
+
[Version.V2]: { count: number; label: string };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const dataModel = DataModel
|
|
80
|
+
.from<VersionedData>(Version.V1)
|
|
81
|
+
.migrate(Version.V2, (v1) => ({ ...v1, label: '' }))
|
|
82
|
+
.recover((version, data) => {
|
|
83
|
+
if (version === 'legacy' && typeof data === 'object' && data !== null && 'count' in data) {
|
|
84
|
+
return { count: (data as { count: number }).count, label: 'recovered' };
|
|
85
|
+
}
|
|
86
|
+
return defaultRecover(version, data);
|
|
87
|
+
})
|
|
88
|
+
.create(() => ({ count: 0, label: '' }));
|
|
89
|
+
|
|
90
|
+
const result = dataModel.migrate(makeDataVersioned('legacy', { count: 7 }));
|
|
91
|
+
expect(result.version).toBe('v2');
|
|
92
|
+
expect(result.data).toStrictEqual({ count: 7, label: 'recovered' });
|
|
93
|
+
expect(result.warning).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('allows recover() to delegate to defaultRecover', () => {
|
|
97
|
+
const Version = {
|
|
98
|
+
V1: 'v1',
|
|
99
|
+
V2: 'v2',
|
|
100
|
+
} as const;
|
|
101
|
+
|
|
102
|
+
type VersionedData = {
|
|
103
|
+
[Version.V1]: { count: number };
|
|
104
|
+
[Version.V2]: { count: number; label: string };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const dataModel = DataModel
|
|
108
|
+
.from<VersionedData>(Version.V1)
|
|
109
|
+
.migrate(Version.V2, (v1) => ({ ...v1, label: '' }))
|
|
110
|
+
.recover((version, data) => defaultRecover(version, data))
|
|
111
|
+
.create(() => ({ count: 0, label: '' }));
|
|
112
|
+
|
|
113
|
+
const result = dataModel.migrate(makeDataVersioned('legacy', { count: 7 }));
|
|
114
|
+
expect(result.version).toBe('v2');
|
|
115
|
+
expect(result.data).toStrictEqual({ count: 0, label: '' });
|
|
116
|
+
expect(result.warning).toBe(`Unknown version 'legacy'`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('returns initial data on migration failure', () => {
|
|
120
|
+
const Version = {
|
|
121
|
+
V1: 'v1',
|
|
122
|
+
V2: 'v2',
|
|
123
|
+
} as const;
|
|
124
|
+
|
|
125
|
+
type VersionedData = {
|
|
126
|
+
[Version.V1]: { numbers: number[] };
|
|
127
|
+
[Version.V2]: { numbers: number[]; label: string };
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const dataModel = DataModel
|
|
131
|
+
.from<VersionedData>(Version.V1)
|
|
132
|
+
.migrate(Version.V2, (v1) => {
|
|
133
|
+
if (v1.numbers.includes(666)) {
|
|
134
|
+
throw new Error('Forbidden number');
|
|
135
|
+
}
|
|
136
|
+
return { ...v1, label: 'ok' };
|
|
137
|
+
})
|
|
138
|
+
.create(() => ({ numbers: [], label: '' }));
|
|
139
|
+
|
|
140
|
+
const result = dataModel.migrate(makeDataVersioned('v1', { numbers: [666] }));
|
|
141
|
+
expect(result.version).toBe('v2');
|
|
142
|
+
expect(result.data).toStrictEqual({ numbers: [], label: '' });
|
|
143
|
+
expect(result.warning).toBe(`Migration v1→v2 failed: Forbidden number`);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Compile-time typing checks
|
|
148
|
+
const Version = defineDataVersions({
|
|
149
|
+
V1: 'v1',
|
|
150
|
+
V2: 'v2',
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
type VersionedData = {
|
|
154
|
+
[Version.V1]: { count: number };
|
|
155
|
+
[Version.V2]: { count: number; label: string };
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
DataModel
|
|
159
|
+
.from<VersionedData>(Version.V1)
|
|
160
|
+
.migrate(Version.V2, (v1) => ({ ...v1, label: '' }))
|
|
161
|
+
.create(() => ({ count: 0, label: '' }));
|
|
162
|
+
|
|
163
|
+
// @ts-expect-error invalid initial version key
|
|
164
|
+
DataModel.from<VersionedData>('v3');
|
|
165
|
+
|
|
166
|
+
// @ts-expect-error invalid migration target key
|
|
167
|
+
DataModel.from<VersionedData>(Version.V1).migrate('v3', (v1) => ({ ...v1, label: '' }));
|
|
168
|
+
|
|
169
|
+
// @ts-expect-error migration return type must match target version
|
|
170
|
+
DataModel.from<VersionedData>(Version.V1).migrate(Version.V2, (v1) => ({ ...v1, invalid: true }));
|
package/src/block_migrations.ts
CHANGED
|
@@ -1,43 +1,148 @@
|
|
|
1
1
|
import { tryRegisterCallback } from './internal';
|
|
2
|
-
import { createBlockStorage } from './block_storage';
|
|
2
|
+
import { createBlockStorage, DATA_MODEL_DEFAULT_VERSION } from './block_storage';
|
|
3
3
|
|
|
4
|
-
export type
|
|
4
|
+
export type DataVersionKey = string;
|
|
5
|
+
export type DataVersionMap = Record<string, unknown>;
|
|
6
|
+
export type DataMigrateFn<From, To> = (prev: Readonly<From>) => To;
|
|
7
|
+
export type DataCreateFn<T> = () => T;
|
|
8
|
+
export type DataRecoverFn<T> = (version: DataVersionKey, data: unknown) => T;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Helper to define version keys with literal type inference and runtime validation.
|
|
12
|
+
* - Validates that all version values are unique
|
|
13
|
+
* - Eliminates need for `as const` assertion
|
|
14
|
+
*
|
|
15
|
+
* @throws Error if duplicate version values are found
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* const Version = defineDataVersions({
|
|
19
|
+
* Initial: 'v1',
|
|
20
|
+
* AddedLabels: 'v2',
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* type VersionedData = {
|
|
24
|
+
* [Version.Initial]: DataV1;
|
|
25
|
+
* [Version.AddedLabels]: DataV2;
|
|
26
|
+
* };
|
|
27
|
+
*/
|
|
28
|
+
export function defineDataVersions<const T extends Record<string, string>>(versions: T): T {
|
|
29
|
+
const values = Object.values(versions);
|
|
30
|
+
const emptyKeys = Object.keys(versions).filter((key) => versions[key] === '');
|
|
31
|
+
if (emptyKeys.length > 0) {
|
|
32
|
+
throw new Error(`Version values must be non-empty strings (empty: ${emptyKeys.join(', ')})`);
|
|
33
|
+
}
|
|
34
|
+
const unique = new Set(values);
|
|
35
|
+
if (unique.size !== values.length) {
|
|
36
|
+
const duplicates = values.filter((v, i) => values.indexOf(v) !== i);
|
|
37
|
+
throw new Error(`Duplicate version values: ${[...new Set(duplicates)].join(', ')}`);
|
|
38
|
+
}
|
|
39
|
+
return versions;
|
|
40
|
+
}
|
|
5
41
|
|
|
6
42
|
/** Versioned data wrapper for persistence */
|
|
7
|
-
export type
|
|
8
|
-
version:
|
|
43
|
+
export type DataVersioned<T> = {
|
|
44
|
+
version: DataVersionKey;
|
|
9
45
|
data: T;
|
|
10
46
|
};
|
|
11
47
|
|
|
12
|
-
/**
|
|
13
|
-
export
|
|
48
|
+
/** Create a DataVersioned wrapper with correct shape */
|
|
49
|
+
export function makeDataVersioned<T>(version: DataVersionKey, data: T): DataVersioned<T> {
|
|
50
|
+
return { version, data };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Result of migration operation, may include warning if migration failed */
|
|
54
|
+
export type DataMigrationResult<T> = DataVersioned<T> & {
|
|
14
55
|
warning?: string;
|
|
15
56
|
};
|
|
16
57
|
|
|
58
|
+
/** Thrown by recover() to signal unrecoverable data. */
|
|
59
|
+
export class DataUnrecoverableError extends Error {
|
|
60
|
+
name = 'DataUnrecoverableError';
|
|
61
|
+
constructor(dataVersion: DataVersionKey) {
|
|
62
|
+
super(`Unknown version '${dataVersion}'`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isDataUnrecoverableError(error: unknown): error is DataUnrecoverableError {
|
|
67
|
+
return error instanceof Error && error.name === 'DataUnrecoverableError';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type MigrationStep = {
|
|
71
|
+
fromVersion: DataVersionKey;
|
|
72
|
+
toVersion: DataVersionKey;
|
|
73
|
+
migrate: (data: unknown) => unknown;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Default recover function for unknown versions */
|
|
77
|
+
export const defaultRecover: DataRecoverFn<never> = (version, _data) => {
|
|
78
|
+
throw new DataUnrecoverableError(version);
|
|
79
|
+
};
|
|
80
|
+
|
|
17
81
|
/** Internal builder for chaining migrations */
|
|
18
|
-
class DataModelBuilder<
|
|
19
|
-
|
|
82
|
+
class DataModelBuilder<
|
|
83
|
+
VersionedData extends DataVersionMap,
|
|
84
|
+
CurrentVersion extends keyof VersionedData & string,
|
|
85
|
+
> {
|
|
86
|
+
private readonly versionChain: DataVersionKey[];
|
|
87
|
+
private readonly migrationSteps: MigrationStep[];
|
|
88
|
+
private readonly recoverFn?: DataRecoverFn<VersionedData[CurrentVersion]>;
|
|
20
89
|
|
|
21
|
-
private constructor(
|
|
90
|
+
private constructor(
|
|
91
|
+
versionChain: DataVersionKey[],
|
|
92
|
+
steps: MigrationStep[] = [],
|
|
93
|
+
recoverFn?: DataRecoverFn<VersionedData[CurrentVersion]>,
|
|
94
|
+
) {
|
|
95
|
+
this.versionChain = versionChain;
|
|
22
96
|
this.migrationSteps = steps;
|
|
97
|
+
this.recoverFn = recoverFn;
|
|
23
98
|
}
|
|
24
99
|
|
|
25
|
-
/** Start a migration chain from an initial
|
|
26
|
-
static from<
|
|
27
|
-
|
|
100
|
+
/** Start a migration chain from an initial version */
|
|
101
|
+
static from<
|
|
102
|
+
VersionedData extends DataVersionMap,
|
|
103
|
+
InitialVersion extends keyof VersionedData & string = keyof VersionedData & string,
|
|
104
|
+
>(initialVersion: InitialVersion): DataModelBuilder<VersionedData, InitialVersion> {
|
|
105
|
+
return new DataModelBuilder<VersionedData, InitialVersion>([initialVersion]);
|
|
28
106
|
}
|
|
29
107
|
|
|
30
|
-
/** Add a migration step */
|
|
31
|
-
migrate<
|
|
32
|
-
|
|
108
|
+
/** Add a migration step to the target version */
|
|
109
|
+
migrate<NextVersion extends keyof VersionedData & string>(
|
|
110
|
+
nextVersion: NextVersion,
|
|
111
|
+
fn: DataMigrateFn<VersionedData[CurrentVersion], VersionedData[NextVersion]>,
|
|
112
|
+
): DataModelBuilder<VersionedData, NextVersion> {
|
|
113
|
+
if (this.versionChain.includes(nextVersion)) {
|
|
114
|
+
throw new Error(`Duplicate version '${nextVersion}' in migration chain`);
|
|
115
|
+
}
|
|
116
|
+
const fromVersion = this.versionChain[this.versionChain.length - 1];
|
|
117
|
+
const step: MigrationStep = { fromVersion, toVersion: nextVersion, migrate: fn as (data: unknown) => unknown };
|
|
118
|
+
return new DataModelBuilder<VersionedData, NextVersion>(
|
|
119
|
+
[...this.versionChain, nextVersion],
|
|
120
|
+
[...this.migrationSteps, step],
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Set recovery handler for unknown or unsupported versions */
|
|
125
|
+
recover(
|
|
126
|
+
fn: DataRecoverFn<VersionedData[CurrentVersion]>,
|
|
127
|
+
): DataModelBuilder<VersionedData, CurrentVersion> {
|
|
128
|
+
return new DataModelBuilder<VersionedData, CurrentVersion>(
|
|
129
|
+
[...this.versionChain],
|
|
130
|
+
[...this.migrationSteps],
|
|
131
|
+
fn,
|
|
132
|
+
);
|
|
33
133
|
}
|
|
34
134
|
|
|
35
135
|
/** Finalize with initial data, creating the DataModel */
|
|
36
|
-
create<S>(
|
|
37
|
-
initialData:
|
|
38
|
-
..._: [
|
|
136
|
+
create<S extends VersionedData[CurrentVersion]>(
|
|
137
|
+
initialData: DataCreateFn<S>,
|
|
138
|
+
..._: [VersionedData[CurrentVersion]] extends [S] ? [] : [never]
|
|
39
139
|
): DataModel<S> {
|
|
40
|
-
return DataModel._fromBuilder<S>(
|
|
140
|
+
return DataModel._fromBuilder<S>(
|
|
141
|
+
this.versionChain,
|
|
142
|
+
this.migrationSteps,
|
|
143
|
+
initialData,
|
|
144
|
+
this.recoverFn as DataRecoverFn<S> | undefined,
|
|
145
|
+
);
|
|
41
146
|
}
|
|
42
147
|
}
|
|
43
148
|
|
|
@@ -53,45 +158,79 @@ class DataModelBuilder<State> {
|
|
|
53
158
|
* }));
|
|
54
159
|
*
|
|
55
160
|
* // Data model with migrations
|
|
161
|
+
* const Version = defineDataVersions({
|
|
162
|
+
* V1: 'v1',
|
|
163
|
+
* V2: 'v2',
|
|
164
|
+
* V3: 'v3',
|
|
165
|
+
* });
|
|
166
|
+
*
|
|
167
|
+
* type VersionedData = {
|
|
168
|
+
* [Version.V1]: { numbers: number[] };
|
|
169
|
+
* [Version.V2]: { numbers: number[]; labels: string[] };
|
|
170
|
+
* [Version.V3]: { numbers: number[]; labels: string[]; description: string };
|
|
171
|
+
* };
|
|
172
|
+
*
|
|
56
173
|
* const dataModel = DataModel
|
|
57
|
-
* .from<
|
|
58
|
-
* .migrate((data) => ({ ...data, labels: [] }))
|
|
59
|
-
* .migrate((data) => ({ ...data, description: '' }))
|
|
60
|
-
* .
|
|
174
|
+
* .from<VersionedData>(Version.V1)
|
|
175
|
+
* .migrate(Version.V2, (data) => ({ ...data, labels: [] }))
|
|
176
|
+
* .migrate(Version.V3, (data) => ({ ...data, description: '' }))
|
|
177
|
+
* .recover((version, data) => {
|
|
178
|
+
* if (version === 'legacy' && typeof data === 'object' && data !== null && 'numbers' in data) {
|
|
179
|
+
* return { numbers: (data as { numbers: number[] }).numbers, labels: [], description: '' };
|
|
180
|
+
* }
|
|
181
|
+
* return defaultRecover(version, data);
|
|
182
|
+
* })
|
|
183
|
+
* .create(() => ({ numbers: [], labels: [], description: '' }));
|
|
61
184
|
*/
|
|
62
185
|
export class DataModel<State> {
|
|
63
|
-
private readonly
|
|
64
|
-
private readonly
|
|
186
|
+
private readonly versionChain: DataVersionKey[];
|
|
187
|
+
private readonly steps: MigrationStep[];
|
|
188
|
+
private readonly initialDataFn: () => State;
|
|
189
|
+
private readonly recoverFn: DataRecoverFn<State>;
|
|
65
190
|
|
|
66
|
-
private constructor(
|
|
191
|
+
private constructor(
|
|
192
|
+
versionChain: DataVersionKey[],
|
|
193
|
+
steps: MigrationStep[],
|
|
194
|
+
initialData: () => State,
|
|
195
|
+
recover: DataRecoverFn<State> = defaultRecover as DataRecoverFn<State>,
|
|
196
|
+
) {
|
|
197
|
+
if (versionChain.length === 0) {
|
|
198
|
+
throw new Error('DataModel requires at least one version key');
|
|
199
|
+
}
|
|
200
|
+
this.versionChain = versionChain;
|
|
67
201
|
this.steps = steps;
|
|
68
|
-
this.
|
|
202
|
+
this.initialDataFn = initialData;
|
|
203
|
+
this.recoverFn = recover;
|
|
69
204
|
}
|
|
70
205
|
|
|
71
206
|
/** Start a migration chain from an initial type */
|
|
72
|
-
static from<
|
|
73
|
-
|
|
207
|
+
static from<
|
|
208
|
+
VersionedData extends DataVersionMap,
|
|
209
|
+
InitialVersion extends keyof VersionedData & string = keyof VersionedData & string,
|
|
210
|
+
>(initialVersion: InitialVersion): DataModelBuilder<VersionedData, InitialVersion> {
|
|
211
|
+
return DataModelBuilder.from<VersionedData, InitialVersion>(initialVersion);
|
|
74
212
|
}
|
|
75
213
|
|
|
76
214
|
/** Create a data model with just initial data (no migrations) */
|
|
77
|
-
static create<S>(initialData: () => S): DataModel<S> {
|
|
78
|
-
return new DataModel<S>([], initialData);
|
|
215
|
+
static create<S>(initialData: () => S, version: DataVersionKey = DATA_MODEL_DEFAULT_VERSION): DataModel<S> {
|
|
216
|
+
return new DataModel<S>([version], [], initialData);
|
|
79
217
|
}
|
|
80
218
|
|
|
81
219
|
/** Create from builder (internal use) */
|
|
82
220
|
static _fromBuilder<S>(
|
|
83
|
-
|
|
221
|
+
versionChain: DataVersionKey[],
|
|
222
|
+
steps: MigrationStep[],
|
|
84
223
|
initialData: () => S,
|
|
224
|
+
recover?: DataRecoverFn<S>,
|
|
85
225
|
): DataModel<S> {
|
|
86
|
-
return new DataModel<S>(steps, initialData);
|
|
226
|
+
return new DataModel<S>(versionChain, steps, initialData, recover);
|
|
87
227
|
}
|
|
88
228
|
|
|
89
229
|
/**
|
|
90
|
-
* Latest version
|
|
91
|
-
* Version 1 = initial state, each migration adds 1.
|
|
230
|
+
* Latest version key.
|
|
92
231
|
*/
|
|
93
|
-
get version():
|
|
94
|
-
return this.
|
|
232
|
+
get version(): DataVersionKey {
|
|
233
|
+
return this.versionChain[this.versionChain.length - 1];
|
|
95
234
|
}
|
|
96
235
|
|
|
97
236
|
/** Number of migration steps */
|
|
@@ -101,50 +240,56 @@ export class DataModel<State> {
|
|
|
101
240
|
|
|
102
241
|
/** Get initial data */
|
|
103
242
|
initialData(): State {
|
|
104
|
-
return this.
|
|
243
|
+
return this.initialDataFn();
|
|
105
244
|
}
|
|
106
245
|
|
|
107
246
|
/** Get default data wrapped with current version */
|
|
108
|
-
getDefaultData():
|
|
109
|
-
return
|
|
247
|
+
getDefaultData(): DataVersioned<State> {
|
|
248
|
+
return makeDataVersioned(this.version, this.initialDataFn());
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private recoverFrom(data: unknown, version: DataVersionKey): DataMigrationResult<State> {
|
|
252
|
+
try {
|
|
253
|
+
return { version: this.version, data: this.recoverFn(version, data) };
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (isDataUnrecoverableError(error)) {
|
|
256
|
+
return { ...this.getDefaultData(), warning: error.message };
|
|
257
|
+
}
|
|
258
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
259
|
+
return {
|
|
260
|
+
...this.getDefaultData(),
|
|
261
|
+
warning: `Recover failed for version '${version}': ${errorMessage}`,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
110
264
|
}
|
|
111
265
|
|
|
112
266
|
/**
|
|
113
|
-
*
|
|
267
|
+
* Migrate versioned data from any version to the latest.
|
|
114
268
|
* Applies only the migrations needed (skips already-applied ones).
|
|
115
269
|
* If a migration fails, returns default data with a warning.
|
|
116
270
|
*/
|
|
117
|
-
|
|
271
|
+
migrate(versioned: DataVersioned<unknown>): DataMigrationResult<State> {
|
|
118
272
|
const { version: fromVersion, data } = versioned;
|
|
119
273
|
|
|
120
|
-
if (fromVersion > this.version) {
|
|
121
|
-
throw new Error(
|
|
122
|
-
`Cannot downgrade from version ${fromVersion} to ${this.version}`,
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
274
|
if (fromVersion === this.version) {
|
|
127
275
|
return { version: this.version, data: data as State };
|
|
128
276
|
}
|
|
129
277
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const migrationsToApply = this.steps.slice(startIndex);
|
|
278
|
+
const startIndex = this.versionChain.indexOf(fromVersion);
|
|
279
|
+
if (startIndex < 0) {
|
|
280
|
+
return this.recoverFrom(data, fromVersion);
|
|
281
|
+
}
|
|
135
282
|
|
|
136
283
|
let currentData: unknown = data;
|
|
137
|
-
for (let i =
|
|
138
|
-
const
|
|
139
|
-
const fromVer = stepIndex + 1;
|
|
140
|
-
const toVer = stepIndex + 2;
|
|
284
|
+
for (let i = startIndex; i < this.steps.length; i++) {
|
|
285
|
+
const step = this.steps[i];
|
|
141
286
|
try {
|
|
142
|
-
currentData =
|
|
287
|
+
currentData = step.migrate(currentData);
|
|
143
288
|
} catch (error) {
|
|
144
289
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
145
290
|
return {
|
|
146
291
|
...this.getDefaultData(),
|
|
147
|
-
warning: `Migration
|
|
292
|
+
warning: `Migration ${step.fromVersion}→${step.toVersion} failed: ${errorMessage}`,
|
|
148
293
|
};
|
|
149
294
|
}
|
|
150
295
|
}
|
|
@@ -158,12 +303,12 @@ export class DataModel<State> {
|
|
|
158
303
|
*
|
|
159
304
|
* All callbacks are prefixed with `__pl_` to indicate internal SDK use:
|
|
160
305
|
* - `__pl_data_initial`: returns initial data for new blocks
|
|
161
|
-
* - `
|
|
306
|
+
* - `__pl_data_migrate`: migrates versioned data from any version to latest
|
|
162
307
|
* - `__pl_storage_initial`: returns initial BlockStorage as JSON string
|
|
163
308
|
*/
|
|
164
309
|
registerCallbacks(): void {
|
|
165
|
-
tryRegisterCallback('__pl_data_initial', () => this.
|
|
166
|
-
tryRegisterCallback('
|
|
310
|
+
tryRegisterCallback('__pl_data_initial', () => this.initialDataFn());
|
|
311
|
+
tryRegisterCallback('__pl_data_migrate', (versioned: DataVersioned<unknown>) => this.migrate(versioned));
|
|
167
312
|
tryRegisterCallback('__pl_storage_initial', () => {
|
|
168
313
|
const { version, data } = this.getDefaultData();
|
|
169
314
|
const storage = createBlockStorage(data, version);
|