@weasel-js/labkit 1.4.3 → 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 (229) hide show
  1. package/README.md +13 -2
  2. package/dist/_dts/{CanvasStackContext-Dde8_1my.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
  3. package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
  4. package/dist/_dts/{frac-B5mB8LsJ.d.ts → frac-8R6UmlvN.d.ts} +256 -33
  5. package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
  6. package/dist/_dts/{index-DelFfeV0.d.ts → index-retnAut7.d.ts} +45 -12
  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-QAGAHJKZ.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-2RQPGOQF.js → chunk-KJALCDWE.js} +9 -7
  23. package/dist/chunk-KJALCDWE.js.map +1 -0
  24. package/dist/{chunk-ISCOFAUO.js → chunk-MUKOW3TC.js} +3 -3
  25. package/dist/{chunk-ISCOFAUO.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-C42T6DJT.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-ASWKLRKJ.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 +123 -348
  53. package/dist/index.js +794 -837
  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 +169 -117
  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 +163 -62
  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/frac.test.ts +4 -4
  77. package/src/annotations/frac.ts +3 -2
  78. package/src/annotations/index.ts +1 -1
  79. package/src/annotations/paint.test.ts +2 -2
  80. package/src/annotations/paint.ts +1 -1
  81. package/src/annotations/preload.ts +6 -0
  82. package/src/annotations/store.test.ts +18 -0
  83. package/src/annotations/store.ts +7 -6
  84. package/src/annotations/svgNodes.test.ts +2 -2
  85. package/src/annotations/svgNodes.ts +1 -2
  86. package/src/annotations/toolMap.ts +3 -3
  87. package/src/annotations/types.ts +6 -3
  88. package/src/canvas/CameraWheelContext.ts +12 -0
  89. package/src/canvas/CanvasStack.test.tsx +25 -0
  90. package/src/canvas/CanvasStack.tsx +31 -4
  91. package/src/canvas/Stage.less +18 -0
  92. package/src/canvas/Stage.test.tsx +186 -0
  93. package/src/canvas/Stage.tsx +134 -0
  94. package/src/canvas/camera.test.ts +17 -2
  95. package/src/canvas/camera.ts +9 -9
  96. package/src/canvas/index.ts +4 -0
  97. package/src/canvas/usePanZoom.ts +10 -5
  98. package/src/chrome/ChromeRegions.stories.tsx +0 -3
  99. package/src/chrome/LabChrome.tsx +114 -0
  100. package/src/chrome/builtins.tsx +20 -31
  101. package/src/chrome/index.ts +15 -0
  102. package/src/chrome/labRegions.test.tsx +208 -0
  103. package/src/chrome/labTypes.ts +50 -0
  104. package/src/chrome/merge.ts +7 -9
  105. package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
  106. package/src/chrome/regions/PaletteRegion.tsx +21 -9
  107. package/src/chrome/regions/SidebarRegion.tsx +25 -13
  108. package/src/chrome/regions/StatusRegion.tsx +15 -8
  109. package/src/chrome/regions/ToolbarRegion.tsx +30 -16
  110. package/src/chrome/types.ts +47 -20
  111. package/src/config/builder.test.ts +4 -0
  112. package/src/config/builder.ts +16 -10
  113. package/src/config/declarationEmit.fixture.ts +11 -0
  114. package/src/config/declarationEmit.test.ts +51 -0
  115. package/src/config/entry.test.ts +46 -0
  116. package/src/config/index.ts +13 -1
  117. package/src/config/nodeClasses.test.ts +18 -0
  118. package/src/config/types.ts +3 -1
  119. package/src/controls/ControlPanel.test.tsx +16 -0
  120. package/src/controls/ControlPanel.tsx +5 -2
  121. package/src/fake-indexeddb-auto.d.ts +5 -0
  122. package/src/index.test.ts +7 -0
  123. package/src/index.ts +39 -6
  124. package/src/instrument/serializers.test.ts +22 -0
  125. package/src/instrument/serializers.ts +11 -0
  126. package/src/instrument/types.ts +25 -1
  127. package/src/lab/Lab.less +4 -13
  128. package/src/lab/Lab.persist.test.tsx +147 -0
  129. package/src/lab/Lab.stories.tsx +0 -2
  130. package/src/lab/Lab.surface.test.tsx +66 -3
  131. package/src/lab/Lab.test.tsx +91 -17
  132. package/src/lab/Lab.tsx +337 -124
  133. package/src/lab/LabFit.stories.less +18 -0
  134. package/src/lab/LabFit.stories.tsx +219 -0
  135. package/src/lab/LabFullChrome.stories.tsx +0 -3
  136. package/src/lab/LabHeader.test.tsx +40 -9
  137. package/src/lab/LabHeader.tsx +7 -13
  138. package/src/lab/LabPalette.tsx +13 -26
  139. package/src/lab/LabShell.less +49 -12
  140. package/src/lab/LabShell.tsx +14 -2
  141. package/src/lab/LabSwitcher.less +92 -0
  142. package/src/lab/LabSwitcher.test.tsx +96 -0
  143. package/src/lab/LabSwitcher.tsx +112 -0
  144. package/src/lab/Workspace.surface.test.tsx +2 -1
  145. package/src/lab/fitCheck.test.ts +57 -0
  146. package/src/lab/fitCheck.ts +125 -0
  147. package/src/lab/index.ts +2 -0
  148. package/src/loupe/AGENTS.md +1 -1
  149. package/src/loupe/Loupe.less +1 -1
  150. package/src/loupe/canvasLens.ts +1 -1
  151. package/src/loupe/types.ts +1 -1
  152. package/src/loupe/useLoupe.test.tsx +1 -1
  153. package/src/loupe/useLoupe.ts +1 -1
  154. package/src/passthrough/weasel-ui.ts +9 -0
  155. package/src/primitives/FloatingPanel.stories.tsx +13 -1
  156. package/src/primitives/FloatingPanel.test.tsx +47 -16
  157. package/src/primitives/FloatingPanel.tsx +11 -25
  158. package/src/primitives/Split.less +14 -0
  159. package/src/primitives/Split.test.tsx +108 -0
  160. package/src/primitives/Split.tsx +159 -0
  161. package/src/primitives/ZoomControl.tsx +5 -2
  162. package/src/primitives/index.ts +2 -0
  163. package/src/state/Persistence.stories.tsx +75 -0
  164. package/src/state/Persistence.tsx +33 -0
  165. package/src/state/SingletonExperiment.test.tsx +47 -53
  166. package/src/state/SingletonExperiment.tsx +24 -13
  167. package/src/state/adapterContract.ts +116 -0
  168. package/src/state/adapters.test.ts +113 -61
  169. package/src/state/adapters.ts +328 -69
  170. package/src/state/document.test.ts +71 -96
  171. package/src/state/document.ts +66 -52
  172. package/src/state/helpers.test.ts +20 -0
  173. package/src/state/helpers.ts +16 -0
  174. package/src/state/index.ts +8 -0
  175. package/src/state/labRecords.test.ts +105 -0
  176. package/src/state/labRecords.ts +160 -0
  177. package/src/state/openLabStore.test.ts +658 -0
  178. package/src/state/openLabStore.ts +403 -0
  179. package/src/state/records.test.ts +234 -0
  180. package/src/state/records.ts +219 -0
  181. package/src/state/store.test.ts +104 -635
  182. package/src/state/store.ts +117 -158
  183. package/src/state/toolSlot.test.ts +1 -1
  184. package/src/state/types.ts +39 -15
  185. package/src/state/useOpenOnce.ts +68 -0
  186. package/src/state/usePersistedState.test.tsx +125 -0
  187. package/src/state/usePersistedState.ts +66 -0
  188. package/src/state/useTrialState.test.tsx +2 -3
  189. package/src/state/view.test.ts +49 -15
  190. package/src/state/view.ts +18 -1
  191. package/src/styles.less +3 -0
  192. package/src/surface/AGENTS.md +39 -7
  193. package/src/surface/SurfaceContext.ts +26 -3
  194. package/src/surface/index.ts +2 -0
  195. package/src/surface/useSurfaceTile.test.tsx +2 -1
  196. package/src/surface/useSurfaceTile.ts +6 -5
  197. package/src/surface/useTiledSurface.test.tsx +175 -0
  198. package/src/surface/useTiledSurface.ts +54 -5
  199. package/src/theme/Interstellar.stories.tsx +7 -7
  200. package/src/theme/base.less +4 -4
  201. package/src/theme/interstellar.test.ts +1 -1
  202. package/src/theme/interstellar.tokens.json +2 -2
  203. package/src/tools/labTool.ts +11 -0
  204. package/src/trial/Trial.annotations.persist.test.tsx +100 -58
  205. package/src/trial/Trial.annotations.test.tsx +47 -4
  206. package/src/trial/Trial.job.test.tsx +3 -3
  207. package/src/trial/Trial.less +1 -26
  208. package/src/trial/Trial.stories.tsx +1 -2
  209. package/src/trial/Trial.test.tsx +28 -4
  210. package/src/trial/Trial.trialId.test.tsx +23 -1
  211. package/src/trial/Trial.tsx +118 -38
  212. package/src/trial/TrialBody.tsx +17 -141
  213. package/src/trial/TrialChrome.tsx +7 -4
  214. package/src/trial/trialOps.ts +7 -4
  215. package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
  216. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  217. package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
  218. package/dist/chunk-2RQPGOQF.js.map +0 -1
  219. package/dist/chunk-5TROQVQ2.js +0 -547
  220. package/dist/chunk-5TROQVQ2.js.map +0 -1
  221. package/dist/chunk-ASWKLRKJ.js.map +0 -1
  222. package/dist/chunk-C42T6DJT.js.map +0 -1
  223. package/dist/chunk-CTRKTLYZ.js.map +0 -1
  224. package/dist/chunk-FQMJUVHJ.js.map +0 -1
  225. package/dist/chunk-I6JCVE24.js.map +0 -1
  226. package/dist/chunk-MHAGC6VD.js +0 -7320
  227. package/dist/chunk-MHAGC6VD.js.map +0 -1
  228. package/dist/chunk-QAGAHJKZ.js.map +0 -1
  229. package/dist/chunk-TJ7QY3OC.js.map +0 -1
@@ -1,56 +1,104 @@
1
1
  import { decodeUrlHash, encodeUrlHash } from './helpers';
2
- import type { StorageAdapter } from './types';
3
-
4
- /** Persist to `localStorage` — state survives a reload and a new tab. Falls
5
- * back to no-ops when storage is unavailable. */
6
- export const localStorageAdapter: StorageAdapter = {
7
- read: (key) => {
8
- try {
9
- return localStorage.getItem(key);
10
- } catch {
11
- return null;
12
- }
13
- },
14
- write: (key, value) => {
15
- try {
16
- localStorage.setItem(key, value);
17
- } catch (e) {
18
- console.warn('[labkit] localStorage write failed:', e);
19
- }
20
- },
21
- delete: (key) => {
22
- try {
23
- localStorage.removeItem(key);
24
- } catch {
25
- /* ignore */
26
- }
27
- },
28
- };
2
+ import type { StorageAdapter, StorageChange } from './types';
29
3
 
30
- /** Persist to `sessionStorage` — state survives a reload but not a new tab. */
31
- export const sessionStorageAdapter: StorageAdapter = {
32
- read: (key) => {
33
- try {
34
- return sessionStorage.getItem(key);
35
- } catch {
36
- return null;
37
- }
38
- },
39
- write: (key, value) => {
40
- try {
41
- sessionStorage.setItem(key, value);
42
- } catch (e) {
43
- console.warn('[labkit] sessionStorage write failed:', e);
44
- }
45
- },
46
- delete: (key) => {
47
- try {
48
- sessionStorage.removeItem(key);
49
- } catch {
50
- /* ignore */
4
+ /** JSON-backed substrates held raw strings before records existed — a theme
5
+ * bucket stored `dark`, not `"dark"` — so an unparseable value comes back as
6
+ * the string it is. */
7
+ function parse(raw: string): unknown {
8
+ try {
9
+ return JSON.parse(raw);
10
+ } catch {
11
+ return raw;
12
+ }
13
+ }
14
+
15
+ /** Whether `JSON.stringify` would keep `value` exactly. An object key holding
16
+ * `undefined` counts as kept: JSON drops the key, and reading back an absent
17
+ * key yields the same `undefined`. */
18
+ function isJsonSafe(value: unknown, seen: Set<object> = new Set()): boolean {
19
+ if (value === null) return true;
20
+ switch (typeof value) {
21
+ case 'string':
22
+ case 'boolean':
23
+ return true;
24
+ case 'number':
25
+ return Number.isFinite(value);
26
+ case 'object': {
27
+ if (seen.has(value)) return false;
28
+ seen.add(value);
29
+ const proto = Object.getPrototypeOf(value);
30
+ const ok = Array.isArray(value)
31
+ ? value.every((v) => isJsonSafe(v, seen))
32
+ : (proto === Object.prototype || proto === null) &&
33
+ Object.values(value).every((v) => v === undefined || isJsonSafe(v, seen));
34
+ seen.delete(value);
35
+ return ok;
51
36
  }
52
- },
53
- };
37
+ default:
38
+ return false;
39
+ }
40
+ }
41
+
42
+ function webStorageAdapter(area: () => Storage, name: string, crossTab: boolean): StorageAdapter {
43
+ const warned = new Set<string>();
44
+ const adapter: StorageAdapter = {
45
+ get: async (key) => {
46
+ const raw = area().getItem(key);
47
+ return raw === null ? undefined : parse(raw);
48
+ },
49
+ list: async (prefix) => {
50
+ const store = area();
51
+ const out: [string, unknown][] = [];
52
+ for (let i = 0; i < store.length; i++) {
53
+ const key = store.key(i);
54
+ if (key === null || !key.startsWith(prefix)) continue;
55
+ const raw = store.getItem(key);
56
+ if (raw !== null) out.push([key, parse(raw)]);
57
+ }
58
+ return out;
59
+ },
60
+ set: async (key, value) => {
61
+ if (!warned.has(key) && !isJsonSafe(value)) {
62
+ warned.add(key);
63
+ console.warn(
64
+ `[labkit] "${key}" holds a value JSON cannot keep exactly; ${name} stores what JSON keeps. Use IndexedDB for binary or non-JSON state.`,
65
+ );
66
+ }
67
+ area().setItem(key, JSON.stringify(value));
68
+ },
69
+ delete: async (key) => {
70
+ area().removeItem(key);
71
+ },
72
+ };
73
+ if (crossTab) {
74
+ adapter.subscribe = (prefix, on) => {
75
+ if (typeof window === 'undefined') return () => {};
76
+ const handler = (e: StorageEvent): void => {
77
+ if (e.key === null || !e.key.startsWith(prefix)) return;
78
+ if (e.storageArea !== area()) return;
79
+ on([[e.key, e.newValue === null ? undefined : parse(e.newValue)]]);
80
+ };
81
+ window.addEventListener('storage', handler);
82
+ return () => window.removeEventListener('storage', handler);
83
+ };
84
+ }
85
+ return adapter;
86
+ }
87
+
88
+ /** Persist to `localStorage`, one JSON value per record. Survives a reload and
89
+ * reaches other tabs through the `storage` event. */
90
+ export const localStorageAdapter: StorageAdapter = webStorageAdapter(
91
+ () => localStorage,
92
+ 'localStorage',
93
+ true,
94
+ );
95
+
96
+ /** Persist to `sessionStorage` — survives a reload but not a new tab. */
97
+ export const sessionStorageAdapter: StorageAdapter = webStorageAdapter(
98
+ () => sessionStorage,
99
+ 'sessionStorage',
100
+ false,
101
+ );
54
102
 
55
103
  const URL_HASH_GUARD = typeof window !== 'undefined';
56
104
 
@@ -65,46 +113,257 @@ function readHashMap(): Record<string, string> {
65
113
  }
66
114
  }
67
115
 
116
+ /** What each live subscription last saw, refreshed on this page's own writes
117
+ * so `hashchange` reports only somebody else's. */
118
+ const hashSubscribers = new Set<{ last: Record<string, string> }>();
119
+
68
120
  function writeHashMap(map: Record<string, string>): void {
69
121
  if (!URL_HASH_GUARD) return;
70
122
  const encoded = encodeUrlHash(JSON.stringify(map));
71
123
  window.history.replaceState(null, '', `#${encoded}`);
124
+ for (const sub of hashSubscribers) sub.last = { ...map };
72
125
  }
73
126
 
74
- /** Persist into the URL fragment, so the page's link carries its state and
75
- * can be shared or bookmarked. */
127
+ /** Persist into the URL fragment, so the page's link carries its state and can
128
+ * be shared or bookmarked. Changes to the fragment made elsewhere — a pasted
129
+ * link, back and forward — arrive through `hashchange`. */
76
130
  export const urlHashAdapter: StorageAdapter = {
77
- read: (key) => readHashMap()[key] ?? null,
78
- write: (key, value) => {
131
+ get: async (key) => {
132
+ const raw = readHashMap()[key];
133
+ return raw === undefined ? undefined : parse(raw);
134
+ },
135
+ list: async (prefix) =>
136
+ Object.entries(readHashMap())
137
+ .filter(([key]) => key.startsWith(prefix))
138
+ .map(([key, raw]): [string, unknown] => [key, parse(raw)]),
139
+ set: async (key, value) => {
79
140
  const map = readHashMap();
80
- map[key] = value;
141
+ map[key] = JSON.stringify(value);
81
142
  writeHashMap(map);
82
143
  },
83
- delete: (key) => {
144
+ delete: async (key) => {
84
145
  const map = readHashMap();
85
146
  delete map[key];
86
147
  writeHashMap(map);
87
148
  },
149
+ subscribe: (prefix, on) => {
150
+ if (!URL_HASH_GUARD) return () => {};
151
+ const sub = { last: readHashMap() };
152
+ hashSubscribers.add(sub);
153
+ const handler = (): void => {
154
+ const next = readHashMap();
155
+ const changes: StorageChange[] = [];
156
+ for (const key of new Set([...Object.keys(sub.last), ...Object.keys(next)])) {
157
+ if (!key.startsWith(prefix) || sub.last[key] === next[key]) continue;
158
+ const raw = next[key];
159
+ changes.push([key, raw === undefined ? undefined : parse(raw)]);
160
+ }
161
+ sub.last = next;
162
+ if (changes.length > 0) on(changes);
163
+ };
164
+ window.addEventListener('hashchange', handler);
165
+ return () => {
166
+ hashSubscribers.delete(sub);
167
+ window.removeEventListener('hashchange', handler);
168
+ };
169
+ },
88
170
  };
89
171
 
90
- /** An in-memory store, discarded on reload. For tests, and for labs that
91
- * should start fresh every time. */
92
- export function createMemoryAdapter(): StorageAdapter {
93
- const store = new Map<string, string>();
94
- return {
95
- read: (key) => store.get(key) ?? null,
96
- write: (key, value) => {
97
- store.set(key, value);
172
+ interface MemorySubscriber {
173
+ owner: StorageAdapter;
174
+ prefix: string;
175
+ on: (changes: StorageChange[]) => void;
176
+ }
177
+
178
+ const memorySubscribers = new WeakMap<Map<string, unknown>, Set<MemorySubscriber>>();
179
+
180
+ /** An in-memory store, discarded on reload. Values are copied in and out, as a
181
+ * real substrate would. Adapters built over one shared `backing` hear each
182
+ * other's writes — two tabs, for a test. */
183
+ export function createMemoryAdapter(backing: Map<string, unknown> = new Map()): StorageAdapter {
184
+ let subscribers = memorySubscribers.get(backing);
185
+ if (!subscribers) {
186
+ subscribers = new Set();
187
+ memorySubscribers.set(backing, subscribers);
188
+ }
189
+ const peers = subscribers;
190
+ const notify = (key: string, value: unknown): void => {
191
+ for (const sub of peers) {
192
+ if (sub.owner === adapter || !key.startsWith(sub.prefix)) continue;
193
+ const copy = value === undefined ? undefined : structuredClone(value);
194
+ queueMicrotask(() => sub.on([[key, copy]]));
195
+ }
196
+ };
197
+ const adapter: StorageAdapter = {
198
+ get: async (key) => (backing.has(key) ? structuredClone(backing.get(key)) : undefined),
199
+ list: async (prefix) =>
200
+ [...backing]
201
+ .filter(([key]) => key.startsWith(prefix))
202
+ .map(([key, value]): [string, unknown] => [key, structuredClone(value)]),
203
+ set: async (key, value) => {
204
+ const copy = structuredClone(value);
205
+ backing.set(key, copy);
206
+ notify(key, copy);
207
+ },
208
+ delete: async (key) => {
209
+ backing.delete(key);
210
+ notify(key, undefined);
98
211
  },
99
- delete: (key) => {
100
- store.delete(key);
212
+ subscribe: (prefix, on) => {
213
+ const sub = { owner: adapter, prefix, on };
214
+ peers.add(sub);
215
+ return () => {
216
+ peers.delete(sub);
217
+ };
101
218
  },
102
219
  };
220
+ return adapter;
103
221
  }
104
222
 
105
223
  /** Persists nothing and reads back nothing. */
106
224
  export const noneAdapter: StorageAdapter = {
107
- read: () => null,
108
- write: () => {},
109
- delete: () => {},
225
+ get: async () => undefined,
226
+ list: async () => [],
227
+ set: async () => {},
228
+ delete: async () => {},
110
229
  };
230
+
231
+ /** Options for `createIndexedDbAdapter`. */
232
+ export interface IndexedDbAdapterOptions {
233
+ /** Default `'labkit'`. */
234
+ database?: string;
235
+ /** Default `'records'`. One object store per database: a store added to a
236
+ * database that already exists is not created. */
237
+ store?: string;
238
+ }
239
+
240
+ interface NodeChannel extends BroadcastChannel {
241
+ unref?: () => void;
242
+ }
243
+
244
+ /** Persist to IndexedDB, which stores structured-clone values natively — typed
245
+ * arrays, `Map`s, `Blob`s — and holds far more than localStorage. Other tabs
246
+ * hear writes through a `BroadcastChannel`. The database opens on first use. */
247
+ export function createIndexedDbAdapter({
248
+ database = 'labkit',
249
+ store = 'records',
250
+ }: IndexedDbAdapterOptions = {}): StorageAdapter {
251
+ let opened: Promise<IDBDatabase> | null = null;
252
+ const open = (): Promise<IDBDatabase> => {
253
+ opened ??= new Promise((resolve, reject) => {
254
+ if (typeof indexedDB === 'undefined') {
255
+ reject(new Error('[labkit] IndexedDB is not available here'));
256
+ return;
257
+ }
258
+ const request = indexedDB.open(database, 1);
259
+ request.onupgradeneeded = () => {
260
+ if (!request.result.objectStoreNames.contains(store)) {
261
+ request.result.createObjectStore(store);
262
+ }
263
+ };
264
+ request.onsuccess = () => {
265
+ const db = request.result;
266
+ if (!db.objectStoreNames.contains(store)) {
267
+ reject(new Error(`[labkit] IndexedDB database "${database}" has no store "${store}"`));
268
+ return;
269
+ }
270
+ resolve(db);
271
+ };
272
+ request.onerror = () => reject(request.error);
273
+ request.onblocked = () => reject(new Error(`[labkit] IndexedDB "${database}" is blocked`));
274
+ });
275
+ return opened;
276
+ };
277
+
278
+ const run = <T>(mode: IDBTransactionMode, body: (s: IDBObjectStore) => () => T): Promise<T> =>
279
+ open().then(
280
+ (db) =>
281
+ new Promise<T>((resolve, reject) => {
282
+ const tx = db.transaction(store, mode);
283
+ const result = body(tx.objectStore(store));
284
+ tx.oncomplete = () => resolve(result());
285
+ tx.onerror = () => reject(tx.error);
286
+ tx.onabort = () => reject(tx.error);
287
+ }),
288
+ );
289
+
290
+ let channel: NodeChannel | null = null;
291
+ const getChannel = (): NodeChannel | null => {
292
+ if (typeof BroadcastChannel === 'undefined') return null;
293
+ if (!channel) {
294
+ channel = new BroadcastChannel(`labkit:${database}:${store}`) as NodeChannel;
295
+ // Node keeps its event loop alive for an open channel; a browser has no unref.
296
+ channel.unref?.();
297
+ }
298
+ return channel;
299
+ };
300
+ const announce = (changes: StorageChange[]): void => {
301
+ getChannel()?.postMessage(changes);
302
+ };
303
+
304
+ const adapter: StorageAdapter = {
305
+ get: (key) =>
306
+ run('readonly', (s) => {
307
+ const request = s.get(key);
308
+ return () => request.result as unknown;
309
+ }),
310
+ list: (prefix) =>
311
+ run('readonly', (s) => {
312
+ const range = IDBKeyRange.bound(prefix, `${prefix}￿`);
313
+ const keys = s.getAllKeys(range);
314
+ const values = s.getAll(range);
315
+ return () =>
316
+ keys.result.map((key, i): [string, unknown] => [String(key), values.result[i]]);
317
+ }),
318
+ set: async (key, value) => {
319
+ await run('readwrite', (s) => {
320
+ s.put(value, key);
321
+ return () => undefined;
322
+ });
323
+ announce([[key, value]]);
324
+ },
325
+ delete: async (key) => {
326
+ await run('readwrite', (s) => {
327
+ s.delete(key);
328
+ return () => undefined;
329
+ });
330
+ announce([[key, undefined]]);
331
+ },
332
+ };
333
+ if (typeof BroadcastChannel !== 'undefined') {
334
+ adapter.subscribe = (prefix, on) => {
335
+ const ch = getChannel();
336
+ if (!ch) return () => {};
337
+ const handler = (e: MessageEvent<StorageChange[]>): void => {
338
+ const mine = e.data.filter(([key]) => key.startsWith(prefix));
339
+ if (mine.length > 0) on(mine);
340
+ };
341
+ ch.addEventListener('message', handler);
342
+ return () => ch.removeEventListener('message', handler);
343
+ };
344
+ }
345
+ return adapter;
346
+ }
347
+
348
+ /** IndexedDB under the default database and store. */
349
+ export const indexedDbAdapter: StorageAdapter = createIndexedDbAdapter();
350
+
351
+ let resolvedDefault: Promise<StorageAdapter> | null = null;
352
+
353
+ /** What a lab given only a `storageKey` persists to: IndexedDB, or
354
+ * localStorage — with a warning — where IndexedDB will not open. */
355
+ export function defaultStorage(): Promise<StorageAdapter> {
356
+ resolvedDefault ??= indexedDbAdapter.list('').then(
357
+ () => indexedDbAdapter,
358
+ (error) => {
359
+ console.warn('[labkit] IndexedDB would not open; persisting to localStorage instead', error);
360
+ return localStorageAdapter;
361
+ },
362
+ );
363
+ return resolvedDefault;
364
+ }
365
+
366
+ /** Forget which default was chosen. Tests only. */
367
+ export function resetDefaultStorage(): void {
368
+ resolvedDefault = null;
369
+ }
@@ -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
+ });