Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions apps/desktop/docs/OPTIMISTIC_ELECTRIC_UPDATES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Optimistic Electric Updates

Desktop uses TanStack DB collections backed by Electric shapes for task and workspace data. The default write model is **optimistic online**, not offline-first.

## Decision

Use TanStack DB collection mutations for routine server-backed writes that already have stable local identity:

1. The UI calls `collection.insert`, `collection.update`, or `collection.delete`.
2. TanStack DB applies optimistic state immediately.
3. The collection handler persists through our API.
4. The API returns the PostgreSQL `txid` from the same database transaction as the write.
5. Electric streams that transaction back to the client.
6. TanStack DB drops the optimistic overlay or rolls it back if persistence fails.

This matches the documented TanStack DB mutation lifecycle and Electric collection txid strategy:

- TanStack DB mutations: https://tanstack.com/db/latest/docs/guides/mutations
- Electric collection txid matching: https://tanstack.com/db/latest/docs/collections/electric-collection

## Scope

Optimistic online is the right default for task edits, status changes, assignment changes, priority changes, title/description edits, and soft deletes. These are simple, single-record writes where immediate feedback matters and rollback is acceptable if the server rejects the write.

New task creation can remain server-confirmed while it relies on server-generated slugs, default status seeding, and navigation to the canonical record. Move creation to optimistic insert only after the client can provide all stable identity and routing fields up front.

Do not treat this as offline-first. If the API call cannot run, the transaction should fail, TanStack DB should roll back the optimistic state, and the UI should show a failure toast. We are not adding a durable outbox, replay queue, conflict resolver, or persisted collection state in this pass.

## Desktop Collection Matrix

Desktop currently has three write categories.

### Server-backed Electric writes

These collections have Electric mutation handlers in `CollectionsProvider/collections.ts`:

| Collection | Handlers | Current write surface | Behavior |
| --- | --- | --- | --- |
| `tasks` | insert, update, delete | `useOptimisticCollectionActions().tasks` for update/delete; create dialog still uses `task.createFromUi` directly | Optimistic for task edits/deletes; collection handlers return `{ txid }`. |
| `v2Projects` | update | `useOptimisticCollectionActions().v2Projects` for rename/repository updates | Optimistic for project row edits; create/delete remain API-confirmed. |
| `v2Workspaces` | update | `useOptimisticCollectionActions().v2Workspaces` for rename-style updates | Optimistic for workspace row edits; create/delete remain host-service sagas. |
| `chatSessions` | delete | `useOptimisticCollectionActions().chatSessions` for chat session deletion | Optimistic delete; create remains server-confirmed because the chat runtime coordinates session creation. |
| `agentCommands` | update | `useCommandWatcher` | Background optimistic update; caller awaits `tx.isPersisted.promise` and retries on failure. |

### Read-only Electric collections

These are Electric-backed in the renderer but have no collection mutation handlers and no direct renderer `collection.insert/update/delete` calls:

- `organizations`
- `taskStatuses`
- `projects`
- `v2Hosts`
- `v2Clients`
- `v2UsersHosts`
- `workspaces`
- `members`
- `users`
- `invitations`
- `integrationConnections`
- `subscriptions`
- `apiKeys`
- `sessionHosts`
- `githubRepositories`
- `githubPullRequests`
- `automations`
- `automationRuns`

Workspace create/delete flows do not use `collections.v2Workspaces.insert/delete`. They go through host-service or tRPC APIs and then Electric streams the confirmed row back:

- workspace create/checkout/adopt writes a local `pendingWorkspaces` row, then the pending page calls host-service
- workspace delete calls host-service `workspaceCleanup.destroy`; the sidebar hides the row through `DeletingWorkspacesProvider` while the saga runs

Workspace rename does use `collections.v2Workspaces.update` via `useOptimisticCollectionActions().v2Workspaces`, backed by `v2Workspace.update` returning `{ txid }` from the same Postgres transaction.

### LocalStorage collections

These are client-local TanStack DB collections. They are synchronous local persistence, not Electric/Postgres optimistic writes:

- `v2SidebarProjects` — sidebar project order/collapse/default app
- `v2WorkspaceLocalState` — sidebar placement, pane layout, viewed files, changes tab
- `v2SidebarSections` — user-created sidebar sections and ordering
- `v2TerminalPresets` — local terminal presets
- `pendingWorkspaces` — durable local bus for workspace creation progress and launch handoff
- `v2UserPreferences` — local v2 preferences such as link behavior and delete-branch default

LocalStorage mutations can still throw for schema/storage errors, but they do not have remote persistence confirmation or Electric rollback semantics.

## Implementation Contract

Collection handlers must return `{ txid }` for server-backed Electric writes. The txid must come from `pg_current_xact_id()` inside the same transaction that performs the mutation. A txid captured before or after the write can leave `tx.isPersisted.promise` waiting for a transaction that Electric will never stream.

Feature code should not scatter direct server-backed collection mutations. Use `useOptimisticCollectionActions` and the relevant grouped action surface, such as `.tasks`, `.v2Workspaces`, `.v2Projects`, or `.chatSessions`, so every call site gets the same behavior:

