@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.
- package/README.md +13 -2
- package/dist/_dts/{CanvasStackContext-Dde8_1my.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
- package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
- package/dist/_dts/{frac-B5mB8LsJ.d.ts → frac-8R6UmlvN.d.ts} +256 -33
- package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
- package/dist/_dts/{index-DelFfeV0.d.ts → index-retnAut7.d.ts} +45 -12
- package/dist/_dts/{types-B9_zrHmb.d.ts → types-Ca2LCnPq.d.ts} +65 -2
- package/dist/_dts/usePanZoom-nS798rOF.d.ts +160 -0
- package/dist/_dts/useTrialState-jcx_TdU6.d.ts +212 -0
- package/dist/canvas/index.d.ts +17 -128
- package/dist/canvas/index.js +4 -2
- package/dist/chrome/index.d.ts +4 -92
- package/dist/chrome/index.js +9 -5
- package/dist/chunk-67SJLMC7.js +704 -0
- package/dist/chunk-67SJLMC7.js.map +1 -0
- package/dist/chunk-6ZDGOZQV.js +310 -0
- package/dist/chunk-6ZDGOZQV.js.map +1 -0
- package/dist/{chunk-QAGAHJKZ.js → chunk-EKIICY6X.js} +111 -25
- package/dist/chunk-EKIICY6X.js.map +1 -0
- package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
- package/dist/chunk-H6ZAOWNE.js.map +1 -0
- package/dist/{chunk-2RQPGOQF.js → chunk-KJALCDWE.js} +9 -7
- package/dist/chunk-KJALCDWE.js.map +1 -0
- package/dist/{chunk-ISCOFAUO.js → chunk-MUKOW3TC.js} +3 -3
- package/dist/{chunk-ISCOFAUO.js.map → chunk-MUKOW3TC.js.map} +1 -1
- package/dist/chunk-NDRYLVOW.js +15 -0
- package/dist/chunk-NDRYLVOW.js.map +1 -0
- package/dist/{chunk-C42T6DJT.js → chunk-NS64DXMN.js} +226 -159
- package/dist/chunk-NS64DXMN.js.map +1 -0
- package/dist/{chunk-I6JCVE24.js → chunk-PGETSEDK.js} +135 -13
- package/dist/chunk-PGETSEDK.js.map +1 -0
- package/dist/{chunk-FQMJUVHJ.js → chunk-QLSV2N3G.js} +32 -37
- package/dist/chunk-QLSV2N3G.js.map +1 -0
- package/dist/chunk-RWNDDKOH.js +682 -0
- package/dist/chunk-RWNDDKOH.js.map +1 -0
- package/dist/chunk-S2SHZRD7.js +7681 -0
- package/dist/chunk-S2SHZRD7.js.map +1 -0
- package/dist/{chunk-ASWKLRKJ.js → chunk-TQBBYTAF.js} +19 -82
- package/dist/chunk-TQBBYTAF.js.map +1 -0
- package/dist/chunk-UU3NJO6N.js +72 -0
- package/dist/chunk-UU3NJO6N.js.map +1 -0
- package/dist/chunk-WIG6XHJ7.js +35 -0
- package/dist/chunk-WIG6XHJ7.js.map +1 -0
- package/dist/{chunk-TJ7QY3OC.js → chunk-ZPH5WTWF.js} +21 -15
- package/dist/chunk-ZPH5WTWF.js.map +1 -0
- package/dist/config/index.d.ts +232 -0
- package/dist/config/index.js +6 -0
- package/dist/config/index.js.map +1 -0
- package/dist/controls/index.d.ts +2 -3
- package/dist/controls/index.js +3 -2
- package/dist/dragdrop/index.d.ts +2 -4
- package/dist/index.d.ts +123 -348
- package/dist/index.js +794 -837
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +3 -5
- package/dist/layers/index.js +2 -2
- package/dist/loupe/index.d.ts +6 -8
- package/dist/loupe/index.js +2 -2
- package/dist/passthrough/weasel-ui.d.ts +169 -117
- package/dist/passthrough/weasel-ui.js +1 -1
- package/dist/primitives/index.d.ts +53 -6
- package/dist/primitives/index.js +6 -4
- package/dist/state/index.d.ts +12 -7
- package/dist/state/index.js +16 -13
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +163 -62
- package/dist/surface/index.d.ts +44 -7
- package/dist/surface/index.js +2 -1
- package/dist/ui/layers/index.js +1 -1
- package/dist/undo/index.d.ts +2 -4
- package/package.json +15 -8
- package/src/annotations/AnnotationOverlay.tsx +53 -5
- package/src/annotations/Annotations.overlay.test.tsx +85 -6
- package/src/annotations/drawOne.test.ts +1 -1
- package/src/annotations/drawOne.ts +2 -2
- package/src/annotations/frac.test.ts +4 -4
- package/src/annotations/frac.ts +3 -2
- package/src/annotations/index.ts +1 -1
- package/src/annotations/paint.test.ts +2 -2
- package/src/annotations/paint.ts +1 -1
- package/src/annotations/preload.ts +6 -0
- package/src/annotations/store.test.ts +18 -0
- package/src/annotations/store.ts +7 -6
- package/src/annotations/svgNodes.test.ts +2 -2
- package/src/annotations/svgNodes.ts +1 -2
- package/src/annotations/toolMap.ts +3 -3
- package/src/annotations/types.ts +6 -3
- package/src/canvas/CameraWheelContext.ts +12 -0
- package/src/canvas/CanvasStack.test.tsx +25 -0
- package/src/canvas/CanvasStack.tsx +31 -4
- package/src/canvas/Stage.less +18 -0
- package/src/canvas/Stage.test.tsx +186 -0
- package/src/canvas/Stage.tsx +134 -0
- package/src/canvas/camera.test.ts +17 -2
- package/src/canvas/camera.ts +9 -9
- package/src/canvas/index.ts +4 -0
- package/src/canvas/usePanZoom.ts +10 -5
- package/src/chrome/ChromeRegions.stories.tsx +0 -3
- package/src/chrome/LabChrome.tsx +114 -0
- package/src/chrome/builtins.tsx +20 -31
- package/src/chrome/index.ts +15 -0
- package/src/chrome/labRegions.test.tsx +208 -0
- package/src/chrome/labTypes.ts +50 -0
- package/src/chrome/merge.ts +7 -9
- package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
- package/src/chrome/regions/PaletteRegion.tsx +21 -9
- package/src/chrome/regions/SidebarRegion.tsx +25 -13
- package/src/chrome/regions/StatusRegion.tsx +15 -8
- package/src/chrome/regions/ToolbarRegion.tsx +30 -16
- package/src/chrome/types.ts +47 -20
- package/src/config/builder.test.ts +4 -0
- package/src/config/builder.ts +16 -10
- package/src/config/declarationEmit.fixture.ts +11 -0
- package/src/config/declarationEmit.test.ts +51 -0
- package/src/config/entry.test.ts +46 -0
- package/src/config/index.ts +13 -1
- package/src/config/nodeClasses.test.ts +18 -0
- package/src/config/types.ts +3 -1
- package/src/controls/ControlPanel.test.tsx +16 -0
- package/src/controls/ControlPanel.tsx +5 -2
- package/src/fake-indexeddb-auto.d.ts +5 -0
- package/src/index.test.ts +7 -0
- package/src/index.ts +39 -6
- package/src/instrument/serializers.test.ts +22 -0
- package/src/instrument/serializers.ts +11 -0
- package/src/instrument/types.ts +25 -1
- package/src/lab/Lab.less +4 -13
- package/src/lab/Lab.persist.test.tsx +147 -0
- package/src/lab/Lab.stories.tsx +0 -2
- package/src/lab/Lab.surface.test.tsx +66 -3
- package/src/lab/Lab.test.tsx +91 -17
- package/src/lab/Lab.tsx +337 -124
- package/src/lab/LabFit.stories.less +18 -0
- package/src/lab/LabFit.stories.tsx +219 -0
- package/src/lab/LabFullChrome.stories.tsx +0 -3
- package/src/lab/LabHeader.test.tsx +40 -9
- package/src/lab/LabHeader.tsx +7 -13
- package/src/lab/LabPalette.tsx +13 -26
- package/src/lab/LabShell.less +49 -12
- package/src/lab/LabShell.tsx +14 -2
- package/src/lab/LabSwitcher.less +92 -0
- package/src/lab/LabSwitcher.test.tsx +96 -0
- package/src/lab/LabSwitcher.tsx +112 -0
- package/src/lab/Workspace.surface.test.tsx +2 -1
- package/src/lab/fitCheck.test.ts +57 -0
- package/src/lab/fitCheck.ts +125 -0
- package/src/lab/index.ts +2 -0
- package/src/loupe/AGENTS.md +1 -1
- package/src/loupe/Loupe.less +1 -1
- package/src/loupe/canvasLens.ts +1 -1
- package/src/loupe/types.ts +1 -1
- package/src/loupe/useLoupe.test.tsx +1 -1
- package/src/loupe/useLoupe.ts +1 -1
- package/src/passthrough/weasel-ui.ts +9 -0
- package/src/primitives/FloatingPanel.stories.tsx +13 -1
- package/src/primitives/FloatingPanel.test.tsx +47 -16
- package/src/primitives/FloatingPanel.tsx +11 -25
- package/src/primitives/Split.less +14 -0
- package/src/primitives/Split.test.tsx +108 -0
- package/src/primitives/Split.tsx +159 -0
- package/src/primitives/ZoomControl.tsx +5 -2
- package/src/primitives/index.ts +2 -0
- package/src/state/Persistence.stories.tsx +75 -0
- package/src/state/Persistence.tsx +33 -0
- package/src/state/SingletonExperiment.test.tsx +47 -53
- package/src/state/SingletonExperiment.tsx +24 -13
- package/src/state/adapterContract.ts +116 -0
- package/src/state/adapters.test.ts +113 -61
- package/src/state/adapters.ts +328 -69
- package/src/state/document.test.ts +71 -96
- package/src/state/document.ts +66 -52
- package/src/state/helpers.test.ts +20 -0
- package/src/state/helpers.ts +16 -0
- package/src/state/index.ts +8 -0
- package/src/state/labRecords.test.ts +105 -0
- package/src/state/labRecords.ts +160 -0
- package/src/state/openLabStore.test.ts +658 -0
- package/src/state/openLabStore.ts +403 -0
- package/src/state/records.test.ts +234 -0
- package/src/state/records.ts +219 -0
- package/src/state/store.test.ts +104 -635
- package/src/state/store.ts +117 -158
- package/src/state/toolSlot.test.ts +1 -1
- package/src/state/types.ts +39 -15
- package/src/state/useOpenOnce.ts +68 -0
- package/src/state/usePersistedState.test.tsx +125 -0
- package/src/state/usePersistedState.ts +66 -0
- package/src/state/useTrialState.test.tsx +2 -3
- package/src/state/view.test.ts +49 -15
- package/src/state/view.ts +18 -1
- package/src/styles.less +3 -0
- package/src/surface/AGENTS.md +39 -7
- package/src/surface/SurfaceContext.ts +26 -3
- package/src/surface/index.ts +2 -0
- package/src/surface/useSurfaceTile.test.tsx +2 -1
- package/src/surface/useSurfaceTile.ts +6 -5
- package/src/surface/useTiledSurface.test.tsx +175 -0
- package/src/surface/useTiledSurface.ts +54 -5
- package/src/theme/Interstellar.stories.tsx +7 -7
- package/src/theme/base.less +4 -4
- package/src/theme/interstellar.test.ts +1 -1
- package/src/theme/interstellar.tokens.json +2 -2
- package/src/tools/labTool.ts +11 -0
- package/src/trial/Trial.annotations.persist.test.tsx +100 -58
- package/src/trial/Trial.annotations.test.tsx +47 -4
- package/src/trial/Trial.job.test.tsx +3 -3
- package/src/trial/Trial.less +1 -26
- package/src/trial/Trial.stories.tsx +1 -2
- package/src/trial/Trial.test.tsx +28 -4
- package/src/trial/Trial.trialId.test.tsx +23 -1
- package/src/trial/Trial.tsx +118 -38
- package/src/trial/TrialBody.tsx +17 -141
- package/src/trial/TrialChrome.tsx +7 -4
- package/src/trial/trialOps.ts +7 -4
- package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
- package/dist/chunk-2RQPGOQF.js.map +0 -1
- package/dist/chunk-5TROQVQ2.js +0 -547
- package/dist/chunk-5TROQVQ2.js.map +0 -1
- package/dist/chunk-ASWKLRKJ.js.map +0 -1
- package/dist/chunk-C42T6DJT.js.map +0 -1
- package/dist/chunk-CTRKTLYZ.js.map +0 -1
- package/dist/chunk-FQMJUVHJ.js.map +0 -1
- package/dist/chunk-I6JCVE24.js.map +0 -1
- package/dist/chunk-MHAGC6VD.js +0 -7320
- package/dist/chunk-MHAGC6VD.js.map +0 -1
- package/dist/chunk-QAGAHJKZ.js.map +0 -1
- package/dist/chunk-TJ7QY3OC.js.map +0 -1
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CURRENT_DOCUMENT_VERSION,
|
|
3
|
+
deleteConfirmed,
|
|
4
|
+
emptyDocument,
|
|
5
|
+
labDocumentKey,
|
|
6
|
+
legacyKeys,
|
|
7
|
+
MIGRATIONS,
|
|
8
|
+
normalizeDocument,
|
|
9
|
+
parseStored,
|
|
10
|
+
quarantineDocument,
|
|
11
|
+
readLegacyDocument,
|
|
12
|
+
runMigrations,
|
|
13
|
+
sameStored,
|
|
14
|
+
} from './document';
|
|
15
|
+
import { serializeTrials } from './helpers';
|
|
16
|
+
import {
|
|
17
|
+
compareTrialOrder,
|
|
18
|
+
documentOfRecords,
|
|
19
|
+
LAYOUT_RECORD,
|
|
20
|
+
type LabMeta,
|
|
21
|
+
labPrefix,
|
|
22
|
+
META_RECORD,
|
|
23
|
+
parseRecordName,
|
|
24
|
+
recordsOfDocument,
|
|
25
|
+
saveRecord,
|
|
26
|
+
trialRecord,
|
|
27
|
+
trialValuesPrefix,
|
|
28
|
+
UNDOCK_RECORD,
|
|
29
|
+
} from './labRecords';
|
|
30
|
+
import { type OwnedRecordCache, openRecords, type RecordCache } from './records';
|
|
31
|
+
import { createLabStore, hydrateSnapshots, hydrateTrials, type LabStore } from './store';
|
|
32
|
+
import type {
|
|
33
|
+
CreateLabStoreOptions,
|
|
34
|
+
LabDocument,
|
|
35
|
+
LabMode,
|
|
36
|
+
SavedSnapshot,
|
|
37
|
+
SerializedTrial,
|
|
38
|
+
StorageAdapter,
|
|
39
|
+
TrialRecord,
|
|
40
|
+
} from './types';
|
|
41
|
+
|
|
42
|
+
/** Options for `openLabStore`. `storageKey` names the lab, so two labs on one
|
|
43
|
+
* origin keep separate records. */
|
|
44
|
+
export interface OpenLabStoreOptions extends Omit<CreateLabStoreOptions, 'initial'> {
|
|
45
|
+
storageKey: string;
|
|
46
|
+
storage: StorageAdapter;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A lab store bound to its records. */
|
|
50
|
+
export interface OpenedLabStore {
|
|
51
|
+
store: LabStore;
|
|
52
|
+
/** Every record of the lab, `usePersistedState` values included. */
|
|
53
|
+
records: RecordCache;
|
|
54
|
+
/** Send queued writes and stop hearing other writers. */
|
|
55
|
+
close(): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read a stored lab and return a store bound to it: every change to the store
|
|
60
|
+
* is written as the records it touched, and another writer's records are
|
|
61
|
+
* applied back into the store. A lab stored before records existed is folded
|
|
62
|
+
* forward on the way in.
|
|
63
|
+
*/
|
|
64
|
+
export async function openLabStore(options: OpenLabStoreOptions): Promise<OpenedLabStore> {
|
|
65
|
+
const records = await openRecords({
|
|
66
|
+
storage: options.storage,
|
|
67
|
+
prefix: labPrefix(options.storageKey),
|
|
68
|
+
});
|
|
69
|
+
const { doc, orders } = await readLab(options, records);
|
|
70
|
+
const store = createLabStore({ ...options, initial: doc });
|
|
71
|
+
const unbind = bindLabStore(store, records, { orders });
|
|
72
|
+
return {
|
|
73
|
+
store,
|
|
74
|
+
records,
|
|
75
|
+
close: async () => {
|
|
76
|
+
unbind();
|
|
77
|
+
await records.close();
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface ReadLab {
|
|
83
|
+
doc: LabDocument;
|
|
84
|
+
orders: Map<string, number>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function emptyLab(mode: LabMode): ReadLab {
|
|
88
|
+
return { doc: emptyDocument(mode), orders: new Map() };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function ordersOf(doc: LabDocument): Map<string, number> {
|
|
92
|
+
return new Map(doc.trials.map((t, i) => [t.id, i]));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function readLab(options: OpenLabStoreOptions, records: OwnedRecordCache): Promise<ReadLab> {
|
|
96
|
+
const mode = options.initialMode ?? 'auto';
|
|
97
|
+
if (!records.writable) return emptyLab(mode);
|
|
98
|
+
|
|
99
|
+
const joined = documentOfRecords(records.entries());
|
|
100
|
+
if (!joined) return foldOldStorage(options, records);
|
|
101
|
+
|
|
102
|
+
const outcome = runMigrations(joined.doc, MIGRATIONS, CURRENT_DOCUMENT_VERSION);
|
|
103
|
+
if (outcome.ok) {
|
|
104
|
+
return { doc: normalizeDocument(outcome.doc, mode), orders: joined.orders };
|
|
105
|
+
}
|
|
106
|
+
const why =
|
|
107
|
+
outcome.reason === 'future' ? 'are from a newer version of labkit' : 'failed to migrate';
|
|
108
|
+
console.warn(`[labkit] lab records ${why}; starting empty and leaving them alone`, outcome.error);
|
|
109
|
+
records.stopWriting();
|
|
110
|
+
return emptyLab(mode);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Fold a version-3 document, or the four keys before it, into records. The
|
|
114
|
+
* old keys are deleted only once every record has been read back. */
|
|
115
|
+
async function foldOldStorage(
|
|
116
|
+
options: OpenLabStoreOptions,
|
|
117
|
+
records: OwnedRecordCache,
|
|
118
|
+
): Promise<ReadLab> {
|
|
119
|
+
const { storage, storageKey } = options;
|
|
120
|
+
const mode = options.initialMode ?? 'auto';
|
|
121
|
+
|
|
122
|
+
let raw: unknown;
|
|
123
|
+
let oldKeys: string[];
|
|
124
|
+
try {
|
|
125
|
+
const stored = await storage.get(labDocumentKey(storageKey));
|
|
126
|
+
if (stored !== undefined) {
|
|
127
|
+
raw = stored;
|
|
128
|
+
oldKeys = [labDocumentKey(storageKey)];
|
|
129
|
+
} else {
|
|
130
|
+
raw = await readLegacyDocument((key) => storage.get(key), storageKey, mode);
|
|
131
|
+
oldKeys = legacyKeys(storageKey);
|
|
132
|
+
if (raw === null) return emptyLab(mode);
|
|
133
|
+
}
|
|
134
|
+
} catch (error) {
|
|
135
|
+
console.warn(
|
|
136
|
+
`[labkit] could not read the old storage for "${storageKey}"; not persisting`,
|
|
137
|
+
error,
|
|
138
|
+
);
|
|
139
|
+
records.stopWriting();
|
|
140
|
+
return emptyLab(mode);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const setAside = async (value: unknown, why: string, error?: unknown): Promise<ReadLab> => {
|
|
144
|
+
if (await quarantineDocument(storage, storageKey, value)) {
|
|
145
|
+
console.warn(`[labkit] ${why}; quarantined it and starting empty`, error);
|
|
146
|
+
await deleteConfirmed(storage, oldKeys);
|
|
147
|
+
records.set(META_RECORD, { version: CURRENT_DOCUMENT_VERSION, mode } satisfies LabMeta);
|
|
148
|
+
} else {
|
|
149
|
+
console.warn(
|
|
150
|
+
`[labkit] ${why} and could not be quarantined; leaving it in place and not persisting`,
|
|
151
|
+
error,
|
|
152
|
+
);
|
|
153
|
+
records.stopWriting();
|
|
154
|
+
}
|
|
155
|
+
return emptyLab(mode);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const parsed = parseStored(raw);
|
|
159
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
160
|
+
return setAside(raw, 'lab document is unparseable');
|
|
161
|
+
}
|
|
162
|
+
const outcome = runMigrations(
|
|
163
|
+
parsed as Record<string, unknown>,
|
|
164
|
+
MIGRATIONS,
|
|
165
|
+
CURRENT_DOCUMENT_VERSION,
|
|
166
|
+
);
|
|
167
|
+
if (!outcome.ok) {
|
|
168
|
+
if (outcome.reason === 'future') {
|
|
169
|
+
console.warn(
|
|
170
|
+
'[labkit] lab document is from a newer version of labkit; starting empty and leaving it alone',
|
|
171
|
+
);
|
|
172
|
+
records.stopWriting();
|
|
173
|
+
return emptyLab(mode);
|
|
174
|
+
}
|
|
175
|
+
return setAside(parsed, 'lab document failed to migrate', outcome.error);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const doc = normalizeDocument(outcome.doc, mode);
|
|
179
|
+
const entries = recordsOfDocument(doc);
|
|
180
|
+
records.seed(entries);
|
|
181
|
+
if (await writeConfirmed(storage, records.prefix, entries)) {
|
|
182
|
+
await deleteConfirmed(storage, oldKeys);
|
|
183
|
+
} else {
|
|
184
|
+
console.warn(
|
|
185
|
+
`[labkit] keeping the old storage for "${storageKey}": could not confirm its records were written`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return { doc, orders: ordersOf(doc) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Write `entries` in order and read every one back. */
|
|
192
|
+
async function writeConfirmed(
|
|
193
|
+
storage: StorageAdapter,
|
|
194
|
+
prefix: string,
|
|
195
|
+
entries: [string, unknown][],
|
|
196
|
+
): Promise<boolean> {
|
|
197
|
+
try {
|
|
198
|
+
for (const [name, value] of entries) await storage.set(prefix + name, value);
|
|
199
|
+
const back = new Map(await storage.list(prefix));
|
|
200
|
+
return entries.every(([name, value]) => sameStored(back.get(prefix + name), value));
|
|
201
|
+
} catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
interface BindOptions {
|
|
207
|
+
orders: Map<string, number>;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function persistedFieldsDiffer(a: TrialRecord, b: TrialRecord): boolean {
|
|
211
|
+
if (a === b) return false;
|
|
212
|
+
const left = a as unknown as Record<string, unknown>;
|
|
213
|
+
const right = b as unknown as Record<string, unknown>;
|
|
214
|
+
for (const key of new Set([...Object.keys(left), ...Object.keys(right)])) {
|
|
215
|
+
if (key !== 'undoStack' && !Object.is(left[key], right[key])) return true;
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Keep `store` and `records` in step, both ways. Returns the unbinding. */
|
|
221
|
+
function bindLabStore(store: LabStore, records: RecordCache, options: BindOptions): () => void {
|
|
222
|
+
const orderOf = new Map(options.orders);
|
|
223
|
+
let applyingRemote = false;
|
|
224
|
+
|
|
225
|
+
const nextOrder = (): number => {
|
|
226
|
+
let max = -1;
|
|
227
|
+
for (const order of orderOf.values()) max = Math.max(max, order);
|
|
228
|
+
return max + 1;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const writeTrial = (trial: TrialRecord): void => {
|
|
232
|
+
const [serialized] = serializeTrials([trial], store.getState().instrumentHooks().serializers);
|
|
233
|
+
records.set(trialRecord(trial.id), { ...serialized, order: orderOf.get(trial.id) });
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const inStoredOrder = (trials: TrialRecord[]): boolean => {
|
|
237
|
+
for (let i = 1; i < trials.length; i++) {
|
|
238
|
+
const a = trials[i - 1] as TrialRecord;
|
|
239
|
+
const b = trials[i] as TrialRecord;
|
|
240
|
+
const at = { trial: a, order: orderOf.get(a.id) ?? 0 };
|
|
241
|
+
const bt = { trial: b, order: orderOf.get(b.id) ?? 0 };
|
|
242
|
+
if (compareTrialOrder(at, bt) >= 0) return false;
|
|
243
|
+
}
|
|
244
|
+
return true;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const offStore = store.subscribe((state, prev) => {
|
|
248
|
+
if (applyingRemote) return;
|
|
249
|
+
let wrote = false;
|
|
250
|
+
|
|
251
|
+
if (state.trials !== prev.trials) {
|
|
252
|
+
const before = new Map(prev.trials.map((t) => [t.id, t]));
|
|
253
|
+
const now = new Set(state.trials.map((t) => t.id));
|
|
254
|
+
for (const t of prev.trials) {
|
|
255
|
+
if (now.has(t.id)) continue;
|
|
256
|
+
records.delete(trialRecord(t.id));
|
|
257
|
+
for (const [name] of records.entries(trialValuesPrefix(t.id))) records.delete(name);
|
|
258
|
+
orderOf.delete(t.id);
|
|
259
|
+
wrote = true;
|
|
260
|
+
}
|
|
261
|
+
const added = state.trials.filter((t) => !before.has(t.id));
|
|
262
|
+
for (const t of added) orderOf.set(t.id, nextOrder());
|
|
263
|
+
if (inStoredOrder(state.trials)) {
|
|
264
|
+
for (const t of state.trials) {
|
|
265
|
+
const old = before.get(t.id);
|
|
266
|
+
if (old && !persistedFieldsDiffer(old, t)) continue;
|
|
267
|
+
writeTrial(t);
|
|
268
|
+
wrote = true;
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
// A reorder, or a trial inserted mid-list: renumber them all.
|
|
272
|
+
for (const [i, t] of state.trials.entries()) orderOf.set(t.id, i);
|
|
273
|
+
for (const t of state.trials) writeTrial(t);
|
|
274
|
+
wrote = true;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (state.savedSnapshots !== prev.savedSnapshots) {
|
|
279
|
+
const before = new Map(prev.savedSnapshots.map((s) => [s.id, s]));
|
|
280
|
+
const now = new Set(state.savedSnapshots.map((s) => s.id));
|
|
281
|
+
for (const s of state.savedSnapshots) {
|
|
282
|
+
if (before.get(s.id) === s) continue;
|
|
283
|
+
records.set(saveRecord(s.id), s);
|
|
284
|
+
wrote = true;
|
|
285
|
+
}
|
|
286
|
+
for (const s of prev.savedSnapshots) {
|
|
287
|
+
if (now.has(s.id)) continue;
|
|
288
|
+
records.delete(saveRecord(s.id));
|
|
289
|
+
wrote = true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (state.layout !== prev.layout) {
|
|
294
|
+
records.set(LAYOUT_RECORD, state.layout);
|
|
295
|
+
wrote = true;
|
|
296
|
+
}
|
|
297
|
+
if (state.undockedPanels !== prev.undockedPanels) {
|
|
298
|
+
records.set(UNDOCK_RECORD, state.undockedPanels);
|
|
299
|
+
wrote = true;
|
|
300
|
+
}
|
|
301
|
+
if (state.mode !== prev.mode || (wrote && !records.has(META_RECORD))) {
|
|
302
|
+
records.set(META_RECORD, {
|
|
303
|
+
version: CURRENT_DOCUMENT_VERSION,
|
|
304
|
+
mode: state.mode,
|
|
305
|
+
} satisfies LabMeta);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const offRecords = records.subscribe((changes) => {
|
|
310
|
+
const remote = changes.filter((c) => c.origin === 'remote');
|
|
311
|
+
if (remote.length === 0) return;
|
|
312
|
+
|
|
313
|
+
const current = store.getState();
|
|
314
|
+
let trials = current.trials;
|
|
315
|
+
let saves = current.savedSnapshots;
|
|
316
|
+
const patch: Partial<Pick<typeof current, 'layout' | 'undockedPanels' | 'mode'>> = {};
|
|
317
|
+
|
|
318
|
+
for (const { name, value } of remote) {
|
|
319
|
+
const parsed = parseRecordName(name);
|
|
320
|
+
if (!parsed) continue;
|
|
321
|
+
switch (parsed.kind) {
|
|
322
|
+
case 'trial': {
|
|
323
|
+
const id = parsed.id;
|
|
324
|
+
if (value === undefined || !value || typeof value !== 'object') {
|
|
325
|
+
trials = trials.filter((t) => t.id !== id);
|
|
326
|
+
orderOf.delete(id);
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
const { order, ...rest } = value as SerializedTrial & { order?: unknown };
|
|
330
|
+
orderOf.set(
|
|
331
|
+
id,
|
|
332
|
+
typeof order === 'number' && Number.isFinite(order) ? order : nextOrder(),
|
|
333
|
+
);
|
|
334
|
+
// Hydrating starts an empty undo history: the old one describes a
|
|
335
|
+
// state that no longer exists.
|
|
336
|
+
const hooks = store.getState().instrumentHooks();
|
|
337
|
+
const [replacement] = hydrateTrials([{ ...rest, id }], hooks.serializers, hooks);
|
|
338
|
+
if (!replacement) break;
|
|
339
|
+
trials = trials.some((t) => t.id === id)
|
|
340
|
+
? trials.map((t) => (t.id === id ? replacement : t))
|
|
341
|
+
: [...trials, replacement];
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
case 'save': {
|
|
345
|
+
const id = parsed.id;
|
|
346
|
+
if (value === undefined || !value || typeof value !== 'object') {
|
|
347
|
+
saves = saves.filter((s) => s.id !== id);
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
const [snapshot] = hydrateSnapshots(
|
|
351
|
+
[{ ...(value as SavedSnapshot), id }],
|
|
352
|
+
store.getState().instrumentHooks(),
|
|
353
|
+
);
|
|
354
|
+
if (!snapshot) break;
|
|
355
|
+
saves = saves.some((s) => s.id === id)
|
|
356
|
+
? saves.map((s) => (s.id === id ? snapshot : s))
|
|
357
|
+
: [...saves, snapshot];
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
case 'layout':
|
|
361
|
+
patch.layout = (value as Record<string, unknown> | undefined) ?? {};
|
|
362
|
+
break;
|
|
363
|
+
case 'undock':
|
|
364
|
+
patch.undockedPanels = (value as typeof current.undockedPanels | undefined) ?? {};
|
|
365
|
+
break;
|
|
366
|
+
case 'meta': {
|
|
367
|
+
const meta = value as Partial<LabMeta> | undefined;
|
|
368
|
+
if (typeof meta?.version === 'number' && meta.version > CURRENT_DOCUMENT_VERSION) {
|
|
369
|
+
console.warn(
|
|
370
|
+
'[labkit] another tab wrote this lab with a newer labkit; no longer persisting',
|
|
371
|
+
);
|
|
372
|
+
if ('stopWriting' in records) (records as OwnedRecordCache).stopWriting();
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
if (meta?.mode === 'light' || meta?.mode === 'dark' || meta?.mode === 'auto') {
|
|
376
|
+
patch.mode = meta.mode;
|
|
377
|
+
}
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (trials !== current.trials) {
|
|
384
|
+
trials = [...trials].sort((a, b) =>
|
|
385
|
+
compareTrialOrder(
|
|
386
|
+
{ trial: a, order: orderOf.get(a.id) ?? 0 },
|
|
387
|
+
{ trial: b, order: orderOf.get(b.id) ?? 0 },
|
|
388
|
+
),
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
applyingRemote = true;
|
|
392
|
+
try {
|
|
393
|
+
store.setState({ ...patch, trials, savedSnapshots: saves });
|
|
394
|
+
} finally {
|
|
395
|
+
applyingRemote = false;
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
return () => {
|
|
400
|
+
offStore();
|
|
401
|
+
offRecords();
|
|
402
|
+
};
|
|
403
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createMemoryAdapter } from './adapters';
|
|
3
|
+
import { createRecordCache, openRecords, type RecordChange } from './records';
|
|
4
|
+
import type { StorageAdapter } from './types';
|
|
5
|
+
|
|
6
|
+
const PREFIX = 'lk:t:';
|
|
7
|
+
|
|
8
|
+
/** Two tabs: each opens its own cache over one shared backing. */
|
|
9
|
+
async function twoTabs(backing = new Map<string, unknown>()) {
|
|
10
|
+
const a = await openRecords({ storage: createMemoryAdapter(backing), prefix: PREFIX });
|
|
11
|
+
const b = await openRecords({ storage: createMemoryAdapter(backing), prefix: PREFIX });
|
|
12
|
+
return { a, b, backing };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Let queued microtasks — the memory adapter's notifications — run. */
|
|
16
|
+
const tick = () => vi.advanceTimersByTimeAsync(0);
|
|
17
|
+
|
|
18
|
+
describe('openRecords', () => {
|
|
19
|
+
beforeEach(() => vi.useFakeTimers());
|
|
20
|
+
afterEach(() => vi.useRealTimers());
|
|
21
|
+
|
|
22
|
+
it('holds every record under its prefix, by name', async () => {
|
|
23
|
+
const backing = new Map<string, unknown>([
|
|
24
|
+
['lk:t:meta', { version: 4 }],
|
|
25
|
+
['lk:t:trial:a', { id: 'a' }],
|
|
26
|
+
['lk:other:meta', { version: 4 }],
|
|
27
|
+
]);
|
|
28
|
+
const cache = await openRecords({ storage: createMemoryAdapter(backing), prefix: PREFIX });
|
|
29
|
+
expect(new Map(cache.entries())).toEqual(
|
|
30
|
+
new Map<string, unknown>([
|
|
31
|
+
['meta', { version: 4 }],
|
|
32
|
+
['trial:a', { id: 'a' }],
|
|
33
|
+
]),
|
|
34
|
+
);
|
|
35
|
+
expect(cache.entries('trial:')).toEqual([['trial:a', { id: 'a' }]]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('opens empty and never writes when the read fails', async () => {
|
|
39
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
40
|
+
const set = vi.fn(async () => {});
|
|
41
|
+
const broken: StorageAdapter = {
|
|
42
|
+
get: async () => undefined,
|
|
43
|
+
list: async () => {
|
|
44
|
+
throw new Error('disk on fire');
|
|
45
|
+
},
|
|
46
|
+
set,
|
|
47
|
+
delete: async () => {},
|
|
48
|
+
};
|
|
49
|
+
const cache = await openRecords({ storage: broken, prefix: PREFIX });
|
|
50
|
+
expect(cache.writable).toBe(false);
|
|
51
|
+
cache.set('meta', { version: 4 });
|
|
52
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
53
|
+
await cache.flush();
|
|
54
|
+
expect(set).not.toHaveBeenCalled();
|
|
55
|
+
expect(cache.get('meta')).toEqual({ version: 4 });
|
|
56
|
+
warn.mockRestore();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('writing', () => {
|
|
61
|
+
beforeEach(() => vi.useFakeTimers());
|
|
62
|
+
afterEach(() => vi.useRealTimers());
|
|
63
|
+
|
|
64
|
+
it('holds a write in memory at once and in storage after the debounce', async () => {
|
|
65
|
+
const backing = new Map<string, unknown>();
|
|
66
|
+
const cache = await openRecords({ storage: createMemoryAdapter(backing), prefix: PREFIX });
|
|
67
|
+
cache.set('layout', { a: 1 });
|
|
68
|
+
expect(cache.get('layout')).toEqual({ a: 1 });
|
|
69
|
+
await vi.advanceTimersByTimeAsync(299);
|
|
70
|
+
expect(backing.has('lk:t:layout')).toBe(false);
|
|
71
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
72
|
+
expect(backing.get('lk:t:layout')).toEqual({ a: 1 });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('writes under continuous change no later than the max wait', async () => {
|
|
76
|
+
const backing = new Map<string, unknown>();
|
|
77
|
+
const cache = await openRecords({ storage: createMemoryAdapter(backing), prefix: PREFIX });
|
|
78
|
+
for (let i = 0; i < 12; i++) {
|
|
79
|
+
cache.set('layout', { i });
|
|
80
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
81
|
+
}
|
|
82
|
+
expect(backing.get('lk:t:layout')).toBeDefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('tells its own listeners about local writes and deletes', async () => {
|
|
86
|
+
const cache = await openRecords({ storage: createMemoryAdapter(), prefix: PREFIX });
|
|
87
|
+
const heard: RecordChange[] = [];
|
|
88
|
+
cache.subscribe((c) => heard.push(...c));
|
|
89
|
+
cache.set('value:lab:x', 1);
|
|
90
|
+
cache.delete('value:lab:x');
|
|
91
|
+
expect(heard).toEqual([
|
|
92
|
+
{ name: 'value:lab:x', value: 1, origin: 'local' },
|
|
93
|
+
{ name: 'value:lab:x', value: undefined, origin: 'local' },
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('deletes from storage', async () => {
|
|
98
|
+
const backing = new Map<string, unknown>([['lk:t:save:s', { id: 's' }]]);
|
|
99
|
+
const cache = await openRecords({ storage: createMemoryAdapter(backing), prefix: PREFIX });
|
|
100
|
+
cache.delete('save:s');
|
|
101
|
+
await cache.flush();
|
|
102
|
+
expect(backing.has('lk:t:save:s')).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('keeps a failed write in memory, and writes it again on its next change', async () => {
|
|
106
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
107
|
+
const backing = new Map<string, unknown>();
|
|
108
|
+
const memory = createMemoryAdapter(backing);
|
|
109
|
+
const flaky: StorageAdapter = {
|
|
110
|
+
...memory,
|
|
111
|
+
set: vi.fn(memory.set).mockImplementationOnce(async () => {
|
|
112
|
+
throw new Error('quota');
|
|
113
|
+
}),
|
|
114
|
+
};
|
|
115
|
+
const cache = await openRecords({ storage: flaky, prefix: PREFIX });
|
|
116
|
+
cache.set('layout', { v: 1 });
|
|
117
|
+
await cache.flush();
|
|
118
|
+
expect(warn).toHaveBeenCalled();
|
|
119
|
+
expect(cache.get('layout')).toEqual({ v: 1 });
|
|
120
|
+
expect(backing.has('lk:t:layout')).toBe(false);
|
|
121
|
+
|
|
122
|
+
cache.set('layout', { v: 2 });
|
|
123
|
+
await cache.flush();
|
|
124
|
+
expect(backing.get('lk:t:layout')).toEqual({ v: 2 });
|
|
125
|
+
warn.mockRestore();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('sends queued writes when the page hides', async () => {
|
|
129
|
+
const backing = new Map<string, unknown>();
|
|
130
|
+
const cache = await openRecords({ storage: createMemoryAdapter(backing), prefix: PREFIX });
|
|
131
|
+
cache.set('layout', { a: 1 });
|
|
132
|
+
window.dispatchEvent(new Event('pagehide'));
|
|
133
|
+
await tick();
|
|
134
|
+
expect(backing.get('lk:t:layout')).toEqual({ a: 1 });
|
|
135
|
+
await cache.close();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('sends queued writes on close, and none after', async () => {
|
|
139
|
+
const backing = new Map<string, unknown>();
|
|
140
|
+
const cache = await openRecords({ storage: createMemoryAdapter(backing), prefix: PREFIX });
|
|
141
|
+
cache.set('layout', { a: 1 });
|
|
142
|
+
await cache.close();
|
|
143
|
+
expect(backing.get('lk:t:layout')).toEqual({ a: 1 });
|
|
144
|
+
cache.set('layout', { a: 2 });
|
|
145
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
146
|
+
expect(backing.get('lk:t:layout')).toEqual({ a: 1 });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('seeds records without writing them', async () => {
|
|
150
|
+
const backing = new Map<string, unknown>();
|
|
151
|
+
const cache = createRecordCache({ storage: createMemoryAdapter(backing), prefix: PREFIX });
|
|
152
|
+
cache.seed([['meta', { version: 4 }]]);
|
|
153
|
+
await cache.flush();
|
|
154
|
+
expect(cache.get('meta')).toEqual({ version: 4 });
|
|
155
|
+
expect(backing.size).toBe(0);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('two writers', () => {
|
|
160
|
+
beforeEach(() => vi.useFakeTimers());
|
|
161
|
+
afterEach(() => vi.useRealTimers());
|
|
162
|
+
|
|
163
|
+
it("applies another writer's change and reports it as remote", async () => {
|
|
164
|
+
const { a, b } = await twoTabs();
|
|
165
|
+
const heard: RecordChange[] = [];
|
|
166
|
+
b.subscribe((c) => heard.push(...c));
|
|
167
|
+
a.set('trial:x', { id: 'x', n: 1 });
|
|
168
|
+
await a.flush();
|
|
169
|
+
await tick();
|
|
170
|
+
expect(b.get('trial:x')).toEqual({ id: 'x', n: 1 });
|
|
171
|
+
expect(heard).toEqual([{ name: 'trial:x', value: { id: 'x', n: 1 }, origin: 'remote' }]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('keeps edits to different records from both writers', async () => {
|
|
175
|
+
const { a, b, backing } = await twoTabs();
|
|
176
|
+
a.set('trial:one', { n: 'a' });
|
|
177
|
+
b.set('trial:two', { n: 'b' });
|
|
178
|
+
await Promise.all([a.flush(), b.flush()]);
|
|
179
|
+
await tick();
|
|
180
|
+
expect(backing.get('lk:t:trial:one')).toEqual({ n: 'a' });
|
|
181
|
+
expect(backing.get('lk:t:trial:two')).toEqual({ n: 'b' });
|
|
182
|
+
expect(a.get('trial:two')).toEqual({ n: 'b' });
|
|
183
|
+
expect(b.get('trial:one')).toEqual({ n: 'a' });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('lets a queued local write beat an incoming one, and land as the newest', async () => {
|
|
187
|
+
const { a, b, backing } = await twoTabs();
|
|
188
|
+
b.set('trial:x', { from: 'b' });
|
|
189
|
+
a.set('trial:x', { from: 'a' });
|
|
190
|
+
await a.flush();
|
|
191
|
+
await tick();
|
|
192
|
+
expect(b.get('trial:x')).toEqual({ from: 'b' });
|
|
193
|
+
await b.flush();
|
|
194
|
+
await tick();
|
|
195
|
+
expect(backing.get('lk:t:trial:x')).toEqual({ from: 'b' });
|
|
196
|
+
expect(a.get('trial:x')).toEqual({ from: 'b' });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("propagates another writer's delete", async () => {
|
|
200
|
+
const backing = new Map<string, unknown>([['lk:t:trial:x', { id: 'x' }]]);
|
|
201
|
+
const { a, b } = await twoTabs(backing);
|
|
202
|
+
a.delete('trial:x');
|
|
203
|
+
await a.flush();
|
|
204
|
+
await tick();
|
|
205
|
+
expect(b.has('trial:x')).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('stops hearing other writers once closed', async () => {
|
|
209
|
+
const { a, b } = await twoTabs();
|
|
210
|
+
await b.close();
|
|
211
|
+
a.set('trial:x', { id: 'x' });
|
|
212
|
+
await a.flush();
|
|
213
|
+
await tick();
|
|
214
|
+
expect(b.has('trial:x')).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('keeps a change that lands between the read and the subscription', async () => {
|
|
218
|
+
const backing = new Map<string, unknown>();
|
|
219
|
+
const writer = createMemoryAdapter(backing);
|
|
220
|
+
const reader = createMemoryAdapter(backing);
|
|
221
|
+
const slowList: StorageAdapter = {
|
|
222
|
+
...reader,
|
|
223
|
+
list: async (prefix) => {
|
|
224
|
+
const listed = await reader.list(prefix);
|
|
225
|
+
await writer.set('lk:t:trial:late', { id: 'late' });
|
|
226
|
+
await Promise.resolve();
|
|
227
|
+
return listed;
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
const cache = await openRecords({ storage: slowList, prefix: PREFIX });
|
|
231
|
+
await tick();
|
|
232
|
+
expect(cache.get('trial:late')).toEqual({ id: 'late' });
|
|
233
|
+
});
|
|
234
|
+
});
|