From b44245196728143d4befde3fcc6590e624f611a9 Mon Sep 17 00:00:00 2001
From: urjitc <135136842+urjitc@users.noreply.github.com>
Date: Fri, 10 Apr 2026 21:15:43 +0000
Subject: [PATCH 1/4] Wire ZeroProvider and replace event-sourced reads with
Zero useQuery
---
src/app/dashboard/page.tsx | 15 ++-
.../workspace-canvas/WorkspaceContent.tsx | 10 +-
src/contexts/RealtimeContext.tsx | 24 ++--
src/hooks/workspace/use-workspace-state.ts | 120 +++++++++++-------
src/lib/zero/provider.tsx | 14 +-
5 files changed, 109 insertions(+), 74 deletions(-)
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index a2eed848..4f713349 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -34,6 +34,7 @@ import { PdfEngineWrapper } from "@/components/pdf/PdfEngineWrapper";
import WorkspaceSettingsModal from "@/components/workspace/WorkspaceSettingsModal";
import ShareWorkspaceDialog from "@/components/workspace/ShareWorkspaceDialog";
import { RealtimeProvider } from "@/contexts/RealtimeContext";
+import { ZeroProvider } from "@/lib/zero/provider";
import { toast } from "sonner";
import { InviteGuard } from "@/components/workspace/InviteGuard";
import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation";
@@ -510,12 +511,14 @@ function DashboardView() {
useFolderUrl();
return (
-
-
-
+
+
+
+
+
);
}
diff --git a/src/components/workspace-canvas/WorkspaceContent.tsx b/src/components/workspace-canvas/WorkspaceContent.tsx
index cda768ee..a5c91e8e 100644
--- a/src/components/workspace-canvas/WorkspaceContent.tsx
+++ b/src/components/workspace-canvas/WorkspaceContent.tsx
@@ -97,18 +97,13 @@ export default function WorkspaceContent({
if (detail?.error) {
console.error("[OCR-processing-complete] OCR failed:", detail.error);
}
- if (workspaceId) {
- queryClient.invalidateQueries({
- queryKey: ["workspace", workspaceId, "events"],
- });
- }
};
window.addEventListener(OCR_COMPLETE_EVENT, handleOcrComplete);
return () => {
window.removeEventListener(OCR_COMPLETE_EVENT, handleOcrComplete);
};
- }, [workspaceId, queryClient]);
+ }, []);
useEffect(() => {
const handleAudioComplete = (e: Event) => {
@@ -135,9 +130,6 @@ export default function WorkspaceContent({
}
if (workspaceId) {
- queryClient.invalidateQueries({
- queryKey: ["workspace", workspaceId, "events"],
- });
queryClient.invalidateQueries({
queryKey: transcriptSegmentsQueryKey(workspaceId, itemId),
});
diff --git a/src/contexts/RealtimeContext.tsx b/src/contexts/RealtimeContext.tsx
index 2b76918b..bae4ecc3 100644
--- a/src/contexts/RealtimeContext.tsx
+++ b/src/contexts/RealtimeContext.tsx
@@ -8,7 +8,7 @@
"use client";
import React, { createContext, useContext, useMemo } from "react";
-import { useWorkspaceRealtime } from "@/hooks/workspace/use-workspace-realtime";
+import { useConnectionState } from "@rocicorp/zero/react";
import {
useWorkspacePresence,
type CollaboratorPresence,
@@ -37,10 +37,21 @@ export function RealtimeProvider({
workspaceId,
}: RealtimeProviderProps) {
const { data: session } = useSession();
+ const zeroConnectionState = useConnectionState();
- // Track connection status
- const [connectionStatus, setConnectionStatus] =
- React.useState("connecting");
+ const connectionStatus = useMemo(
+ () => {
+ switch (zeroConnectionState.name) {
+ case "connected":
+ return "connected";
+ case "connecting":
+ return "connecting";
+ default:
+ return "disconnected";
+ }
+ },
+ [zeroConnectionState],
+ );
// Current user info for presence
const currentUser = session?.user
@@ -51,11 +62,6 @@ export function RealtimeProvider({
}
: null;
- // Subscribe to realtime events
- useWorkspaceRealtime(workspaceId, {
- onStatusChange: setConnectionStatus,
- });
-
// Track presence (which users are in workspace)
const { collaborators } = useWorkspacePresence(workspaceId, {
currentUser,
diff --git a/src/hooks/workspace/use-workspace-state.ts b/src/hooks/workspace/use-workspace-state.ts
index c638f2a3..5aeff238 100644
--- a/src/hooks/workspace/use-workspace-state.ts
+++ b/src/hooks/workspace/use-workspace-state.ts
@@ -1,52 +1,84 @@
import { useMemo } from "react";
-import { useQuery } from "@tanstack/react-query";
-import { useWorkspaceEvents } from "./use-workspace-events";
-import {
- deriveWorkspaceStateFromCaches,
- type WorkspaceStateResponse,
- workspaceStateQueryKey,
-} from "./workspace-state-cache";
-
-async function fetchWorkspaceState(
- workspaceId: string,
-): Promise {
- const response = await fetch(`/api/workspaces/${workspaceId}/state`);
-
- if (!response.ok) {
- throw new Error(`Failed to fetch workspace state: ${response.statusText}`);
- }
-
- return (await response.json()) as WorkspaceStateResponse;
-}
+import { useQuery } from "@rocicorp/zero/react";
+import { rehydrateWorkspaceItem } from "@/lib/workspace/workspace-item-model";
+import type { WorkspaceItemContentProjection } from "@/lib/workspace/workspace-item-model-types";
+import type { Item } from "@/lib/workspace-state/types";
+import { zql } from "@/lib/zero/zero-schema.gen";
+import type {
+ WorkspaceItemContentRow,
+ WorkspaceItemsRow,
+} from "@/lib/zero/zero-schema.gen";
+
+type WorkspaceItemQueryRow = WorkspaceItemsRow & {
+ workspaceItemContent?: readonly WorkspaceItemContentRow[] | WorkspaceItemContentRow;
+};
export function useWorkspaceState(workspaceId: string | null) {
- const stateQuery = useQuery({
- queryKey: workspaceStateQueryKey(workspaceId),
- queryFn: () => fetchWorkspaceState(workspaceId!),
- enabled: !!workspaceId,
- staleTime: 1000 * 30,
- gcTime: 1000 * 60 * 10,
- refetchOnWindowFocus: false,
- });
- const eventsQuery = useWorkspaceEvents(workspaceId);
-
- const derived = useMemo(
- () =>
- deriveWorkspaceStateFromCaches({
- workspaceId,
- stateData: stateQuery.data,
- eventLog: eventsQuery.data,
- }),
- [workspaceId, stateQuery.data, eventsQuery.data],
+ const [rawRows, status] = useQuery(
+ workspaceId
+ ? zql.workspace_items
+ .where("workspaceId", workspaceId)
+ .related("workspaceItemContent")
+ : null,
);
+ const rows = rawRows as unknown as readonly WorkspaceItemQueryRow[] | undefined;
+
+ const state = useMemo- (() => {
+ if (!rows) {
+ return [];
+ }
+
+ return rows.map((row) => {
+ const contentRow = Array.isArray(row.workspaceItemContent)
+ ? row.workspaceItemContent[0] ?? null
+ : (row.workspaceItemContent ?? null);
+
+ return rehydrateWorkspaceItem({
+ shell: {
+ itemId: row.itemId,
+ type: row.type as Item["type"],
+ name: row.name,
+ subtitle: row.subtitle,
+ color: (row.color as Item["color"]) ?? null,
+ folderId: row.folderId ?? null,
+ layout: (row.layout as Item["layout"]) ?? null,
+ lastModified: row.lastModified ?? null,
+ ocrStatus: row.ocrStatus ?? null,
+ processingStatus: row.processingStatus ?? null,
+ },
+ content: contentRow
+ ? {
+ textContent: contentRow.textContent ?? null,
+ structuredData:
+ (contentRow.structuredData as Record) ?? null,
+ assetData:
+ (contentRow.assetData as Record) ?? null,
+ embedData:
+ (contentRow.embedData as Record) ?? null,
+ sourceData:
+ (contentRow.sourceData as WorkspaceItemContentProjection["sourceData"]) ??
+ null,
+ }
+ : null,
+ extracted: null,
+ userStates: null,
+ });
+ });
+ }, [rows]);
+
+ const error = useMemo(() => {
+ if (status.type !== "error") {
+ return null;
+ }
+
+ return new Error(status.error.message);
+ }, [status]);
return {
- state: derived.state,
- isLoading: stateQuery.isLoading && !stateQuery.data,
- error: stateQuery.error ?? eventsQuery.error,
- version: derived.version,
- refetch: async () => {
- await Promise.all([stateQuery.refetch(), eventsQuery.refetch()]);
- },
+ state,
+ isLoading: Boolean(workspaceId) && status.type === "unknown",
+ error,
+ version: 0,
+ refetch: async () => {},
};
}
diff --git a/src/lib/zero/provider.tsx b/src/lib/zero/provider.tsx
index 8010cdbc..e90cae19 100644
--- a/src/lib/zero/provider.tsx
+++ b/src/lib/zero/provider.tsx
@@ -61,17 +61,19 @@ export function ZeroProvider({ children }: { children: ReactNode }) {
}, [userId]);
const zero = useMemo(() => {
- if (!userId || !token) {
- if (!userId) {
- destroyZero();
- }
+ if (!userId) {
+ destroyZero();
return null;
}
- return getZero({ userId, auth: token });
+ return getZero({ userId, auth: token ?? "" });
}, [token, userId]);
- if (isPending || !zero) {
+ if (isPending) {
+ return null;
+ }
+
+ if (!zero) {
return <>{children}>;
}
From 014a6b0d26d8d6a7b5c47f1c490621c112443f5b Mon Sep 17 00:00:00 2001
From: urjitc <135136842+urjitc@users.noreply.github.com>
Date: Fri, 10 Apr 2026 21:19:07 +0000
Subject: [PATCH 2/4] Fix ZeroProvider unauthenticated render
---
src/lib/zero/provider.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/lib/zero/provider.tsx b/src/lib/zero/provider.tsx
index e90cae19..5a773119 100644
--- a/src/lib/zero/provider.tsx
+++ b/src/lib/zero/provider.tsx
@@ -74,7 +74,7 @@ export function ZeroProvider({ children }: { children: ReactNode }) {
}
if (!zero) {
- return <>{children}>;
+ return null;
}
return {children};
From 4733193a83cf3ab34dd0df06d113832aeabc574b Mon Sep 17 00:00:00 2001
From: urjitc <135136842+urjitc@users.noreply.github.com>
Date: Fri, 10 Apr 2026 21:26:12 +0000
Subject: [PATCH 3/4] Fix ZeroProvider token initialization
---
src/lib/zero/provider.tsx | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/src/lib/zero/provider.tsx b/src/lib/zero/provider.tsx
index 5a773119..ce856a6f 100644
--- a/src/lib/zero/provider.tsx
+++ b/src/lib/zero/provider.tsx
@@ -61,12 +61,11 @@ export function ZeroProvider({ children }: { children: ReactNode }) {
}, [userId]);
const zero = useMemo(() => {
- if (!userId) {
- destroyZero();
+ if (!userId || !token) {
return null;
}
- return getZero({ userId, auth: token ?? "" });
+ return getZero({ userId, auth: token });
}, [token, userId]);
if (isPending) {
From b5fc22ab212f1fc2136cf66fc1db003b46e5fbc8 Mon Sep 17 00:00:00 2001
From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com>
Date: Thu, 16 Apr 2026 18:07:38 -0400
Subject: [PATCH 4/4] Refactor: workspace writes to Zero mutators (#336)
* Replace event-sourcing writes with Zero mutators
* Fix audio duration persistence
* Make workflow persistence atomic
* Refactor: remove event sourcing and user state infrastructure (#339)
* Delete event-sourcing infrastructure and remove per-user state persistence
* Remove auth secret fallback
* Preserve legacy workspace tables
* Remove unused workspace table migration
* Fix Zero createdAt column type
* Migrate Zero integration to 1.x query/mutator architecture
* Refactor Zero auth to cookie-based + dedup server mutators
* Fix Zero query auth: fail-closed + rename hasWorkspaceReadAccess (#385)
* Enforce authentication and workspace read access checks on Zero query endpoint
* Fix Zero query workspace context checks
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---
.env.example | 2 +-
package.json | 1 -
src/app/api/share/[id]/route.ts | 2 +-
.../workspaces/[id]/collaborators/route.ts | 12 +-
src/app/api/workspaces/[id]/events/route.ts | 271 ------
src/app/api/workspaces/[id]/route.ts | 2 +-
src/app/api/workspaces/[id]/state/route.ts | 28 -
src/app/api/workspaces/route.ts | 55 +-
src/app/api/workspaces/slug/[slug]/route.ts | 2 +-
src/app/api/zero/mutate/route.ts | 19 +-
src/app/api/zero/query/route.ts | 119 +++
src/app/api/zero/token/route.ts | 33 -
src/app/dashboard/page.tsx | 88 +-
.../FlashcardWorkspaceCard.tsx | 5 +-
.../workspace-canvas/QuizContent.tsx | 80 +-
.../workspace-canvas/WorkspaceContent.tsx | 22 +-
src/hooks/workspace/use-workspace-events.ts | 35 -
src/hooks/workspace/use-workspace-mutation.ts | 224 -----
.../workspace/use-workspace-operations.ts | 875 +++++++++---------
src/hooks/workspace/use-workspace-realtime.ts | 207 -----
src/hooks/workspace/use-workspace-state.ts | 19 +-
.../workspace/workspace-state-cache.test.ts | 129 ---
src/hooks/workspace/workspace-state-cache.ts | 102 --
src/lib/ai/tools/tool-utils.ts | 2 +-
src/lib/ai/tools/workspace-tools.ts | 2 +-
.../__tests__/workspace-worker.edit.test.ts | 428 ---------
src/lib/ai/workers/common.ts | 2 +-
src/lib/ai/workers/workspace-worker.ts | 397 ++++----
src/lib/auth.ts | 35 +-
src/lib/db/relations.ts | 48 +-
src/lib/db/schema.ts | 167 ----
src/lib/db/types.ts | 28 +-
src/lib/realtime/server-broadcast.ts | 75 --
src/lib/stores/workspace-store.ts | 58 +-
src/lib/utils/format-workspace-context.ts | 25 +-
src/lib/workspace-state/item-data-schemas.ts | 41 -
src/lib/workspace-state/item-helpers.ts | 5 -
src/lib/workspace-state/state.ts | 1 -
src/lib/workspace-state/types.ts | 23 +-
.../event-reducer.bulk-items-patched.test.ts | 80 --
.../event-reducer.lightweight-ocr.test.ts | 105 ---
.../__tests__/version-helpers.test.ts | 99 --
.../workspace-event-client-payload.test.ts | 86 --
.../__tests__/workspace-event-store.test.ts | 127 ---
.../__tests__/workspace-item-model.test.ts | 173 +---
.../workspace-items-projector.test.ts | 188 ----
src/lib/workspace/event-reducer.ts | 181 ----
src/lib/workspace/events.ts | 158 ----
src/lib/workspace/mutation-helpers.ts | 2 +-
src/lib/workspace/state-loader.ts | 38 -
src/lib/workspace/version-helpers.ts | 61 --
.../workspace-event-client-payload.ts | 117 ---
src/lib/workspace/workspace-event-store.ts | 226 -----
.../workspace/workspace-item-model-schemas.ts | 8 +-
.../workspace/workspace-item-model-shared.ts | 24 +-
.../workspace/workspace-item-model-types.ts | 20 +-
src/lib/workspace/workspace-item-model.ts | 112 +--
src/lib/workspace/workspace-item-sanitize.ts | 11 +
src/lib/workspace/workspace-item-write.ts | 303 ++++++
.../workspace/workspace-items-projector.ts | 787 ----------------
src/lib/workspace/workspace-state-read.ts | 108 +++
src/lib/zero/client.ts | 16 +-
src/lib/zero/mutators.ts | 523 ++++++-----
src/lib/zero/provider.tsx | 52 +-
src/lib/zero/queries.ts | 15 +
src/lib/zero/server-mutators.ts | 279 +++---
src/lib/zero/zero-schema.gen.ts | 18 +-
.../audio-transcribe/steps/persist-result.ts | 191 ++--
.../ocr-dispatch/steps/persist-results.ts | 138 +--
69 files changed, 1996 insertions(+), 5919 deletions(-)
delete mode 100644 src/app/api/workspaces/[id]/events/route.ts
delete mode 100644 src/app/api/workspaces/[id]/state/route.ts
create mode 100644 src/app/api/zero/query/route.ts
delete mode 100644 src/app/api/zero/token/route.ts
delete mode 100644 src/hooks/workspace/use-workspace-events.ts
delete mode 100644 src/hooks/workspace/use-workspace-mutation.ts
delete mode 100644 src/hooks/workspace/use-workspace-realtime.ts
delete mode 100644 src/hooks/workspace/workspace-state-cache.test.ts
delete mode 100644 src/hooks/workspace/workspace-state-cache.ts
delete mode 100644 src/lib/ai/workers/__tests__/workspace-worker.edit.test.ts
delete mode 100644 src/lib/realtime/server-broadcast.ts
delete mode 100644 src/lib/workspace/__tests__/event-reducer.bulk-items-patched.test.ts
delete mode 100644 src/lib/workspace/__tests__/event-reducer.lightweight-ocr.test.ts
delete mode 100644 src/lib/workspace/__tests__/version-helpers.test.ts
delete mode 100644 src/lib/workspace/__tests__/workspace-event-client-payload.test.ts
delete mode 100644 src/lib/workspace/__tests__/workspace-event-store.test.ts
delete mode 100644 src/lib/workspace/__tests__/workspace-items-projector.test.ts
delete mode 100644 src/lib/workspace/event-reducer.ts
delete mode 100644 src/lib/workspace/events.ts
delete mode 100644 src/lib/workspace/state-loader.ts
delete mode 100644 src/lib/workspace/version-helpers.ts
delete mode 100644 src/lib/workspace/workspace-event-client-payload.ts
delete mode 100644 src/lib/workspace/workspace-event-store.ts
create mode 100644 src/lib/workspace/workspace-item-sanitize.ts
create mode 100644 src/lib/workspace/workspace-item-write.ts
delete mode 100644 src/lib/workspace/workspace-items-projector.ts
create mode 100644 src/lib/workspace/workspace-state-read.ts
create mode 100644 src/lib/zero/queries.ts
diff --git a/.env.example b/.env.example
index f4607424..04a42e85 100644
--- a/.env.example
+++ b/.env.example
@@ -48,4 +48,4 @@ MISTRAL_API_KEY=your-mistral-api-key
# Optional: MISTRAL_OCR_MODEL=mistral-ocr-latest
# Zero Sync
NEXT_PUBLIC_ZERO_SERVER=http://localhost:4848
-ZERO_AUTH_SECRET=your-shared-secret-for-jwt-signing
+ZERO_COOKIE_DOMAIN=.thinkex.app
diff --git a/package.json b/package.json
index 1a4ca3b9..629f271f 100644
--- a/package.json
+++ b/package.json
@@ -120,7 +120,6 @@
"drizzle-orm": "^0.45.2",
"gsap": "^3.14.2",
"heic2any": "^0.0.4",
- "jose": "^6.2.2",
"jsonrepair": "^3.13.2",
"katex": "^0.16.45",
"lodash.throttle": "^4.1.1",
diff --git a/src/app/api/share/[id]/route.ts b/src/app/api/share/[id]/route.ts
index d94e9b08..c235fd1b 100644
--- a/src/app/api/share/[id]/route.ts
+++ b/src/app/api/share/[id]/route.ts
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { db, workspaces } from "@/lib/db/client";
import { eq } from "drizzle-orm";
-import { loadWorkspaceState } from "@/lib/workspace/state-loader";
+import { loadWorkspaceState } from "@/lib/workspace/workspace-state-read";
/**
* GET /api/share/[id]
diff --git a/src/app/api/workspaces/[id]/collaborators/route.ts b/src/app/api/workspaces/[id]/collaborators/route.ts
index 6b23cb1d..6e35c36f 100644
--- a/src/app/api/workspaces/[id]/collaborators/route.ts
+++ b/src/app/api/workspaces/[id]/collaborators/route.ts
@@ -25,7 +25,9 @@ import {
import { Resend } from "resend";
import { InviteEmailTemplate } from "@/components/email/invite-email";
-const resend = new Resend(process.env.RESEND_API_KEY);
+function getResend() {
+ return process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null;
+}
// GET /api/workspaces/[id]/collaborators
async function handleGET(
@@ -195,6 +197,10 @@ async function handlePOST(
try {
const identifier = ws.slug || workspaceId;
const workspaceUrl = `https://thinkex.app/workspace/${identifier}`;
+ const resend = getResend();
+ if (!resend) {
+ throw new Error("RESEND_API_KEY is not configured");
+ }
const { data, error } = await resend.emails.send(
{
from: 'ThinkEx ',
@@ -283,6 +289,10 @@ async function handlePOST(
try {
const identifier = ws.slug || workspaceId;
const workspaceUrl = `https://thinkex.app/workspace/${identifier}?invite=${token}`;
+ const resend = getResend();
+ if (!resend) {
+ throw new Error("RESEND_API_KEY is not configured");
+ }
const { error } = await resend.emails.send(
{
from: 'ThinkEx ',
diff --git a/src/app/api/workspaces/[id]/events/route.ts b/src/app/api/workspaces/[id]/events/route.ts
deleted file mode 100644
index b562ce66..00000000
--- a/src/app/api/workspaces/[id]/events/route.ts
+++ /dev/null
@@ -1,271 +0,0 @@
-import { NextRequest, NextResponse } from "next/server";
-import {
- isClientVisibleWorkspaceEvent,
- type EventResponse,
- type WorkspaceEvent,
-} from "@/lib/workspace/events";
-import { loadWorkspaceState } from "@/lib/workspace/state-loader";
-import { appendWorkspaceEventWithBaseVersion } from "@/lib/workspace/workspace-event-store";
-import { toClientWorkspaceEvents } from "@/lib/workspace/workspace-event-client-payload";
-import { hasDuplicateName } from "@/lib/workspace/unique-name";
-import { db, workspaceEvents } from "@/lib/db/client";
-import { eq, sql } from "drizzle-orm";
-import {
- requireAuthWithUserInfo,
- verifyWorkspaceAccess,
- withErrorHandling,
-} from "@/lib/api/workspace-helpers";
-
-/**
- * GET /api/workspaces/[id]/events
- * Fetch all events for a workspace (owner or collaborator)
- */
-async function handleGET(
- _request: NextRequest,
- { params }: { params: Promise<{ id: string }> },
-) {
- const paramsPromise = params;
- const authPromise = requireAuthWithUserInfo();
-
- const paramsResolved = await paramsPromise;
- const id = paramsResolved.id;
- const { userId } = await authPromise;
- await verifyWorkspaceAccess(id, userId, "viewer");
- const eventCountResult = await db
- .select({ count: sql`count(*)::int` })
- .from(workspaceEvents)
- .where(eq(workspaceEvents.workspaceId, id));
- const eventCount = eventCountResult[0]?.count ?? 0;
-
- const PAGE_SIZE = 1000;
- let eventsData: any[] = [];
-
- if (eventCount === 0) {
- } else if (eventCount <= PAGE_SIZE) {
- const fastQueryResult = await db.execute(sql`
- SELECT
- event_id as "eventId",
- event_type as "eventType",
- payload,
- timestamp,
- user_id as "userId",
- user_name as "userName",
- version
- FROM get_workspace_events_fast(
- ${id}::uuid,
- 0::integer,
- ${PAGE_SIZE}::integer
- )
- `);
-
- eventsData = fastQueryResult.map((row: any) => ({
- eventId: row.eventId,
- eventType: row.eventType,
- payload: row.payload,
- timestamp: row.timestamp,
- userId: row.userId,
- userName: row.userName,
- version: row.version,
- }));
- } else {
- let allEvents: any[] = [];
- let page = 0;
- let hasMore = true;
-
- while (hasMore) {
- const pageDataResult = await db.execute(sql`
- SELECT
- event_id as "eventId",
- event_type as "eventType",
- payload,
- timestamp,
- user_id as "userId",
- user_name as "userName",
- version
- FROM workspace_events
- WHERE workspace_id = ${id}::uuid
- ORDER BY version ASC
- LIMIT ${PAGE_SIZE}
- OFFSET ${page * PAGE_SIZE}
- `);
-
- const pageData = pageDataResult.map((row: any) => ({
- eventId: row.eventId,
- eventType: row.eventType,
- payload: row.payload,
- timestamp: row.timestamp,
- userId: row.userId,
- userName: row.userName,
- version: row.version,
- }));
-
- allEvents = allEvents.concat(pageData);
- hasMore = pageData.length === PAGE_SIZE;
- page++;
- }
-
- eventsData = allEvents;
- }
-
- const events = eventsData
- .map(
- (e) =>
- ({
- type: e.eventType,
- payload: e.payload,
- timestamp: Number.isFinite(Number(e.timestamp))
- ? Number(e.timestamp)
- : Date.now(),
- userId: e.userId,
- userName: e.userName || undefined,
- id: e.eventId,
- version: e.version,
- }) as WorkspaceEvent,
- )
- .filter(isClientVisibleWorkspaceEvent);
-
- const maxVersion =
- eventsData && eventsData.length > 0
- ? Math.max(...eventsData.map((e) => e.version))
- : 0;
-
- const response: EventResponse = {
- events: toClientWorkspaceEvents(events),
- version: maxVersion,
- };
-
- return NextResponse.json(response);
-}
-
-export const GET = withErrorHandling(
- handleGET,
- "GET /api/workspaces/[id]/events",
-);
-
-/**
- * POST /api/workspaces/[id]/events
- * Append new event(s) to workspace event log (owner only)
- */
-async function handlePOST(
- request: NextRequest,
- { params }: { params: Promise<{ id: string }> },
-) {
- const startTime = Date.now();
- const timings: Record = {};
-
- const paramsPromise = params;
- const authPromise = requireAuthWithUserInfo();
- const bodyPromise = request.json();
-
- const paramsResolved = await paramsPromise;
- const id = paramsResolved.id;
-
- const authStart = Date.now();
- const { userId, name: userName } = await authPromise;
- timings.auth = Date.now() - authStart;
-
- const bodyStart = Date.now();
- const body = await bodyPromise;
- timings.bodyParse = Date.now() - bodyStart;
-
- const { event: rawEvent, baseVersion } = body ?? {};
- if (
- !rawEvent ||
- typeof rawEvent.type !== "string" ||
- typeof rawEvent.id !== "string" ||
- baseVersion === undefined ||
- isNaN(baseVersion)
- ) {
- return NextResponse.json(
- { error: "Event and valid baseVersion are required" },
- { status: 400 },
- );
- }
-
- const event: WorkspaceEvent = {
- ...rawEvent,
- userId,
- userName,
- };
-
- const workspaceCheckStart = Date.now();
- await verifyWorkspaceAccess(id, userId, "editor");
- timings.workspaceCheck = Date.now() - workspaceCheckStart;
-
- if (event.type === "ITEM_CREATED") {
- const item = event.payload?.item;
- if (item?.name != null && item?.type) {
- const state = await loadWorkspaceState(id, { userId });
- if (
- hasDuplicateName(state, item.name, item.type, item.folderId ?? null)
- ) {
- return NextResponse.json(
- {
- error: `A ${item.type} named "${item.name}" already exists in this folder`,
- },
- { status: 400 },
- );
- }
- }
- }
- if (event.type === "ITEM_UPDATED" && event.payload?.changes?.name != null) {
- const itemId = event.payload?.id;
- const newName = event.payload.changes.name as string;
- if (itemId && newName) {
- const state = await loadWorkspaceState(id, { userId });
- const existingItem = state.find((i: { id: string }) => i.id === itemId);
- if (existingItem) {
- const newFolderId =
- (event.payload.changes as Record).folderId as string | undefined ?? existingItem.folderId ?? null;
- if (
- hasDuplicateName(
- state,
- newName,
- existingItem.type,
- newFolderId,
- itemId,
- )
- ) {
- return NextResponse.json(
- {
- error: `A ${existingItem.type} named "${newName}" already exists in this folder`,
- },
- { status: 400 },
- );
- }
- }
- }
- }
-
- const appendStart = Date.now();
- const appendResult = await appendWorkspaceEventWithBaseVersion({
- workspaceId: id,
- event,
- baseVersion,
- });
- timings.appendFunction = Date.now() - appendStart;
-
- if (appendResult.conflict) {
- const totalTime = Date.now() - startTime;
- timings.total = totalTime;
-
- return NextResponse.json({
- conflict: true,
- version: appendResult.version,
- });
- }
-
- const totalTime = Date.now() - startTime;
- timings.total = totalTime;
-
- return NextResponse.json({
- success: true,
- version: appendResult.version,
- conflict: false,
- });
-}
-
-export const POST = withErrorHandling(
- handlePOST,
- "POST /api/workspaces/[id]/events",
-);
diff --git a/src/app/api/workspaces/[id]/route.ts b/src/app/api/workspaces/[id]/route.ts
index 9ca08c58..e9dbb39d 100644
--- a/src/app/api/workspaces/[id]/route.ts
+++ b/src/app/api/workspaces/[id]/route.ts
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { db, workspaces } from "@/lib/db/client";
import { eq, and, ne } from "drizzle-orm";
-import { loadWorkspaceState } from "@/lib/workspace/state-loader";
+import { loadWorkspaceState } from "@/lib/workspace/workspace-state-read";
import {
requireAuth,
verifyWorkspaceOwnership,
diff --git a/src/app/api/workspaces/[id]/state/route.ts b/src/app/api/workspaces/[id]/state/route.ts
deleted file mode 100644
index 279ca028..00000000
--- a/src/app/api/workspaces/[id]/state/route.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { NextRequest, NextResponse } from "next/server";
-import {
- requireAuth,
- verifyWorkspaceAccess,
- withErrorHandling,
-} from "@/lib/api/workspace-helpers";
-import { loadWorkspaceStatePayload } from "@/lib/workspace/state-loader";
-
-async function handleGET(
- _request: NextRequest,
- { params }: { params: Promise<{ id: string }> },
-) {
- const paramsPromise = params;
- const authPromise = requireAuth();
-
- const { id } = await paramsPromise;
- const userId = await authPromise;
-
- await verifyWorkspaceAccess(id, userId, "viewer");
-
- const statePayload = await loadWorkspaceStatePayload(id, { userId });
- return NextResponse.json(statePayload);
-}
-
-export const GET = withErrorHandling(
- handleGET,
- "GET /api/workspaces/[id]/state",
-);
diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts
index d0f3dd4f..8a80fa4c 100644
--- a/src/app/api/workspaces/route.ts
+++ b/src/app/api/workspaces/route.ts
@@ -7,7 +7,6 @@ import type {
} from "@/lib/workspace-state/types";
import type { CardColor } from "@/lib/workspace-state/colors";
import { normalizeWorkspaceItems } from "@/lib/workspace-state/state";
-import { createEvent } from "@/lib/workspace/events";
import { db, workspaces } from "@/lib/db/client";
import { workspaceCollaborators } from "@/lib/db/schema";
import { eq, desc, asc, inArray } from "drizzle-orm";
@@ -16,8 +15,7 @@ import {
requireAuthWithUserInfo,
withErrorHandling,
} from "@/lib/api/workspace-helpers";
-import { appendWorkspaceEventOrThrow } from "@/lib/workspace/workspace-event-store";
-import { workspaceItemProjectionState } from "@/lib/db/schema";
+import { insertWorkspaceItem } from "@/lib/workspace/workspace-item-write";
/**
* GET /api/workspaces
@@ -245,44 +243,19 @@ async function handlePOST(request: NextRequest) {
? normalizeWorkspaceItems(customInitialItems)
: getTemplateInitialItems(effectiveTemplate);
- if (initialItems.length > 0) {
- await db
- .insert(workspaceItemProjectionState)
- .values({
- workspaceId: workspace.id,
- lastAppliedVersion: 0,
- createdAt: new Date().toISOString(),
- updatedAt: new Date().toISOString(),
- })
- .onConflictDoNothing();
-
- const event = createEvent(
- "BULK_ITEMS_CREATED",
- { items: initialItems },
- userId,
- user.name || user.email || undefined,
- );
-
- try {
- await appendWorkspaceEventOrThrow({
- workspaceId: workspace.id,
- event,
- baseVersion: 0,
- });
- } catch (eventError) {
- await db.delete(workspaces).where(eq(workspaces.id, workspace.id));
- throw eventError;
- }
- } else {
- await db
- .insert(workspaceItemProjectionState)
- .values({
- workspaceId: workspace.id,
- lastAppliedVersion: 0,
- createdAt: new Date().toISOString(),
- updatedAt: new Date().toISOString(),
- })
- .onConflictDoNothing();
+ try {
+ await db.transaction(async (tx) => {
+ for (const item of initialItems) {
+ await insertWorkspaceItem(tx, {
+ workspaceId: workspace.id,
+ item,
+ sourceVersion: 0,
+ });
+ }
+ });
+ } catch (insertError) {
+ await db.delete(workspaces).where(eq(workspaces.id, workspace.id));
+ throw insertError;
}
// Return workspace with full state for immediate use
diff --git a/src/app/api/workspaces/slug/[slug]/route.ts b/src/app/api/workspaces/slug/[slug]/route.ts
index 7790dfe4..0b62811c 100644
--- a/src/app/api/workspaces/slug/[slug]/route.ts
+++ b/src/app/api/workspaces/slug/[slug]/route.ts
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { db, workspaces } from "@/lib/db/client";
import { workspaceCollaborators } from "@/lib/db/schema";
import { eq, and, or, inArray } from "drizzle-orm";
-import { loadWorkspaceState } from "@/lib/workspace/state-loader";
+import { loadWorkspaceState } from "@/lib/workspace/workspace-state-read";
import { requireAuth, withErrorHandling } from "@/lib/api/workspace-helpers";
/**
diff --git a/src/app/api/zero/mutate/route.ts b/src/app/api/zero/mutate/route.ts
index 37be54a8..3f811f33 100644
--- a/src/app/api/zero/mutate/route.ts
+++ b/src/app/api/zero/mutate/route.ts
@@ -1,8 +1,7 @@
-import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
-import { zeroDrizzle } from "@rocicorp/zero/server/adapters/drizzle";
-import { handleMutateRequest } from "@rocicorp/zero/server";
import { mustGetMutator } from "@rocicorp/zero";
+import { handleMutateRequest } from "@rocicorp/zero/server";
+import { zeroDrizzle } from "@rocicorp/zero/server/adapters/drizzle";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db/client";
import { serverMutators } from "@/lib/zero/server-mutators";
@@ -11,24 +10,20 @@ import { schema } from "@/lib/zero/zero-schema.gen";
const dbProvider = zeroDrizzle(schema, db);
export async function POST(request: NextRequest) {
- const session = await auth.api.getSession({ headers: await headers() });
+ const session = await auth.api.getSession({ headers: request.headers });
- if (!session) {
+ if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
+ const ctx = { userId: session.user.id };
+
const result = await handleMutateRequest(
dbProvider,
async (transact) => {
return transact(async (tx, name, args) => {
const mutator = mustGetMutator(serverMutators, name);
- await mutator.fn({
- tx,
- args,
- ctx: {
- userId: session.user.id,
- },
- });
+ await mutator.fn({ tx, args, ctx });
});
},
request,
diff --git a/src/app/api/zero/query/route.ts b/src/app/api/zero/query/route.ts
new file mode 100644
index 00000000..9a1c1d92
--- /dev/null
+++ b/src/app/api/zero/query/route.ts
@@ -0,0 +1,119 @@
+import { NextRequest, NextResponse } from "next/server";
+import { mustGetQuery, type ReadonlyJSONValue } from "@rocicorp/zero";
+import { handleQueryRequest } from "@rocicorp/zero/server";
+import { and, eq } from "drizzle-orm";
+import { auth } from "@/lib/auth";
+import { db } from "@/lib/db/client";
+import { workspaceCollaborators, workspaces } from "@/lib/db/schema";
+import { queries } from "@/lib/zero/queries";
+import { schema } from "@/lib/zero/zero-schema.gen";
+
+const WORKSPACE_ACCESS_DENIED_ERROR = "Workspace access denied";
+
+type QueryRequest = {
+ args?: readonly unknown[];
+};
+
+async function hasWorkspaceReadAccess(
+ workspaceId: string,
+ userId: string,
+): Promise {
+ const [workspace] = await db
+ .select({ ownerId: workspaces.userId })
+ .from(workspaces)
+ .where(eq(workspaces.id, workspaceId))
+ .limit(1);
+
+ if (!workspace) {
+ return false;
+ }
+
+ if (workspace.ownerId === userId) {
+ return true;
+ }
+
+ const [collaborator] = await db
+ .select({ id: workspaceCollaborators.id })
+ .from(workspaceCollaborators)
+ .where(
+ and(
+ eq(workspaceCollaborators.workspaceId, workspaceId),
+ eq(workspaceCollaborators.userId, userId),
+ ),
+ )
+ .limit(1);
+
+ return !!collaborator;
+}
+
+export async function POST(request: NextRequest) {
+ const session = await auth.api.getSession({ headers: request.headers });
+
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const userId = session.user.id;
+ const ctx = { userId };
+
+ try {
+ const body = (await request.json()) as unknown;
+ const queryRequests =
+ Array.isArray(body) && Array.isArray(body[1])
+ ? (body[1] as QueryRequest[])
+ : [];
+ const workspaceIds = [
+ ...new Set(
+ queryRequests.flatMap((queryRequest) => {
+ const args = queryRequest.args;
+ const firstArg = args?.[0];
+
+ if (
+ firstArg &&
+ typeof firstArg === "object" &&
+ "workspaceId" in firstArg &&
+ typeof firstArg.workspaceId === "string"
+ ) {
+ return [firstArg.workspaceId];
+ }
+
+ return [];
+ }),
+ ),
+ ];
+
+ if (workspaceIds.length === 0) {
+ return NextResponse.json(
+ { error: "No workspace context provided" },
+ { status: 400 },
+ );
+ }
+
+ for (const workspaceId of workspaceIds) {
+ const hasAccess = await hasWorkspaceReadAccess(workspaceId, userId);
+ if (!hasAccess) {
+ throw new Error(WORKSPACE_ACCESS_DENIED_ERROR);
+ }
+ }
+
+ const result = await handleQueryRequest(
+ (name, args) => mustGetQuery(queries, name).fn({ args, ctx }),
+ schema,
+ body as ReadonlyJSONValue,
+ );
+
+ return NextResponse.json(result);
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ error.message === WORKSPACE_ACCESS_DENIED_ERROR
+ ) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/zero/token/route.ts b/src/app/api/zero/token/route.ts
deleted file mode 100644
index ffb23aa3..00000000
--- a/src/app/api/zero/token/route.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { headers } from "next/headers";
-import { NextResponse } from "next/server";
-import { SignJWT } from "jose";
-import { auth } from "@/lib/auth";
-
-export async function GET() {
- const session = await auth.api.getSession({ headers: await headers() });
-
- if (!session) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
-
- const zeroAuthSecret = process.env.ZERO_AUTH_SECRET;
- if (!zeroAuthSecret) {
- return NextResponse.json(
- { error: "ZERO_AUTH_SECRET is not configured" },
- { status: 500 },
- );
- }
-
- const secret = new TextEncoder().encode(zeroAuthSecret);
- const token = await new SignJWT({
- sub: session.user.id,
- name: session.user.name,
- email: session.user.email,
- })
- .setProtectedHeader({ alg: "HS256" })
- .setIssuedAt()
- .setExpirationTime("1h")
- .sign(secret);
-
- return NextResponse.json({ token });
-}
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index 4f713349..c1d9bfff 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -7,7 +7,6 @@ import { useKeyboardShortcuts } from "@/hooks/ui/use-keyboard-shortcuts";
import useMediaQuery from "@/hooks/ui/use-media-query";
import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state";
import { useWorkspaceOperations } from "@/hooks/workspace/use-workspace-operations";
-import { useWorkspaceEvents } from "@/hooks/workspace/use-workspace-events";
import {
WorkspaceProvider,
useWorkspaceContext,
@@ -35,6 +34,7 @@ import WorkspaceSettingsModal from "@/components/workspace/WorkspaceSettingsModa
import ShareWorkspaceDialog from "@/components/workspace/ShareWorkspaceDialog";
import { RealtimeProvider } from "@/contexts/RealtimeContext";
import { ZeroProvider } from "@/lib/zero/provider";
+import { useConnectionState } from "@rocicorp/zero/react";
import { toast } from "sonner";
import { InviteGuard } from "@/components/workspace/InviteGuard";
import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation";
@@ -83,22 +83,11 @@ function DashboardContent({
// }, [shouldShowOnboarding, isLoadingOnboarding]);
// Get workspace context (now only manages workspace list)
const { currentSlug, switchWorkspace } = useWorkspaceContext();
+ const zeroConnectionState = useConnectionState();
+ const isSaving = zeroConnectionState.name === "connecting";
- // Get save status from Zustand store
- const {
- isSaving,
- updateSaveStatus,
- updateLastSaved,
- updateHasUnsavedChanges,
- } = useWorkspaceStore();
-
- // ===== EVENT-BASED STATE MANAGEMENT =====
- // Event sourcing + React Query replaces the old autosave/loader hooks
- // State is derived from events, mutations are optimistic
- const {
- state,
- isLoading: isLoadingWorkspace,
- } = useWorkspaceState(currentWorkspaceId);
+ const { state, isLoading: isLoadingWorkspace } =
+ useWorkspaceState(currentWorkspaceId);
// Open audio recorder when landing from home Record flow (store flag set before navigate).
const openAudioDialog = useAudioRecordingStore((s) => s.openDialog);
@@ -136,11 +125,9 @@ function DashboardContent({
setShouldOpenOnWorkspaceLoad,
]);
- // Workspace operations (emits events with optimistic updates)
+ // Workspace operations
const operations = useWorkspaceOperations(currentWorkspaceId, state);
- const { data: eventLog } = useWorkspaceEvents(currentWorkspaceId);
-
// Track sign-in prompt dismissal per workspace for anonymous users.
const [
dismissedSignInPromptWorkspaceId,
@@ -153,71 +140,14 @@ function DashboardContent({
const showSignInPrompt =
!!session?.user?.isAnonymous &&
- !!eventLog &&
!isLoadingWorkspace &&
!!currentWorkspaceId &&
- (eventLog.events?.length ?? 0) >= 15 &&
+ state.length >= 15 &&
dismissedSignInPromptWorkspaceId !== currentWorkspaceId;
// Get sidebar state and controls
const { toggleSidebar } = useSidebar();
- // Update save status based on mutation status
- useEffect(() => {
- updateSaveStatus(operations.isPending);
- if (!operations.isPending && !operations.isError) {
- updateHasUnsavedChanges(false);
- updateLastSaved(new Date());
- }
- }, [
- operations.isPending,
- operations.isError,
- updateSaveStatus,
- updateHasUnsavedChanges,
- updateLastSaved,
- ]);
-
- // Mark as saved when workspace is loaded from events
- useEffect(() => {
- if (!isLoadingWorkspace && currentWorkspaceId) {
- // Use the last event's timestamp if available, otherwise use current time
- let lastSavedDate: Date;
- if (eventLog?.events && eventLog.events.length > 0) {
- // Events are ordered by version, so the last event is the most recent
- const lastEvent = eventLog.events[eventLog.events.length - 1];
- // Ensure timestamp exists and is valid
- if (lastEvent.timestamp != null) {
- // Ensure timestamp is a number (might be string from JSON)
- const timestamp =
- typeof lastEvent.timestamp === "number"
- ? lastEvent.timestamp
- : Number(lastEvent.timestamp);
- lastSavedDate = new Date(timestamp);
- // Validate the date is valid
- if (isNaN(lastSavedDate.getTime())) {
- // If invalid, fallback to current time
- lastSavedDate = new Date();
- }
- } else {
- // If timestamp is missing, fallback to current time
- lastSavedDate = new Date();
- }
- } else {
- // Fallback to current time if no events exist (new workspace)
- lastSavedDate = new Date();
- }
- updateLastSaved(lastSavedDate);
- updateHasUnsavedChanges(false);
- }
- }, [
- isLoadingWorkspace,
- currentWorkspaceId,
- state,
- eventLog,
- updateLastSaved,
- updateHasUnsavedChanges,
- ]);
-
// UI State from Zustand stores - using individual selectors to prevent unnecessary re-renders
// NOTE: Each selector only triggers a re-render when that specific value changes
const isDesktop = useMediaQuery("(min-width: 768px)");
@@ -360,9 +290,7 @@ function DashboardContent({
setIsChatExpanded={setIsChatExpanded}
setIsChatMaximized={setIsChatMaximized}
workspaceHeader={
- !isChatMaximized &&
- currentWorkspaceId &&
- !isLoadingWorkspace ? (
+ !isChatMaximized && currentWorkspaceId && !isLoadingWorkspace ? (
setSearchDialogOpen(true)}
currentWorkspaceId={currentWorkspaceId}
diff --git a/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx b/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx
index 583e64a4..b5b02a19 100644
--- a/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx
+++ b/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx
@@ -173,9 +173,7 @@ export function FlashcardWorkspaceCard({
const flashcardData = item.data as FlashcardData;
// Navigation State
- const [currentIndex, setCurrentIndex] = useState(
- flashcardData.currentIndex || 0,
- );
+ const [currentIndex, setCurrentIndex] = useState(0);
const cards = useMemo(
() => flashcardData.cards ?? [],
@@ -195,7 +193,6 @@ export function FlashcardWorkspaceCard({
// For now just local state is smooth, maybe updating item is fine.
// Let's keep it local for now, prop update on unmount?
setCurrentIndex(newIndex);
- // If we want persistence: onUpdateItem(item.id, { data: { ...flashcardData, currentIndex: newIndex }});
}, []);
const currentCard = useMemo((): FlashcardItem => {
diff --git a/src/components/workspace-canvas/QuizContent.tsx b/src/components/workspace-canvas/QuizContent.tsx
index f7e4d287..18ef0237 100644
--- a/src/components/workspace-canvas/QuizContent.tsx
+++ b/src/components/workspace-canvas/QuizContent.tsx
@@ -6,7 +6,6 @@ import type {
ItemData,
QuizData,
QuizQuestion,
- QuizSessionData,
} from "@/lib/workspace-state/types";
import { cn } from "@/lib/utils";
import {
@@ -35,7 +34,7 @@ interface QuizContentProps {
export function QuizContent({
item,
- onUpdateData,
+ onUpdateData: _onUpdateData,
isScrollLocked = false,
className,
}: QuizContentProps) {
@@ -48,24 +47,13 @@ export function QuizContent({
const toggleCardSelection = useUIStore((state) => state.toggleCardSelection);
// Session state
- const [currentIndex, setCurrentIndex] = useState(
- quizData.session?.currentIndex || 0,
- );
+ const [currentIndex, setCurrentIndex] = useState(0);
const [selectedAnswer, setSelectedAnswer] = useState(null);
const [isSubmitted, setIsSubmitted] = useState(false);
const [answeredQuestions, setAnsweredQuestions] = useState<
- QuizSessionData["answeredQuestions"]
- >(quizData.session?.answeredQuestions || []);
-
- // Initialize showResults based on whether quiz is completed
- // Quiz is completed ONLY if: completedAt exists AND all questions are answered
- const isInitiallyCompleted = !!(
- quizData.session?.completedAt &&
- quizData.session?.answeredQuestions?.length &&
- questions.length > 0 &&
- quizData.session.answeredQuestions.length >= questions.length
- );
- const [showResults, setShowResults] = useState(isInitiallyCompleted);
+ Array<{ questionId: string; userAnswer: number; isCorrect: boolean }>
+ >([]);
+ const [showResults, setShowResults] = useState(false);
// Track previous question count and IDs to detect when new questions are added
const prevQuestionCountRef = useRef(questions.length);
@@ -102,19 +90,6 @@ export function QuizContent({
setCurrentIndex(prevCount); // Go to first new question
setSelectedAnswer(null);
setIsSubmitted(false);
-
- // Clear completedAt since we're no longer complete
- onUpdateData((prev) => {
- const current = prev as QuizData;
- return {
- ...current,
- session: {
- ...current.session,
- currentIndex: prevCount,
- completedAt: undefined,
- } as QuizSessionData,
- };
- });
} else {
// Just notify user
toast.success(
@@ -130,13 +105,7 @@ export function QuizContent({
// Update refs for next comparison
prevQuestionCountRef.current = currentCount;
prevQuestionIdsRef.current = currentIds;
- }, [
- questions,
- showResults,
- answeredQuestions.length,
- currentIndex,
- onUpdateData,
- ]);
+ }, [questions, showResults, answeredQuestions.length, currentIndex]);
// Check if current question was already answered
const previousAnswer = useMemo(() => {
@@ -154,25 +123,6 @@ export function QuizContent({
}
}, [currentIndex, previousAnswer]);
- // Persist session state
- const persistSession = useCallback(
- (updates: Partial) => {
- onUpdateData((prev) => {
- const current = prev as QuizData;
- return {
- ...current,
- session: {
- currentIndex,
- answeredQuestions,
- ...current.session,
- ...updates,
- },
- };
- });
- },
- [onUpdateData, currentIndex, answeredQuestions],
- );
-
// Handle answer selection
const handleSelectAnswer = (index: number) => {
if (isSubmitted) return;
@@ -197,13 +147,6 @@ export function QuizContent({
setAnsweredQuestions(newAnsweredQuestions);
setIsSubmitted(true);
-
- // Persist to data
- persistSession({
- currentIndex,
- answeredQuestions: newAnsweredQuestions,
- startedAt: quizData.session?.startedAt || Date.now(),
- });
};
// Navigation
@@ -211,11 +154,9 @@ export function QuizContent({
if (currentIndex < totalQuestions - 1) {
const nextIndex = currentIndex + 1;
setCurrentIndex(nextIndex);
- persistSession({ currentIndex: nextIndex });
} else {
// Show results
setShowResults(true);
- persistSession({ completedAt: Date.now() });
}
};
@@ -224,7 +165,6 @@ export function QuizContent({
if (currentIndex < totalQuestions - 1) {
const nextIndex = currentIndex + 1;
setCurrentIndex(nextIndex);
- persistSession({ currentIndex: nextIndex });
}
};
@@ -232,7 +172,6 @@ export function QuizContent({
if (currentIndex > 0) {
const prevIndex = currentIndex - 1;
setCurrentIndex(prevIndex);
- persistSession({ currentIndex: prevIndex });
}
};
@@ -242,13 +181,6 @@ export function QuizContent({
setIsSubmitted(false);
setAnsweredQuestions([]);
setShowResults(false);
- onUpdateData((prev) => {
- const current = prev as QuizData;
- return {
- ...current,
- session: undefined,
- };
- });
};
// Handle Update Quiz - programmatically send message to add more questions
diff --git a/src/components/workspace-canvas/WorkspaceContent.tsx b/src/components/workspace-canvas/WorkspaceContent.tsx
index a5c91e8e..1cccec17 100644
--- a/src/components/workspace-canvas/WorkspaceContent.tsx
+++ b/src/components/workspace-canvas/WorkspaceContent.tsx
@@ -7,6 +7,7 @@ import type { Item, CardType } from "@/lib/workspace-state/types";
import { filterItemsByFolder } from "@/lib/workspace-state/search";
import { useAutoScroll } from "@/hooks/ui/use-auto-scroll";
import { transcriptSegmentsQueryKey } from "@/hooks/workspace/use-transcript-segments";
+import { AUDIO_COMPLETE_EVENT } from "@/lib/audio/poll-audio-processing";
import { WorkspaceGrid } from "./WorkspaceGrid";
import { useUIStore } from "@/lib/stores/ui-store";
import { useSelectedCardIds } from "@/hooks/ui/use-selected-card-ids";
@@ -116,9 +117,9 @@ export default function WorkspaceContent({
).detail ?? {};
if (!itemId) return;
- const existingData = viewState.find((i) => i.id === itemId)?.data ?? {};
-
if (retrying) {
+ const existingData =
+ viewState.find((item) => item.id === itemId)?.data ?? {};
updateItem(itemId, {
data: {
...existingData,
@@ -129,19 +130,16 @@ export default function WorkspaceContent({
return;
}
- if (workspaceId) {
- queryClient.invalidateQueries({
- queryKey: transcriptSegmentsQueryKey(workspaceId, itemId),
- });
- }
+ if (!workspaceId) return;
+
+ queryClient.invalidateQueries({
+ queryKey: transcriptSegmentsQueryKey(workspaceId, itemId),
+ });
};
- window.addEventListener("audio-processing-complete", handleAudioComplete);
+ window.addEventListener(AUDIO_COMPLETE_EVENT, handleAudioComplete);
return () => {
- window.removeEventListener(
- "audio-processing-complete",
- handleAudioComplete,
- );
+ window.removeEventListener(AUDIO_COMPLETE_EVENT, handleAudioComplete);
};
}, [updateItem, viewState, workspaceId, queryClient]);
diff --git a/src/hooks/workspace/use-workspace-events.ts b/src/hooks/workspace/use-workspace-events.ts
deleted file mode 100644
index 61f12ef1..00000000
--- a/src/hooks/workspace/use-workspace-events.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import type { EventResponse } from "@/lib/workspace/events";
-
-export function workspaceEventsQueryKey(workspaceId: string | null) {
- return ["workspace", workspaceId, "events"] as const;
-}
-
-async function fetchWorkspaceEvents(
- workspaceId: string,
-): Promise {
- try {
- const response = await fetch(`/api/workspaces/${workspaceId}/events`);
-
- if (!response.ok) {
- throw new Error(`Failed to fetch events: ${response.statusText}`);
- }
-
- return await response.json();
- } catch (err) {
- console.error(`[EVENTS] Failed to fetch events for ${workspaceId}`, err);
- throw err;
- }
-}
-
-export function useWorkspaceEvents(workspaceId: string | null) {
- return useQuery({
- queryKey: workspaceEventsQueryKey(workspaceId),
- queryFn: () => fetchWorkspaceEvents(workspaceId!),
- enabled: !!workspaceId,
- staleTime: 1000 * 30,
- gcTime: 1000 * 60 * 10,
- refetchInterval: false,
- refetchOnWindowFocus: false,
- });
-}
diff --git a/src/hooks/workspace/use-workspace-mutation.ts b/src/hooks/workspace/use-workspace-mutation.ts
deleted file mode 100644
index d43e9bfa..00000000
--- a/src/hooks/workspace/use-workspace-mutation.ts
+++ /dev/null
@@ -1,224 +0,0 @@
-import { useMutation, useQueryClient } from "@tanstack/react-query";
-import type { WorkspaceEvent, EventResponse } from "@/lib/workspace/events";
-import {
- computeBaseVersion,
- removeOptimisticEvent,
- confirmOptimisticEvent,
-} from "@/lib/workspace/version-helpers";
-import { workspaceEventsQueryKey } from "./use-workspace-events";
-import { applyConfirmedWorkspaceEventToStateQuery } from "./workspace-state-cache";
-import { useRef } from "react";
-import { toast } from "sonner";
-
-const MAX_RETRY_ATTEMPTS = 3;
-
-type AppendEventResponse =
- | { success: true; version: number; conflict: false }
- | { conflict: true; version: number };
-
-async function appendWorkspaceEvent(params: {
- workspaceId: string;
- event: WorkspaceEvent;
- baseVersion: number;
-}): Promise {
- const { workspaceId, event, baseVersion } = params;
- const response = await fetch(`/api/workspaces/${workspaceId}/events`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ event, baseVersion }),
- });
-
- if (!response.ok) {
- if (response.status === 403) {
- throw new Error("PERMISSION_DENIED");
- }
- const errorText = await response.text();
- throw new Error(
- `Failed to append event: ${response.statusText} - ${errorText}`,
- );
- }
-
- return response.json();
-}
-
-/**
- * Hook to mutate workspace by appending events.
- * Implements optimistic updates with automatic conflict retry.
- */
-export function useWorkspaceMutation(workspaceId: string | null) {
- const queryClient = useQueryClient();
- const retryAttemptsRef = useRef