- apply the optimistic collection mutation immediately
- attach a rejection handler to `tx.isPersisted.promise`
- show a user-visible error when persistence fails
- let TanStack DB own rollback

Use `{ optimistic: false }` only for exceptional flows where the UI must wait for server confirmation before revealing the result, such as a workflow that depends on a server-generated identifier or a multi-step server-side effect.

## Offline-First Boundary

Offline-first needs more than optimistic state. It needs durable local persistence, queued transactions, replay ordering, idempotency, and conflict handling. If we decide to support offline task writes later, design it as a separate feature with:

- a durable transaction queue
- client-generated stable IDs for created records
- idempotent API mutations
- explicit conflict policy per write type
- UI for pending and failed replays

Until then, Electric is our read/sync confirmation path and the API remains the write authority.
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { alert } from "@superset/ui/atoms/Alert";
import { toast } from "@superset/ui/sonner";
import { useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { apiTrpcClient } from "renderer/lib/api-trpc-client";
import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState";
import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions";
import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal";
import type { DashboardSidebarProject } from "../../../../types";

Expand All @@ -16,6 +16,7 @@ export function useDashboardSidebarProjectSectionActions({
}: UseDashboardSidebarProjectSectionActionsOptions) {
const openModal = useOpenNewWorkspaceModal();
const navigate = useNavigate();
const { v2Projects: projectActions } = useOptimisticCollectionActions();
const {
createSection,
deleteSection,
Expand All @@ -37,20 +38,11 @@ export function useDashboardSidebarProjectSectionActions({
setRenameValue(project.name);
};

const submitRename = async () => {
const submitRename = () => {
setIsRenaming(false);
const trimmed = renameValue.trim();
if (!trimmed || trimmed === project.name) return;
try {
await apiTrpcClient.v2Project.update.mutate({
id: project.id,
name: trimmed,
});
} catch (error) {
toast.error(
`Failed to rename: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
projectActions.renameProject(project.id, trimmed);
};

const handleOpenInFinder = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { toast } from "@superset/ui/sonner";
import { useMatchRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard";
import { apiTrpcClient } from "renderer/lib/api-trpc-client";
import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";
import { electronTrpcClient } from "renderer/lib/trpc-client";
import { useNavigateAwayFromWorkspace } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useNavigateAwayFromWorkspace";
import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState";
import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions";
import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider";

interface UseDashboardSidebarWorkspaceItemActionsOptions {
Expand All @@ -27,6 +27,7 @@ export function useDashboardSidebarWorkspaceItemActions({
const navigateAway = useNavigateAwayFromWorkspace();
const { activeHostUrl } = useLocalHostService();
const { copyToClipboard } = useCopyToClipboard();
const { v2Workspaces: workspaceActions } = useOptimisticCollectionActions();
const { createSection, moveWorkspaceToSection, removeWorkspaceFromSidebar } =
useDashboardSidebarState();

Expand Down Expand Up @@ -58,20 +59,11 @@ export function useDashboardSidebarWorkspaceItemActions({
setRenameValue(workspaceName);
};

const submitRename = async () => {
const submitRename = () => {
setIsRenaming(false);
const trimmed = renameValue.trim();
if (!trimmed || trimmed === workspaceName) return;
try {
await apiTrpcClient.v2Workspace.update.mutate({
id: workspaceId,
name: trimmed,
});
} catch (error) {
toast.error(
`Failed to rename: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
workspaceActions.renameWorkspace(workspaceId, trimmed);
};

const handleDeleted = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { useLiveQuery } from "@tanstack/react-db";
import { useMemo, useState } from "react";
import { HiOutlineUserCircle } from "react-icons/hi2";
import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
import type { TaskWithStatus } from "../../../../../components/TasksView/hooks/useTasksTable";

Expand All @@ -17,6 +18,7 @@ interface AssigneePropertyProps {

export function AssigneeProperty({ task }: AssigneePropertyProps) {
const collections = useCollections();
const { tasks: taskActions } = useOptimisticCollectionActions();
const [open, setOpen] = useState(false);

const { data: allUsers } = useLiveQuery(
Expand All @@ -32,14 +34,10 @@ export function AssigneeProperty({ task }: AssigneePropertyProps) {
return;
}

setOpen(false);

collections.tasks.update(task.id, (draft) => {
draft.assigneeId = userId;
draft.assigneeExternalId = null;
draft.assigneeDisplayName = null;
draft.assigneeAvatarUrl = null;
});
const transaction = taskActions.updateAssignee(task.id, userId);
if (transaction) {
setOpen(false);
}
};

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
DropdownMenuTrigger,
} from "@superset/ui/dropdown-menu";
import { useState } from "react";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions";
import { PriorityIcon } from "../../../../../components/TasksView/components/shared/PriorityIcon";
import type { TaskWithStatus } from "../../../../../components/TasksView/hooks/useTasksTable";
import { ALL_PRIORITIES } from "../../../../../components/TasksView/utils/sorting";
Expand All @@ -24,7 +24,7 @@ interface PriorityPropertyProps {
}

export function PriorityProperty({ task }: PriorityPropertyProps) {
const collections = useCollections();
const { tasks: taskActions } = useOptimisticCollectionActions();
const [open, setOpen] = useState(false);

const currentPriority = task.priority;
Expand All @@ -36,13 +36,9 @@ export function PriorityProperty({ task }: PriorityPropertyProps) {
return;
}

try {
collections.tasks.update(task.id, (draft) => {
draft.priority = newPriority;
});
const transaction = taskActions.updatePriority(task.id, newPriority);
if (transaction) {
setOpen(false);
} catch (error) {
console.error("[PriorityProperty] Failed to update priority:", error);
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "@superset/ui/dropdown-menu";
import { useLiveQuery } from "@tanstack/react-db";
import { useMemo, useState } from "react";
import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
import {
StatusIcon,
Expand All @@ -22,6 +23,7 @@ interface StatusPropertyProps {

export function StatusProperty({ task }: StatusPropertyProps) {
const collections = useCollections();
const { tasks: taskActions } = useOptimisticCollectionActions();
const [open, setOpen] = useState(false);

const { data: allStatuses } = useLiveQuery(
Expand All @@ -42,13 +44,9 @@ export function StatusProperty({ task }: StatusPropertyProps) {
return;
}

try {
collections.tasks.update(task.id, (draft) => {
draft.statusId = newStatus.id;
});
const transaction = taskActions.updateStatus(task.id, newStatus.id);
if (transaction) {
setOpen(false);
} catch (error) {
console.error("[StatusProperty] Failed to update status:", error);
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
HiOutlineTrash,
} from "react-icons/hi2";
import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions";
import type { TaskWithStatus } from "../../../components/TasksView/hooks/useTasksTable";

interface TaskActionMenuProps {
Expand All @@ -22,7 +22,7 @@ interface TaskActionMenuProps {
}

export function TaskActionMenu({ task, onDelete }: TaskActionMenuProps) {
const collections = useCollections();
const { tasks: taskActions } = useOptimisticCollectionActions();
const [open, setOpen] = useState(false);

const { copyToClipboard } = useCopyToClipboard();
Expand All @@ -37,13 +37,11 @@ export function TaskActionMenu({ task, onDelete }: TaskActionMenuProps) {
setOpen(false);
};

const handleDelete = async () => {
try {
await collections.tasks.delete(task.id);
const handleDelete = () => {
const transaction = taskActions.deleteTask(task.id);
if (transaction) {
setOpen(false);
onDelete?.();
} catch (error) {
console.error("[TaskActionMenu] Failed to delete task:", error);
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useMemo } from "react";
import { MarkdownEditor } from "renderer/components/MarkdownEditor";
import { apiTrpcClient } from "renderer/lib/api-trpc-client";
import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
import { Route as TasksLayoutRoute } from "../layout";
import { ActivitySection } from "./components/ActivitySection";
Expand All @@ -37,6 +38,7 @@ function TaskDetailPage() {
const { tab, assignee, search } = TasksLayoutRoute.useSearch();
const navigate = useNavigate();
const collections = useCollections();
const { tasks: taskActions } = useOptimisticCollectionActions();
const isUuidTaskId =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
taskId,
Expand Down Expand Up @@ -108,16 +110,12 @@ function TaskDetailPage() {

const handleSaveTitle = (title: string) => {
if (!task) return;
collections.tasks.update(task.id, (draft) => {
draft.title = title;
});
taskActions.updateTitle(task.id, title);
};

const handleSaveDescription = (markdown: string) => {
if (!task) return;
collections.tasks.update(task.id, (draft) => {
draft.description = markdown;
});
taskActions.updateDescription(task.id, markdown);
};

const handleDelete = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import type { SelectTaskStatus } from "@superset/db/schema";
import { useCallback, useMemo, useState } from "react";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions";
import type { TaskWithStatus } from "../../hooks/useTasksData";
import { compareStatusesForDropdown } from "../../utils/sorting";
import { KanbanCard } from "./components/KanbanCard";
Expand All @@ -29,7 +29,7 @@ export function TasksBoardView({
allStatuses,
onTaskClick,
}: TasksBoardViewProps) {
const collections = useCollections();
const { tasks: taskActions } = useOptimisticCollectionActions();
const [activeTask, setActiveTask] = useState<TaskWithStatus | null>(null);

const sensors = useSensors(
Expand Down Expand Up @@ -95,11 +95,9 @@ export function TasksBoardView({
const task = data.find((t) => t.id === taskId);
if (!task || task.statusId === targetStatusId) return;

collections.tasks.update(taskId, (draft) => {
draft.statusId = targetStatusId;
});
taskActions.updateStatus(taskId, targetStatusId);
},
[data, collections],
[data, taskActions],
);

const handleDragCancel = useCallback(() => {
Expand Down
Loading
Loading