feat: optimistic workspace creation + Electric write-sync correctness

Merged
SA
6mo ago
+730-719

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:

  1. Electric write-path correctness — getCurrentTxid() xid cast, electric-proxy protocol-param constant, and no-op mutations no longer fabricate txids.
  2. Workspace create-flow rewrite — optimistic v2Workspaces insert + a synchronous fire-and-forget submit(); the in-flight Zustand sidecar (workspace-creates store + Manager) is gone, replaced by a minimal failedWorkspaceCreates collection that only holds failures.
  3. Shape-stream onError — Electric streams now retry (and refresh the JWT on 401) instead of silently dying on the first non-retryable error.
  4. 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 and Number.isSafeInteger-validated.
  • electric-proxy uses the official ELECTRIC_PROTOCOL_QUERY_PARAMS constant from @electric-sql/client instead of a hand-maintained PROTOCOL_PARAMS set (so new protocol params don't silently get dropped).
  • v2-workspace / v2-host / v2-project / chat routers gate getCurrentTxid() behind .returning() / affected-row checks — no-ops return no txid (or null), missing rows throw NOT_FOUND.
  • host-service workspaces.create surfaces the cloud txid (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 optimistic collections.v2Workspaces.insert() (carrying a create-mutation metadata payload) and writes the pane layout up-front; v2Workspaces.onInsert calls host-service workspaces.create and 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-backed failedWorkspaceCreates collection holds failures, and the route's WorkspaceCreateErrorState reads it. Retry re-submit()s the stashed input; Dismiss deletes the entry.
  • v2-workspace/layout.tsx is 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 creationStatus enum collapses to an isSynced boolean (workspaces.$synced), getCreationStatusText is deleted, and in-flight creates simply render as loading rows. <WorkspaceCreatesManager /> is removed from the dashboard layout.

Key Decisions

DecisionChoiceRationale
In-flight state storeTanStack DB optimistic rowThe collection already tracks it via $synced; a separate store is duplicate state.
Failure stateMinimal failedWorkspaceCreates localStorage collectionThe only state the optimistic row can't represent (it rolls back). Powers the existing retry UI.
Canonical-id mismatchOptimistic row self-drops (txid null)Avoids a bespoke rollback sentinel — earlier WorkspaceCreateRollbackError approach was dropped as over-complex.
submit() shapeSynchronous, 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 so submit() 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() now awaits waitForWorkspaceDeleted(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 keepDeleting and 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), desktop CollectionsProvider (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.
  • onError returns {} to retry rather than rethrowing — a dead stream is strictly worse than a retrying one; matches the Electric client's intended use of onError.
  • Delete waits on row-absence, not awaitTxId — observing the row leave the collection avoids retyping all 23 collection slots into ElectricCollectionUtils just to call awaitTxId on the delete path.

Known Limitations

  • No parser is configured for timestamptz columns — they sync as ISO strings, not Dates. Deferred.
  • $synced / isDeleting guards 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 WIP commits — squash-merge recommended so the history reads from this description.

Open in Stage

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 v2Workspaces insert with a synchronous submit() that returns { workspaceId, completed }; pane layout is written up-front.
    • Replaces the in-flight Zustand sidecar with a minimal failedWorkspaceCreates local collection; retry re-submits, dismiss clears.
    • UI uses isSynced instead of creationStatus; 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 onError that retries and refreshes the JWT on 401 across all collections.
  • 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::text with integer validation; no-op mutations return no txid; routers gate txid and throw NOT_FOUND on missing rows.
    • TRPC mutations wrap writes in transactions and return txids (projects, hosts, workspaces, chat) so UI awaitTxId can match correctly.
    • electric-proxy now uses ELECTRIC_PROTOCOL_QUERY_PARAMS from @electric-sql/client.

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.

Review Change Stack