@buoy-gg/time-machine 7.0.17 → 7.0.19
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/lib/commonjs/components/HeaderReloadButton.js +61 -0
- package/lib/commonjs/components/HeaderReloadButton.js.map +1 -0
- package/lib/commonjs/components/TimeMachineBody.js +343 -127
- package/lib/commonjs/components/TimeMachineBody.js.map +1 -1
- package/lib/commonjs/components/TimeMachineModal.js +1 -0
- package/lib/commonjs/components/TimeMachineModal.js.map +1 -1
- package/lib/commonjs/components/preview/ItemDetailScreen.js +189 -0
- package/lib/commonjs/components/preview/ItemDetailScreen.js.map +1 -0
- package/lib/commonjs/components/preview/RestorePreviewScreen.js +1056 -0
- package/lib/commonjs/components/preview/RestorePreviewScreen.js.map +1 -0
- package/lib/commonjs/components/preview/verdictMeta.js +58 -0
- package/lib/commonjs/components/preview/verdictMeta.js.map +1 -0
- package/lib/commonjs/preset.js +11 -2
- package/lib/commonjs/preset.js.map +1 -1
- package/lib/commonjs/store/snapshotVault.js +19 -0
- package/lib/commonjs/store/snapshotVault.js.map +1 -1
- package/lib/commonjs/store/timeMachineStore.js +298 -7
- package/lib/commonjs/store/timeMachineStore.js.map +1 -1
- package/lib/commonjs/sync/timeMachineSyncAdapter.js +62 -2
- package/lib/commonjs/sync/timeMachineSyncAdapter.js.map +1 -1
- package/lib/module/components/HeaderReloadButton.js +56 -0
- package/lib/module/components/HeaderReloadButton.js.map +1 -0
- package/lib/module/components/TimeMachineBody.js +345 -129
- package/lib/module/components/TimeMachineBody.js.map +1 -1
- package/lib/module/components/TimeMachineModal.js +1 -0
- package/lib/module/components/TimeMachineModal.js.map +1 -1
- package/lib/module/components/preview/ItemDetailScreen.js +184 -0
- package/lib/module/components/preview/ItemDetailScreen.js.map +1 -0
- package/lib/module/components/preview/RestorePreviewScreen.js +1051 -0
- package/lib/module/components/preview/RestorePreviewScreen.js.map +1 -0
- package/lib/module/components/preview/verdictMeta.js +53 -0
- package/lib/module/components/preview/verdictMeta.js.map +1 -0
- package/lib/module/preset.js +11 -2
- package/lib/module/preset.js.map +1 -1
- package/lib/module/store/snapshotVault.js +18 -0
- package/lib/module/store/snapshotVault.js.map +1 -1
- package/lib/module/store/timeMachineStore.js +299 -8
- package/lib/module/store/timeMachineStore.js.map +1 -1
- package/lib/module/sync/timeMachineSyncAdapter.js +62 -2
- package/lib/module/sync/timeMachineSyncAdapter.js.map +1 -1
- package/lib/typescript/components/HeaderReloadButton.d.ts +2 -0
- package/lib/typescript/components/HeaderReloadButton.d.ts.map +1 -0
- package/lib/typescript/components/TimeMachineBody.d.ts.map +1 -1
- package/lib/typescript/components/TimeMachineModal.d.ts.map +1 -1
- package/lib/typescript/components/preview/ItemDetailScreen.d.ts +11 -0
- package/lib/typescript/components/preview/ItemDetailScreen.d.ts.map +1 -0
- package/lib/typescript/components/preview/RestorePreviewScreen.d.ts +15 -0
- package/lib/typescript/components/preview/RestorePreviewScreen.d.ts.map +1 -0
- package/lib/typescript/components/preview/verdictMeta.d.ts +10 -0
- package/lib/typescript/components/preview/verdictMeta.d.ts.map +1 -0
- package/lib/typescript/preset.d.ts +15 -3
- package/lib/typescript/preset.d.ts.map +1 -1
- package/lib/typescript/store/snapshotVault.d.ts +7 -0
- package/lib/typescript/store/snapshotVault.d.ts.map +1 -1
- package/lib/typescript/store/timeMachineStore.d.ts +49 -2
- package/lib/typescript/store/timeMachineStore.d.ts.map +1 -1
- package/lib/typescript/sync/timeMachineSyncAdapter.d.ts +36 -0
- package/lib/typescript/sync/timeMachineSyncAdapter.d.ts.map +1 -1
- package/package.json +19 -7
- package/src/components/HeaderReloadButton.tsx +45 -0
- package/src/components/TimeMachineBody.tsx +416 -137
- package/src/components/TimeMachineModal.tsx +1 -0
- package/src/components/preview/ItemDetailScreen.tsx +172 -0
- package/src/components/preview/RestorePreviewScreen.tsx +1089 -0
- package/src/components/preview/verdictMeta.ts +40 -0
- package/src/preset.tsx +11 -2
- package/src/store/snapshotVault.ts +28 -0
- package/src/store/timeMachineStore.ts +330 -7
- 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 {
|
|
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:
|
|
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
|
-
|
|
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", {
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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) ?? {};
|