diff --git a/apps/desktop/docs/OPTIMISTIC_ELECTRIC_UPDATES.md b/apps/desktop/docs/OPTIMISTIC_ELECTRIC_UPDATES.md new file mode 100644 index 00000000000..bf40a83d511 --- /dev/null +++ b/apps/desktop/docs/OPTIMISTIC_ELECTRIC_UPDATES.md @@ -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. diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts index 0fafd1a535c..bedc2d31c6d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts @@ -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"; @@ -16,6 +16,7 @@ export function useDashboardSidebarProjectSectionActions({ }: UseDashboardSidebarProjectSectionActionsOptions) { const openModal = useOpenNewWorkspaceModal(); const navigate = useNavigate(); + const { v2Projects: projectActions } = useOptimisticCollectionActions(); const { createSection, deleteSection, @@ -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 = () => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts index 7c0dc64ce0e..35744563872 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts @@ -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 { @@ -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(); @@ -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 = () => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/AssigneeProperty/AssigneeProperty.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/AssigneeProperty/AssigneeProperty.tsx index e9c5ac0d6b5..90706ba0dc5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/AssigneeProperty/AssigneeProperty.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/AssigneeProperty/AssigneeProperty.tsx @@ -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"; @@ -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( @@ -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 ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/PriorityProperty/PriorityProperty.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/PriorityProperty/PriorityProperty.tsx index 645eb240a54..3f3de904ff4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/PriorityProperty/PriorityProperty.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/PriorityProperty/PriorityProperty.tsx @@ -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"; @@ -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; @@ -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); } }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/StatusProperty/StatusProperty.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/StatusProperty/StatusProperty.tsx index 448d83c2d76..abf102a0e36 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/StatusProperty/StatusProperty.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/StatusProperty/StatusProperty.tsx @@ -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, @@ -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( @@ -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); } }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskActionMenu/TaskActionMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskActionMenu/TaskActionMenu.tsx index ca37d3f5b7c..1a56a7c2ccc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskActionMenu/TaskActionMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskActionMenu/TaskActionMenu.tsx @@ -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 { @@ -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(); @@ -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); } }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx index a51f58201ef..c28b9c33fd4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx @@ -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"; @@ -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, @@ -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 = () => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksBoardView/TasksBoardView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksBoardView/TasksBoardView.tsx index 4965ac63ba3..ec08f8c9dbc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksBoardView/TasksBoardView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksBoardView/TasksBoardView.tsx @@ -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"; @@ -29,7 +29,7 @@ export function TasksBoardView({ allStatuses, onTaskClick, }: TasksBoardViewProps) { - const collections = useCollections(); + const { tasks: taskActions } = useOptimisticCollectionActions(); const [activeTask, setActiveTask] = useState(null); const sensors = useSensors( @@ -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(() => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTableView/TasksTableView.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTableView/TasksTableView.test.ts index 0c5fd6b7ff5..209c0cf7614 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTableView/TasksTableView.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTableView/TasksTableView.test.ts @@ -11,12 +11,13 @@ function readComponent(relativePath: string): string { } describe("Tasks table delete wiring", () => { - test("TaskContextMenu deletes tasks through the collections API", () => { + test("TaskContextMenu deletes tasks through optimistic task actions", () => { const source = readComponent( "components/TaskContextMenu/TaskContextMenu.tsx", ); - expect(source).toContain("collections.tasks.delete(task.id)"); + expect(source).toContain("useOptimisticCollectionActions"); + expect(source).toContain("taskActions.deleteTask(task.id)"); expect(source).toContain("onSelect={handleDelete}"); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTableView/components/TaskContextMenu/TaskContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTableView/components/TaskContextMenu/TaskContextMenu.tsx index 6f3f02a3b32..2e86c76cbee 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTableView/components/TaskContextMenu/TaskContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTableView/components/TaskContextMenu/TaskContextMenu.tsx @@ -17,6 +17,7 @@ import { HiOutlineUserCircle, } from "react-icons/hi2"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; +import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { TaskWithStatus } from "../../../../hooks/useTasksTable"; import { compareStatusesForDropdown } from "../../../../utils/sorting"; @@ -38,6 +39,7 @@ export function TaskContextMenu({ onDelete, }: TaskContextMenuProps) { const collections = useCollections(); + const { tasks: taskActions } = useOptimisticCollectionActions(); const { data: allStatuses } = useLiveQuery( (q) => q.from({ taskStatuses: collections.taskStatuses }), @@ -57,36 +59,15 @@ export function TaskContextMenu({ const users = useMemo(() => allUsers || [], [allUsers]); const handleStatusChange = (status: SelectTaskStatus) => { - try { - collections.tasks.update(task.id, (draft) => { - draft.statusId = status.id; - }); - } catch (error) { - console.error("[TaskContextMenu] Failed to update status:", error); - } + taskActions.updateStatus(task.id, status.id); }; const handleAssigneeChange = (userId: string | null) => { - try { - collections.tasks.update(task.id, (draft) => { - draft.assigneeId = userId; - draft.assigneeExternalId = null; - draft.assigneeDisplayName = null; - draft.assigneeAvatarUrl = null; - }); - } catch (error) { - console.error("[TaskContextMenu] Failed to update assignee:", error); - } + taskActions.updateAssignee(task.id, userId); }; const handlePriorityChange = (priority: typeof task.priority) => { - try { - collections.tasks.update(task.id, (draft) => { - draft.priority = priority; - }); - } catch (error) { - console.error("[TaskContextMenu] Failed to update priority:", error); - } + taskActions.updatePriority(task.id, priority); }; const { copyToClipboard } = useCopyToClipboard(); @@ -100,11 +81,9 @@ export function TaskContextMenu({ }; const handleDelete = () => { - try { - collections.tasks.delete(task.id); + const transaction = taskActions.deleteTask(task.id); + if (transaction) { onDelete?.(); - } catch (error) { - console.error("[TaskContextMenu] Failed to delete task:", error); } }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/AssigneeCell/AssigneeCell.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/AssigneeCell/AssigneeCell.tsx index 368d922e484..a2926a4d961 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/AssigneeCell/AssigneeCell.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/AssigneeCell/AssigneeCell.tsx @@ -9,6 +9,7 @@ import { useLiveQuery } from "@tanstack/react-db"; import type { CellContext } from "@tanstack/react-table"; 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 "../../useTasksTable"; @@ -18,6 +19,7 @@ interface AssigneeCellProps { export function AssigneeCell({ info }: AssigneeCellProps) { const collections = useCollections(); + const { tasks: taskActions } = useOptimisticCollectionActions(); const [open, setOpen] = useState(false); const task = info.row.original; @@ -36,14 +38,10 @@ export function AssigneeCell({ info }: AssigneeCellProps) { 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 ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/PriorityCell/PriorityCell.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/PriorityCell/PriorityCell.tsx index b605da46445..d2f0703ad41 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/PriorityCell/PriorityCell.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/PriorityCell/PriorityCell.tsx @@ -7,7 +7,7 @@ import { } from "@superset/ui/dropdown-menu"; import type { CellContext } from "@tanstack/react-table"; import { useState } from "react"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { PriorityIcon } from "../../../../components/shared/PriorityIcon"; import { ALL_PRIORITIES } from "../../../../utils/sorting"; import type { TaskWithStatus } from "../../useTasksTable"; @@ -25,7 +25,7 @@ const PRIORITY_LABELS: Record = { }; export function PriorityCell({ info }: PriorityCellProps) { - const collections = useCollections(); + const { tasks: taskActions } = useOptimisticCollectionActions(); const [open, setOpen] = useState(false); const task = info.row.original; @@ -38,13 +38,9 @@ export function PriorityCell({ info }: PriorityCellProps) { 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("[PriorityCell] Failed to update priority:", error); } }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/StatusCell/StatusCell.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/StatusCell/StatusCell.tsx index 7d9981b7a0a..44b886868cc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/StatusCell/StatusCell.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/StatusCell/StatusCell.tsx @@ -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, @@ -22,6 +23,7 @@ interface StatusCellProps { export function StatusCell({ taskWithStatus }: StatusCellProps) { const collections = useCollections(); + const { tasks: taskActions } = useOptimisticCollectionActions(); const [open, setOpen] = useState(false); const { data: allStatuses } = useLiveQuery( @@ -42,13 +44,12 @@ export function StatusCell({ taskWithStatus }: StatusCellProps) { return; } - try { - collections.tasks.update(taskWithStatus.id, (draft) => { - draft.statusId = newStatus.id; - }); + const transaction = taskActions.updateStatus( + taskWithStatus.id, + newStatus.id, + ); + if (transaction) { setOpen(false); - } catch (error) { - console.error("[StatusCell] Failed to update status:", error); } }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatController/useWorkspaceChatController.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatController/useWorkspaceChatController.ts index 8282debbfe8..3df58c50391 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatController/useWorkspaceChatController.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatController/useWorkspaceChatController.ts @@ -9,6 +9,7 @@ import { resolveDesktopChatOrganizationId, } from "renderer/lib/dev-chat"; import { posthog } from "renderer/lib/posthog"; +import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; interface SessionSelectorItem { @@ -48,16 +49,6 @@ async function createSessionRecord(input: { }); } -async function deleteSessionRecord(sessionId: string): Promise { - if (isDesktopChatDevMode()) return; - const result = await apiTrpcClient.chat.deleteSession.mutate({ - sessionId, - }); - if (!result.deleted) { - throw new Error(`Failed to delete session ${sessionId}`); - } -} - export function useWorkspaceChatController({ sessionId, onSessionIdChange, @@ -72,6 +63,7 @@ export function useWorkspaceChatController({ session?.session?.activeOrganizationId, ); const collections = useCollections(); + const { chatSessions: chatSessionActions } = useOptimisticCollectionActions(); const { data: workspace } = workspaceTrpc.workspace.get.useQuery( { id: workspaceId }, @@ -104,7 +96,11 @@ export function useWorkspaceChatController({ const handleDeleteSession = useCallback( async (sessionIdToDelete: string) => { - await deleteSessionRecord(sessionIdToDelete); + const transaction = chatSessionActions.deleteSession(sessionIdToDelete); + if (!transaction && !isDesktopChatDevMode()) { + throw new Error("Failed to delete chat session"); + } + posthog.capture("chat_session_deleted", { workspace_id: workspaceId, session_id: sessionIdToDelete, @@ -114,7 +110,13 @@ export function useWorkspaceChatController({ onSessionIdChange(null); } }, - [onSessionIdChange, organizationId, sessionId, workspaceId], + [ + chatSessionActions, + onSessionIdChange, + organizationId, + sessionId, + workspaceId, + ], ); const getOrCreateSession = useCallback(async (): Promise => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/index.ts new file mode 100644 index 00000000000..7aa8751235c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/index.ts @@ -0,0 +1,4 @@ +export { + type PersistableTransaction, + useOptimisticCollectionActions, +} from "./useOptimisticCollectionActions"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/useOptimisticCollectionActions.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/useOptimisticCollectionActions.ts new file mode 100644 index 00000000000..b78b8618336 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/useOptimisticCollectionActions.ts @@ -0,0 +1,202 @@ +import type { TaskPriority } from "@superset/db/enums"; +import { toast } from "@superset/ui/sonner"; +import { useCallback, useMemo } from "react"; +import { isDesktopChatDevMode } from "renderer/lib/dev-chat"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; + +export type PersistableTransaction = { + isPersisted: { + promise: Promise; + }; +}; + +interface V2ProjectPatch { + name?: string; + slug?: string; + repoCloneUrl?: string | null; + githubRepositoryId?: string | null; +} + +interface V2WorkspacePatch { + name?: string; + branch?: string; + hostId?: string; +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim()) { + return error.message; + } + + if (typeof error === "string" && error.trim()) { + return error; + } + + return "The local change was rolled back."; +} + +function useOptimisticMutationRunner() { + const reportFailure = useCallback( + (scope: string, title: string, error: unknown) => { + console.error(`[${scope}] ${title}:`, error); + toast.error(title, { + description: getErrorMessage(error), + }); + }, + [], + ); + + return useCallback( + ( + scope: string, + failureTitle: string, + mutation: () => PersistableTransaction, + ): PersistableTransaction | null => { + try { + const transaction = mutation(); + + void transaction.isPersisted.promise.catch((error) => { + reportFailure(scope, failureTitle, error); + }); + + return transaction; + } catch (error) { + reportFailure(scope, failureTitle, error); + return null; + } + }, + [reportFailure], + ); +} + +export function useOptimisticCollectionActions() { + const collections = useCollections(); + const runMutation = useOptimisticMutationRunner(); + + return useMemo(() => { + const runTaskMutation = ( + failureTitle: string, + mutation: () => PersistableTransaction, + ) => runMutation("optimistic.tasks", failureTitle, mutation); + + const runProjectMutation = ( + failureTitle: string, + mutation: () => PersistableTransaction, + ) => runMutation("optimistic.v2Projects", failureTitle, mutation); + + const runWorkspaceMutation = ( + failureTitle: string, + mutation: () => PersistableTransaction, + ) => runMutation("optimistic.v2Workspaces", failureTitle, mutation); + + const runChatSessionMutation = ( + failureTitle: string, + mutation: () => PersistableTransaction, + ) => runMutation("optimistic.chatSessions", failureTitle, mutation); + + return { + tasks: { + updateTitle: (taskId: string, title: string) => + runTaskMutation("Failed to update task title", () => + collections.tasks.update(taskId, (draft) => { + draft.title = title; + }), + ), + updateDescription: (taskId: string, description: string) => + runTaskMutation("Failed to update task description", () => + collections.tasks.update(taskId, (draft) => { + draft.description = description; + }), + ), + updateStatus: (taskId: string, statusId: string) => + runTaskMutation("Failed to update task status", () => + collections.tasks.update(taskId, (draft) => { + draft.statusId = statusId; + }), + ), + updatePriority: (taskId: string, priority: TaskPriority) => + runTaskMutation("Failed to update task priority", () => + collections.tasks.update(taskId, (draft) => { + draft.priority = priority; + }), + ), + updateAssignee: (taskId: string, assigneeId: string | null) => + runTaskMutation("Failed to update task assignee", () => + collections.tasks.update(taskId, (draft) => { + draft.assigneeId = assigneeId; + draft.assigneeExternalId = null; + draft.assigneeDisplayName = null; + draft.assigneeAvatarUrl = null; + }), + ), + deleteTask: (taskId: string) => + runTaskMutation("Failed to delete task", () => + collections.tasks.delete(taskId), + ), + }, + v2Projects: { + updateProject: (projectId: string, patch: V2ProjectPatch) => + runProjectMutation("Failed to update project", () => + collections.v2Projects.update(projectId, (draft) => { + if (patch.name !== undefined) { + draft.name = patch.name; + } + if (patch.slug !== undefined) { + draft.slug = patch.slug; + } + if (patch.repoCloneUrl !== undefined) { + draft.repoCloneUrl = patch.repoCloneUrl; + } + if (patch.githubRepositoryId !== undefined) { + draft.githubRepositoryId = patch.githubRepositoryId; + } + }), + ), + renameProject: (projectId: string, name: string) => + runProjectMutation("Failed to rename project", () => + collections.v2Projects.update(projectId, (draft) => { + draft.name = name; + }), + ), + updateRepository: (projectId: string, repoCloneUrl: string | null) => + runProjectMutation("Failed to update project repository", () => + collections.v2Projects.update(projectId, (draft) => { + draft.repoCloneUrl = repoCloneUrl; + draft.githubRepositoryId = null; + }), + ), + }, + v2Workspaces: { + updateWorkspace: (workspaceId: string, patch: V2WorkspacePatch) => + runWorkspaceMutation("Failed to update workspace", () => + collections.v2Workspaces.update(workspaceId, (draft) => { + if (patch.name !== undefined) { + draft.name = patch.name; + } + if (patch.branch !== undefined) { + draft.branch = patch.branch; + } + if (patch.hostId !== undefined) { + draft.hostId = patch.hostId; + } + }), + ), + renameWorkspace: (workspaceId: string, name: string) => + runWorkspaceMutation("Failed to rename workspace", () => + collections.v2Workspaces.update(workspaceId, (draft) => { + draft.name = name; + }), + ), + }, + chatSessions: { + deleteSession: (sessionId: string) => { + if (isDesktopChatDevMode()) return null; + + return runChatSessionMutation("Failed to delete chat session", () => + collections.chatSessions.delete(sessionId), + ); + }, + }, + }; + }, [collections, runMutation]); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts index 4c451a1c55c..b9841717a7d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts @@ -271,6 +271,22 @@ function createOrgCollections(organizationId: string): OrgCollections { columnMapper, }, getKey: (item) => item.id, + onUpdate: async ({ transaction }) => { + const { original, changes } = transaction.mutations[0]; + const githubRepositoryId = + changes.githubRepositoryId === null && + changes.repoCloneUrl !== undefined + ? undefined + : changes.githubRepositoryId; + const result = await apiClient.v2Project.update.mutate({ + id: original.id, + name: changes.name, + slug: changes.slug, + repoCloneUrl: changes.repoCloneUrl, + githubRepositoryId, + }); + return { txid: result.txid }; + }, }), ); @@ -335,6 +351,17 @@ function createOrgCollections(organizationId: string): OrgCollections { columnMapper, }, getKey: (item) => item.id, + onUpdate: async ({ transaction }) => { + const { original, changes } = transaction.mutations[0]; + const { branch, hostId, name } = changes; + const result = await apiClient.v2Workspace.update.mutate({ + id: original.id, + branch, + hostId, + name, + }); + return { txid: result.txid }; + }, }), ); @@ -487,6 +514,16 @@ function createOrgCollections(organizationId: string): OrgCollections { columnMapper, }, getKey: (item) => item.id, + onDelete: async ({ transaction }) => { + const item = transaction.mutations[0].original; + const result = await apiClient.chat.deleteSession.mutate({ + sessionId: item.id, + }); + if (!result.deleted) { + throw new Error("Chat session was not deleted"); + } + return { txid: result.txid }; + }, }), ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/RepositorySection/RepositorySection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/RepositorySection/RepositorySection.tsx index c22b3b463bb..eaaa5376eb4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/RepositorySection/RepositorySection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/RepositorySection/RepositorySection.tsx @@ -1,8 +1,7 @@ import { Button } from "@superset/ui/button"; import { Input } from "@superset/ui/input"; -import { toast } from "@superset/ui/sonner"; import { useEffect, useRef, useState } from "react"; -import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; interface RepositorySectionProps { projectId: string; @@ -13,9 +12,9 @@ export function RepositorySection({ projectId, currentRepoCloneUrl, }: RepositorySectionProps) { + const { v2Projects: projectActions } = useOptimisticCollectionActions(); const [isEditing, setIsEditing] = useState(false); const [value, setValue] = useState(currentRepoCloneUrl ?? ""); - const [isSaving, setIsSaving] = useState(false); const inputRef = useRef(null); useEffect(() => { @@ -32,25 +31,18 @@ export function RepositorySection({ setIsEditing(false); }; - const save = async () => { - if (isSaving) return; + const save = () => { const trimmed = value.trim(); if (trimmed === (currentRepoCloneUrl ?? "")) { setIsEditing(false); return; } - setIsSaving(true); - try { - await apiTrpcClient.v2Project.update.mutate({ - id: projectId, - repoCloneUrl: trimmed === "" ? null : trimmed, - }); - toast.success("Repository updated"); + const transaction = projectActions.updateRepository( + projectId, + trimmed === "" ? null : trimmed, + ); + if (transaction) { setIsEditing(false); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to update"); - } finally { - setIsSaving(false); } }; @@ -67,7 +59,7 @@ export function RepositorySection({ onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); - void save(); + save(); } else if (e.key === "Escape") { e.preventDefault(); cancelEdit(); @@ -79,12 +71,11 @@ export function RepositorySection({ variant="outline" size="sm" onClick={cancelEdit} - disabled={isSaving} > Cancel - ) : ( diff --git a/packages/trpc/src/router/chat/chat.ts b/packages/trpc/src/router/chat/chat.ts index 637ed8154e0..3a4ad94cb2e 100644 --- a/packages/trpc/src/router/chat/chat.ts +++ b/packages/trpc/src/router/chat/chat.ts @@ -1,5 +1,6 @@ -import { db } from "@superset/db/client"; +import { db, dbWs } from "@superset/db/client"; import { chatSessions } from "@superset/db/schema"; +import { getCurrentTxid } from "@superset/db/utils"; import type { TRPCRouterRecord } from "@trpc/server"; import { TRPCError } from "@trpc/server"; import { and, eq } from "drizzle-orm"; @@ -130,18 +131,25 @@ export const chatRouter = { }); } - const [deleted] = await db - .delete(chatSessions) - .where( - and( - eq(chatSessions.id, input.sessionId), - eq(chatSessions.organizationId, organizationId), - eq(chatSessions.createdBy, ctx.session.user.id), - ), - ) - .returning({ id: chatSessions.id }); - - return { deleted: !!deleted }; + const result = await dbWs.transaction(async (tx) => { + const [deleted] = await tx + .delete(chatSessions) + .where( + and( + eq(chatSessions.id, input.sessionId), + eq(chatSessions.organizationId, organizationId), + eq(chatSessions.createdBy, ctx.session.user.id), + ), + ) + .returning({ id: chatSessions.id }); + + const txid = await getCurrentTxid(tx); + + return { deleted, txid }; + }); + const { deleted, txid } = result; + + return { deleted: !!deleted, txid }; }), uploadAttachment: protectedProcedure diff --git a/packages/trpc/src/router/v2-project/v2-project.ts b/packages/trpc/src/router/v2-project/v2-project.ts index 3fc3291eafb..d832cda7cf4 100644 --- a/packages/trpc/src/router/v2-project/v2-project.ts +++ b/packages/trpc/src/router/v2-project/v2-project.ts @@ -4,6 +4,7 @@ import { organizations, v2Projects, } from "@superset/db/schema"; +import { getCurrentTxid } from "@superset/db/utils"; import { parseGitHubRemote } from "@superset/shared/github-remote"; import type { TRPCRouterRecord } from "@trpc/server"; import { TRPCError } from "@trpc/server"; @@ -290,7 +291,7 @@ export const v2ProjectRouter = { id: z.string().uuid(), name: z.string().min(1).optional(), slug: z.string().min(1).optional(), - githubRepositoryId: z.string().uuid().optional(), + githubRepositoryId: z.string().uuid().nullable().optional(), repoCloneUrl: z.string().min(1).nullable().optional(), }), ) @@ -351,18 +352,25 @@ export const v2ProjectRouter = { message: "No fields to update", }); } - const [updated] = await dbWs - .update(v2Projects) - .set(data) - .where(eq(v2Projects.id, project.id)) - .returning(); + const result = await dbWs.transaction(async (tx) => { + const [updated] = await tx + .update(v2Projects) + .set(data) + .where(eq(v2Projects.id, project.id)) + .returning(); + + const txid = await getCurrentTxid(tx); + + return { updated, txid }; + }); + const { updated, txid } = result; if (!updated) { throw new TRPCError({ code: "NOT_FOUND", message: "Project not found", }); } - return updated; + return { ...updated, txid }; }), delete: protectedProcedure diff --git a/packages/trpc/src/router/v2-workspace/v2-workspace.ts b/packages/trpc/src/router/v2-workspace/v2-workspace.ts index 26f292c8ea9..f4fb04c76df 100644 --- a/packages/trpc/src/router/v2-workspace/v2-workspace.ts +++ b/packages/trpc/src/router/v2-workspace/v2-workspace.ts @@ -1,5 +1,6 @@ import { dbWs } from "@superset/db/client"; import { v2Hosts, v2Projects, v2Workspaces } from "@superset/db/schema"; +import { getCurrentTxid } from "@superset/db/utils"; import type { TRPCRouterRecord } from "@trpc/server"; import { TRPCError } from "@trpc/server"; import { and, eq, inArray } from "drizzle-orm"; @@ -170,18 +171,25 @@ export const v2WorkspaceRouter = { message: "No fields to update", }); } - const [updated] = await dbWs - .update(v2Workspaces) - .set(data) - .where(eq(v2Workspaces.id, workspace.id)) - .returning(); + const result = await dbWs.transaction(async (tx) => { + const [updated] = await tx + .update(v2Workspaces) + .set(data) + .where(eq(v2Workspaces.id, workspace.id)) + .returning(); + + const txid = await getCurrentTxid(tx); + + return { updated, txid }; + }); + const { updated, txid } = result; if (!updated) { throw new TRPCError({ code: "NOT_FOUND", message: "Workspace not found", }); } - return updated; + return { ...updated, txid }; }), // JWT-authed so host-service can apply AI-generated workspace names