@buoy-gg/time-machine 7.0.17

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 (60) hide show
  1. package/LICENSE +58 -0
  2. package/README.md +55 -0
  3. package/lib/commonjs/components/TimeMachineBody.js +420 -0
  4. package/lib/commonjs/components/TimeMachineBody.js.map +1 -0
  5. package/lib/commonjs/components/TimeMachineModal.js +74 -0
  6. package/lib/commonjs/components/TimeMachineModal.js.map +1 -0
  7. package/lib/commonjs/hooks/useTimeMachine.js +24 -0
  8. package/lib/commonjs/hooks/useTimeMachine.js.map +1 -0
  9. package/lib/commonjs/index.js +64 -0
  10. package/lib/commonjs/index.js.map +1 -0
  11. package/lib/commonjs/package.json +1 -0
  12. package/lib/commonjs/preset.js +45 -0
  13. package/lib/commonjs/preset.js.map +1 -0
  14. package/lib/commonjs/store/snapshotVault.js +83 -0
  15. package/lib/commonjs/store/snapshotVault.js.map +1 -0
  16. package/lib/commonjs/store/timeMachineStore.js +335 -0
  17. package/lib/commonjs/store/timeMachineStore.js.map +1 -0
  18. package/lib/commonjs/sync/timeMachineSyncAdapter.js +139 -0
  19. package/lib/commonjs/sync/timeMachineSyncAdapter.js.map +1 -0
  20. package/lib/module/components/TimeMachineBody.js +415 -0
  21. package/lib/module/components/TimeMachineBody.js.map +1 -0
  22. package/lib/module/components/TimeMachineModal.js +68 -0
  23. package/lib/module/components/TimeMachineModal.js.map +1 -0
  24. package/lib/module/hooks/useTimeMachine.js +19 -0
  25. package/lib/module/hooks/useTimeMachine.js.map +1 -0
  26. package/lib/module/index.js +29 -0
  27. package/lib/module/index.js.map +1 -0
  28. package/lib/module/preset.js +40 -0
  29. package/lib/module/preset.js.map +1 -0
  30. package/lib/module/store/snapshotVault.js +74 -0
  31. package/lib/module/store/snapshotVault.js.map +1 -0
  32. package/lib/module/store/timeMachineStore.js +327 -0
  33. package/lib/module/store/timeMachineStore.js.map +1 -0
  34. package/lib/module/sync/timeMachineSyncAdapter.js +134 -0
  35. package/lib/module/sync/timeMachineSyncAdapter.js.map +1 -0
  36. package/lib/typescript/components/TimeMachineBody.d.ts +2 -0
  37. package/lib/typescript/components/TimeMachineBody.d.ts.map +1 -0
  38. package/lib/typescript/components/TimeMachineModal.d.ts +12 -0
  39. package/lib/typescript/components/TimeMachineModal.d.ts.map +1 -0
  40. package/lib/typescript/hooks/useTimeMachine.d.ts +6 -0
  41. package/lib/typescript/hooks/useTimeMachine.d.ts.map +1 -0
  42. package/lib/typescript/index.d.ts +18 -0
  43. package/lib/typescript/index.d.ts.map +1 -0
  44. package/lib/typescript/preset.d.ts +38 -0
  45. package/lib/typescript/preset.d.ts.map +1 -0
  46. package/lib/typescript/store/snapshotVault.d.ts +46 -0
  47. package/lib/typescript/store/snapshotVault.d.ts.map +1 -0
  48. package/lib/typescript/store/timeMachineStore.d.ts +97 -0
  49. package/lib/typescript/store/timeMachineStore.d.ts.map +1 -0
  50. package/lib/typescript/sync/timeMachineSyncAdapter.d.ts +59 -0
  51. package/lib/typescript/sync/timeMachineSyncAdapter.d.ts.map +1 -0
  52. package/package.json +59 -0
  53. package/src/components/TimeMachineBody.tsx +455 -0
  54. package/src/components/TimeMachineModal.tsx +75 -0
  55. package/src/hooks/useTimeMachine.ts +26 -0
  56. package/src/index.tsx +45 -0
  57. package/src/preset.tsx +38 -0
  58. package/src/store/snapshotVault.ts +117 -0
  59. package/src/store/timeMachineStore.ts +368 -0
  60. package/src/sync/timeMachineSyncAdapter.ts +128 -0
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Snapshot persistence for Time Machine.
3
+ *
4
+ * Snapshots live ON-DEVICE in persistentStorage (FileSystem → AsyncStorage →
5
+ * localStorage → Memory fallback): no size caps, survives restarts, and never
6
+ * crosses the 2MB external-sync snapshot budget — only metadata does.
7
+ *
8
+ * Layout: ONE storage key per snapshot (persistentStorage's FileSystem
9
+ * backend writes one file per key, and its multiGet is sequential — a single
10
+ * big key beats many small ones), plus one index key holding the metadata
11
+ * list for cheap listing.
12
+ *
13
+ * Keys use the `@react_buoy` family so every tool's isDevToolsStorageKey
14
+ * filter (including our own storage provider) excludes the vault itself from
15
+ * snapshots — a snapshot must never contain snapshots.
16
+ */
17
+ import {
18
+ persistentStorage,
19
+ type SuperJSONResult,
20
+ } from "@buoy-gg/shared-ui";
21
+
22
+ const INDEX_KEY = "@react_buoy_time_machine:index";
23
+ const SNAP_PREFIX = "@react_buoy_time_machine:snap:";
24
+
25
+ export interface SnapshotSourceMeta {
26
+ warnings: string[];
27
+ }
28
+
29
+ export interface SnapshotMeta {
30
+ id: string;
31
+ name: string;
32
+ createdAt: number;
33
+ /** Serialized size of the whole stored snapshot. */
34
+ sizeBytes: number;
35
+ /**
36
+ * A baseline snapshot has NO captured data — restoring it wipes to
37
+ * fresh-install state (clear + reload).
38
+ */
39
+ baseline?: boolean;
40
+ /** Source ids captured, with their capture warnings. */
41
+ sources: Record<string, SnapshotSourceMeta>;
42
+ }
43
+
44
+ export interface StoredSnapshot {
45
+ meta: SnapshotMeta;
46
+ /** Per-source superjson payloads (absent for baseline snapshots). */
47
+ data: Record<string, SuperJSONResult>;
48
+ }
49
+
50
+ export async function loadIndex(): Promise<SnapshotMeta[]> {
51
+ try {
52
+ const raw = await persistentStorage.getItem(INDEX_KEY);
53
+ if (!raw) return [];
54
+ const parsed = JSON.parse(raw) as SnapshotMeta[];
55
+ return Array.isArray(parsed) ? parsed : [];
56
+ } catch {
57
+ return [];
58
+ }
59
+ }
60
+
61
+ async function saveIndex(metas: SnapshotMeta[]): Promise<void> {
62
+ await persistentStorage.setItem(INDEX_KEY, JSON.stringify(metas));
63
+ }
64
+
65
+ /** Persists the snapshot, stamps meta.sizeBytes, and updates the index. */
66
+ export async function saveSnapshot(
67
+ snapshot: StoredSnapshot,
68
+ metas: SnapshotMeta[]
69
+ ): Promise<SnapshotMeta[]> {
70
+ const serialized = JSON.stringify(snapshot);
71
+ snapshot.meta.sizeBytes = serialized.length;
72
+ await persistentStorage.setItem(
73
+ SNAP_PREFIX + snapshot.meta.id,
74
+ // Re-stringify so the persisted copy carries the final sizeBytes.
75
+ JSON.stringify(snapshot)
76
+ );
77
+ const next = [
78
+ snapshot.meta,
79
+ ...metas.filter((m) => m.id !== snapshot.meta.id),
80
+ ];
81
+ await saveIndex(next);
82
+ return next;
83
+ }
84
+
85
+ export async function loadSnapshot(id: string): Promise<StoredSnapshot | null> {
86
+ try {
87
+ const raw = await persistentStorage.getItem(SNAP_PREFIX + id);
88
+ return raw ? (JSON.parse(raw) as StoredSnapshot) : null;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ export async function deleteSnapshot(
95
+ id: string,
96
+ metas: SnapshotMeta[]
97
+ ): Promise<SnapshotMeta[]> {
98
+ await persistentStorage.removeItem(SNAP_PREFIX + id);
99
+ const next = metas.filter((m) => m.id !== id);
100
+ await saveIndex(next);
101
+ return next;
102
+ }
103
+
104
+ export async function renameSnapshot(
105
+ id: string,
106
+ name: string,
107
+ metas: SnapshotMeta[]
108
+ ): Promise<SnapshotMeta[]> {
109
+ const next = metas.map((m) => (m.id === id ? { ...m, name } : m));
110
+ await saveIndex(next);
111
+ const stored = await loadSnapshot(id);
112
+ if (stored) {
113
+ stored.meta.name = name;
114
+ await persistentStorage.setItem(SNAP_PREFIX + id, JSON.stringify(stored));
115
+ }
116
+ return next;
117
+ }
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Time Machine orchestrator — snapshot CRUD + capture/restore across every
3
+ * registered StateSnapshotProvider (storage, redux, zustand, jotai, query,
4
+ * plus any custom providers the app registers).
5
+ *
6
+ * Restore ordering is fixed: persisted storage FIRST (so store persist
7
+ * middleware writing through after an in-memory restore converges with what
8
+ * we just wrote), then redux → zustand → jotai, query LAST (restored queries
9
+ * may derive from store state). Custom providers run after, in data order.
10
+ *
11
+ * Two restore modes:
12
+ * - "live": in-place state replacement, capability-gated per source.
13
+ * - "reload": restore ONLY persisted storage, then reload the JS bundle —
14
+ * in-memory stores rebuild themselves from the restored storage.
15
+ * Baseline snapshots always use this path (clear + reload).
16
+ */
17
+ import {
18
+ getStateSnapshotProvider,
19
+ getStateSnapshotProviders,
20
+ scheduleReloadApp,
21
+ superJsonDeserialize,
22
+ superJsonSerialize,
23
+ type StateSnapshotRestoreResult,
24
+ type SuperJSONResult,
25
+ } from "@buoy-gg/shared-ui";
26
+ import {
27
+ deleteSnapshot,
28
+ loadIndex,
29
+ loadSnapshot,
30
+ renameSnapshot,
31
+ saveSnapshot,
32
+ type SnapshotMeta,
33
+ type StoredSnapshot,
34
+ } from "./snapshotVault";
35
+
36
+ export const RESTORE_ORDER = ["storage", "redux", "zustand", "jotai", "query"];
37
+
38
+ export type RestoreMode = "live" | "reload";
39
+
40
+ export interface RestoreOutcome {
41
+ snapshotId: string;
42
+ snapshotName: string;
43
+ mode: RestoreMode;
44
+ at: number;
45
+ /** True when a JS reload was scheduled as part of this restore. */
46
+ willReload: boolean;
47
+ results: Record<string, StateSnapshotRestoreResult>;
48
+ }
49
+
50
+ /** Provider capability row as mirrored to a remote dashboard. */
51
+ export interface RemoteProviderStatus {
52
+ id: string;
53
+ label: string;
54
+ canCapture: boolean;
55
+ canRestore: boolean;
56
+ detail?: string;
57
+ }
58
+
59
+ export interface TimeMachineState {
60
+ snapshots: SnapshotMeta[];
61
+ hasLoaded: boolean;
62
+ busy: "capture" | "restore" | null;
63
+ lastOutcome: RestoreOutcome | null;
64
+ /**
65
+ * Non-null when this store is a REMOTE MIRROR (Buoy Desktop): provider
66
+ * statuses come from the device's sync payload instead of the local
67
+ * registry, and every operation forwards over the socket.
68
+ */
69
+ remoteProviders: RemoteProviderStatus[] | null;
70
+ }
71
+
72
+ type RemoteHandler = (
73
+ action: string,
74
+ params?: Record<string, unknown>
75
+ ) => Promise<unknown>;
76
+
77
+ let remoteHandler: RemoteHandler | null = null;
78
+
79
+ function skippedResult(reason: string): StateSnapshotRestoreResult {
80
+ return { ok: false, applied: 0, skipped: [{ id: "*", reason }], warnings: [] };
81
+ }
82
+
83
+ let idCounter = 0;
84
+ function nextId(): string {
85
+ idCounter += 1;
86
+ return `snap_${Date.now().toString(36)}_${idCounter.toString(36)}`;
87
+ }
88
+
89
+ class TimeMachineStore {
90
+ private state: TimeMachineState = {
91
+ snapshots: [],
92
+ hasLoaded: false,
93
+ busy: null,
94
+ lastOutcome: null,
95
+ remoteProviders: null,
96
+ };
97
+ private listeners = new Set<() => void>();
98
+ private loadPromise: Promise<void> | null = null;
99
+
100
+ getState(): TimeMachineState {
101
+ return this.state;
102
+ }
103
+
104
+ subscribe = (listener: () => void): (() => void) => {
105
+ this.listeners.add(listener);
106
+ void this.ensureLoaded();
107
+ return () => {
108
+ this.listeners.delete(listener);
109
+ };
110
+ };
111
+
112
+ private setState(patch: Partial<TimeMachineState>) {
113
+ this.state = { ...this.state, ...patch };
114
+ this.listeners.forEach((l) => {
115
+ try {
116
+ l();
117
+ } catch {
118
+ // listener errors must not break the store
119
+ }
120
+ });
121
+ }
122
+
123
+ ensureLoaded(): Promise<void> {
124
+ if (remoteHandler) return Promise.resolve(); // mirror mode — device owns the vault
125
+ if (!this.loadPromise) {
126
+ this.loadPromise = loadIndex().then((snapshots) => {
127
+ if (remoteHandler) return;
128
+ this.setState({ snapshots, hasLoaded: true });
129
+ });
130
+ }
131
+ return this.loadPromise;
132
+ }
133
+
134
+ /** @internal Buoy Desktop: mirror the device's synced metadata payload. */
135
+ replaceFromSnapshot(payload: {
136
+ snapshots?: SnapshotMeta[];
137
+ busy?: TimeMachineState["busy"];
138
+ lastOutcome?: RestoreOutcome | null;
139
+ providers?: RemoteProviderStatus[];
140
+ }): void {
141
+ this.setState({
142
+ snapshots: payload.snapshots ?? [],
143
+ busy: payload.busy ?? null,
144
+ lastOutcome: payload.lastOutcome ?? null,
145
+ remoteProviders: payload.providers ?? [],
146
+ hasLoaded: true,
147
+ });
148
+ }
149
+
150
+ /** Capture all capturable sources (or a subset) into a named snapshot. */
151
+ async capture(name: string, sourceIds?: string[]): Promise<SnapshotMeta> {
152
+ if (remoteHandler) {
153
+ const result = (await remoteHandler("capture", { name, sourceIds })) as {
154
+ snapshot: SnapshotMeta;
155
+ };
156
+ return result.snapshot;
157
+ }
158
+ await this.ensureLoaded();
159
+ this.setState({ busy: "capture" });
160
+ try {
161
+ const providers = getStateSnapshotProviders().filter(
162
+ (p) =>
163
+ p.status().canCapture && (!sourceIds || sourceIds.includes(p.id))
164
+ );
165
+ const data: Record<string, SuperJSONResult> = {};
166
+ const sources: SnapshotMeta["sources"] = {};
167
+ for (const provider of providers) {
168
+ try {
169
+ const captured = await provider.capture();
170
+ data[provider.id] = superJsonSerialize(captured.data);
171
+ sources[provider.id] = { warnings: captured.warnings };
172
+ } catch (e) {
173
+ sources[provider.id] = {
174
+ warnings: [`capture failed: ${String(e)}`],
175
+ };
176
+ }
177
+ }
178
+ const snapshot: StoredSnapshot = {
179
+ meta: {
180
+ id: nextId(),
181
+ name: name.trim() || "Untitled snapshot",
182
+ createdAt: Date.now(),
183
+ sizeBytes: 0,
184
+ sources,
185
+ },
186
+ data,
187
+ };
188
+ const snapshots = await saveSnapshot(snapshot, this.state.snapshots);
189
+ this.setState({ snapshots });
190
+ return snapshot.meta;
191
+ } finally {
192
+ this.setState({ busy: null });
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Capture the fresh-install baseline: an EMPTY snapshot whose restore
198
+ * wipes app state (clear + reload) — regardless of what state the app is
199
+ * in when the button is pressed.
200
+ */
201
+ async captureBaseline(): Promise<SnapshotMeta> {
202
+ if (remoteHandler) {
203
+ const result = (await remoteHandler("captureBaseline", {})) as {
204
+ snapshot: SnapshotMeta;
205
+ };
206
+ return result.snapshot;
207
+ }
208
+ await this.ensureLoaded();
209
+ const snapshot: StoredSnapshot = {
210
+ meta: {
211
+ id: nextId(),
212
+ name: "Fresh install",
213
+ createdAt: Date.now(),
214
+ sizeBytes: 0,
215
+ baseline: true,
216
+ sources: {},
217
+ },
218
+ data: {},
219
+ };
220
+ const snapshots = await saveSnapshot(snapshot, this.state.snapshots);
221
+ this.setState({ snapshots });
222
+ return snapshot.meta;
223
+ }
224
+
225
+ async restore(id: string, mode: RestoreMode): Promise<RestoreOutcome> {
226
+ if (remoteHandler) {
227
+ const outcome = (await remoteHandler("restore", { id, mode })) as RestoreOutcome;
228
+ this.setState({ lastOutcome: outcome });
229
+ return outcome;
230
+ }
231
+ await this.ensureLoaded();
232
+ this.setState({ busy: "restore" });
233
+ try {
234
+ const stored = await loadSnapshot(id);
235
+ if (!stored) throw new Error(`No snapshot with id "${id}"`);
236
+
237
+ const outcome: RestoreOutcome = {
238
+ snapshotId: id,
239
+ snapshotName: stored.meta.name,
240
+ mode,
241
+ at: Date.now(),
242
+ willReload: false,
243
+ results: {},
244
+ };
245
+
246
+ if (stored.meta.baseline) {
247
+ // Fresh install: wipe every clearable source, then reload so all
248
+ // in-memory state rebuilds from nothing.
249
+ for (const provider of getStateSnapshotProviders()) {
250
+ if (provider.clear) {
251
+ try {
252
+ outcome.results[provider.id] = await provider.clear();
253
+ } catch (e) {
254
+ outcome.results[provider.id] = skippedResult(String(e));
255
+ }
256
+ }
257
+ }
258
+ outcome.willReload = this.scheduleReload("buoy time-machine baseline");
259
+ this.setState({ lastOutcome: outcome });
260
+ return outcome;
261
+ }
262
+
263
+ const sourceIds = [
264
+ ...RESTORE_ORDER.filter((sid) => sid in stored.data),
265
+ ...Object.keys(stored.data).filter((sid) => !RESTORE_ORDER.includes(sid)),
266
+ ];
267
+
268
+ if (mode === "reload") {
269
+ // Storage only; everything in-memory rebuilds after the reload.
270
+ if (stored.data.storage) {
271
+ outcome.results.storage = await this.restoreSource(
272
+ "storage",
273
+ stored.data.storage
274
+ );
275
+ }
276
+ outcome.willReload = this.scheduleReload("buoy time-machine restore");
277
+ this.setState({ lastOutcome: outcome });
278
+ return outcome;
279
+ }
280
+
281
+ for (const sid of sourceIds) {
282
+ outcome.results[sid] = await this.restoreSource(sid, stored.data[sid]);
283
+ }
284
+ this.setState({ lastOutcome: outcome });
285
+ return outcome;
286
+ } finally {
287
+ this.setState({ busy: null });
288
+ }
289
+ }
290
+
291
+ private async restoreSource(
292
+ sourceId: string,
293
+ payload: SuperJSONResult
294
+ ): Promise<StateSnapshotRestoreResult> {
295
+ const provider = getStateSnapshotProvider(sourceId);
296
+ if (!provider) {
297
+ return skippedResult("provider not available (package missing or not initialized)");
298
+ }
299
+ const status = provider.status();
300
+ if (!status.canRestore) {
301
+ return skippedResult(status.detail ?? "restore unavailable");
302
+ }
303
+ try {
304
+ return await provider.restore(superJsonDeserialize(payload));
305
+ } catch (e) {
306
+ return skippedResult(String(e));
307
+ }
308
+ }
309
+
310
+ private scheduleReload(reason: string): boolean {
311
+ try {
312
+ scheduleReloadApp({ reason });
313
+ return true;
314
+ } catch {
315
+ // No reload mechanism (bare production build?) — restore still stands.
316
+ return false;
317
+ }
318
+ }
319
+
320
+ async delete(id: string): Promise<void> {
321
+ if (remoteHandler) {
322
+ await remoteHandler("delete", { id });
323
+ return;
324
+ }
325
+ await this.ensureLoaded();
326
+ const snapshots = await deleteSnapshot(id, this.state.snapshots);
327
+ this.setState({ snapshots });
328
+ }
329
+
330
+ async rename(id: string, name: string): Promise<void> {
331
+ if (remoteHandler) {
332
+ await remoteHandler("rename", { id, name });
333
+ return;
334
+ }
335
+ await this.ensureLoaded();
336
+ const snapshots = await renameSnapshot(id, name, this.state.snapshots);
337
+ this.setState({ snapshots });
338
+ }
339
+
340
+ clearLastOutcome(): void {
341
+ this.setState({ lastOutcome: null });
342
+ }
343
+ }
344
+
345
+ export const timeMachineStore = new TimeMachineStore();
346
+
347
+ /**
348
+ * Buoy Desktop: route every Time Machine operation through a remote handler
349
+ * (the device's sync adapter actions) instead of running locally. Pass null
350
+ * to detach. While attached, feed device payloads to
351
+ * {@link replaceTimeMachineFromSnapshot}.
352
+ */
353
+ export function setRemoteTimeMachineHandler(handler: RemoteHandler | null): void {
354
+ remoteHandler = handler;
355
+ if (!handler) {
356
+ timeMachineStore.replaceFromSnapshot({});
357
+ }
358
+ }
359
+
360
+ /** Buoy Desktop: mirror a device's synced Time Machine metadata payload. */
361
+ export function replaceTimeMachineFromSnapshot(payload: {
362
+ snapshots?: SnapshotMeta[];
363
+ busy?: TimeMachineState["busy"];
364
+ lastOutcome?: RestoreOutcome | null;
365
+ providers?: RemoteProviderStatus[];
366
+ }): void {
367
+ timeMachineStore.replaceFromSnapshot(payload);
368
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Sync adapter for the Time Machine tool, consumed by @buoy-gg/external-sync's
3
+ * `useExternalSync` (structurally matches its ToolSyncAdapter interface so
4
+ * this package doesn't need a dependency on it).
5
+ *
6
+ * Snapshots stay ON-DEVICE — getSnapshot ships METADATA only (list, provider
7
+ * statuses, last restore outcome), far under the 2MB per-tool budget. Payload
8
+ * inspection goes through the `inspect` action (8MB action-result budget).
9
+ *
10
+ * The `restore` action uses the store's scheduleReloadApp path for reload
11
+ * mode, so the action RESULT is flushed to the broker before the JS realm
12
+ * dies — a remote caller always gets its answer.
13
+ */
14
+ import {
15
+ getStateSnapshotProviders,
16
+ subscribeToStateSnapshotProviders,
17
+ superJsonDeserialize,
18
+ } from "@buoy-gg/shared-ui";
19
+ import { timeMachineStore, type RestoreMode } from "../store/timeMachineStore";
20
+ import { loadSnapshot } from "../store/snapshotVault";
21
+
22
+ interface CaptureParams {
23
+ name?: string;
24
+ sourceIds?: string[];
25
+ }
26
+ interface RestoreParams {
27
+ id: string;
28
+ mode?: RestoreMode;
29
+ }
30
+ interface IdParams {
31
+ id: string;
32
+ }
33
+ interface RenameParams {
34
+ id: string;
35
+ name: string;
36
+ }
37
+ interface InspectParams {
38
+ id: string;
39
+ sourceId: string;
40
+ }
41
+
42
+ function providerStatuses() {
43
+ return getStateSnapshotProviders().map((p) => {
44
+ const s = p.status();
45
+ return {
46
+ id: p.id,
47
+ label: p.label,
48
+ canCapture: s.canCapture,
49
+ canRestore: s.canRestore,
50
+ detail: s.detail,
51
+ };
52
+ });
53
+ }
54
+
55
+ function metadata() {
56
+ const { snapshots, busy, lastOutcome } = timeMachineStore.getState();
57
+ return { snapshots, busy, lastOutcome, providers: providerStatuses() };
58
+ }
59
+
60
+ export const timeMachineSyncAdapter = {
61
+ version: 1,
62
+ getSnapshot: () => metadata(),
63
+ subscribe: (onChange: () => void) => {
64
+ const unsubStore = timeMachineStore.subscribe(onChange);
65
+ const unsubProviders = subscribeToStateSnapshotProviders(onChange);
66
+ return () => {
67
+ unsubStore();
68
+ unsubProviders();
69
+ };
70
+ },
71
+ actions: {
72
+ list: () => metadata(),
73
+
74
+ capture: async (params: unknown) => {
75
+ const { name = "", sourceIds } = (params as CaptureParams) ?? {};
76
+ const meta = await timeMachineStore.capture(name, sourceIds);
77
+ return { captured: true, snapshot: meta };
78
+ },
79
+
80
+ captureBaseline: async () => {
81
+ const meta = await timeMachineStore.captureBaseline();
82
+ return { captured: true, snapshot: meta };
83
+ },
84
+
85
+ /** mode "live" (default) replaces state in place; "reload" restores
86
+ * persisted storage then reloads the JS bundle (always the path for
87
+ * baseline snapshots). */
88
+ restore: async (params: unknown) => {
89
+ const { id, mode = "live" } = (params as RestoreParams) ?? {};
90
+ if (!id) throw new Error("restore requires a snapshot `id`");
91
+ return await timeMachineStore.restore(id, mode);
92
+ },
93
+
94
+ delete: async (params: unknown) => {
95
+ const { id } = (params as IdParams) ?? {};
96
+ if (!id) throw new Error("delete requires a snapshot `id`");
97
+ await timeMachineStore.delete(id);
98
+ return { deleted: true, id };
99
+ },
100
+
101
+ rename: async (params: unknown) => {
102
+ const { id, name } = (params as RenameParams) ?? {};
103
+ if (!id || !name) throw new Error("rename requires `id` and `name`");
104
+ await timeMachineStore.rename(id, name);
105
+ return { renamed: true, id, name };
106
+ },
107
+
108
+ /** One source's captured payload, deserialized — for remote inspection. */
109
+ inspect: async (params: unknown) => {
110
+ const { id, sourceId } = (params as InspectParams) ?? {};
111
+ if (!id || !sourceId) {
112
+ throw new Error("inspect requires `id` and `sourceId`");
113
+ }
114
+ const stored = await loadSnapshot(id);
115
+ if (!stored) throw new Error(`No snapshot with id "${id}"`);
116
+ const payload = stored.data[sourceId];
117
+ if (!payload) {
118
+ throw new Error(`Snapshot "${id}" has no "${sourceId}" source`);
119
+ }
120
+ return {
121
+ id,
122
+ sourceId,
123
+ warnings: stored.meta.sources[sourceId]?.warnings ?? [],
124
+ data: superJsonDeserialize(payload),
125
+ };
126
+ },
127
+ },
128
+ };