mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 10:39:40 +02:00
fix(workbench): resilient liveQuery + rmw-safe scene writes
Addresses a "frozen workbench until reload" bug where adding a new page sometimes stopped updating the UI and no further changes rendered until the user reloaded. - Wrap the workbench-scenes liveQuery `next` handler in try/catch so a single malformed row can't kill the reactive chain. Re-subscribe on terminal errors (up to 3× with backoff) so transient Dexie failures (e.g. DatabaseClosed during a schema upgrade in another tab) recover automatically instead of requiring reload. - Rewrite `patchActiveScene` as a Dexie rw-transaction that reads the row fresh and skips writes that produce the same array reference, so two rapid writes (add A, then add B before the liveQuery echoes the first change) can no longer clobber each other with a stale snapshot. - Restore `viewingAsAgentId` and `scopeTagIds` in `toScene` — they were silently dropped, breaking the agent-avatar pill in SceneAppBar and the auto-inferred scope in SceneHeader. - Surface Dexie write failures from the workbench CRUD handlers. Previous fire-and-forget calls swallowed quota / structured-clone rejections, leaving the picker closed but no new page visible. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8823cc0bf0
commit
a1baf1053e
2 changed files with 96 additions and 35 deletions
|
|
@ -39,6 +39,8 @@ let activeSceneIdState = $state<string | null>(null);
|
||||||
let initializedState = $state(false);
|
let initializedState = $state(false);
|
||||||
|
|
||||||
let subscription: Subscription | null = null;
|
let subscription: Subscription | null = null;
|
||||||
|
let subscribeRetryCount = 0;
|
||||||
|
const MAX_SUBSCRIBE_RETRIES = 3;
|
||||||
|
|
||||||
function readActiveIdFromStorage(): string | null {
|
function readActiveIdFromStorage(): string | null {
|
||||||
if (!browser) return null;
|
if (!browser) return null;
|
||||||
|
|
@ -67,6 +69,8 @@ function toScene(local: LocalWorkbenchScene): WorkbenchScene {
|
||||||
openApps: local.openApps ?? [],
|
openApps: local.openApps ?? [],
|
||||||
order: local.order,
|
order: local.order,
|
||||||
wallpaper: local.wallpaper,
|
wallpaper: local.wallpaper,
|
||||||
|
viewingAsAgentId: local.viewingAsAgentId,
|
||||||
|
scopeTagIds: local.scopeTagIds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,11 +124,75 @@ async function patchScene(
|
||||||
async function patchActiveScene(fn: (apps: WorkbenchSceneApp[]) => WorkbenchSceneApp[]) {
|
async function patchActiveScene(fn: (apps: WorkbenchSceneApp[]) => WorkbenchSceneApp[]) {
|
||||||
const id = activeSceneIdState;
|
const id = activeSceneIdState;
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
const current = scenesState.find((s) => s.id === id);
|
// Read fresh from Dexie inside a rw-transaction so two rapid writes (e.g.
|
||||||
if (!current) return;
|
// user adds app A then app B before the liveQuery has echoed the first
|
||||||
// Snapshot before handing to the mutator so callers operate on plain objects.
|
// change back into scenesState) can't clobber each other with a stale
|
||||||
const plainApps = $state.snapshot(current.openApps) as WorkbenchSceneApp[];
|
// [...oldApps, X] snapshot.
|
||||||
await patchScene(id, { openApps: fn(plainApps) });
|
await db.transaction('rw', TABLE, async () => {
|
||||||
|
const row = await db.table<LocalWorkbenchScene>(TABLE).get(id);
|
||||||
|
if (!row || row.deletedAt) return;
|
||||||
|
const plainApps = (row.openApps ?? []) as WorkbenchSceneApp[];
|
||||||
|
const next = fn(plainApps);
|
||||||
|
if (next === plainApps) return;
|
||||||
|
await db.table<LocalWorkbenchScene>(TABLE).update(id, {
|
||||||
|
openApps: next,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to the workbenchScenes Dexie liveQuery. Wrapped so we can
|
||||||
|
* re-invoke it on transient errors (e.g. DatabaseClosed during another
|
||||||
|
* tab's schema upgrade) — otherwise the subscription dies silently and
|
||||||
|
* local writes land in IndexedDB but never reach `scenesState`, leaving
|
||||||
|
* the user with a "frozen" workbench that only a full reload fixes.
|
||||||
|
*
|
||||||
|
* The `next` handler is individually try/catched so a single malformed
|
||||||
|
* row (bad enum, missing field) can't kill the whole reactive chain.
|
||||||
|
*/
|
||||||
|
function openSubscription(): void {
|
||||||
|
subscription = liveQuery(() => db.table<LocalWorkbenchScene>(TABLE).toArray()).subscribe({
|
||||||
|
next: (rows) => {
|
||||||
|
try {
|
||||||
|
const visible = rows
|
||||||
|
.filter((r) => !r.deletedAt)
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map(toScene);
|
||||||
|
scenesState = visible;
|
||||||
|
|
||||||
|
const next = pickActiveId(visible, activeSceneIdState);
|
||||||
|
if (next !== activeSceneIdState) {
|
||||||
|
activeSceneIdState = next;
|
||||||
|
writeActiveIdToStorage(next);
|
||||||
|
}
|
||||||
|
// Sync scope when scenes reload (init, sync pull, tab focus).
|
||||||
|
const activeScope = visible.find((s) => s.id === (next ?? activeSceneIdState));
|
||||||
|
try {
|
||||||
|
setSceneScopeTagIds(activeScope?.scopeTagIds);
|
||||||
|
} catch (scopeErr) {
|
||||||
|
console.error('[workbench-scenes] setSceneScopeTagIds failed:', scopeErr);
|
||||||
|
}
|
||||||
|
initializedState = true;
|
||||||
|
subscribeRetryCount = 0;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[workbench-scenes] error processing rows:', err);
|
||||||
|
initializedState = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('[workbench-scenes] liveQuery failed:', err);
|
||||||
|
initializedState = true;
|
||||||
|
subscription = null;
|
||||||
|
if (subscribeRetryCount < MAX_SUBSCRIBE_RETRIES) {
|
||||||
|
subscribeRetryCount++;
|
||||||
|
const delay = 500 * subscribeRetryCount;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!subscription) openSubscription();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Public store ─────────────────────────────────────────────
|
// ─── Public store ─────────────────────────────────────────────
|
||||||
|
|
@ -156,30 +224,7 @@ export const workbenchScenesStore = {
|
||||||
}
|
}
|
||||||
|
|
||||||
activeSceneIdState = readActiveIdFromStorage();
|
activeSceneIdState = readActiveIdFromStorage();
|
||||||
|
openSubscription();
|
||||||
subscription = liveQuery(() => db.table<LocalWorkbenchScene>(TABLE).toArray()).subscribe({
|
|
||||||
next: (rows) => {
|
|
||||||
const visible = rows
|
|
||||||
.filter((r) => !r.deletedAt)
|
|
||||||
.sort((a, b) => a.order - b.order)
|
|
||||||
.map(toScene);
|
|
||||||
scenesState = visible;
|
|
||||||
|
|
||||||
const next = pickActiveId(visible, activeSceneIdState);
|
|
||||||
if (next !== activeSceneIdState) {
|
|
||||||
activeSceneIdState = next;
|
|
||||||
writeActiveIdToStorage(next);
|
|
||||||
}
|
|
||||||
// Sync scope when scenes reload (init, sync pull, tab focus).
|
|
||||||
const activeScope = visible.find((s) => s.id === (next ?? activeSceneIdState));
|
|
||||||
setSceneScopeTagIds(activeScope?.scopeTagIds);
|
|
||||||
initializedState = true;
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
console.error('[workbench-scenes] liveQuery failed:', err);
|
|
||||||
initializedState = true;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
|
|
|
||||||
|
|
@ -254,24 +254,40 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── App CRUD (delegated to active scene) ────────────────
|
// ── App CRUD (delegated to active scene) ────────────────
|
||||||
|
// Surface Dexie write failures — a silent rejection (quota, structured
|
||||||
|
// clone) must not leave the picker closed while the new page never
|
||||||
|
// actually lands, which previously looked like a frozen workbench until
|
||||||
|
// the user reloaded.
|
||||||
function handleAddApp(appId: string) {
|
function handleAddApp(appId: string) {
|
||||||
workbenchScenesStore.addApp(appId);
|
|
||||||
showPicker = false;
|
showPicker = false;
|
||||||
|
workbenchScenesStore.addApp(appId).catch((err) => {
|
||||||
|
console.error('[workbench] addApp failed:', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function handleRemoveApp(id: string) {
|
function handleRemoveApp(id: string) {
|
||||||
workbenchScenesStore.removeApp(id);
|
workbenchScenesStore.removeApp(id).catch((err) => {
|
||||||
|
console.error('[workbench] removeApp failed:', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function handleMaximizeApp(id: string) {
|
function handleMaximizeApp(id: string) {
|
||||||
workbenchScenesStore.toggleMaximizeApp(id);
|
workbenchScenesStore.toggleMaximizeApp(id).catch((err) => {
|
||||||
|
console.error('[workbench] toggleMaximizeApp failed:', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function handleResize(id: string, widthPx: number) {
|
function handleResize(id: string, widthPx: number) {
|
||||||
workbenchScenesStore.resizeApp(id, widthPx);
|
workbenchScenesStore.resizeApp(id, widthPx).catch((err) => {
|
||||||
|
console.error('[workbench] resizeApp failed:', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function handleMoveLeft(id: string) {
|
function handleMoveLeft(id: string) {
|
||||||
workbenchScenesStore.moveAppLeft(id);
|
workbenchScenesStore.moveAppLeft(id).catch((err) => {
|
||||||
|
console.error('[workbench] moveAppLeft failed:', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function handleMoveRight(id: string) {
|
function handleMoveRight(id: string) {
|
||||||
workbenchScenesStore.moveAppRight(id);
|
workbenchScenesStore.moveAppRight(id).catch((err) => {
|
||||||
|
console.error('[workbench] moveAppRight failed:', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// ── Card / tab context menus ────────────────────────────
|
// ── Card / tab context menus ────────────────────────────
|
||||||
const ctxMenu = createWorkbenchContextMenu();
|
const ctxMenu = createWorkbenchContextMenu();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue