@buoy-gg/time-machine 7.0.17 → 7.0.18

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 (69) hide show
  1. package/lib/commonjs/components/HeaderReloadButton.js +61 -0
  2. package/lib/commonjs/components/HeaderReloadButton.js.map +1 -0
  3. package/lib/commonjs/components/TimeMachineBody.js +343 -127
  4. package/lib/commonjs/components/TimeMachineBody.js.map +1 -1
  5. package/lib/commonjs/components/TimeMachineModal.js +1 -0
  6. package/lib/commonjs/components/TimeMachineModal.js.map +1 -1
  7. package/lib/commonjs/components/preview/ItemDetailScreen.js +189 -0
  8. package/lib/commonjs/components/preview/ItemDetailScreen.js.map +1 -0
  9. package/lib/commonjs/components/preview/RestorePreviewScreen.js +1056 -0
  10. package/lib/commonjs/components/preview/RestorePreviewScreen.js.map +1 -0
  11. package/lib/commonjs/components/preview/verdictMeta.js +58 -0
  12. package/lib/commonjs/components/preview/verdictMeta.js.map +1 -0
  13. package/lib/commonjs/preset.js +11 -2
  14. package/lib/commonjs/preset.js.map +1 -1
  15. package/lib/commonjs/store/snapshotVault.js +19 -0
  16. package/lib/commonjs/store/snapshotVault.js.map +1 -1
  17. package/lib/commonjs/store/timeMachineStore.js +298 -7
  18. package/lib/commonjs/store/timeMachineStore.js.map +1 -1
  19. package/lib/commonjs/sync/timeMachineSyncAdapter.js +62 -2
  20. package/lib/commonjs/sync/timeMachineSyncAdapter.js.map +1 -1
  21. package/lib/module/components/HeaderReloadButton.js +56 -0
  22. package/lib/module/components/HeaderReloadButton.js.map +1 -0
  23. package/lib/module/components/TimeMachineBody.js +345 -129
  24. package/lib/module/components/TimeMachineBody.js.map +1 -1
  25. package/lib/module/components/TimeMachineModal.js +1 -0
  26. package/lib/module/components/TimeMachineModal.js.map +1 -1
  27. package/lib/module/components/preview/ItemDetailScreen.js +184 -0
  28. package/lib/module/components/preview/ItemDetailScreen.js.map +1 -0
  29. package/lib/module/components/preview/RestorePreviewScreen.js +1051 -0
  30. package/lib/module/components/preview/RestorePreviewScreen.js.map +1 -0
  31. package/lib/module/components/preview/verdictMeta.js +53 -0
  32. package/lib/module/components/preview/verdictMeta.js.map +1 -0
  33. package/lib/module/preset.js +11 -2
  34. package/lib/module/preset.js.map +1 -1
  35. package/lib/module/store/snapshotVault.js +18 -0
  36. package/lib/module/store/snapshotVault.js.map +1 -1
  37. package/lib/module/store/timeMachineStore.js +299 -8
  38. package/lib/module/store/timeMachineStore.js.map +1 -1
  39. package/lib/module/sync/timeMachineSyncAdapter.js +62 -2
  40. package/lib/module/sync/timeMachineSyncAdapter.js.map +1 -1
  41. package/lib/typescript/components/HeaderReloadButton.d.ts +2 -0
  42. package/lib/typescript/components/HeaderReloadButton.d.ts.map +1 -0
  43. package/lib/typescript/components/TimeMachineBody.d.ts.map +1 -1
  44. package/lib/typescript/components/TimeMachineModal.d.ts.map +1 -1
  45. package/lib/typescript/components/preview/ItemDetailScreen.d.ts +11 -0
  46. package/lib/typescript/components/preview/ItemDetailScreen.d.ts.map +1 -0
  47. package/lib/typescript/components/preview/RestorePreviewScreen.d.ts +15 -0
  48. package/lib/typescript/components/preview/RestorePreviewScreen.d.ts.map +1 -0
  49. package/lib/typescript/components/preview/verdictMeta.d.ts +10 -0
  50. package/lib/typescript/components/preview/verdictMeta.d.ts.map +1 -0
  51. package/lib/typescript/preset.d.ts +15 -3
  52. package/lib/typescript/preset.d.ts.map +1 -1
  53. package/lib/typescript/store/snapshotVault.d.ts +7 -0
  54. package/lib/typescript/store/snapshotVault.d.ts.map +1 -1
  55. package/lib/typescript/store/timeMachineStore.d.ts +49 -2
  56. package/lib/typescript/store/timeMachineStore.d.ts.map +1 -1
  57. package/lib/typescript/sync/timeMachineSyncAdapter.d.ts +36 -0
  58. package/lib/typescript/sync/timeMachineSyncAdapter.d.ts.map +1 -1
  59. package/package.json +13 -6
  60. package/src/components/HeaderReloadButton.tsx +45 -0
  61. package/src/components/TimeMachineBody.tsx +416 -137
  62. package/src/components/TimeMachineModal.tsx +1 -0
  63. package/src/components/preview/ItemDetailScreen.tsx +172 -0
  64. package/src/components/preview/RestorePreviewScreen.tsx +1089 -0
  65. package/src/components/preview/verdictMeta.ts +40 -0
  66. package/src/preset.tsx +11 -2
  67. package/src/store/snapshotVault.ts +28 -0
  68. package/src/store/timeMachineStore.ts +330 -7
  69. package/src/sync/timeMachineSyncAdapter.ts +63 -3
