@weasel-js/labkit 1.0.3 → 1.1.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/README.md +6 -6
- package/dist/_dts/{DrawCommand-uKHt4Vul.d.ts → DrawCommand-BkZztJsW.d.ts} +3 -1
- package/dist/_dts/{index-CFlDPeZh.d.ts → index-iAP6XbH3.d.ts} +17 -7
- package/dist/_dts/{useExperimentState-vrttakTt.d.ts → useTrialState-DYe2vUwN.d.ts} +62 -34
- package/dist/canvas/index.d.ts +1 -1
- package/dist/chunk-3TYUJR7Z.js +488 -0
- package/dist/chunk-3TYUJR7Z.js.map +1 -0
- package/dist/{chunk-N5KTQKQA.js → chunk-C6GJKPUI.js} +835 -770
- package/dist/chunk-C6GJKPUI.js.map +1 -0
- package/dist/{chunk-3WPOGKUP.js → chunk-DJLDIRFN.js} +543 -226
- package/dist/chunk-DJLDIRFN.js.map +1 -0
- package/dist/{chunk-73PXCRCR.js → chunk-NRKWVTVT.js} +6 -6
- package/dist/chunk-NRKWVTVT.js.map +1 -0
- package/dist/dragdrop/index.d.ts +1 -1
- package/dist/index.d.ts +197 -148
- package/dist/index.js +275 -161
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +2 -2
- package/dist/passthrough/weasel-canvas.d.ts +1 -1
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +10 -3
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/state/index.d.ts +14 -14
- package/dist/state/index.js +5 -5
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +179 -14
- package/dist/ui/layers/index.js +3 -3
- package/dist/undo/index.d.ts +1 -1
- package/package.json +2 -1
- package/src/canvas/AGENTS.md +6 -2
- package/src/dragdrop/dragDrop.test.tsx +4 -7
- package/src/index.test.ts +52 -0
- package/src/index.ts +11 -7
- package/src/instrument/SineWave.smoke.test.tsx +1 -1
- package/src/instrument/capabilityDetector.ts +1 -1
- package/src/instrument/types.ts +18 -6
- package/src/lab/Lab.less +16 -0
- package/src/lab/Lab.stories.tsx +5 -5
- package/src/lab/Lab.test.tsx +25 -25
- package/src/lab/Lab.tsx +56 -44
- package/src/lab/LabContext.ts +12 -11
- package/src/lab/LabShell.tsx +1 -1
- package/src/lab/Workspace.less +40 -0
- package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +27 -11
- package/src/lab/Workspace.test.tsx +114 -0
- package/src/lab/Workspace.tsx +196 -0
- package/src/lab/index.ts +2 -4
- package/src/layers/AGENTS.md +3 -3
- package/src/primitives/Toolbar.stories.tsx +2 -2
- package/src/state/SingletonExperiment.test.tsx +8 -8
- package/src/state/SingletonExperiment.tsx +9 -9
- package/src/state/context.tsx +12 -12
- package/src/state/document.test.ts +369 -0
- package/src/state/document.ts +194 -0
- package/src/state/helpers.test.ts +74 -46
- package/src/state/helpers.ts +23 -31
- package/src/state/index.ts +16 -8
- package/src/state/store.test.ts +440 -40
- package/src/state/store.ts +166 -90
- package/src/state/types.ts +30 -10
- package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
- package/src/state/useTrialState.ts +29 -0
- package/src/styles.less +2 -2
- package/src/test-setup.ts +19 -0
- package/src/theme/base.less +1 -1
- package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
- package/src/{workspace → trial}/DefaultStatusBar.tsx +5 -4
- package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
- package/src/trial/Trial.canvas.test.tsx +80 -0
- package/src/{workspace/Workspace.less → trial/Trial.less} +1 -1
- package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +13 -17
- package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -25
- package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +48 -43
- package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +42 -42
- package/src/{workspace → trial}/index.ts +13 -12
- package/src/{workspace → trial}/slotTypes.ts +16 -16
- package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +60 -33
- package/src/trial/trialOps.ts +99 -0
- package/src/ui/format.test.ts +33 -0
- package/src/ui/format.ts +11 -0
- package/src/ui/properties/CurveField.tsx +6 -6
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +3 -3
- package/dist/chunk-3WPOGKUP.js.map +0 -1
- package/dist/chunk-73PXCRCR.js.map +0 -1
- package/dist/chunk-N5KTQKQA.js.map +0 -1
- package/dist/chunk-T7OKNJTY.js +0 -351
- package/dist/chunk-T7OKNJTY.js.map +0 -1
- package/src/lab/WorkspaceGrid.less +0 -8
- package/src/lab/WorkspaceGrid.test.tsx +0 -40
- package/src/lab/WorkspaceGrid.tsx +0 -22
- package/src/lab/gridDims.test.ts +0 -35
- package/src/lab/gridDims.ts +0 -13
- package/src/state/useExperimentState.ts +0 -31
- package/src/workspace/workspaceOps.ts +0 -86
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createMemoryAdapter } from './adapters';
|
|
3
|
+
import {
|
|
4
|
+
CURRENT_DOCUMENT_VERSION,
|
|
5
|
+
deleteLegacyKeys,
|
|
6
|
+
emptyDocument,
|
|
7
|
+
labDocumentKey,
|
|
8
|
+
MIGRATIONS,
|
|
9
|
+
migrateV0toV1,
|
|
10
|
+
migrateV1toV2,
|
|
11
|
+
normalizeDocument,
|
|
12
|
+
quarantineDocument,
|
|
13
|
+
quarantineKey,
|
|
14
|
+
readLegacyDocument,
|
|
15
|
+
runMigrations,
|
|
16
|
+
} from './document';
|
|
17
|
+
import { labStorageKey } from './helpers';
|
|
18
|
+
|
|
19
|
+
describe('document keys', () => {
|
|
20
|
+
it('namespaces the document key by storageKey', () => {
|
|
21
|
+
expect(labDocumentKey('mylab')).toBe('lk:mylab:doc');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('namespaces the quarantine key by storageKey', () => {
|
|
25
|
+
expect(quarantineKey('mylab')).toBe('lk:mylab:quarantine');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('cannot collide with a legacy bucket key of another lab', () => {
|
|
29
|
+
for (const bucket of ['workspaces', 'saves', 'layout', 'theme'] as const) {
|
|
30
|
+
expect(labDocumentKey(`a:${bucket}`)).not.toBe(labStorageKey('a', bucket));
|
|
31
|
+
expect(labDocumentKey('a')).not.toBe(labStorageKey('a', bucket));
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('cannot collide with a quarantine key of another lab', () => {
|
|
36
|
+
expect(labDocumentKey('a:quarantine')).not.toBe(quarantineKey('a'));
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('emptyDocument', () => {
|
|
41
|
+
it('is stamped at the current version with the given mode', () => {
|
|
42
|
+
const doc = emptyDocument('light');
|
|
43
|
+
expect(doc.version).toBe(CURRENT_DOCUMENT_VERSION);
|
|
44
|
+
expect(doc.mode).toBe('light');
|
|
45
|
+
expect(doc.trials).toEqual([]);
|
|
46
|
+
expect(doc.saves).toEqual([]);
|
|
47
|
+
expect(doc.layout).toEqual({});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const bump = (to: number) => (doc: Record<string, unknown>) => ({ ...doc, version: to });
|
|
52
|
+
|
|
53
|
+
describe('runMigrations', () => {
|
|
54
|
+
it('returns a current-version document untouched', () => {
|
|
55
|
+
const doc = { version: 2, a: 1 };
|
|
56
|
+
const out = runMigrations(doc, [bump(1), bump(2)], 2);
|
|
57
|
+
expect(out).toEqual({ ok: true, doc: { version: 2, a: 1 }, migrated: false });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('runs every migration from the stored version to the current one', () => {
|
|
61
|
+
const out = runMigrations({ version: 0 }, [bump(1), bump(2)], 2);
|
|
62
|
+
expect(out).toEqual({ ok: true, doc: { version: 2 }, migrated: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('refuses a document from a future version', () => {
|
|
66
|
+
const out = runMigrations({ version: 9 }, [bump(1)], 1);
|
|
67
|
+
expect(out).toEqual({ ok: false, reason: 'future' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('reports a throwing migration as failed', () => {
|
|
71
|
+
const boom = () => {
|
|
72
|
+
throw new Error('nope');
|
|
73
|
+
};
|
|
74
|
+
const out = runMigrations({ version: 0 }, [boom], 1);
|
|
75
|
+
expect(out.ok).toBe(false);
|
|
76
|
+
if (!out.ok) expect(out.reason).toBe('failed');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('treats a document with no version as version 0', () => {
|
|
80
|
+
const out = runMigrations({}, [bump(1)], 1);
|
|
81
|
+
expect(out).toEqual({ ok: true, doc: { version: 1 }, migrated: true });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('readLegacyDocument', () => {
|
|
86
|
+
it('returns null when no legacy key is present', () => {
|
|
87
|
+
expect(readLegacyDocument(createMemoryAdapter(), 'lab', 'auto')).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('assembles a version-0 document from the four buckets', () => {
|
|
91
|
+
const storage = createMemoryAdapter();
|
|
92
|
+
storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
|
|
93
|
+
storage.write(labStorageKey('lab', 'saves'), JSON.stringify([{ id: 's1' }]));
|
|
94
|
+
storage.write(labStorageKey('lab', 'layout'), JSON.stringify({ w1: { h: 4 } }));
|
|
95
|
+
storage.write(labStorageKey('lab', 'theme'), 'dark');
|
|
96
|
+
|
|
97
|
+
expect(readLegacyDocument(storage, 'lab', 'auto')).toEqual({
|
|
98
|
+
version: 0,
|
|
99
|
+
workspaces: [{ id: 'w1' }],
|
|
100
|
+
saves: [{ id: 's1' }],
|
|
101
|
+
layout: { w1: { h: 4 } },
|
|
102
|
+
mode: 'dark',
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('survives one unparseable bucket without losing the others', () => {
|
|
107
|
+
const storage = createMemoryAdapter();
|
|
108
|
+
storage.write(labStorageKey('lab', 'workspaces'), '{{{not json');
|
|
109
|
+
storage.write(labStorageKey('lab', 'saves'), JSON.stringify([{ id: 's1' }]));
|
|
110
|
+
|
|
111
|
+
const doc = readLegacyDocument(storage, 'lab', 'auto');
|
|
112
|
+
expect(doc?.workspaces).toEqual([]);
|
|
113
|
+
expect(doc?.saves).toEqual([{ id: 's1' }]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('falls back to fallbackMode when the theme key is absent', () => {
|
|
117
|
+
const storage = createMemoryAdapter();
|
|
118
|
+
storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([]));
|
|
119
|
+
|
|
120
|
+
expect(readLegacyDocument(storage, 'lab', 'dark')?.mode).toBe('dark');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('falls back to fallbackMode when the theme value is unrecognized', () => {
|
|
124
|
+
const storage = createMemoryAdapter();
|
|
125
|
+
storage.write(labStorageKey('lab', 'theme'), 'chartreuse');
|
|
126
|
+
|
|
127
|
+
expect(readLegacyDocument(storage, 'lab', 'dark')?.mode).toBe('dark');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('a valid theme value beats the fallback', () => {
|
|
131
|
+
const storage = createMemoryAdapter();
|
|
132
|
+
storage.write(labStorageKey('lab', 'theme'), 'light');
|
|
133
|
+
|
|
134
|
+
expect(readLegacyDocument(storage, 'lab', 'dark')?.mode).toBe('light');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('passes interstellar through untouched for migrateV0toV1 to coerce', () => {
|
|
138
|
+
const storage = createMemoryAdapter();
|
|
139
|
+
storage.write(labStorageKey('lab', 'theme'), 'interstellar');
|
|
140
|
+
|
|
141
|
+
expect(readLegacyDocument(storage, 'lab', 'dark')?.mode).toBe('interstellar');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('deleteLegacyKeys', () => {
|
|
146
|
+
it('deletes all four legacy buckets when the document matches', () => {
|
|
147
|
+
const storage = createMemoryAdapter();
|
|
148
|
+
storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
|
|
149
|
+
storage.write(labStorageKey('lab', 'saves'), JSON.stringify([{ id: 's1' }]));
|
|
150
|
+
storage.write(labStorageKey('lab', 'layout'), JSON.stringify({ w1: { h: 4 } }));
|
|
151
|
+
storage.write(labStorageKey('lab', 'theme'), 'dark');
|
|
152
|
+
storage.write(labDocumentKey('lab'), 'the-document');
|
|
153
|
+
|
|
154
|
+
expect(deleteLegacyKeys(storage, 'lab', 'the-document')).toBe(true);
|
|
155
|
+
|
|
156
|
+
expect(storage.read(labStorageKey('lab', 'workspaces'))).toBeNull();
|
|
157
|
+
expect(storage.read(labStorageKey('lab', 'saves'))).toBeNull();
|
|
158
|
+
expect(storage.read(labStorageKey('lab', 'layout'))).toBeNull();
|
|
159
|
+
expect(storage.read(labStorageKey('lab', 'theme'))).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('the document key itself survives deletion', () => {
|
|
163
|
+
const storage = createMemoryAdapter();
|
|
164
|
+
storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
|
|
165
|
+
storage.write(labDocumentKey('lab'), 'the-document');
|
|
166
|
+
|
|
167
|
+
deleteLegacyKeys(storage, 'lab', 'the-document');
|
|
168
|
+
|
|
169
|
+
expect(storage.read(labDocumentKey('lab'))).toBe('the-document');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('deletes nothing and returns false when the document key is absent', () => {
|
|
173
|
+
const storage = createMemoryAdapter();
|
|
174
|
+
storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
|
|
175
|
+
|
|
176
|
+
expect(deleteLegacyKeys(storage, 'lab', 'the-document')).toBe(false);
|
|
177
|
+
expect(storage.read(labStorageKey('lab', 'workspaces'))).not.toBeNull();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('returns false when the adapter cannot delete, and the keys survive', () => {
|
|
181
|
+
const mem = createMemoryAdapter();
|
|
182
|
+
const storage = { read: mem.read, write: mem.write };
|
|
183
|
+
storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
|
|
184
|
+
storage.write(labDocumentKey('lab'), 'the-document');
|
|
185
|
+
|
|
186
|
+
expect(deleteLegacyKeys(storage, 'lab', 'the-document')).toBe(false);
|
|
187
|
+
expect(storage.read(labStorageKey('lab', 'workspaces'))).not.toBeNull();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('deletes nothing and returns false when the document key holds different content', () => {
|
|
191
|
+
const storage = createMemoryAdapter();
|
|
192
|
+
storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
|
|
193
|
+
storage.write(labDocumentKey('lab'), 'something-else');
|
|
194
|
+
|
|
195
|
+
expect(deleteLegacyKeys(storage, 'lab', 'the-document')).toBe(false);
|
|
196
|
+
expect(storage.read(labStorageKey('lab', 'workspaces'))).not.toBeNull();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('migrateV0toV1', () => {
|
|
201
|
+
it('stamps version 1', () => {
|
|
202
|
+
expect(migrateV0toV1({ version: 0 }).version).toBe(1);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('renames the old interstellar mode to dark', () => {
|
|
206
|
+
expect(migrateV0toV1({ version: 0, mode: 'interstellar' }).mode).toBe('dark');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('replaces an unrecognized mode with auto', () => {
|
|
210
|
+
expect(migrateV0toV1({ version: 0, mode: 'chartreuse' }).mode).toBe('auto');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it.each(['light', 'dark', 'auto'] as const)('passes mode %s through unchanged', (mode) => {
|
|
214
|
+
expect(migrateV0toV1({ version: 0, mode }).mode).toBe(mode);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('fills in every missing section', () => {
|
|
218
|
+
expect(migrateV0toV1({ version: 0 })).toEqual({
|
|
219
|
+
version: 1,
|
|
220
|
+
workspaces: [],
|
|
221
|
+
saves: [],
|
|
222
|
+
layout: {},
|
|
223
|
+
mode: 'auto',
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('preserves a populated workspaces array', () => {
|
|
228
|
+
const workspaces = [{ id: 'w1', instrumentName: 'osc' }];
|
|
229
|
+
expect(migrateV0toV1({ version: 0, workspaces }).workspaces).toEqual(workspaces);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('preserves a populated saves array', () => {
|
|
233
|
+
const saves = [{ id: 's1', name: 'my save' }];
|
|
234
|
+
expect(migrateV0toV1({ version: 0, saves }).saves).toEqual(saves);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('preserves a populated layout object', () => {
|
|
238
|
+
const layout = { w1: { h: 4 } };
|
|
239
|
+
expect(migrateV0toV1({ version: 0, layout }).layout).toEqual(layout);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('falls back layout to {} and workspaces/saves to [] without swapping them', () => {
|
|
243
|
+
const out = migrateV0toV1({ version: 0, workspaces: 'nope', saves: 'nope', layout: 'nope' });
|
|
244
|
+
expect(out.workspaces).toEqual([]);
|
|
245
|
+
expect(out.saves).toEqual([]);
|
|
246
|
+
expect(out.layout).toEqual({});
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('migrateV1toV2', () => {
|
|
251
|
+
it('stamps version 2', () => {
|
|
252
|
+
expect(migrateV1toV2({ version: 1, workspaces: [] }).version).toBe(2);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('renames workspaces to trials, record for record', () => {
|
|
256
|
+
const records = [{ id: 'w1', instrumentName: 'osc', state: { n: 1 } }];
|
|
257
|
+
const out = migrateV1toV2({ version: 1, workspaces: records });
|
|
258
|
+
expect(out.trials).toEqual(records);
|
|
259
|
+
expect(out).not.toHaveProperty('workspaces');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('leaves the other sections alone', () => {
|
|
263
|
+
const out = migrateV1toV2({
|
|
264
|
+
version: 1,
|
|
265
|
+
workspaces: [],
|
|
266
|
+
saves: [{ id: 's1' }],
|
|
267
|
+
layout: { w1: { h: 4 } },
|
|
268
|
+
mode: 'dark',
|
|
269
|
+
});
|
|
270
|
+
expect(out.saves).toEqual([{ id: 's1' }]);
|
|
271
|
+
expect(out.layout).toEqual({ w1: { h: 4 } });
|
|
272
|
+
expect(out.mode).toBe('dark');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('falls back to an empty trials list when workspaces is the wrong shape', () => {
|
|
276
|
+
expect(migrateV1toV2({ version: 1, workspaces: 'nope' }).trials).toEqual([]);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe('MIGRATIONS', () => {
|
|
281
|
+
it('has one entry per version below the current one', () => {
|
|
282
|
+
expect(MIGRATIONS).toHaveLength(CURRENT_DOCUMENT_VERSION);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('indexes migrateV0toV1 at position 0', () => {
|
|
286
|
+
expect(MIGRATIONS[0]).toBe(migrateV0toV1);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('indexes migrateV1toV2 at position 1', () => {
|
|
290
|
+
expect(MIGRATIONS[1]).toBe(migrateV1toV2);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('walks a version-0 document all the way to trials', () => {
|
|
294
|
+
const outcome = runMigrations(
|
|
295
|
+
{ version: 0, workspaces: [{ id: 'w1' }] },
|
|
296
|
+
MIGRATIONS,
|
|
297
|
+
CURRENT_DOCUMENT_VERSION,
|
|
298
|
+
);
|
|
299
|
+
expect(outcome).toMatchObject({ ok: true, migrated: true });
|
|
300
|
+
expect(outcome.ok && outcome.doc.trials).toEqual([{ id: 'w1' }]);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe('quarantineDocument', () => {
|
|
305
|
+
it('copies the document aside and confirms it landed', () => {
|
|
306
|
+
const storage = createMemoryAdapter();
|
|
307
|
+
|
|
308
|
+
expect(quarantineDocument(storage, 'lab', 'the-bytes')).toBe(true);
|
|
309
|
+
expect(storage.read(quarantineKey('lab'))).toBe('the-bytes');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('returns false when the quarantine write silently fails', () => {
|
|
313
|
+
const storage = createMemoryAdapter();
|
|
314
|
+
storage.write = () => {};
|
|
315
|
+
|
|
316
|
+
expect(quarantineDocument(storage, 'lab', 'the-bytes')).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe('normalizeDocument', () => {
|
|
321
|
+
it('fills in every missing section', () => {
|
|
322
|
+
expect(normalizeDocument({ version: CURRENT_DOCUMENT_VERSION }, 'auto')).toEqual({
|
|
323
|
+
version: CURRENT_DOCUMENT_VERSION,
|
|
324
|
+
trials: [],
|
|
325
|
+
saves: [],
|
|
326
|
+
layout: {},
|
|
327
|
+
mode: 'auto',
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('uses the fallback mode when the document has none', () => {
|
|
332
|
+
expect(normalizeDocument({ version: CURRENT_DOCUMENT_VERSION }, 'light').mode).toBe('light');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('uses the fallback mode when the stored mode is unrecognized', () => {
|
|
336
|
+
expect(
|
|
337
|
+
normalizeDocument({ version: CURRENT_DOCUMENT_VERSION, mode: 'chartreuse' }, 'light').mode,
|
|
338
|
+
).toBe('light');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('keeps a stored mode over the fallback', () => {
|
|
342
|
+
expect(
|
|
343
|
+
normalizeDocument({ version: CURRENT_DOCUMENT_VERSION, mode: 'dark' }, 'light').mode,
|
|
344
|
+
).toBe('dark');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('preserves populated sections', () => {
|
|
348
|
+
const trials = [{ id: 'w1' }];
|
|
349
|
+
const saves = [{ id: 's1' }];
|
|
350
|
+
const layout = { w1: { h: 4 } };
|
|
351
|
+
const out = normalizeDocument(
|
|
352
|
+
{ version: CURRENT_DOCUMENT_VERSION, trials, saves, layout },
|
|
353
|
+
'auto',
|
|
354
|
+
);
|
|
355
|
+
expect(out.trials).toEqual(trials);
|
|
356
|
+
expect(out.saves).toEqual(saves);
|
|
357
|
+
expect(out.layout).toEqual(layout);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('replaces wrong-shaped sections without swapping them', () => {
|
|
361
|
+
const out = normalizeDocument(
|
|
362
|
+
{ version: CURRENT_DOCUMENT_VERSION, trials: 'nope', saves: 'nope', layout: 'nope' },
|
|
363
|
+
'auto',
|
|
364
|
+
);
|
|
365
|
+
expect(out.trials).toEqual([]);
|
|
366
|
+
expect(out.saves).toEqual([]);
|
|
367
|
+
expect(out.layout).toEqual({});
|
|
368
|
+
});
|
|
369
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { labStorageKey } from './helpers';
|
|
2
|
+
import type {
|
|
3
|
+
LabDocument,
|
|
4
|
+
LabMode,
|
|
5
|
+
Migration,
|
|
6
|
+
SavedSnapshot,
|
|
7
|
+
SerializedTrial,
|
|
8
|
+
StorageAdapter,
|
|
9
|
+
} from './types';
|
|
10
|
+
|
|
11
|
+
/** Bumped whenever the persisted shape changes; every bump needs a migration. */
|
|
12
|
+
export const CURRENT_DOCUMENT_VERSION = 2;
|
|
13
|
+
|
|
14
|
+
/** The one key a lab persists under. The `:doc` suffix keeps it out of the
|
|
15
|
+
* legacy bucket namespace, where a lab named `a:saves` would otherwise write
|
|
16
|
+
* its document over lab `a`'s saves bucket. */
|
|
17
|
+
export function labDocumentKey(storageKey: string): string {
|
|
18
|
+
return `lk:${storageKey}:doc`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Where a document that failed to migrate is set aside, so a bad migration
|
|
22
|
+
* loses state loudly rather than silently. */
|
|
23
|
+
export function quarantineKey(storageKey: string): string {
|
|
24
|
+
return `lk:${storageKey}:quarantine`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A fresh document at the current version. */
|
|
28
|
+
export function emptyDocument(mode: LabMode): LabDocument {
|
|
29
|
+
return {
|
|
30
|
+
version: CURRENT_DOCUMENT_VERSION,
|
|
31
|
+
trials: [],
|
|
32
|
+
saves: [],
|
|
33
|
+
layout: {},
|
|
34
|
+
mode,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What a migration run produced: the migrated document, or why it refused. */
|
|
39
|
+
export type MigrationOutcome =
|
|
40
|
+
| { ok: true; doc: Record<string, unknown>; migrated: boolean }
|
|
41
|
+
| { ok: false; reason: 'future' | 'failed'; error?: unknown };
|
|
42
|
+
|
|
43
|
+
/** Walk a document forward to `target`, one migration at a time. A version
|
|
44
|
+
* above `target` is refused rather than parsed under a shape it may not
|
|
45
|
+
* have. */
|
|
46
|
+
export function runMigrations(
|
|
47
|
+
raw: Record<string, unknown>,
|
|
48
|
+
migrations: Migration[],
|
|
49
|
+
target: number,
|
|
50
|
+
): MigrationOutcome {
|
|
51
|
+
const from = typeof raw.version === 'number' ? raw.version : 0;
|
|
52
|
+
if (from > target) return { ok: false, reason: 'future' };
|
|
53
|
+
if (from === target) return { ok: true, doc: raw, migrated: false };
|
|
54
|
+
|
|
55
|
+
let doc = raw;
|
|
56
|
+
try {
|
|
57
|
+
for (let v = from; v < target; v++) {
|
|
58
|
+
const migration = migrations[v];
|
|
59
|
+
if (!migration) throw new Error(`[labkit] no migration from version ${v}`);
|
|
60
|
+
doc = migration(doc);
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return { ok: false, reason: 'failed', error };
|
|
64
|
+
}
|
|
65
|
+
return { ok: true, doc, migrated: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const LEGACY_BUCKETS = ['workspaces', 'saves', 'layout', 'theme'] as const;
|
|
69
|
+
|
|
70
|
+
function parseOr<T>(raw: string | null, fallback: T, bucket: string): T {
|
|
71
|
+
if (raw === null) return fallback;
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(raw) as T;
|
|
74
|
+
} catch {
|
|
75
|
+
console.warn(`[labkit] unparseable legacy bucket "${bucket}", dropping it`);
|
|
76
|
+
return fallback;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Assemble a version-0 document out of the four pre-document keys, or null
|
|
81
|
+
* if none of them is present. `fallbackMode` is used when the theme key is
|
|
82
|
+
* absent or holds something unrecognized; `interstellar` passes through
|
|
83
|
+
* untouched for `migrateV0toV1` to coerce. */
|
|
84
|
+
export function readLegacyDocument(
|
|
85
|
+
storage: StorageAdapter,
|
|
86
|
+
storageKey: string,
|
|
87
|
+
fallbackMode: LabMode,
|
|
88
|
+
): Record<string, unknown> | null {
|
|
89
|
+
const present = LEGACY_BUCKETS.some((b) => storage.read(labStorageKey(storageKey, b)) !== null);
|
|
90
|
+
if (!present) return null;
|
|
91
|
+
|
|
92
|
+
const rawMode = storage.read(labStorageKey(storageKey, 'theme'));
|
|
93
|
+
const mode =
|
|
94
|
+
rawMode === 'light' || rawMode === 'dark' || rawMode === 'auto' || rawMode === 'interstellar'
|
|
95
|
+
? rawMode
|
|
96
|
+
: fallbackMode;
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
version: 0,
|
|
100
|
+
workspaces: parseOr(storage.read(labStorageKey(storageKey, 'workspaces')), [], 'workspaces'),
|
|
101
|
+
saves: parseOr(storage.read(labStorageKey(storageKey, 'saves')), [], 'saves'),
|
|
102
|
+
layout: parseOr(storage.read(labStorageKey(storageKey, 'layout')), {}, 'layout'),
|
|
103
|
+
mode,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Delete the four pre-document keys, but only once `expectedDocument` is
|
|
108
|
+
* confirmed to be exactly what is stored under the document key — a
|
|
109
|
+
* read-back, not a trust of the write that produced it. On any mismatch
|
|
110
|
+
* (write failed, landed partially, or never happened) it deletes nothing,
|
|
111
|
+
* warns, and returns `false` so the legacy buckets stay as a recoverable
|
|
112
|
+
* copy. Deletion itself is read back too — `StorageAdapter.delete` is
|
|
113
|
+
* optional, so an adapter without it leaves the keys in place — and `true`
|
|
114
|
+
* is returned only once all four are actually gone. */
|
|
115
|
+
export function deleteLegacyKeys(
|
|
116
|
+
storage: StorageAdapter,
|
|
117
|
+
storageKey: string,
|
|
118
|
+
expectedDocument: string,
|
|
119
|
+
): boolean {
|
|
120
|
+
const actual = storage.read(labDocumentKey(storageKey));
|
|
121
|
+
if (actual !== expectedDocument) {
|
|
122
|
+
console.warn(
|
|
123
|
+
`[labkit] keeping legacy keys for "${storageKey}": could not confirm the migrated document was written`,
|
|
124
|
+
);
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
let allGone = true;
|
|
128
|
+
for (const bucket of LEGACY_BUCKETS) {
|
|
129
|
+
const key = labStorageKey(storageKey, bucket);
|
|
130
|
+
storage.delete?.(key);
|
|
131
|
+
if (storage.read(key) !== null) allGone = false;
|
|
132
|
+
}
|
|
133
|
+
return allGone;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Set `raw` aside under the quarantine key, reading it back to confirm it
|
|
137
|
+
* landed. Returns `false` when it did not — a full disk is exactly when a
|
|
138
|
+
* document goes unreadable — so the caller can leave the original alone
|
|
139
|
+
* rather than overwrite the only copy of it. */
|
|
140
|
+
export function quarantineDocument(
|
|
141
|
+
storage: StorageAdapter,
|
|
142
|
+
storageKey: string,
|
|
143
|
+
raw: string,
|
|
144
|
+
): boolean {
|
|
145
|
+
storage.write(quarantineKey(storageKey), raw);
|
|
146
|
+
return storage.read(quarantineKey(storageKey)) === raw;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Fill in whatever a document is missing or holds the wrong shape of, so a
|
|
150
|
+
* hydrated store never sees `undefined` where a section should be. Applied to
|
|
151
|
+
* every document, migrated or already current. */
|
|
152
|
+
export function normalizeDocument(
|
|
153
|
+
doc: Record<string, unknown>,
|
|
154
|
+
fallbackMode: LabMode,
|
|
155
|
+
): LabDocument {
|
|
156
|
+
const mode =
|
|
157
|
+
doc.mode === 'light' || doc.mode === 'dark' || doc.mode === 'auto' ? doc.mode : fallbackMode;
|
|
158
|
+
return {
|
|
159
|
+
version: CURRENT_DOCUMENT_VERSION,
|
|
160
|
+
trials: Array.isArray(doc.trials) ? (doc.trials as SerializedTrial[]) : [],
|
|
161
|
+
saves: Array.isArray(doc.saves) ? (doc.saves as SavedSnapshot[]) : [],
|
|
162
|
+
layout:
|
|
163
|
+
doc.layout && typeof doc.layout === 'object' ? (doc.layout as Record<string, unknown>) : {},
|
|
164
|
+
mode,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Version 0 (four loose keys) to version 1 (one document). Normalizes the
|
|
169
|
+
* mode, including `interstellar` — the dark mode's name back when it was a
|
|
170
|
+
* theme. Coerces against version 1's own field names rather than reusing
|
|
171
|
+
* `normalizeDocument`, which produces the current shape and would drop
|
|
172
|
+
* `workspaces` from a document it labels version 1. */
|
|
173
|
+
export function migrateV0toV1(doc: Record<string, unknown>): Record<string, unknown> {
|
|
174
|
+
const raw = doc.mode === 'interstellar' ? 'dark' : doc.mode;
|
|
175
|
+
const mode = raw === 'light' || raw === 'dark' || raw === 'auto' ? raw : 'auto';
|
|
176
|
+
return {
|
|
177
|
+
version: 1,
|
|
178
|
+
workspaces: Array.isArray(doc.workspaces) ? doc.workspaces : [],
|
|
179
|
+
saves: Array.isArray(doc.saves) ? doc.saves : [],
|
|
180
|
+
layout: doc.layout && typeof doc.layout === 'object' ? doc.layout : {},
|
|
181
|
+
mode,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Version 1 to version 2: the vocabulary refresh renamed a tile from
|
|
186
|
+
* workspace to trial. Only the records field moves; a record's own fields are
|
|
187
|
+
* unchanged. */
|
|
188
|
+
export function migrateV1toV2(doc: Record<string, unknown>): Record<string, unknown> {
|
|
189
|
+
const { workspaces, ...rest } = doc;
|
|
190
|
+
return { ...rest, trials: Array.isArray(workspaces) ? workspaces : [], version: 2 };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Index `i` migrates a version-`i` document to version `i + 1`. */
|
|
194
|
+
export const MIGRATIONS: Migration[] = [migrateV0toV1, migrateV1toV2];
|