feat: optimistic workspace creation + Electric write-sync correctness
Summary
Reworks workspace creation onto TanStack DB optimistic inserts with Electric txid-confirmed sync, and fixes the write-path correctness gaps underneath it. Four parts:
- Electric write-path correctness —
getCurrentTxid()xid cast, electric-proxy protocol-param constant, and no-op mutations no longer fabricate txids. - Workspace create-flow rewrite — optimistic
v2Workspacesinsert + a synchronous fire-and-forgetsubmit(); the in-flight Zustand sidecar (workspace-createsstore +Manager) is gone, replaced by a minimalfailedWorkspaceCreatescollection that only holds failures. - Shape-stream
onError— Electric streams now retry (and refresh the JWT on 401) instead of silently dying on the first non-retryable error. - Electric-confirmed deletion — the delete dialog waits for the row to actually leave the local collection before clearing deleting-state.
Relationship to #4632
This is an alternative to the open Electric-team PR #4632. It keeps that PR's write-path correctness fixes and the optimistic-insert direction, but preserves the in-product retry UI — #4632 removes WorkspaceCreateErrorState / WorkspaceCreatingState; here they're kept and wired to a real retry mechanism — and keeps in-flight creates visible in the sidebar as loading rows. It also adds shape-stream onError (Part 3), a read-path gap #4632 doesn't touch.
Part 1: Electric write-path correctness
Why
getCurrentTxid() returned pg_current_xact_id()::text — a 64-bit xid8. Electric receives the 32-bit xid from logical replication. The two agree on a fresh database but diverge once the xid epoch advances, after which awaitTxId can never match and every optimistic write times out. Separately, no-op writes (raced deletes, no-change updates) returned a freshly-minted txid that Electric will never emit downstream, so the renderer waited the full 30s sync timeout for nothing.
What
getCurrentTxid()→pg_current_xact_id()::xid::text, parsed andNumber.isSafeInteger-validated.- electric-proxy uses the official
ELECTRIC_PROTOCOL_QUERY_PARAMSconstant from@electric-sql/clientinstead of a hand-maintainedPROTOCOL_PARAMSset (so new protocol params don't silently get dropped). v2-workspace/v2-host/v2-project/chatrouters gategetCurrentTxid()behind.returning()/ affected-row checks — no-ops return no txid (ornull), missing rows throwNOT_FOUND.- host-service
workspaces.createsurfaces the cloudtxid(extractCreateTxid) so the renderer can hand it back to Electric.
Part 2: Workspace create-flow rewrite
Why
Workspace creation ran through a heavyweight renderer-memory sidecar — the workspace-creates Zustand store plus a Manager component reconciling it against Electric. It modeled in-flight rows outside TanStack DB, duplicating state the collection already tracks, and was hard to reason about.
What / How
submit()is now synchronous fire-and-forget — it returns{ workspaceId, completed: Promise<SubmitOutcome> }. It does an optimisticcollections.v2Workspaces.insert()(carrying a create-mutation metadata payload) and writes the pane layout up-front;v2Workspaces.onInsertcalls host-serviceworkspaces.createand returns the resulting txid for Electric to match.- TanStack DB's optimistic row already models in-flight (
$synced: false) and succeeded ($synced: true). The one state it can't model is failed — the optimistic row rolls back on error — so a small localStorage-backedfailedWorkspaceCreatescollection holds failures, and the route'sWorkspaceCreateErrorStatereads it. Retry re-submit()s the stashed input; Dismiss deletes the entry. v2-workspace/layout.tsxis now a reactive 4-way tree: synced row → real workspace UI; unsynced row →WorkspaceCreatingState; no row + failed entry →WorkspaceCreateErrorState; otherwise →WorkspaceNotFoundState.- The sidebar is de-coupled from in-flight bookkeeping: the
creationStatusenum collapses to anisSyncedboolean (workspaces.$synced),getCreationStatusTextis deleted, and in-flight creates simply render as loading rows.<WorkspaceCreatesManager />is removed from the dashboard layout.
Key Decisions
| Decision | Choice | Rationale |
|---|---|---|
| In-flight state store | TanStack DB optimistic row | The collection already tracks it via $synced; a separate store is duplicate state. |
| Failure state | Minimal failedWorkspaceCreates localStorage collection | The only state the optimistic row can't represent (it rolls back). Powers the existing retry UI. |
| Canonical-id mismatch | Optimistic row self-drops (txid null) | Avoids a bespoke rollback sentinel — earlier WorkspaceCreateRollbackError approach was dropped as over-complex. |
submit() shape | Synchronous, returns { workspaceId, completed } | Callers can navigate optimistically immediately and still await the outcome if they want. |
New Files
workspace-creates/writeWorkspacePaneLayout.ts— pane-layout write, extracted sosubmit()can run it up-front.CollectionsProvider/workspaceSyncWaits.ts—waitForWorkspaceDeleted()(used by Part 4).
Removed Files
workspace-creates/store.ts,workspace-creates/Manager.tsx— the Zustand sidecar.DashboardSidebarWorkspaceItem/utils/getCreationStatusText.ts(+utils/index.ts).
Part 3: Shape-stream onError
Why
Electric ShapeStreams had no onError handler. A single non-retryable error (notably a 401 once the JWT expires) permanently kills the stream — after which every awaitTxId on that collection times out. This is the most likely root cause of the txid-timeout reports against #4632.
What
- Added a shared
handleElectricSyncError(ElectricSyncErrorHandler) wired to all 24 collections'shapeOptions.onError. - On a 401 it refreshes the JWT (
authClient.token()+setJwt) so the stream can resume authenticated; otherwise it logs. It always returns{}so the stream retries rather than dying.
Part 4: Electric-confirmed deletion
Why
The destroy dialog cleared its deleting-state as soon as the host-service teardown returned, before Electric had synced the row's removal — leaving a brief window where a deleted workspace could flash back into the sidebar.
What
useDestroyDialogState.run()nowawaitswaitForWorkspaceDeleted(collections.v2Workspaces, workspaceId)after the destroy saga — it resolves when the row leaves the local collection and rejects after the 30s sync timeout.- On a slow sync it sets
keepDeletingand surfaces a warning toast instead of clearing state, so the row stays visually deleting rather than reappearing.
Manual QA Checklist
Automated checks only were run (see Testing). The desktop app cannot be exercised from this environment — the items below are unverified and should be checked before merge.
Workspace creation
- Creating a workspace navigates optimistically and the sidebar shows a loading row
- On sync the loading row becomes a normal workspace row
- A failed create surfaces
WorkspaceCreateErrorState; Retry re-runs the create; Dismiss removes it - Multiple queued creates from the task list each resolve independently
- In-flight create survives an app restart sensibly (re-syncs or shows error)
Workspace deletion
- Deleting a workspace removes it from the sidebar and does not flash back
- A slow delete-sync shows the "taking longer than expected" warning toast
- Dirty-worktree force-retry path still works
Electric sync
- Collections keep syncing after the JWT would have expired (401 → silent token refresh)
- No-op updates/deletes don't hang on the 30s txid timeout
Testing
bun run typecheck—@superset/desktop,trpc,host-service,db,electric-proxy✓bun run lint— 0 issues ✓bun test—v2-project(36),task(8),host-service(645), desktopCollectionsProvider(15) ✓- Manual / packaged-build E2E not run — requires a running desktop app, unavailable in this environment.
Design Decisions
- Optimistic row over a sidecar store — see Part 2. The collection is the source of truth; the only thing it can't express (a rolled-back failure) gets the minimal sidecar.
onErrorreturns{}to retry rather than rethrowing — a dead stream is strictly worse than a retrying one; matches the Electric client's intended use ofonError.- Delete waits on row-absence, not
awaitTxId— observing the row leave the collection avoids retyping all 23 collection slots intoElectricCollectionUtilsjust to callawaitTxIdon the delete path.
Known Limitations
- No
parseris configured fortimestamptzcolumns — they sync as ISO strings, notDates. Deferred. $synced/isDeletingguards are intentionally not added to ancillary surfaces (command palette, resource monitor, ports list,/v2-workspaces). The workspace-layout guard covers the real hazard; showing in-flight rows in those lists is consistent with the sidebar.
Risks / Rollback
- Risk: Broad blast radius — tRPC router changes touch multiple desktop call-sites; the create flow and sidebar are rewritten. Type-checked and lint-clean, but no manual E2E.
- Rollback: Revert the branch. No schema or data migrations; nothing to recover. electric-proxy and the routers are forward-compatible with the old renderer (extra/absent txids are tolerated), so a renderer-only revert is also safe.
Notes
- The branch carries two pre-existing
WIPcommits — squash-merge recommended so the history reads from this description.
Summary by cubic
Moves workspace creation to optimistic TanStack DB inserts with Electric txid-confirmed sync, removes the in-flight store, and fixes Electric write-sync correctness and stream resilience. Unsynced workspaces are now non-actionable until they sync, and deletions wait for Electric to confirm before clearing UI state.
-
New Features
- Optimistic
v2Workspacesinsert with a synchronoussubmit()that returns{ workspaceId, completed }; pane layout is written up-front. - Replaces the in-flight Zustand sidecar with a minimal
failedWorkspaceCreateslocal collection; retry re-submits, dismiss clears. - UI uses
isSyncedinstead ofcreationStatus; sidebar renders loading rows for unsynced workspaces; delete waits for the row to leave the local collection and warns on slow sync. - Electric shape streams add
onErrorthat retries and refreshes the JWT on 401 across all collections.
- Optimistic
-
Bug Fixes
- Unsynced rows are non-actionable: host URL resolution is gated on
$synced, and sidebar jump shortcuts ignore unsynced or deleting workspaces. - Create retry now redirects to the canonical workspace id if it differs from the optimistic id.
- Electric txid correctness:
pg_current_xact_id()::xid::textwith integer validation; no-op mutations return no txid; routers gate txid and throwNOT_FOUNDon missing rows. - TRPC mutations wrap writes in transactions and return txids (projects, hosts, workspaces, chat) so UI
awaitTxIdcan match correctly. electric-proxynow usesELECTRIC_PROTOCOL_QUERY_PARAMSfrom@electric-sql/client.
- Unsynced rows are non-actionable: host URL resolution is gated on
Summary by CodeRabbit
-
New Features
- Persist failed workspace creations for reliable retry.
- Deletions wait for cloud sync confirmation before finalizing.
-
Improvements
- Clearer "synced" vs "syncing" status in sidebar icons and labels.
- Simplified pending/creation UI and tooltips.
- Keyboard navigation and hotkeys skip unsynced or deleting workspaces.
- Optimistic workspace navigation reconciles to the canonical workspace when operations complete.