@@ -0,0 +1,40 @@
1
+ /**
2
+ * One place for the verdict → glyph/color/label mapping. Color is ALWAYS
3
+ * paired with a distinct glyph (true minus U+2212, approx U+2248) — the
4
+ * repo-wide accessibility convention for diff surfaces.
5
+ */
6
+ import { buoyColors } from "@buoy-gg/shared-ui";
7
+ import type { StateSnapshotPreviewVerdict } from "@buoy-gg/shared-ui";
8
+
9
+ export interface VerdictMeta {
10
+ glyph: string;
11
+ color: string;
12
+ label: string;
13
+ /** Counts toward "will change" totals. */
14
+ applies: boolean;
15
+ }
16
+
17
+ export const VERDICT_META: Record<StateSnapshotPreviewVerdict, VerdictMeta> = {
18
+ added: { glyph: "+", color: buoyColors.success, label: "added", applies: true },
19
+ removed: { glyph: "−", color: buoyColors.error, label: "removed", applies: true },
20
+ changed: { glyph: "≈", color: buoyColors.info, label: "changed", applies: true },
21
+ "too-large": {
22
+ glyph: "≈",
23
+ color: buoyColors.warning,
24
+ label: "changed (large)",
25
+ applies: true,
26
+ },
27
+ unchanged: { glyph: "=", color: buoyColors.textMuted, label: "unchanged", applies: false },
28
+ "wont-apply": {
29
+ glyph: "⊘",
30
+ color: buoyColors.warning,
31
+ label: "won't apply",
32
+ applies: false,
33
+ },
34
+ recomputes: {
35
+ glyph: "↻",
36
+ color: buoyColors.textMuted,
37
+ label: "recomputes",
38
+ applies: false,
39
+ },
40
+ };
package/src/preset.tsx CHANGED
@@ -13,7 +13,8 @@ import {
13
13
  TimeMachineIcon,
14
14
  TIME_MACHINE_ICON_COLOR,
15
15
  } from "@buoy-gg/shared-ui";
16
- import { TimeMachineModal } from "./components/TimeMachineModal";
16
+ import { TimeMachineBody } from "./components/TimeMachineBody";
17
+ import { HeaderReloadButton } from "./components/HeaderReloadButton";
17
18
 
18
19
  export const timeMachineToolPreset = {
19
20
  id: "time-machine",
@@ -23,7 +24,15 @@ export const timeMachineToolPreset = {
23
24
  icon: ({ size, color }: { size: number; color?: string }) => (
24
25
  <TimeMachineIcon size={size} color={color || TIME_MACHINE_ICON_COLOR} />
25
26
  ),
26
- component: TimeMachineModal,
27
+ component: TimeMachineBody,
28
+ // Declarative chrome: core's ToolModalShell provides the JsModal, the
29
+ // managed header (screens override it via useToolHeader) and header search.
30
+ modal: {
31
+ subtitle: "snapshot & restore app state",
32
+ searchable: true,
33
+ searchPlaceholder: "Filter…",
34
+ headerActions: <HeaderReloadButton />,
35
+ },
27
36
  };
28
37
 
29
38
  /** Create a custom Time Machine tool configuration. */
@@ -37,6 +37,12 @@ export interface SnapshotMeta {
37
37
  * fresh-install state (clear + reload).
38
38
  */
39
39
  baseline?: boolean;
40
+ /**
41
+ * User-saved restore scope: compound "sourceId::itemKey" strings. When set,
42
+ * restoring this snapshot touches ONLY these items — "this restore point
43
+ * means just my cart". Absent = full restore.
44
+ */
45
+ scope?: string[];
40
46
  /** Source ids captured, with their capture warnings. */
41
47
  sources: Record<string, SnapshotSourceMeta>;
42
48
  }
@@ -101,6 +107,28 @@ export async function deleteSnapshot(
101
107
  return next;
102
108
  }
103
109
 
110
+ export async function setSnapshotScope(
111
+ id: string,
112
+ scope: string[] | null,
113
+ metas: SnapshotMeta[]
114
+ ): Promise<SnapshotMeta[]> {
115
+ const apply = (m: SnapshotMeta): SnapshotMeta => {
116
+ if (m.id !== id) return m;
117
+ const next = { ...m };
118
+ if (scope && scope.length > 0) next.scope = scope;
119
+ else delete next.scope;
120
+ return next;
121
+ };
122
+ const next = metas.map(apply);
123
+ await persistentStorage.setItem(INDEX_KEY, JSON.stringify(next));
124
+ const stored = await loadSnapshot(id);
125
+ if (stored) {
126
+ stored.meta = apply(stored.meta);
127
+ await persistentStorage.setItem(SNAP_PREFIX + id, JSON.stringify(stored));
128
+ }
129
+ return next;
130
+ }
131
+
104
132
  export async function renameSnapshot(
105
133
  id: string,
106
134
  name: string,
@@ -21,14 +21,18 @@ import {
21
21
  superJsonDeserialize,
22
22
  superJsonSerialize,
23
23
  type StateSnapshotRestoreResult,
24
+ type StateSnapshotPreview,
25
+ type StateSnapshotPreviewItem,
24
26
  type SuperJSONResult,
25
27
  } from "@buoy-gg/shared-ui";
28
+ import { objectDiff } from "@buoy-gg/shared-ui/dataViewer";
26
29
  import {
27
30
  deleteSnapshot,
28
31
  loadIndex,
29
32
  loadSnapshot,
30
33
  renameSnapshot,
31
34
  saveSnapshot,
35
+ setSnapshotScope,
32
36
  type SnapshotMeta,
33
37
  type StoredSnapshot,
34
38
  } from "./snapshotVault";
@@ -56,11 +60,32 @@ export interface RemoteProviderStatus {
56
60
  detail?: string;
57
61
  }
58
62
 
63
+ /** One side of a preview comparison. */
64
+ export type PreviewSideRef = "current" | { snapshotId: string };
65
+
66
+ export interface PreviewSource {
67
+ id: string;
68
+ label: string;
69
+ items: StateSnapshotPreviewItem[];
70
+ notes: string[];
71
+ error?: string;
72
+ }
73
+
74
+ export interface PreviewState {
75
+ left: PreviewSideRef;
76
+ right: PreviewSideRef;
77
+ /** True while the pipeline is still filling `sources` in. */
78
+ building: boolean;
79
+ builtAt: number;
80
+ sources: PreviewSource[];
81
+ }
82
+
59
83
  export interface TimeMachineState {
60
84
  snapshots: SnapshotMeta[];
61
85
  hasLoaded: boolean;
62
86
  busy: "capture" | "restore" | null;
63
87
  lastOutcome: RestoreOutcome | null;
88
+ preview: PreviewState | null;
64
89
  /**
65
90
  * Non-null when this store is a REMOTE MIRROR (Buoy Desktop): provider
66
91
  * statuses come from the device's sync payload instead of the local
@@ -92,8 +117,12 @@ class TimeMachineStore {
92
117
  hasLoaded: false,
93
118
  busy: null,
94
119
  lastOutcome: null,
120
+ preview: null,
95
121
  remoteProviders: null,
96
122
  };
123
+ /** Monotonic token — a preview build checks it after every await and
124
+ * abandons itself when a newer build (or clearPreview) supersedes it. */
125
+ private previewRunId = 0;
97
126
  private listeners = new Set<() => void>();
98
127
  private loadPromise: Promise<void> | null = null;
99
128
 
@@ -222,9 +251,22 @@ class TimeMachineStore {
222
251
  return snapshot.meta;
223
252
  }
224
253
 
225
- async restore(id: string, mode: RestoreMode): Promise<RestoreOutcome> {
254
+ /**
255
+ * `excludeKeys` entries are compound `"sourceId::itemKey"` strings (the
256
+ * itemKey being what previewItems emitted) — split per source before each
257
+ * provider sees only its own keys.
258
+ */
259
+ async restore(
260
+ id: string,
261
+ mode: RestoreMode,
262
+ excludeKeys?: readonly string[]
263
+ ): Promise<RestoreOutcome> {
226
264
  if (remoteHandler) {
227
- const outcome = (await remoteHandler("restore", { id, mode })) as RestoreOutcome;
265
+ const outcome = (await remoteHandler("restore", {
266
+ id,
267
+ mode,
268
+ excludeKeys,
269
+ })) as RestoreOutcome;
228
270
  this.setState({ lastOutcome: outcome });
229
271
  return outcome;
230
272
  }
@@ -265,12 +307,40 @@ class TimeMachineStore {
265
307
  ...Object.keys(stored.data).filter((sid) => !RESTORE_ORDER.includes(sid)),
266
308
  ];
267
309
 
310
+ const splitCompound = (keys: readonly string[]) => {
311
+ const bySource = new Map<string, string[]>();
312
+ for (const compound of keys) {
313
+ const sep = compound.indexOf("::");
314
+ if (sep <= 0) continue;
315
+ const sid = compound.slice(0, sep);
316
+ const itemKey = compound.slice(sep + 2);
317
+ const list = bySource.get(sid) ?? [];
318
+ list.push(itemKey);
319
+ bySource.set(sid, list);
320
+ }
321
+ return bySource;
322
+ };
323
+
324
+ const excludeBySource = splitCompound(excludeKeys ?? []);
325
+ // A saved scope makes this a targeted restore point. An explicit
326
+ // exclusion list from the preview already reflects the scope (the
327
+ // preview seeds its selection from it), so it takes precedence.
328
+ const includeBySource =
329
+ !excludeKeys && stored.meta.scope?.length
330
+ ? splitCompound(stored.meta.scope)
331
+ : null;
332
+
268
333
  if (mode === "reload") {
269
334
  // Storage only; everything in-memory rebuilds after the reload.
270
- if (stored.data.storage) {
335
+ if (
336
+ stored.data.storage &&
337
+ (!includeBySource || includeBySource.has("storage"))
338
+ ) {
271
339
  outcome.results.storage = await this.restoreSource(
272
340
  "storage",
273
- stored.data.storage
341
+ stored.data.storage,
342
+ excludeBySource.get("storage"),
343
+ includeBySource?.get("storage")
274
344
  );
275
345
  }
276
346
  outcome.willReload = this.scheduleReload("buoy time-machine restore");
@@ -279,7 +349,14 @@ class TimeMachineStore {
279
349
  }
280
350
 
281
351
  for (const sid of sourceIds) {
282
- outcome.results[sid] = await this.restoreSource(sid, stored.data[sid]);
352
+ // Sources entirely outside the scope are untouched and unreported.
353
+ if (includeBySource && !includeBySource.has(sid)) continue;
354
+ outcome.results[sid] = await this.restoreSource(
355
+ sid,
356
+ stored.data[sid],
357
+ excludeBySource.get(sid),
358
+ includeBySource?.get(sid)
359
+ );
283
360
  }
284
361
  this.setState({ lastOutcome: outcome });
285
362
  return outcome;
@@ -290,7 +367,9 @@ class TimeMachineStore {
290
367
 
291
368
  private async restoreSource(
292
369
  sourceId: string,
293
- payload: SuperJSONResult
370
+ payload: SuperJSONResult,
371
+ excludeKeys?: readonly string[],
372
+ includeKeys?: readonly string[]
294
373
  ): Promise<StateSnapshotRestoreResult> {
295
374
  const provider = getStateSnapshotProvider(sourceId);
296
375
  if (!provider) {
@@ -301,7 +380,14 @@ class TimeMachineStore {
301
380
  return skippedResult(status.detail ?? "restore unavailable");
302
381
  }
303
382
  try {
304
- return await provider.restore(superJsonDeserialize(payload));
383
+ const options =
384
+ excludeKeys?.length || includeKeys?.length
385
+ ? {
386
+ ...(excludeKeys?.length ? { excludeKeys } : {}),
387
+ ...(includeKeys?.length ? { includeKeys } : {}),
388
+ }
389
+ : undefined;
390
+ return await provider.restore(superJsonDeserialize(payload), options);
305
391
  } catch (e) {
306
392
  return skippedResult(String(e));
307
393
  }
@@ -337,9 +423,246 @@ class TimeMachineStore {
337
423
  this.setState({ snapshots });
338
424
  }
339
425
 
426
+ /**
427
+ * Save (or clear with null/empty) a snapshot's restore scope — compound
428
+ * "sourceId::itemKey" strings. A scoped restore point only ever touches
429
+ * its scoped items.
430
+ */
431
+ async setScope(id: string, scope: string[] | null): Promise<void> {
432
+ if (remoteHandler) {
433
+ await remoteHandler("setScope", { id, scope });
434
+ return;
435
+ }
436
+ await this.ensureLoaded();
437
+ const snapshots = await setSnapshotScope(id, scope, this.state.snapshots);
438
+ this.setState({ snapshots });
439
+ }
440
+
340
441
  clearLastOutcome(): void {
341
442
  this.setState({ lastOutcome: null });
342
443
  }
444
+
445
+ /**
446
+ * Wipe every clearable source to fresh-install state and reload — the
447
+ * standalone utility behind the home screen's "Wipe all app state" button
448
+ * (no baseline snapshot involved).
449
+ */
450
+ async wipeAll(): Promise<RestoreOutcome> {
451
+ if (remoteHandler) {
452
+ const outcome = (await remoteHandler("wipeAll", {})) as RestoreOutcome;
453
+ this.setState({ lastOutcome: outcome });
454
+ return outcome;
455
+ }
456
+ this.setState({ busy: "restore" });
457
+ try {
458
+ const outcome: RestoreOutcome = {
459
+ snapshotId: "wipe-all",
460
+ snapshotName: "Wipe all",
461
+ mode: "reload",
462
+ at: Date.now(),
463
+ willReload: false,
464
+ results: {},
465
+ };
466
+ for (const provider of getStateSnapshotProviders()) {
467
+ if (provider.clear) {
468
+ try {
469
+ outcome.results[provider.id] = await provider.clear();
470
+ } catch (e) {
471
+ outcome.results[provider.id] = skippedResult(String(e));
472
+ }
473
+ }
474
+ }
475
+ outcome.willReload = this.scheduleReload("buoy time-machine wipe all");
476
+ this.setState({ lastOutcome: outcome });
477
+ return outcome;
478
+ } finally {
479
+ this.setState({ busy: null });
480
+ }
481
+ }
482
+
483
+ clearPreview(): void {
484
+ this.previewRunId += 1;
485
+ this.setState({ preview: null });
486
+ }
487
+
488
+ /**
489
+ * Build (or rebuild) the restore preview: item-level verdicts per source,
490
+ * filled in incrementally with a frame yield between sources so a big app
491
+ * never freezes the JS thread in one go.
492
+ */
493
+ async buildPreview(
494
+ right: PreviewSideRef,
495
+ left: PreviewSideRef = "current"
496
+ ): Promise<void> {
497
+ if (remoteHandler) {
498
+ // Buoy Desktop: the device builds it; result mirrors into state.
499
+ const runId = ++this.previewRunId;
500
+ this.setState({
501
+ preview: { left, right, building: true, builtAt: Date.now(), sources: [] },
502
+ });
503
+ try {
504
+ const result = (await remoteHandler("preview", {
505
+ id: right === "current" ? undefined : right.snapshotId,
506
+ compareTo: left === "current" ? undefined : left.snapshotId,
507
+ })) as { sources: PreviewSource[] };
508
+ if (runId !== this.previewRunId) return;
509
+ this.setState({
510
+ preview: {
511
+ left,
512
+ right,
513
+ building: false,
514
+ builtAt: Date.now(),
515
+ sources: result.sources ?? [],
516
+ },
517
+ });
518
+ } catch (e) {
519
+ if (runId !== this.previewRunId) return;
520
+ this.setState({
521
+ preview: {
522
+ left,
523
+ right,
524
+ building: false,
525
+ builtAt: Date.now(),
526
+ sources: [
527
+ { id: "error", label: "Preview", items: [], notes: [], error: String(e) },
528
+ ],
529
+ },
530
+ });
531
+ }
532
+ return;
533
+ }
534
+
535
+ await this.ensureLoaded();
536
+ const runId = ++this.previewRunId;
537
+ this.setState({
538
+ preview: { left, right, building: true, builtAt: Date.now(), sources: [] },
539
+ });
540
+
541
+ const loadSide = async (
542
+ ref: PreviewSideRef
543
+ ): Promise<Record<string, SuperJSONResult> | null> => {
544
+ if (ref === "current") return null; // providers capture live themselves
545
+ const stored = await loadSnapshot(ref.snapshotId);
546
+ if (!stored) throw new Error(`No snapshot with id "${ref.snapshotId}"`);
547
+ return stored.data;
548
+ };
549
+
550
+ try {
551
+ if (right === "current") throw new Error("preview target must be a snapshot");
552
+ const [leftData, rightStored] = await Promise.all([
553
+ loadSide(left),
554
+ loadSnapshot(right.snapshotId),
555
+ ]);
556
+ if (runId !== this.previewRunId) return;
557
+ if (!rightStored) {
558
+ throw new Error(`No snapshot with id "${right.snapshotId}"`);
559
+ }
560
+ const rightData = rightStored.data;
561
+
562
+ // Baseline = empty target: preview every clearable source against live,
563
+ // so each live key naturally reads as "removed" — the informed wipe.
564
+ const isBaseline = rightStored.meta.baseline === true;
565
+ const sourceIds = isBaseline
566
+ ? getStateSnapshotProviders()
567
+ .filter((p) => p.clear)
568
+ .map((p) => p.id)
569
+ : [
570
+ ...RESTORE_ORDER.filter((sid) => sid in rightData),
571
+ ...Object.keys(rightData).filter(
572
+ (sid) => !RESTORE_ORDER.includes(sid)
573
+ ),
574
+ ];
575
+
576
+ const sources: PreviewSource[] = [];
577
+ for (const sid of sourceIds) {
578
+ // Yield a frame between sources — verdict passes are bounded but a
579
+ // five-source build shouldn't run as one synchronous block.
580
+ await new Promise((r) => setTimeout(r, 0));
581
+ if (runId !== this.previewRunId) return;
582
+
583
+ const provider = getStateSnapshotProvider(sid);
584
+ const label = provider?.label ?? sid;
585
+ try {
586
+ const targetData = isBaseline
587
+ ? undefined // provider treats an absent target as "empty"
588
+ : superJsonDeserialize(rightData[sid]);
589
+ const baseData =
590
+ leftData === null
591
+ ? undefined
592
+ : sid in leftData
593
+ ? superJsonDeserialize(leftData[sid])
594
+ : undefined;
595
+ let result: StateSnapshotPreview;
596
+ if (!provider) {
597
+ result = {
598
+ items: [
599
+ {
600
+ key: sid,
601
+ label,
602
+ verdict: "wont-apply",
603
+ reason: "tool not installed — restore skips this source",
604
+ },
605
+ ],
606
+ notes: [],
607
+ };
608
+ } else if (provider.previewItems) {
609
+ result = await provider.previewItems(targetData, baseData);
610
+ } else {
611
+ // Whole-source fallback for custom providers without previewItems.
612
+ const diff = objectDiff(baseData, targetData);
613
+ result = {
614
+ items: [
615
+ {
616
+ key: sid,
617
+ label,
618
+ verdict: diff.equal ? "unchanged" : "changed",
619
+ oldValue: baseData,
620
+ newValue: targetData,
621
+ },
622
+ ],
623
+ notes: diff.truncated ? ["diff truncated (large value)"] : [],
624
+ };
625
+ }
626
+ if (runId !== this.previewRunId) return;
627
+ sources.push({ id: sid, label, ...result });
628
+ } catch (e) {
629
+ sources.push({ id: sid, label, items: [], notes: [], error: String(e) });
630
+ }
631
+ this.setState({
632
+ preview: {
633
+ left,
634
+ right,
635
+ building: true,
636
+ builtAt: Date.now(),
637
+ sources: [...sources],
638
+ },
639
+ });
640
+ }
641
+ if (runId !== this.previewRunId) return;
642
+ this.setState({
643
+ preview: {
644
+ left,
645
+ right,
646
+ building: false,
647
+ builtAt: Date.now(),
648
+ sources,
649
+ },
650
+ });
651
+ } catch (e) {
652
+ if (runId !== this.previewRunId) return;
653
+ this.setState({
654
+ preview: {
655
+ left,
656
+ right,
657
+ building: false,
658
+ builtAt: Date.now(),
659
+ sources: [
660
+ { id: "error", label: "Preview", items: [], notes: [], error: String(e) },
661
+ ],
662
+ },
663
+ });
664
+ }
665
+ }
343
666
  }
344
667
 
345
668
  export const timeMachineStore = new TimeMachineStore();
@@ -16,7 +16,12 @@ import {
16
16
  subscribeToStateSnapshotProviders,
17
17
  superJsonDeserialize,
18
18
  } from "@buoy-gg/shared-ui";
19
- import { timeMachineStore, type RestoreMode } from "../store/timeMachineStore";
19
+ import { jsonLikelyLargerThan } from "@buoy-gg/shared-ui/dataViewer";
20
+ import {
21
+ timeMachineStore,
22
+ type PreviewSideRef,
23
+ type RestoreMode,
24
+ } from "../store/timeMachineStore";
20
25
  import { loadSnapshot } from "../store/snapshotVault";
21
26
 
22
27
  interface CaptureParams {
@@ -26,6 +31,8 @@ interface CaptureParams {
26
31
  interface RestoreParams {
27
32
  id: string;
28
33
  mode?: RestoreMode;
34
+ /** Compound "sourceId::itemKey" strings — items the caller opted out of. */
35
+ excludeKeys?: string[];
29
36
  }
30
37
  interface IdParams {
31
38
  id: string;
@@ -82,13 +89,16 @@ export const timeMachineSyncAdapter = {
82
89
  return { captured: true, snapshot: meta };
83
90
  },
84
91
 
92
+ /** Wipe every clearable source to fresh-install state, then reload. */
93
+ wipeAll: async () => await timeMachineStore.wipeAll(),
94
+
85
95
  /** mode "live" (default) replaces state in place; "reload" restores
86
96
  * persisted storage then reloads the JS bundle (always the path for
87
97
  * baseline snapshots). */
88
98
  restore: async (params: unknown) => {
89
- const { id, mode = "live" } = (params as RestoreParams) ?? {};
99
+ const { id, mode = "live", excludeKeys } = (params as RestoreParams) ?? {};
90
100
  if (!id) throw new Error("restore requires a snapshot `id`");
91
- return await timeMachineStore.restore(id, mode);
101
+ return await timeMachineStore.restore(id, mode, excludeKeys);
92
102
  },
93
103
 
94
104
  delete: async (params: unknown) => {
@@ -105,6 +115,56 @@ export const timeMachineSyncAdapter = {
105
115
  return { renamed: true, id, name };
106
116
  },
107
117
 
118
+ /** Save (or clear with null/empty) a snapshot's targeted-restore scope. */
119
+ setScope: async (params: unknown) => {
120
+ const { id, scope } =
121
+ (params as { id?: string; scope?: string[] | null }) ?? {};
122
+ if (!id) throw new Error("setScope requires a snapshot `id`");
123
+ await timeMachineStore.setScope(id, scope ?? null);
124
+ return { scoped: true, id, count: scope?.length ?? 0 };
125
+ },
126
+
127
+ /**
128
+ * Item-level restore preview, computed ON-DEVICE. Values above ~4KB per
129
+ * item are omitted (valueOmitted) so the result stays far under the 8MB
130
+ * action budget on any app size.
131
+ */
132
+ preview: async (params: unknown) => {
133
+ const { id, compareTo } =
134
+ (params as { id?: string; compareTo?: string }) ?? {};
135
+ if (!id) throw new Error("preview requires a snapshot `id`");
136
+ const right: PreviewSideRef = { snapshotId: id };
137
+ const left: PreviewSideRef = compareTo
138
+ ? { snapshotId: compareTo }
139
+ : "current";
140
+ await timeMachineStore.buildPreview(right, left);
141
+ const preview = timeMachineStore.getState().preview;
142
+ if (!preview) throw new Error("preview build produced no result");
143
+ const VALUE_BUDGET = 4 * 1024;
144
+ return {
145
+ left,
146
+ right,
147
+ builtAt: preview.builtAt,
148
+ sources: preview.sources.map((src) => ({
149
+ ...src,
150
+ items: src.items.map((item) => {
151
+ const oldBig =
152
+ item.oldValue !== undefined &&
153
+ jsonLikelyLargerThan(item.oldValue, VALUE_BUDGET);
154
+ const newBig =
155
+ item.newValue !== undefined &&
156
+ jsonLikelyLargerThan(item.newValue, VALUE_BUDGET);
157
+ return {
158
+ ...item,
159
+ oldValue: oldBig ? undefined : item.oldValue,
160
+ newValue: newBig ? undefined : item.newValue,
161
+ ...(oldBig || newBig ? { valueOmitted: true } : {}),
162
+ };
163
+ }),
164
+ })),
165
+ };
166
+ },
167
+
108
168
  /** One source's captured payload, deserialized — for remote inspection. */
109
169
  inspect: async (params: unknown) => {
110
170
  const { id, sourceId } = (params as InspectParams) ?? {};