@weasel-js/labkit 1.4.4 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +13 -2
  2. package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
  3. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
  4. package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-8R6UmlvN.d.ts} +253 -31
  5. package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
  6. package/dist/_dts/{index-Blv9uQzu.d.ts → index-retnAut7.d.ts} +18 -10
  7. package/dist/_dts/{types-B9_zrHmb.d.ts → types-Ca2LCnPq.d.ts} +65 -2
  8. package/dist/_dts/usePanZoom-nS798rOF.d.ts +160 -0
  9. package/dist/_dts/useTrialState-jcx_TdU6.d.ts +212 -0
  10. package/dist/canvas/index.d.ts +17 -128
  11. package/dist/canvas/index.js +4 -2
  12. package/dist/chrome/index.d.ts +4 -92
  13. package/dist/chrome/index.js +9 -5
  14. package/dist/chunk-67SJLMC7.js +704 -0
  15. package/dist/chunk-67SJLMC7.js.map +1 -0
  16. package/dist/chunk-6ZDGOZQV.js +310 -0
  17. package/dist/chunk-6ZDGOZQV.js.map +1 -0
  18. package/dist/{chunk-MOM3GOVY.js → chunk-EKIICY6X.js} +111 -25
  19. package/dist/chunk-EKIICY6X.js.map +1 -0
  20. package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
  21. package/dist/chunk-H6ZAOWNE.js.map +1 -0
  22. package/dist/{chunk-SMHP6XZ4.js → chunk-KJALCDWE.js} +9 -7
  23. package/dist/chunk-KJALCDWE.js.map +1 -0
  24. package/dist/{chunk-BKFVHKJH.js → chunk-MUKOW3TC.js} +3 -3
  25. package/dist/{chunk-BKFVHKJH.js.map → chunk-MUKOW3TC.js.map} +1 -1
  26. package/dist/chunk-NDRYLVOW.js +15 -0
  27. package/dist/chunk-NDRYLVOW.js.map +1 -0
  28. package/dist/{chunk-64ZCN3DA.js → chunk-NS64DXMN.js} +226 -159
  29. package/dist/chunk-NS64DXMN.js.map +1 -0
  30. package/dist/{chunk-I6JCVE24.js → chunk-PGETSEDK.js} +135 -13
  31. package/dist/chunk-PGETSEDK.js.map +1 -0
  32. package/dist/{chunk-FQMJUVHJ.js → chunk-QLSV2N3G.js} +32 -37
  33. package/dist/chunk-QLSV2N3G.js.map +1 -0
  34. package/dist/chunk-RWNDDKOH.js +682 -0
  35. package/dist/chunk-RWNDDKOH.js.map +1 -0
  36. package/dist/chunk-S2SHZRD7.js +7681 -0
  37. package/dist/chunk-S2SHZRD7.js.map +1 -0
  38. package/dist/{chunk-U3IYHIAE.js → chunk-TQBBYTAF.js} +19 -82
  39. package/dist/chunk-TQBBYTAF.js.map +1 -0
  40. package/dist/chunk-UU3NJO6N.js +72 -0
  41. package/dist/chunk-UU3NJO6N.js.map +1 -0
  42. package/dist/chunk-WIG6XHJ7.js +35 -0
  43. package/dist/chunk-WIG6XHJ7.js.map +1 -0
  44. package/dist/{chunk-TJ7QY3OC.js → chunk-ZPH5WTWF.js} +21 -15
  45. package/dist/chunk-ZPH5WTWF.js.map +1 -0
  46. package/dist/config/index.d.ts +232 -0
  47. package/dist/config/index.js +6 -0
  48. package/dist/config/index.js.map +1 -0
  49. package/dist/controls/index.d.ts +2 -3
  50. package/dist/controls/index.js +3 -2
  51. package/dist/dragdrop/index.d.ts +2 -4
  52. package/dist/index.d.ts +110 -381
  53. package/dist/index.js +691 -817
  54. package/dist/index.js.map +1 -1
  55. package/dist/layers/index.d.ts +3 -5
  56. package/dist/layers/index.js +2 -2
  57. package/dist/loupe/index.d.ts +6 -8
  58. package/dist/loupe/index.js +2 -2
  59. package/dist/passthrough/weasel-ui.d.ts +164 -122
  60. package/dist/passthrough/weasel-ui.js +1 -1
  61. package/dist/primitives/index.d.ts +53 -6
  62. package/dist/primitives/index.js +6 -4
  63. package/dist/state/index.d.ts +12 -7
  64. package/dist/state/index.js +16 -13
  65. package/dist/state/index.js.map +1 -1
  66. package/dist/styles.css +84 -50
  67. package/dist/surface/index.d.ts +44 -7
  68. package/dist/surface/index.js +2 -1
  69. package/dist/ui/layers/index.js +1 -1
  70. package/dist/undo/index.d.ts +2 -4
  71. package/package.json +15 -8
  72. package/src/annotations/AnnotationOverlay.tsx +53 -5
  73. package/src/annotations/Annotations.overlay.test.tsx +85 -6
  74. package/src/annotations/drawOne.test.ts +1 -1
  75. package/src/annotations/drawOne.ts +2 -2
  76. package/src/annotations/paint.test.ts +2 -2
  77. package/src/annotations/paint.ts +1 -1
  78. package/src/annotations/preload.ts +6 -0
  79. package/src/annotations/store.test.ts +18 -0
  80. package/src/annotations/store.ts +5 -4
  81. package/src/annotations/svgNodes.test.ts +2 -2
  82. package/src/annotations/toolMap.ts +3 -3
  83. package/src/annotations/types.ts +6 -3
  84. package/src/canvas/CameraWheelContext.ts +12 -0
  85. package/src/canvas/CanvasStack.test.tsx +25 -0
  86. package/src/canvas/CanvasStack.tsx +31 -4
  87. package/src/canvas/Stage.less +18 -0
  88. package/src/canvas/Stage.test.tsx +186 -0
  89. package/src/canvas/Stage.tsx +134 -0
  90. package/src/canvas/camera.test.ts +17 -2
  91. package/src/canvas/camera.ts +9 -9
  92. package/src/canvas/index.ts +4 -0
  93. package/src/canvas/usePanZoom.ts +10 -5
  94. package/src/chrome/ChromeRegions.stories.tsx +0 -3
  95. package/src/chrome/LabChrome.tsx +114 -0
  96. package/src/chrome/builtins.tsx +20 -31
  97. package/src/chrome/index.ts +15 -0
  98. package/src/chrome/labRegions.test.tsx +208 -0
  99. package/src/chrome/labTypes.ts +50 -0
  100. package/src/chrome/merge.ts +7 -9
  101. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  102. package/src/chrome/regions/PaletteRegion.tsx +21 -9
  103. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  104. package/src/chrome/regions/StatusRegion.tsx +15 -8
  105. package/src/chrome/regions/ToolbarRegion.tsx +30 -16
  106. package/src/chrome/types.ts +47 -20
  107. package/src/config/builder.test.ts +4 -0
  108. package/src/config/builder.ts +16 -10
  109. package/src/config/declarationEmit.fixture.ts +11 -0
  110. package/src/config/declarationEmit.test.ts +51 -0
  111. package/src/config/entry.test.ts +46 -0
  112. package/src/config/index.ts +13 -1
  113. package/src/config/nodeClasses.test.ts +18 -0
  114. package/src/config/types.ts +3 -1
  115. package/src/controls/ControlPanel.test.tsx +16 -0
  116. package/src/controls/ControlPanel.tsx +5 -2
  117. package/src/fake-indexeddb-auto.d.ts +5 -0
  118. package/src/index.test.ts +7 -0
  119. package/src/index.ts +38 -5
  120. package/src/instrument/serializers.test.ts +22 -0
  121. package/src/instrument/serializers.ts +11 -0
  122. package/src/instrument/types.ts +25 -1
  123. package/src/lab/Lab.less +4 -13
  124. package/src/lab/Lab.persist.test.tsx +147 -0
  125. package/src/lab/Lab.stories.tsx +0 -2
  126. package/src/lab/Lab.surface.test.tsx +66 -3
  127. package/src/lab/Lab.test.tsx +91 -17
  128. package/src/lab/Lab.tsx +337 -124
  129. package/src/lab/LabFit.stories.less +18 -0
  130. package/src/lab/LabFit.stories.tsx +219 -0
  131. package/src/lab/LabFullChrome.stories.tsx +0 -3
  132. package/src/lab/LabHeader.test.tsx +40 -9
  133. package/src/lab/LabHeader.tsx +7 -13
  134. package/src/lab/LabPalette.tsx +13 -26
  135. package/src/lab/LabShell.less +49 -12
  136. package/src/lab/LabSwitcher.less +8 -1
  137. package/src/lab/Workspace.surface.test.tsx +2 -1
  138. package/src/lab/fitCheck.test.ts +57 -0
  139. package/src/lab/fitCheck.ts +125 -0
  140. package/src/loupe/AGENTS.md +1 -1
  141. package/src/loupe/Loupe.less +1 -1
  142. package/src/loupe/canvasLens.ts +1 -1
  143. package/src/loupe/types.ts +1 -1
  144. package/src/loupe/useLoupe.test.tsx +1 -1
  145. package/src/loupe/useLoupe.ts +1 -1
  146. package/src/passthrough/weasel-ui.ts +9 -0
  147. package/src/primitives/FloatingPanel.stories.tsx +13 -1
  148. package/src/primitives/FloatingPanel.test.tsx +47 -16
  149. package/src/primitives/FloatingPanel.tsx +11 -25
  150. package/src/primitives/Split.less +14 -0
  151. package/src/primitives/Split.test.tsx +108 -0
  152. package/src/primitives/Split.tsx +159 -0
  153. package/src/primitives/ZoomControl.tsx +5 -2
  154. package/src/primitives/index.ts +2 -0
  155. package/src/state/Persistence.stories.tsx +75 -0
  156. package/src/state/Persistence.tsx +33 -0
  157. package/src/state/SingletonExperiment.test.tsx +47 -53
  158. package/src/state/SingletonExperiment.tsx +24 -13
  159. package/src/state/adapterContract.ts +116 -0
  160. package/src/state/adapters.test.ts +113 -61
  161. package/src/state/adapters.ts +328 -69
  162. package/src/state/document.test.ts +71 -96
  163. package/src/state/document.ts +66 -52
  164. package/src/state/helpers.test.ts +1 -1
  165. package/src/state/index.ts +8 -0
  166. package/src/state/labRecords.test.ts +105 -0
  167. package/src/state/labRecords.ts +160 -0
  168. package/src/state/openLabStore.test.ts +658 -0
  169. package/src/state/openLabStore.ts +403 -0
  170. package/src/state/records.test.ts +234 -0
  171. package/src/state/records.ts +219 -0
  172. package/src/state/store.test.ts +104 -635
  173. package/src/state/store.ts +116 -157
  174. package/src/state/toolSlot.test.ts +1 -1
  175. package/src/state/types.ts +39 -15
  176. package/src/state/useOpenOnce.ts +68 -0
  177. package/src/state/usePersistedState.test.tsx +125 -0
  178. package/src/state/usePersistedState.ts +66 -0
  179. package/src/state/useTrialState.test.tsx +2 -3
  180. package/src/state/view.test.ts +49 -15
  181. package/src/state/view.ts +18 -1
  182. package/src/styles.less +2 -0
  183. package/src/surface/AGENTS.md +39 -7
  184. package/src/surface/SurfaceContext.ts +26 -3
  185. package/src/surface/index.ts +2 -0
  186. package/src/surface/useSurfaceTile.test.tsx +2 -1
  187. package/src/surface/useSurfaceTile.ts +6 -5
  188. package/src/surface/useTiledSurface.test.tsx +175 -0
  189. package/src/surface/useTiledSurface.ts +54 -5
  190. package/src/theme/Interstellar.stories.tsx +7 -7
  191. package/src/theme/interstellar.test.ts +1 -1
  192. package/src/theme/interstellar.tokens.json +2 -2
  193. package/src/tools/labTool.ts +11 -0
  194. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  195. package/src/trial/Trial.annotations.test.tsx +47 -4
  196. package/src/trial/Trial.job.test.tsx +3 -3
  197. package/src/trial/Trial.less +1 -26
  198. package/src/trial/Trial.stories.tsx +1 -2
  199. package/src/trial/Trial.test.tsx +28 -4
  200. package/src/trial/Trial.trialId.test.tsx +23 -1
  201. package/src/trial/Trial.tsx +118 -38
  202. package/src/trial/TrialBody.tsx +17 -141
  203. package/src/trial/TrialChrome.tsx +7 -4
  204. package/src/trial/trialOps.ts +5 -3
  205. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  206. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  207. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  208. package/dist/chunk-2P6PP5N4.js +0 -553
  209. package/dist/chunk-2P6PP5N4.js.map +0 -1
  210. package/dist/chunk-64ZCN3DA.js.map +0 -1
  211. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  212. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  213. package/dist/chunk-I6JCVE24.js.map +0 -1
  214. package/dist/chunk-MOM3GOVY.js.map +0 -1
  215. package/dist/chunk-SMHP6XZ4.js.map +0 -1
  216. package/dist/chunk-TJ7QY3OC.js.map +0 -1
  217. package/dist/chunk-U3IYHIAE.js.map +0 -1
  218. package/dist/chunk-W3ECWC2K.js +0 -7335
  219. package/dist/chunk-W3ECWC2K.js.map +0 -1
@@ -1,14 +1,15 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import { createMemoryAdapter } from './adapters';
3
3
  import {
4
4
  CURRENT_DOCUMENT_VERSION,
5
- deleteLegacyKeys,
5
+ deleteConfirmed,
6
6
  emptyDocument,
7
7
  labDocumentKey,
8
8
  MIGRATIONS,
9
9
  migrateV0toV1,
10
10
  migrateV1toV2,
11
11
  migrateV2toV3,
12
+ migrateV3toV4,
12
13
  normalizeDocument,
13
14
  quarantineDocument,
14
15
  quarantineKey,
@@ -83,19 +84,24 @@ describe('runMigrations', () => {
83
84
  });
84
85
  });
85
86
 
87
+ function reader(entries: Record<string, unknown>) {
88
+ const map = new Map(Object.entries(entries));
89
+ return async (key: string) => map.get(key);
90
+ }
91
+
86
92
  describe('readLegacyDocument', () => {
87
- it('returns null when no legacy key is present', () => {
88
- expect(readLegacyDocument(createMemoryAdapter(), 'lab', 'auto')).toBeNull();
93
+ it('returns null when no legacy key is present', async () => {
94
+ expect(await readLegacyDocument(reader({}), 'lab', 'auto')).toBeNull();
89
95
  });
90
96
 
91
- it('assembles a version-0 document from the four buckets', () => {
92
- const storage = createMemoryAdapter();
93
- storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
94
- storage.write(labStorageKey('lab', 'saves'), JSON.stringify([{ id: 's1' }]));
95
- storage.write(labStorageKey('lab', 'layout'), JSON.stringify({ w1: { h: 4 } }));
96
- storage.write(labStorageKey('lab', 'theme'), 'dark');
97
-
98
- expect(readLegacyDocument(storage, 'lab', 'auto')).toEqual({
97
+ it('assembles a version-0 document from the four buckets, parsed or not', async () => {
98
+ const read = reader({
99
+ [labStorageKey('lab', 'workspaces')]: JSON.stringify([{ id: 'w1' }]),
100
+ [labStorageKey('lab', 'saves')]: [{ id: 's1' }],
101
+ [labStorageKey('lab', 'layout')]: JSON.stringify({ w1: { h: 4 } }),
102
+ [labStorageKey('lab', 'theme')]: 'dark',
103
+ });
104
+ expect(await readLegacyDocument(read, 'lab', 'auto')).toEqual({
99
105
  version: 0,
100
106
  workspaces: [{ id: 'w1' }],
101
107
  saves: [{ id: 's1' }],
@@ -104,97 +110,54 @@ describe('readLegacyDocument', () => {
104
110
  });
105
111
  });
106
112
 
107
- it('survives one unparseable bucket without losing the others', () => {
108
- const storage = createMemoryAdapter();
109
- storage.write(labStorageKey('lab', 'workspaces'), '{{{not json');
110
- storage.write(labStorageKey('lab', 'saves'), JSON.stringify([{ id: 's1' }]));
111
-
112
- const doc = readLegacyDocument(storage, 'lab', 'auto');
113
+ it('survives one unparseable bucket without losing the others', async () => {
114
+ const read = reader({
115
+ [labStorageKey('lab', 'workspaces')]: '{{{not json',
116
+ [labStorageKey('lab', 'saves')]: JSON.stringify([{ id: 's1' }]),
117
+ });
118
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
119
+ const doc = await readLegacyDocument(read, 'lab', 'auto');
113
120
  expect(doc?.workspaces).toEqual([]);
114
121
  expect(doc?.saves).toEqual([{ id: 's1' }]);
122
+ warn.mockRestore();
115
123
  });
116
124
 
117
- it('falls back to fallbackMode when the theme key is absent', () => {
118
- const storage = createMemoryAdapter();
119
- storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([]));
120
-
121
- expect(readLegacyDocument(storage, 'lab', 'dark')?.mode).toBe('dark');
125
+ it('falls back to fallbackMode when the theme key is absent', async () => {
126
+ const read = reader({ [labStorageKey('lab', 'workspaces')]: '[]' });
127
+ expect((await readLegacyDocument(read, 'lab', 'dark'))?.mode).toBe('dark');
122
128
  });
123
129
 
124
- it('falls back to fallbackMode when the theme value is unrecognized', () => {
125
- const storage = createMemoryAdapter();
126
- storage.write(labStorageKey('lab', 'theme'), 'chartreuse');
127
-
128
- expect(readLegacyDocument(storage, 'lab', 'dark')?.mode).toBe('dark');
130
+ it('falls back to fallbackMode when the theme value is unrecognized', async () => {
131
+ const read = reader({ [labStorageKey('lab', 'theme')]: 'chartreuse' });
132
+ expect((await readLegacyDocument(read, 'lab', 'dark'))?.mode).toBe('dark');
129
133
  });
130
134
 
131
- it('a valid theme value beats the fallback', () => {
132
- const storage = createMemoryAdapter();
133
- storage.write(labStorageKey('lab', 'theme'), 'light');
134
-
135
- expect(readLegacyDocument(storage, 'lab', 'dark')?.mode).toBe('light');
135
+ it('a valid theme value beats the fallback', async () => {
136
+ const read = reader({ [labStorageKey('lab', 'theme')]: 'light' });
137
+ expect((await readLegacyDocument(read, 'lab', 'dark'))?.mode).toBe('light');
136
138
  });
137
139
 
138
- it('passes interstellar through untouched for migrateV0toV1 to coerce', () => {
139
- const storage = createMemoryAdapter();
140
- storage.write(labStorageKey('lab', 'theme'), 'interstellar');
141
-
142
- expect(readLegacyDocument(storage, 'lab', 'dark')?.mode).toBe('interstellar');
140
+ it('passes interstellar through untouched for migrateV0toV1 to coerce', async () => {
141
+ const read = reader({ [labStorageKey('lab', 'theme')]: 'interstellar' });
142
+ expect((await readLegacyDocument(read, 'lab', 'dark'))?.mode).toBe('interstellar');
143
143
  });
144
144
  });
145
145
 
146
- describe('deleteLegacyKeys', () => {
147
- it('deletes all four legacy buckets when the document matches', () => {
148
- const storage = createMemoryAdapter();
149
- storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
150
- storage.write(labStorageKey('lab', 'saves'), JSON.stringify([{ id: 's1' }]));
151
- storage.write(labStorageKey('lab', 'layout'), JSON.stringify({ w1: { h: 4 } }));
152
- storage.write(labStorageKey('lab', 'theme'), 'dark');
153
- storage.write(labDocumentKey('lab'), 'the-document');
154
-
155
- expect(deleteLegacyKeys(storage, 'lab', 'the-document')).toBe(true);
156
-
157
- expect(storage.read(labStorageKey('lab', 'workspaces'))).toBeNull();
158
- expect(storage.read(labStorageKey('lab', 'saves'))).toBeNull();
159
- expect(storage.read(labStorageKey('lab', 'layout'))).toBeNull();
160
- expect(storage.read(labStorageKey('lab', 'theme'))).toBeNull();
161
- });
162
-
163
- it('the document key itself survives deletion', () => {
164
- const storage = createMemoryAdapter();
165
- storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
166
- storage.write(labDocumentKey('lab'), 'the-document');
167
-
168
- deleteLegacyKeys(storage, 'lab', 'the-document');
169
-
170
- expect(storage.read(labDocumentKey('lab'))).toBe('the-document');
171
- });
172
-
173
- it('deletes nothing and returns false when the document key is absent', () => {
146
+ describe('deleteConfirmed', () => {
147
+ it('deletes the keys and confirms they are gone', async () => {
174
148
  const storage = createMemoryAdapter();
175
- storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
176
-
177
- expect(deleteLegacyKeys(storage, 'lab', 'the-document')).toBe(false);
178
- expect(storage.read(labStorageKey('lab', 'workspaces'))).not.toBeNull();
149
+ await storage.set('a', 1);
150
+ await storage.set('b', 2);
151
+ expect(await deleteConfirmed(storage, ['a', 'b'])).toBe(true);
152
+ expect(await storage.get('a')).toBeUndefined();
179
153
  });
180
154
 
181
- it('returns false when the adapter cannot delete, and the keys survive', () => {
182
- const mem = createMemoryAdapter();
183
- const storage = { read: mem.read, write: mem.write };
184
- storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
185
- storage.write(labDocumentKey('lab'), 'the-document');
186
-
187
- expect(deleteLegacyKeys(storage, 'lab', 'the-document')).toBe(false);
188
- expect(storage.read(labStorageKey('lab', 'workspaces'))).not.toBeNull();
189
- });
190
-
191
- it('deletes nothing and returns false when the document key holds different content', () => {
192
- const storage = createMemoryAdapter();
193
- storage.write(labStorageKey('lab', 'workspaces'), JSON.stringify([{ id: 'w1' }]));
194
- storage.write(labDocumentKey('lab'), 'something-else');
195
-
196
- expect(deleteLegacyKeys(storage, 'lab', 'the-document')).toBe(false);
197
- expect(storage.read(labStorageKey('lab', 'workspaces'))).not.toBeNull();
155
+ it('returns false when a delete silently does nothing', async () => {
156
+ const memory = createMemoryAdapter();
157
+ await memory.set('a', 1);
158
+ const storage = { ...memory, delete: async () => {} };
159
+ expect(await deleteConfirmed(storage, ['a'])).toBe(false);
160
+ expect(await memory.get('a')).toBe(1);
198
161
  });
199
162
  });
200
163
 
@@ -303,18 +266,15 @@ describe('MIGRATIONS', () => {
303
266
  });
304
267
 
305
268
  describe('quarantineDocument', () => {
306
- it('copies the document aside and confirms it landed', () => {
269
+ it('copies the document aside and confirms it landed', async () => {
307
270
  const storage = createMemoryAdapter();
308
-
309
- expect(quarantineDocument(storage, 'lab', 'the-bytes')).toBe(true);
310
- expect(storage.read(quarantineKey('lab'))).toBe('the-bytes');
271
+ expect(await quarantineDocument(storage, 'lab', 'the-bytes')).toBe(true);
272
+ expect(await storage.get(quarantineKey('lab'))).toBe('the-bytes');
311
273
  });
312
274
 
313
- it('returns false when the quarantine write silently fails', () => {
314
- const storage = createMemoryAdapter();
315
- storage.write = () => {};
316
-
317
- expect(quarantineDocument(storage, 'lab', 'the-bytes')).toBe(false);
275
+ it('returns false when the quarantine write silently fails', async () => {
276
+ const storage = { ...createMemoryAdapter(), set: async () => {} };
277
+ expect(await quarantineDocument(storage, 'lab', 'the-bytes')).toBe(false);
318
278
  });
319
279
  });
320
280
 
@@ -390,3 +350,18 @@ describe('version 2 to version 3', () => {
390
350
  expect(out.mode).toBe('dark');
391
351
  });
392
352
  });
353
+
354
+ describe('version 3 to version 4', () => {
355
+ it('only restamps the document, whose shape does not change', () => {
356
+ const doc = {
357
+ version: 3,
358
+ trials: [{ id: 'w1' }],
359
+ saves: [],
360
+ layout: {},
361
+ undockedPanels: {},
362
+ mode: 'auto',
363
+ };
364
+ expect(migrateV3toV4(doc)).toEqual({ ...doc, version: 4 });
365
+ expect(MIGRATIONS[3]).toBe(migrateV3toV4);
366
+ });
367
+ });
@@ -10,11 +10,10 @@ import type {
10
10
  import type { UndockedPanels } from './undock';
11
11
 
12
12
  /** Bumped whenever the persisted shape changes; every bump needs a migration. */
13
- export const CURRENT_DOCUMENT_VERSION = 3;
13
+ export const CURRENT_DOCUMENT_VERSION = 4;
14
14
 
15
- /** The one key a lab persists under. The `:doc` suffix keeps it out of the
16
- * legacy bucket namespace, where a lab named `a:saves` would otherwise write
17
- * its document over lab `a`'s saves bucket. */
15
+ /** Where a version-3 lab kept its one document. Read once, to fold it into
16
+ * records, then deleted. */
18
17
  export function labDocumentKey(storageKey: string): string {
19
18
  return `lk:${storageKey}:doc`;
20
19
  }
@@ -69,83 +68,92 @@ export function runMigrations(
69
68
 
70
69
  const LEGACY_BUCKETS = ['workspaces', 'saves', 'layout', 'theme'] as const;
71
70
 
72
- function parseOr<T>(raw: string | null, fallback: T, bucket: string): T {
73
- if (raw === null) return fallback;
71
+ /** The four keys a lab used before it was one document. */
72
+ export function legacyKeys(storageKey: string): string[] {
73
+ return LEGACY_BUCKETS.map((bucket) => labStorageKey(storageKey, bucket));
74
+ }
75
+
76
+ /** A value some substrate returned for an old key: already parsed, or a JSON
77
+ * string from a store that does not parse. `undefined` when it is neither. */
78
+ export function parseStored(value: unknown): unknown {
79
+ if (typeof value !== 'string') return value;
74
80
  try {
75
- return JSON.parse(raw) as T;
81
+ return JSON.parse(value);
76
82
  } catch {
77
- console.warn(`[labkit] unparseable legacy bucket "${bucket}", dropping it`);
78
- return fallback;
83
+ return undefined;
79
84
  }
80
85
  }
81
86
 
87
+ function bucketOr<T>(value: unknown, fallback: T, bucket: string, shape: 'array' | 'object'): T {
88
+ if (value === undefined) return fallback;
89
+ const parsed = parseStored(value);
90
+ const fits = shape === 'array' ? Array.isArray(parsed) : !!parsed && typeof parsed === 'object';
91
+ if (fits) return parsed as T;
92
+ console.warn(`[labkit] unparseable legacy bucket "${bucket}", dropping it`);
93
+ return fallback;
94
+ }
95
+
82
96
  /** Assemble a version-0 document out of the four pre-document keys, or null
83
97
  * if none of them is present. `fallbackMode` is used when the theme key is
84
98
  * absent or holds something unrecognized; `interstellar` passes through
85
99
  * untouched for `migrateV0toV1` to coerce. */
86
- export function readLegacyDocument(
87
- storage: StorageAdapter,
100
+ export async function readLegacyDocument(
101
+ read: (key: string) => Promise<unknown>,
88
102
  storageKey: string,
89
103
  fallbackMode: LabMode,
90
- ): Record<string, unknown> | null {
91
- const present = LEGACY_BUCKETS.some((b) => storage.read(labStorageKey(storageKey, b)) !== null);
92
- if (!present) return null;
104
+ ): Promise<Record<string, unknown> | null> {
105
+ const [workspaces, saves, layout, theme] = await Promise.all(
106
+ LEGACY_BUCKETS.map((bucket) => read(labStorageKey(storageKey, bucket))),
107
+ );
108
+ if ([workspaces, saves, layout, theme].every((v) => v === undefined)) return null;
93
109
 
94
- const rawMode = storage.read(labStorageKey(storageKey, 'theme'));
95
110
  const mode =
96
- rawMode === 'light' || rawMode === 'dark' || rawMode === 'auto' || rawMode === 'interstellar'
97
- ? rawMode
111
+ theme === 'light' || theme === 'dark' || theme === 'auto' || theme === 'interstellar'
112
+ ? theme
98
113
  : fallbackMode;
99
114
 
100
115
  return {
101
116
  version: 0,
102
- workspaces: parseOr(storage.read(labStorageKey(storageKey, 'workspaces')), [], 'workspaces'),
103
- saves: parseOr(storage.read(labStorageKey(storageKey, 'saves')), [], 'saves'),
104
- layout: parseOr(storage.read(labStorageKey(storageKey, 'layout')), {}, 'layout'),
117
+ workspaces: bucketOr(workspaces, [], 'workspaces', 'array'),
118
+ saves: bucketOr(saves, [], 'saves', 'array'),
119
+ layout: bucketOr(layout, {}, 'layout', 'object'),
105
120
  mode,
106
121
  };
107
122
  }
108
123
 
109
- /** Delete the four pre-document keys, but only once `expectedDocument` is
110
- * confirmed to be exactly what is stored under the document key — a
111
- * read-back, not a trust of the write that produced it. On any mismatch
112
- * (write failed, landed partially, or never happened) it deletes nothing,
113
- * warns, and returns `false` so the legacy buckets stay as a recoverable
114
- * copy. Deletion itself is read back too — `StorageAdapter.delete` is
115
- * optional, so an adapter without it leaves the keys in place — and `true`
116
- * is returned only once all four are actually gone. */
117
- export function deleteLegacyKeys(
118
- storage: StorageAdapter,
119
- storageKey: string,
120
- expectedDocument: string,
121
- ): boolean {
122
- const actual = storage.read(labDocumentKey(storageKey));
123
- if (actual !== expectedDocument) {
124
- console.warn(
125
- `[labkit] keeping legacy keys for "${storageKey}": could not confirm the migrated document was written`,
126
- );
124
+ /** Whether two stored values hold the same data. Stored values are
125
+ * structured-clone copies, so identity says nothing. */
126
+ export function sameStored(a: unknown, b: unknown): boolean {
127
+ return JSON.stringify(a) === JSON.stringify(b);
128
+ }
129
+
130
+ /** Delete `keys` and read each back, so an adapter whose delete silently did
131
+ * nothing is caught. `true` only once all of them are gone. */
132
+ export async function deleteConfirmed(storage: StorageAdapter, keys: string[]): Promise<boolean> {
133
+ try {
134
+ await Promise.all(keys.map((key) => storage.delete(key)));
135
+ const left = await Promise.all(keys.map((key) => storage.get(key)));
136
+ return left.every((v) => v === undefined);
137
+ } catch {
127
138
  return false;
128
139
  }
129
- let allGone = true;
130
- for (const bucket of LEGACY_BUCKETS) {
131
- const key = labStorageKey(storageKey, bucket);
132
- storage.delete?.(key);
133
- if (storage.read(key) !== null) allGone = false;
134
- }
135
- return allGone;
136
140
  }
137
141
 
138
- /** Set `raw` aside under the quarantine key, reading it back to confirm it
142
+ /** Set `value` aside under the quarantine key, reading it back to confirm it
139
143
  * landed. Returns `false` when it did not — a full disk is exactly when a
140
144
  * document goes unreadable — so the caller can leave the original alone
141
145
  * rather than overwrite the only copy of it. */
142
- export function quarantineDocument(
146
+ export async function quarantineDocument(
143
147
  storage: StorageAdapter,
144
148
  storageKey: string,
145
- raw: string,
146
- ): boolean {
147
- storage.write(quarantineKey(storageKey), raw);
148
- return storage.read(quarantineKey(storageKey)) === raw;
149
+ value: unknown,
150
+ ): Promise<boolean> {
151
+ try {
152
+ await storage.set(quarantineKey(storageKey), value);
153
+ return sameStored(await storage.get(quarantineKey(storageKey)), value);
154
+ } catch {
155
+ return false;
156
+ }
149
157
  }
150
158
 
151
159
  /** Fill in whatever a document is missing or holds the wrong shape of, so a
@@ -202,5 +210,11 @@ export function migrateV2toV3(doc: Record<string, unknown>): Record<string, unkn
202
210
  return { ...doc, undockedPanels: {}, version: 3 };
203
211
  }
204
212
 
213
+ /** Version 3 to version 4: a lab is stored as records under one prefix
214
+ * instead of one document. The joined shape does not change. */
215
+ export function migrateV3toV4(doc: Record<string, unknown>): Record<string, unknown> {
216
+ return { ...doc, version: 4 };
217
+ }
218
+
205
219
  /** Index `i` migrates a version-`i` document to version `i + 1`. */
206
- export const MIGRATIONS: Migration[] = [migrateV0toV1, migrateV1toV2, migrateV2toV3];
220
+ export const MIGRATIONS: Migration[] = [migrateV0toV1, migrateV1toV2, migrateV2toV3, migrateV3toV4];
@@ -4,8 +4,8 @@ import {
4
4
  deserializeTrials,
5
5
  emptyUndoStack,
6
6
  encodeUrlHash,
7
- newId,
8
7
  labStorageKey,
8
+ newId,
9
9
  serializeTrials,
10
10
  } from './helpers';
11
11
 
@@ -1,5 +1,8 @@
1
1
  export {
2
+ createIndexedDbAdapter,
2
3
  createMemoryAdapter,
4
+ type IndexedDbAdapterOptions,
5
+ indexedDbAdapter,
3
6
  localStorageAdapter,
4
7
  noneAdapter,
5
8
  sessionStorageAdapter,
@@ -26,6 +29,9 @@ export {
26
29
  labStorageKey,
27
30
  serializeTrials,
28
31
  } from './helpers';
32
+ export { type OpenedLabStore, type OpenLabStoreOptions, openLabStore } from './openLabStore';
33
+ export { Persistence, type PersistenceProps } from './Persistence';
34
+ export type { RecordCache, RecordChange } from './records';
29
35
  export {
30
36
  SingletonExperimentProvider,
31
37
  type SingletonExperimentProviderProps,
@@ -41,10 +47,12 @@ export type {
41
47
  SavedSnapshot,
42
48
  SerializedTrial,
43
49
  StorageAdapter,
50
+ StorageChange,
44
51
  TrialRecord,
45
52
  TrialStateHandle,
46
53
  UndoStack,
47
54
  } from './types';
48
55
  export type { UndockedPanel, UndockedPanels } from './undock';
49
56
  export { dockPanel, panelKey, undockPanel } from './undock';
57
+ export { type PersistedStateOptions, usePersistedState } from './usePersistedState';
50
58
  export { useTrialState } from './useTrialState';
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { CURRENT_DOCUMENT_VERSION } from './document';
3
+ import {
4
+ documentOfRecords,
5
+ labPrefix,
6
+ parseRecordName,
7
+ recordsOfDocument,
8
+ trialRecord,
9
+ valueRecord,
10
+ } from './labRecords';
11
+ import type { LabDocument } from './types';
12
+
13
+ const VIEW = { zoom: 1, pan: { x: 0, y: 0 } };
14
+
15
+ describe('record names', () => {
16
+ it('encodes the storage key, so no lab prefix contains another', () => {
17
+ expect(labPrefix('drag-lab')).toBe('lk:drag-lab:');
18
+ expect(labPrefix('a:trial:x')).toBe('lk:a%3Atrial%3Ax:');
19
+ expect(labPrefix('a:trial:x').startsWith(labPrefix('a'))).toBe(false);
20
+ });
21
+
22
+ it('round-trips ids holding the separator', () => {
23
+ expect(parseRecordName(trialRecord('odd:id'))).toEqual({ kind: 'trial', id: 'odd:id' });
24
+ });
25
+
26
+ it('scopes a value to its trial or to the lab', () => {
27
+ expect(valueRecord('t:1', 'tab')).toBe('value:trial:t%3A1:tab');
28
+ expect(valueRecord(null, 'tab')).toBe('value:lab:tab');
29
+ expect(parseRecordName(valueRecord('t:1', 'a:b'))).toEqual({ kind: 'value' });
30
+ });
31
+
32
+ it('answers null for a name no lab writes', () => {
33
+ for (const name of [
34
+ 'doc',
35
+ 'meta:x',
36
+ 'trial:',
37
+ 'trial:a:b',
38
+ 'value:lab',
39
+ 'value:x:y',
40
+ 'trial:%E0%A4%A',
41
+ ]) {
42
+ expect(parseRecordName(name)).toBeNull();
43
+ }
44
+ });
45
+ });
46
+
47
+ describe('documents and records', () => {
48
+ const doc: LabDocument = {
49
+ version: CURRENT_DOCUMENT_VERSION,
50
+ trials: [
51
+ { id: 'b', instrumentName: 'T', config: {}, state: { n: 2 }, view: VIEW },
52
+ { id: 'a', instrumentName: 'T', config: {}, state: { n: 1 }, view: VIEW },
53
+ ],
54
+ saves: [
55
+ {
56
+ id: 's2',
57
+ name: 'later',
58
+ trialId: 'a',
59
+ instrumentName: 'T',
60
+ config: {},
61
+ state: {},
62
+ savedAt: 2,
63
+ },
64
+ {
65
+ id: 's1',
66
+ name: 'earlier',
67
+ trialId: 'a',
68
+ instrumentName: 'T',
69
+ config: {},
70
+ state: {},
71
+ savedAt: 1,
72
+ },
73
+ ],
74
+ layout: { a: { h: 1 } },
75
+ undockedPanels: {},
76
+ mode: 'dark',
77
+ };
78
+
79
+ it('writes meta last', () => {
80
+ const names = recordsOfDocument(doc).map(([name]) => name);
81
+ expect(names.at(-1)).toBe('meta');
82
+ });
83
+
84
+ it('joins back into the document it came from, trials in stored order', () => {
85
+ const joined = documentOfRecords(recordsOfDocument(doc).reverse());
86
+ expect(joined?.doc).toEqual({ ...doc, saves: [doc.saves[1], doc.saves[0]] });
87
+ expect([...(joined?.orders ?? [])]).toEqual([
88
+ ['b', 0],
89
+ ['a', 1],
90
+ ]);
91
+ });
92
+
93
+ it('breaks an order tie by id', () => {
94
+ const joined = documentOfRecords([
95
+ ['meta', { version: 4, mode: 'auto' }],
96
+ ['trial:z', { instrumentName: 'T', order: 0 }],
97
+ ['trial:m', { instrumentName: 'T', order: 0 }],
98
+ ]);
99
+ expect((joined?.doc.trials as { id: string }[]).map((t) => t.id)).toEqual(['m', 'z']);
100
+ });
101
+
102
+ it('is null without a meta record', () => {
103
+ expect(documentOfRecords([['trial:a', { instrumentName: 'T', order: 0 }]])).toBeNull();
104
+ });
105
+ });