From 8c054f1c88d7f865953a9f26bf0a0b319b67bbad Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:36:52 -0400 Subject: [PATCH 01/11] feat(db): workspace_items projection tables and migrations Add workspace_items and workspace_item_projection_state with RLS policies. Migration 0006 introduces projection tables; 0007 drops workspace_items.sort_order. Update Drizzle schema, relations, and inferred types. Made-with: Cursor --- drizzle/0006_workspace_items_projection.sql | 63 ++++++++++ .../0007_remove_workspace_item_sort_order.sql | 2 + drizzle/meta/_journal.json | 14 +++ src/lib/db/relations.ts | 20 +++ src/lib/db/schema.ts | 118 +++++++++++++++++- src/lib/db/types.ts | 12 +- 6 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 drizzle/0006_workspace_items_projection.sql create mode 100644 drizzle/0007_remove_workspace_item_sort_order.sql diff --git a/drizzle/0006_workspace_items_projection.sql b/drizzle/0006_workspace_items_projection.sql new file mode 100644 index 00000000..84e224f0 --- /dev/null +++ b/drizzle/0006_workspace_items_projection.sql @@ -0,0 +1,63 @@ +CREATE TABLE "workspace_items" ( + "workspace_id" uuid NOT NULL, + "item_id" text NOT NULL, + "type" text NOT NULL, + "name" text NOT NULL, + "subtitle" text DEFAULT '' NOT NULL, + "sort_order" integer NOT NULL, + "data" jsonb NOT NULL, + "color" text, + "folder_id" text, + "layout" jsonb, + "last_modified" bigint, + "source_version" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "workspace_items_pkey" PRIMARY KEY("workspace_id","item_id") +); +--> statement-breakpoint +ALTER TABLE "workspace_items" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "workspace_item_projection_state" ( + "workspace_id" uuid PRIMARY KEY NOT NULL, + "last_applied_version" integer NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "workspace_item_projection_state" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "workspace_items" ADD CONSTRAINT "workspace_items_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_item_projection_state" ADD CONSTRAINT "workspace_item_projection_state_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_workspace_items_workspace" ON "workspace_items" USING btree ("workspace_id" uuid_ops);--> statement-breakpoint +CREATE INDEX "idx_workspace_items_workspace_folder" ON "workspace_items" USING btree ("workspace_id" uuid_ops,"folder_id" text_ops);--> statement-breakpoint +CREATE INDEX "idx_workspace_items_workspace_type" ON "workspace_items" USING btree ("workspace_id" uuid_ops,"type" text_ops);--> statement-breakpoint +CREATE INDEX "idx_workspace_items_workspace_updated" ON "workspace_items" USING btree ("workspace_id" uuid_ops,"updated_at" timestamptz_ops DESC);--> statement-breakpoint +CREATE INDEX "idx_workspace_items_workspace_version" ON "workspace_items" USING btree ("workspace_id" uuid_ops,"source_version" int4_ops DESC);--> statement-breakpoint +CREATE INDEX "idx_workspace_items_workspace_order" ON "workspace_items" USING btree ("workspace_id" uuid_ops,"sort_order" int4_ops);--> statement-breakpoint +CREATE INDEX "idx_workspace_item_projection_state_version" ON "workspace_item_projection_state" USING btree ("last_applied_version" int4_ops DESC);--> statement-breakpoint +CREATE POLICY "Users can read projected workspace items they have access to" ON "workspace_items" AS PERMISSIVE FOR SELECT TO public USING ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text))))));--> statement-breakpoint +CREATE POLICY "Users can write projected workspace items they have write access to" ON "workspace_items" AS PERMISSIVE FOR ALL TO public USING ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))) WITH CHECK ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text)))));--> statement-breakpoint +CREATE POLICY "Users can read projection state they have access to" ON "workspace_item_projection_state" AS PERMISSIVE FOR SELECT TO public USING ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_projection_state.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_projection_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text))))));--> statement-breakpoint +CREATE POLICY "Users can write projection state they have write access to" ON "workspace_item_projection_state" AS PERMISSIVE FOR ALL TO public USING ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_projection_state.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_projection_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))) WITH CHECK ((EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_projection_state.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_projection_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text)))));--> statement-breakpoint diff --git a/drizzle/0007_remove_workspace_item_sort_order.sql b/drizzle/0007_remove_workspace_item_sort_order.sql new file mode 100644 index 00000000..e457965d --- /dev/null +++ b/drizzle/0007_remove_workspace_item_sort_order.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS "idx_workspace_items_workspace_order";--> statement-breakpoint +ALTER TABLE "workspace_items" DROP COLUMN IF EXISTS "sort_order"; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 8b5c51d8..a3ce77e7 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,20 @@ "when": 1771800001000, "tag": "0005_add_chat_messages_thread_message_unique", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1775558400000, + "tag": "0006_workspace_items_projection", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1775606400000, + "tag": "0007_remove_workspace_item_sort_order", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/db/relations.ts b/src/lib/db/relations.ts index 1bf2a106..63ca2b91 100644 --- a/src/lib/db/relations.ts +++ b/src/lib/db/relations.ts @@ -3,6 +3,8 @@ import { workspaces, workspaceSnapshots, workspaceEvents, + workspaceItems, + workspaceItemProjectionState, chatThreads, chatMessages, workspaceItemReads, @@ -13,6 +15,7 @@ import { export const workspacesRelations = relations(workspaces, ({ many }) => ({ workspaceSnapshots: many(workspaceSnapshots), workspaceEvents: many(workspaceEvents), + workspaceItems: many(workspaceItems), })); export const workspaceSnapshotsRelations = relations(workspaceSnapshots, ({ one }) => ({ @@ -29,6 +32,23 @@ export const workspaceEventsRelations = relations(workspaceEvents, ({ one }) => }), })); +export const workspaceItemsRelations = relations(workspaceItems, ({ one }) => ({ + workspace: one(workspaces, { + fields: [workspaceItems.workspaceId], + references: [workspaces.id], + }), +})); + +export const workspaceItemProjectionStateRelations = relations( + workspaceItemProjectionState, + ({ one }) => ({ + workspace: one(workspaces, { + fields: [workspaceItemProjectionState.workspaceId], + references: [workspaces.id], + }), + }), +); + export const chatThreadsRelations = relations(chatThreads, ({ one, many }) => ({ workspace: one(workspaces, { fields: [chatThreads.workspaceId], diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 37417bc6..241a8faa 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, index, foreignKey, pgPolicy, uuid, text, jsonb, timestamp, boolean, uniqueIndex, integer, unique, check, bigint } from "drizzle-orm/pg-core" +import { pgTable, index, foreignKey, pgPolicy, uuid, text, jsonb, timestamp, boolean, uniqueIndex, integer, unique, check, bigint, primaryKey } from "drizzle-orm/pg-core" import { sql } from "drizzle-orm" // Better Auth tables @@ -185,6 +185,122 @@ export const workspaceEvents = pgTable("workspace_events", { WHERE ((c.workspace_id = workspace_events.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))` }), ]); +export const workspaceItems = pgTable("workspace_items", { + workspaceId: uuid("workspace_id").notNull(), + itemId: text("item_id").notNull(), + type: text().notNull(), + name: text().notNull(), + subtitle: text().default('').notNull(), + data: jsonb().notNull(), + color: text(), + folderId: text("folder_id"), + layout: jsonb(), + lastModified: bigint("last_modified", { mode: "number" }), + sourceVersion: integer("source_version").notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}, (table) => [ + primaryKey({ + columns: [table.workspaceId, table.itemId], + name: "workspace_items_pkey", + }), + index("idx_workspace_items_workspace").using( + "btree", + table.workspaceId.asc().nullsLast().op("uuid_ops"), + ), + index("idx_workspace_items_workspace_folder").using( + "btree", + table.workspaceId.asc().nullsLast().op("uuid_ops"), + table.folderId.asc().nullsLast().op("text_ops"), + ), + index("idx_workspace_items_workspace_type").using( + "btree", + table.workspaceId.asc().nullsLast().op("uuid_ops"), + table.type.asc().nullsLast().op("text_ops"), + ), + index("idx_workspace_items_workspace_updated").using( + "btree", + table.workspaceId.asc().nullsLast().op("uuid_ops"), + table.updatedAt.desc().nullsFirst().op("timestamptz_ops"), + ), + index("idx_workspace_items_workspace_version").using( + "btree", + table.workspaceId.asc().nullsLast().op("uuid_ops"), + table.sourceVersion.desc().nullsFirst().op("int4_ops"), + ), + foreignKey({ + columns: [table.workspaceId], + foreignColumns: [workspaces.id], + name: "workspace_items_workspace_id_fkey", + }).onDelete("cascade"), + pgPolicy("Users can read projected workspace items they have access to", { + as: "permissive", + for: "select", + to: ["public"], + using: sql`(EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))`, + }), + pgPolicy("Users can write projected workspace items they have write access to", { + as: "permissive", + for: "all", + to: ["public"], + using: sql`(EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))`, + withCheck: sql`(EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))`, + }), +]); + +export const workspaceItemProjectionState = pgTable("workspace_item_projection_state", { + workspaceId: uuid("workspace_id").primaryKey().notNull(), + lastAppliedVersion: integer("last_applied_version").notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}, (table) => [ + index("idx_workspace_item_projection_state_version").using( + "btree", + table.lastAppliedVersion.desc().nullsFirst().op("int4_ops"), + ), + foreignKey({ + columns: [table.workspaceId], + foreignColumns: [workspaces.id], + name: "workspace_item_projection_state_workspace_id_fkey", + }).onDelete("cascade"), + pgPolicy("Users can read projection state they have access to", { + as: "permissive", + for: "select", + to: ["public"], + using: sql`(EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_projection_state.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_projection_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))`, + }), + pgPolicy("Users can write projection state they have write access to", { + as: "permissive", + for: "all", + to: ["public"], + using: sql`(EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_projection_state.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_projection_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))`, + withCheck: sql`(EXISTS ( SELECT 1 + FROM workspaces + WHERE ((workspaces.id = workspace_item_projection_state.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1 + FROM workspace_collaborators c + WHERE ((c.workspace_id = workspace_item_projection_state.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))`, + }), +]); + export const workspaceCollaborators = pgTable("workspace_collaborators", { id: uuid().defaultRandom().primaryKey().notNull(), workspaceId: uuid("workspace_id").notNull(), diff --git a/src/lib/db/types.ts b/src/lib/db/types.ts index f25615f9..e0f1163f 100644 --- a/src/lib/db/types.ts +++ b/src/lib/db/types.ts @@ -8,10 +8,12 @@ import { workspaces, workspaceEvents, workspaceSnapshots, + workspaceItems, + workspaceItemProjectionState, userProfiles, workspaceCollaborators } from './schema'; -import type { AgentState } from '@/lib/workspace-state/types'; +import type { WorkspaceState } from '@/lib/workspace-state/types'; // Base database types (what Drizzle returns) export type Workspace = InferSelectModel; @@ -26,6 +28,12 @@ export type WorkspaceEventInsert = InferInsertModel; export type WorkspaceSnapshot = InferSelectModel; export type WorkspaceSnapshotInsert = InferInsertModel; +export type WorkspaceItemProjection = InferSelectModel; +export type WorkspaceItemProjectionInsert = InferInsertModel; + +export type WorkspaceItemProjectionState = InferSelectModel; +export type WorkspaceItemProjectionStateInsert = InferInsertModel; + export type UserProfile = InferSelectModel; export type UserProfileInsert = InferInsertModel; @@ -37,7 +45,7 @@ export type PermissionLevel = 'viewer' | 'editor'; // Extended types for frontend use export interface WorkspaceWithState extends Workspace { - state?: AgentState; + state?: WorkspaceState; isShared?: boolean; permissionLevel?: 'viewer' | 'editor' | 'admin'; // admin is implied for owner but good to have in types } From 7a7fa429b35d30452b511be252ef2cc0404a063c Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:36:54 -0400 Subject: [PATCH 02/11] feat(workspace): item projection loader, event store, activity helpers Project workspace events into workspace_items; append with transactional projection. Activity summary for lightweight version/event metadata without full event fetch. Add projection backfill/reconcile API route and unit tests. Made-with: Cursor --- .../[id]/projection/backfill/route.ts | 32 ++ src/hooks/workspace/workspace-query-keys.ts | 7 + .../__tests__/workspace-activity.test.ts | 97 +++++ .../workspace-items-projection.test.ts | 226 ++++++++++ src/lib/workspace/workspace-activity.ts | 146 +++++++ src/lib/workspace/workspace-event-store.ts | 105 +++++ .../workspace/workspace-items-projection.ts | 395 ++++++++++++++++++ 7 files changed, 1008 insertions(+) create mode 100644 src/app/api/workspaces/[id]/projection/backfill/route.ts create mode 100644 src/hooks/workspace/workspace-query-keys.ts create mode 100644 src/lib/workspace/__tests__/workspace-activity.test.ts create mode 100644 src/lib/workspace/__tests__/workspace-items-projection.test.ts create mode 100644 src/lib/workspace/workspace-activity.ts create mode 100644 src/lib/workspace/workspace-event-store.ts create mode 100644 src/lib/workspace/workspace-items-projection.ts diff --git a/src/app/api/workspaces/[id]/projection/backfill/route.ts b/src/app/api/workspaces/[id]/projection/backfill/route.ts new file mode 100644 index 00000000..34634e9c --- /dev/null +++ b/src/app/api/workspaces/[id]/projection/backfill/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { + requireAuth, + verifyWorkspaceOwnership, + withErrorHandling, +} from "@/lib/api/workspace-helpers"; +import { + backfillWorkspaceItemsProjection, + reconcileWorkspaceItemsProjection, +} from "@/lib/workspace/workspace-items-projection"; + +async function handlePOST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const [{ id }, userId] = await Promise.all([params, requireAuth()]); + await verifyWorkspaceOwnership(id, userId); + + const backfill = await backfillWorkspaceItemsProjection(id); + const reconciliation = await reconcileWorkspaceItemsProjection(id); + + return NextResponse.json({ + success: true, + backfill, + reconciliation, + }); +} + +export const POST = withErrorHandling( + handlePOST, + "POST /api/workspaces/[id]/projection/backfill", +); diff --git a/src/hooks/workspace/workspace-query-keys.ts b/src/hooks/workspace/workspace-query-keys.ts new file mode 100644 index 00000000..93b89ba8 --- /dev/null +++ b/src/hooks/workspace/workspace-query-keys.ts @@ -0,0 +1,7 @@ +export function workspaceEventsQueryKey(workspaceId: string | null) { + return ["workspace", workspaceId, "events"] as const; +} + +export function workspaceStateQueryKey(workspaceId: string | null) { + return ["workspace", workspaceId, "state"] as const; +} diff --git a/src/lib/workspace/__tests__/workspace-activity.test.ts b/src/lib/workspace/__tests__/workspace-activity.test.ts new file mode 100644 index 00000000..27946c69 --- /dev/null +++ b/src/lib/workspace/__tests__/workspace-activity.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + applyEventToWorkspaceStatePayload, + confirmWorkspaceStatePayloadVersion, + createFallbackWorkspaceStatePayload, + getWorkspaceLastSavedAt, + normalizeWorkspaceStatePayload, + shouldShowAnonymousSignInPrompt, +} from "../workspace-activity"; + +describe("workspace activity helpers", () => { + it("normalizes workspace state payload metadata", () => { + const result = normalizeWorkspaceStatePayload("ws-1", { + workspace: { + state: { + workspaceId: "ws-1", + items: [], + }, + activity: { + version: 7, + eventCount: 21, + lastEventAt: 123456, + }, + }, + }); + + expect(result.activity).toEqual({ + version: 7, + eventCount: 21, + lastEventAt: 123456, + }); + expect(result.state.workspaceId).toBe("ws-1"); + }); + + it("shows the anonymous prompt from event count metadata only", () => { + expect( + shouldShowAnonymousSignInPrompt({ + isAnonymous: true, + isLoadingWorkspace: false, + currentWorkspaceId: "ws-1", + dismissedWorkspaceId: null, + eventCount: 15, + }), + ).toBe(true); + + expect( + shouldShowAnonymousSignInPrompt({ + isAnonymous: true, + isLoadingWorkspace: false, + currentWorkspaceId: "ws-1", + dismissedWorkspaceId: null, + eventCount: 14, + }), + ).toBe(false); + }); + + it("derives last saved date from activity metadata", () => { + expect(getWorkspaceLastSavedAt(123456).getTime()).toBe(123456); + }); + + it("applies optimistic events to the cached workspace payload", () => { + const payload = createFallbackWorkspaceStatePayload("ws-1"); + const next = applyEventToWorkspaceStatePayload(payload, { + id: "evt-1", + type: "BULK_ITEMS_CREATED", + payload: { items: [] }, + timestamp: 500, + userId: "user-1", + }); + + expect(next.state.items).toEqual([]); + expect(next.activity.version).toBe(0); + expect(next.activity.eventCount).toBe(1); + expect(next.activity.lastEventAt).toBe(500); + }); + + it("confirms cache payload version after the server accepts an event", () => { + const payload = applyEventToWorkspaceStatePayload( + createFallbackWorkspaceStatePayload("ws-1"), + { + id: "evt-1", + type: "BULK_ITEMS_CREATED", + payload: { items: [] }, + timestamp: 500, + userId: "user-1", + }, + ); + + const confirmed = confirmWorkspaceStatePayloadVersion(payload, 9, { + timestamp: 500, + }); + + expect(confirmed.activity.version).toBe(9); + expect(confirmed.activity.eventCount).toBe(1); + expect(confirmed.activity.lastEventAt).toBe(500); + }); +}); diff --git a/src/lib/workspace/__tests__/workspace-items-projection.test.ts b/src/lib/workspace/__tests__/workspace-items-projection.test.ts new file mode 100644 index 00000000..dea23eca --- /dev/null +++ b/src/lib/workspace/__tests__/workspace-items-projection.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "vitest"; +import { replayEvents } from "../event-reducer"; +import type { WorkspaceEvent } from "../events"; +import { + applyEventToProjectedItems, + didProjectedItemChange, +} from "../workspace-items-projection"; +import type { Item } from "@/lib/workspace-state/types"; + +const workspaceId = "ws-projection-test"; + +function projectEvents(events: WorkspaceEvent[], initialItems: Item[] = []): Item[] { + return events.reduce( + (items, event) => applyEventToProjectedItems(items, workspaceId, event), + initialItems, + ); +} + +describe("workspace items projection parity", () => { + it("matches replay for create, update, folder move, and folder delete flows", () => { + const folder: Item = { + id: "folder-1", + type: "folder", + name: "Folder", + subtitle: "", + data: {}, + }; + + const document: Item = { + id: "doc-1", + type: "document", + name: "Doc", + subtitle: "", + data: { markdown: "hello" }, + layout: { x: 0, y: 0, w: 2, h: 2 }, + }; + + const events: WorkspaceEvent[] = [ + { + id: "evt-1", + type: "ITEM_CREATED", + userId: "user-1", + timestamp: 1, + payload: { id: folder.id, item: folder }, + }, + { + id: "evt-2", + type: "ITEM_CREATED", + userId: "user-1", + timestamp: 2, + payload: { id: document.id, item: document }, + }, + { + id: "evt-3", + type: "ITEM_UPDATED", + userId: "user-1", + timestamp: 3, + payload: { + id: document.id, + changes: { + data: { markdown: "hello world", sources: [{ title: "A", url: "https://a.test" }] } as Item["data"], + }, + }, + }, + { + id: "evt-4", + type: "ITEM_MOVED_TO_FOLDER", + userId: "user-1", + timestamp: 4, + payload: { itemId: document.id, folderId: folder.id }, + }, + { + id: "evt-5", + type: "ITEM_DELETED", + userId: "user-1", + timestamp: 5, + payload: { id: folder.id }, + }, + ]; + + const replayedItems = replayEvents(events, workspaceId).items; + const projectedItems = projectEvents(events); + + expect(projectedItems).toEqual(replayedItems); + }); + + it("matches replay for seeded item creation followed by tail updates", () => { + const seededItems: Item[] = [ + { + id: "doc-1", + type: "document", + name: "Doc", + subtitle: "", + data: { markdown: "alpha" }, + }, + { + id: "quiz-1", + type: "quiz", + name: "Quiz", + subtitle: "", + data: { questions: [] }, + }, + ]; + + const events: WorkspaceEvent[] = [ + { + id: "evt-seed", + type: "BULK_ITEMS_CREATED", + userId: "user-1", + timestamp: 1, + payload: { items: seededItems }, + }, + { + id: "evt-tail", + type: "ITEM_UPDATED", + userId: "user-1", + timestamp: 2, + payload: { + id: "doc-1", + changes: { + data: { markdown: "beta" } as Item["data"], + name: "Doc 2", + }, + }, + }, + ]; + + const replayedItems = replayEvents(events, workspaceId).items; + const projectedItems = projectEvents(events); + + expect(projectedItems).toEqual(replayedItems); + }); + + it("matches replay for bulk patch, layout, and bulk delete sequences", () => { + const doc: Item = { + id: "doc-1", + type: "document", + name: "Doc", + subtitle: "", + data: { markdown: "start" }, + layout: { x: 0, y: 0, w: 2, h: 2 }, + }; + const pdf: Item = { + id: "pdf-1", + type: "pdf", + name: "Pdf", + subtitle: "", + data: { + fileUrl: "https://example.com/file.pdf", + filename: "file.pdf", + ocrStatus: "processing", + }, + }; + + const events: WorkspaceEvent[] = [ + { + id: "evt-1", + type: "BULK_ITEMS_CREATED", + userId: "user-1", + timestamp: 1, + payload: { items: [doc, pdf] }, + }, + { + id: "evt-2", + type: "BULK_ITEMS_PATCHED", + userId: "user-1", + timestamp: 2, + payload: { + updates: [ + { + id: "doc-1", + changes: { + data: { markdown: "patched" } as Item["data"], + }, + }, + { + id: "pdf-1", + changes: { + data: { + ocrStatus: "complete", + ocrPages: [{ index: 0, markdown: "page" }], + } as Item["data"], + }, + }, + ], + }, + }, + { + id: "evt-3", + type: "BULK_ITEMS_UPDATED", + userId: "user-1", + timestamp: 3, + payload: { + layoutUpdates: [{ id: "doc-1", x: 3, y: 4, w: 5, h: 6 }], + }, + }, + { + id: "evt-4", + type: "BULK_ITEMS_UPDATED", + userId: "user-1", + timestamp: 4, + payload: { + deletedIds: ["pdf-1"], + }, + }, + ]; + + const replayedItems = replayEvents(events, workspaceId).items; + const projectedItems = projectEvents(events); + + expect(projectedItems).toEqual(replayedItems); + }); + + it("treats matching items as unchanged when contents are identical", () => { + const item: Item = { + id: "doc-1", + type: "document", + name: "Doc", + subtitle: "", + data: { markdown: "same" }, + }; + + expect(didProjectedItemChange(undefined, item)).toBe(true); + expect(didProjectedItemChange(item, item)).toBe(false); + }); +}); diff --git a/src/lib/workspace/workspace-activity.ts b/src/lib/workspace/workspace-activity.ts new file mode 100644 index 00000000..5b5d7aae --- /dev/null +++ b/src/lib/workspace/workspace-activity.ts @@ -0,0 +1,146 @@ +import type { WorkspaceEvent } from "@/lib/workspace/events"; +import { initialState } from "@/lib/workspace-state/state"; +import { eventReducer } from "@/lib/workspace/event-reducer"; +import type { WorkspaceState } from "@/lib/workspace-state/types"; + +export interface WorkspaceActivitySummary { + version: number; + eventCount: number; + lastEventAt: number | null; +} + +export const EMPTY_WORKSPACE_ACTIVITY_SUMMARY: WorkspaceActivitySummary = { + version: 0, + eventCount: 0, + lastEventAt: null, +}; + +export type WorkspaceStatePayload = { + state: WorkspaceState; + activity: WorkspaceActivitySummary; +}; + +export function createFallbackWorkspaceState( + workspaceId: string | null | undefined, +): WorkspaceState { + return { + ...initialState, + workspaceId: workspaceId || undefined, + }; +} + +export function createFallbackWorkspaceStatePayload( + workspaceId: string | null | undefined, +): WorkspaceStatePayload { + return { + state: createFallbackWorkspaceState(workspaceId), + activity: EMPTY_WORKSPACE_ACTIVITY_SUMMARY, + }; +} + +export function normalizeWorkspaceStatePayload( + workspaceId: string, + data: { + workspace?: { + state?: WorkspaceState; + activity?: WorkspaceActivitySummary; + }; + }, +): WorkspaceStatePayload { + return { + state: data.workspace?.state ?? createFallbackWorkspaceState(workspaceId), + activity: + data.workspace?.activity ?? EMPTY_WORKSPACE_ACTIVITY_SUMMARY, + }; +} + +function normalizeEventTimestamp( + timestamp: number | null | undefined, +): number | null { + if (typeof timestamp === "number" && Number.isFinite(timestamp)) { + return timestamp; + } + + return null; +} + +export function applyEventToWorkspaceStatePayload( + payload: WorkspaceStatePayload, + event: WorkspaceEvent, + options?: { + incrementEventCount?: boolean; + confirmedVersion?: number; + }, +): WorkspaceStatePayload { + const nextTimestamp = normalizeEventTimestamp(event.timestamp); + + return { + state: eventReducer(payload.state, event), + activity: { + version: + typeof options?.confirmedVersion === "number" + ? Math.max(payload.activity.version, options.confirmedVersion) + : payload.activity.version, + eventCount: + payload.activity.eventCount + + (options?.incrementEventCount === false ? 0 : 1), + lastEventAt: nextTimestamp ?? payload.activity.lastEventAt, + }, + }; +} + +export function confirmWorkspaceStatePayloadVersion( + payload: WorkspaceStatePayload, + version: number, + event?: Pick, +): WorkspaceStatePayload { + const nextTimestamp = normalizeEventTimestamp(event?.timestamp); + + return { + ...payload, + activity: { + ...payload.activity, + version: Math.max(payload.activity.version, version), + lastEventAt: nextTimestamp ?? payload.activity.lastEventAt, + }, + }; +} + +export function getWorkspaceLastSavedAt( + lastEventAt: number | null | undefined, +): Date { + if (typeof lastEventAt === "number" && Number.isFinite(lastEventAt)) { + const date = new Date(lastEventAt); + if (!Number.isNaN(date.getTime())) { + return date; + } + } + + return new Date(); +} + +export function shouldShowAnonymousSignInPrompt(params: { + isAnonymous: boolean; + isLoadingWorkspace: boolean; + currentWorkspaceId: string | null; + dismissedWorkspaceId: string | null; + eventCount: number; + threshold?: number; +}): boolean { + const { + isAnonymous, + isLoadingWorkspace, + currentWorkspaceId, + dismissedWorkspaceId, + eventCount, + threshold = 15, + } = params; + + return ( + isAnonymous && + !isLoadingWorkspace && + !!currentWorkspaceId && + eventCount >= threshold && + dismissedWorkspaceId !== currentWorkspaceId + ); +} diff --git a/src/lib/workspace/workspace-event-store.ts b/src/lib/workspace/workspace-event-store.ts new file mode 100644 index 00000000..cdd3ec4d --- /dev/null +++ b/src/lib/workspace/workspace-event-store.ts @@ -0,0 +1,105 @@ +import { sql } from "drizzle-orm"; +import { db } from "@/lib/db/client"; +import type { WorkspaceEvent } from "./events"; +import { projectWorkspaceEvent } from "./workspace-items-projection"; + +export interface AppendWorkspaceEventResult { + version: number; + conflict: boolean; +} + +export function parseAppendWorkspaceEventResult( + rawResult: unknown, +): AppendWorkspaceEventResult { + if (typeof rawResult === "object" && rawResult !== null) { + const version = Number((rawResult as { version?: unknown }).version ?? 0); + const conflictValue = (rawResult as { conflict?: unknown }).conflict; + return { + version: Number.isFinite(version) ? version : 0, + conflict: + conflictValue === true || + conflictValue === "t" || + conflictValue === "true", + }; + } + + const resultString = + typeof rawResult === "string" ? rawResult : String(rawResult); + const match = resultString.match(/\(\s*(\d+)\s*,\s*(t|f|true|false)\s*\)/i); + + if (!match) { + throw new Error( + `append_workspace_event returned unexpected format: ${resultString}`, + ); + } + + return { + version: Number.parseInt(match[1], 10), + conflict: ["t", "true"].includes(match[2].toLowerCase()), + }; +} + +export async function appendWorkspaceEventWithProjection(params: { + workspaceId: string; + event: WorkspaceEvent; + baseVersion: number; +}): Promise { + const { workspaceId, event, baseVersion } = params; + + const appendOnce = async (executor: { execute: typeof db.execute }) => { + const result = await executor.execute(sql` + SELECT append_workspace_event( + ${workspaceId}::uuid, + ${event.id}::text, + ${event.type}::text, + ${JSON.stringify(event.payload)}::jsonb, + ${event.timestamp}::bigint, + ${event.userId}::text, + ${baseVersion}::integer, + ${event.userName || null}::text + ) as result + `); + + if (!result || result.length === 0 || !result[0]) { + throw new Error("append_workspace_event returned no result"); + } + + return parseAppendWorkspaceEventResult(result[0].result); + }; + + const supportsTransaction = typeof (db as { transaction?: unknown }).transaction === "function"; + + const appendResult = supportsTransaction + ? await db.transaction(async (tx) => { + const parsed = await appendOnce(tx); + if (!parsed.conflict) { + await projectWorkspaceEvent( + workspaceId, + { ...event, version: parsed.version }, + tx, + ); + } + + return parsed; + }) + : await appendOnce(db); + + return appendResult; +} + +export async function appendWorkspaceEventAtCurrentVersionWithProjection(params: { + workspaceId: string; + event: WorkspaceEvent; +}): Promise { + const { workspaceId, event } = params; + const result = await db.execute(sql` + SELECT get_workspace_version(${workspaceId}::uuid) as version + `); + const baseVersion = Number(result[0]?.version ?? 0); + + return appendWorkspaceEventWithProjection({ + workspaceId, + event, + baseVersion: Number.isFinite(baseVersion) ? baseVersion : 0, + }); +} diff --git a/src/lib/workspace/workspace-items-projection.ts b/src/lib/workspace/workspace-items-projection.ts new file mode 100644 index 00000000..8871d8a9 --- /dev/null +++ b/src/lib/workspace/workspace-items-projection.ts @@ -0,0 +1,395 @@ +import { and, asc, eq, inArray, sql } from "drizzle-orm"; +import { + db, + type Database, + workspaceItemProjectionState, + workspaceItems, + workspaces, +} from "@/lib/db/client"; +import type { WorkspaceEvent } from "@/lib/workspace/events"; +import { eventReducer } from "@/lib/workspace/event-reducer"; +import { initialState } from "@/lib/workspace-state/state"; +import type { Item, WorkspaceState } from "@/lib/workspace-state/types"; +import { loadWorkspaceState } from "@/lib/workspace/state-loader"; + +/** DB or Drizzle transaction (same ops used by projection; not only `typeof db`). */ +type DbExecutor = Database | Parameters[0]>[0]; + +function normalizeNullable(value: T | null | undefined): T | null { + return value ?? null; +} + +function serializeComparableItem(item: Item) { + return JSON.stringify({ + id: item.id, + type: item.type, + name: item.name, + subtitle: item.subtitle, + data: item.data ?? null, + color: normalizeNullable(item.color), + folderId: normalizeNullable(item.folderId), + layout: normalizeNullable(item.layout), + lastModified: item.lastModified ?? null, + }); +} + +function itemsEqual(left: Item | undefined, right: Item | undefined): boolean { + if (!left || !right) return false; + return serializeComparableItem(left) === serializeComparableItem(right); +} + +export function didProjectedItemChange( + previousItem: Item | undefined, + nextItem: Item, +): boolean { + if (!previousItem) return true; + return !itemsEqual(previousItem, nextItem); +} + +function projectionRowToItem(row: { + itemId: string; + type: string; + name: string; + subtitle: string; + data: unknown; + color: string | null; + folderId: string | null; + layout: unknown; + lastModified: number | null; +}): Item { + return { + id: row.itemId, + type: row.type as Item["type"], + name: row.name, + subtitle: row.subtitle, + data: row.data as Item["data"], + ...(row.color ? { color: row.color as Item["color"] } : {}), + ...(row.folderId ? { folderId: row.folderId } : {}), + ...(row.layout ? { layout: row.layout as Item["layout"] } : {}), + ...(typeof row.lastModified === "number" + ? { lastModified: row.lastModified } + : {}), + }; +} + +function itemToProjectionRow( + workspaceId: string, + item: Item, + sourceVersion: number, +) { + return { + workspaceId, + itemId: item.id, + type: item.type, + name: item.name, + subtitle: item.subtitle, + data: item.data, + color: item.color ?? null, + folderId: item.folderId ?? null, + layout: item.layout ?? null, + lastModified: item.lastModified ?? null, + sourceVersion, + updatedAt: new Date().toISOString(), + }; +} + +async function getWorkspaceProjectionVersion( + workspaceId: string, + executor: DbExecutor = db, +): Promise { + const [state] = await executor + .select({ + lastAppliedVersion: workspaceItemProjectionState.lastAppliedVersion, + }) + .from(workspaceItemProjectionState) + .where(eq(workspaceItemProjectionState.workspaceId, workspaceId)) + .limit(1); + + return state?.lastAppliedVersion ?? null; +} + +export async function getWorkspaceEventVersion( + workspaceId: string, + executor: DbExecutor = db, +): Promise { + const result = await executor.execute(sql` + SELECT get_workspace_version(${workspaceId}::uuid) as version + `); + + const version = Number(result[0]?.version ?? 0); + return Number.isFinite(version) ? version : 0; +} + +export async function loadProjectedWorkspaceItems( + workspaceId: string, + executor: DbExecutor = db, +): Promise { + const rows = await executor + .select({ + itemId: workspaceItems.itemId, + type: workspaceItems.type, + name: workspaceItems.name, + subtitle: workspaceItems.subtitle, + data: workspaceItems.data, + color: workspaceItems.color, + folderId: workspaceItems.folderId, + layout: workspaceItems.layout, + lastModified: workspaceItems.lastModified, + }) + .from(workspaceItems) + .where(eq(workspaceItems.workspaceId, workspaceId)) + .orderBy(asc(workspaceItems.itemId)); + + return rows.map(projectionRowToItem); +} + +async function isProjectionCurrent(workspaceId: string): Promise { + const [projectionVersion, currentVersion] = await Promise.all([ + getWorkspaceProjectionVersion(workspaceId), + getWorkspaceEventVersion(workspaceId), + ]); + + return projectionVersion !== null && projectionVersion === currentVersion; +} + +export async function loadWorkspaceItems( + workspaceId: string, +): Promise { + if (await isProjectionCurrent(workspaceId)) { + return loadProjectedWorkspaceItems(workspaceId); + } + + const state = await loadWorkspaceState(workspaceId); + return state.items; +} + +export async function loadWorkspaceItemsState( + workspaceId: string, +): Promise> { + return { + items: await loadWorkspaceItems(workspaceId), + }; +} + +export async function loadWorkspaceCurrentState( + workspaceId: string, +): Promise { + return { + ...initialState, + workspaceId, + items: await loadWorkspaceItems(workspaceId), + }; +} + +export function applyEventToProjectedItems( + previousItems: Item[], + workspaceId: string, + event: WorkspaceEvent, +): Item[] { + const previousState: WorkspaceState = { + ...initialState, + workspaceId, + items: previousItems, + }; + + return eventReducer(previousState, event).items; +} + +async function upsertProjectionCheckpoint( + workspaceId: string, + version: number, + executor: DbExecutor = db, +) { + await executor + .insert(workspaceItemProjectionState) + .values({ + workspaceId, + lastAppliedVersion: version, + updatedAt: new Date().toISOString(), + }) + .onConflictDoUpdate({ + target: workspaceItemProjectionState.workspaceId, + set: { + lastAppliedVersion: version, + updatedAt: new Date().toISOString(), + }, + }); +} + +async function syncProjectedItems( + workspaceId: string, + previousItems: Item[], + nextItems: Item[], + sourceVersion: number, + executor: DbExecutor = db, +) { + const previousItemsById = new Map(previousItems.map((item) => [item.id, item])); + const previousIds = new Set(previousItems.map((item) => item.id)); + const nextIds = new Set(nextItems.map((item) => item.id)); + + const deletedIds = [...previousIds].filter((itemId) => !nextIds.has(itemId)); + if (deletedIds.length > 0) { + await executor + .delete(workspaceItems) + .where( + and( + eq(workspaceItems.workspaceId, workspaceId), + inArray(workspaceItems.itemId, deletedIds), + ), + ); + } + + const changedProjectionRows = nextItems.flatMap((item) => { + const previousItem = previousItemsById.get(item.id); + + if (!didProjectedItemChange(previousItem, item)) { + return []; + } + + return [ + itemToProjectionRow( + workspaceId, + item, + sourceVersion, + ), + ]; + }); + + if (changedProjectionRows.length > 0) { + await executor + .insert(workspaceItems) + .values(changedProjectionRows) + .onConflictDoUpdate({ + target: [workspaceItems.workspaceId, workspaceItems.itemId], + set: { + type: sql`excluded.type`, + name: sql`excluded.name`, + subtitle: sql`excluded.subtitle`, + data: sql`excluded.data`, + color: sql`excluded.color`, + folderId: sql`excluded.folder_id`, + layout: sql`excluded.layout`, + lastModified: sql`excluded.last_modified`, + sourceVersion: sql`excluded.source_version`, + updatedAt: sql`excluded.updated_at`, + }, + }); + } +} + +export async function projectWorkspaceEvent( + workspaceId: string, + event: WorkspaceEvent, + executor: DbExecutor = db, +): Promise { + if (typeof event.version !== "number") { + throw new Error( + `Cannot project workspace event ${event.id} without a persisted version`, + ); + } + + const previousItems = await loadProjectedWorkspaceItems(workspaceId, executor); + const nextItems = applyEventToProjectedItems(previousItems, workspaceId, event); + + await syncProjectedItems( + workspaceId, + previousItems, + nextItems, + event.version, + executor, + ); + await upsertProjectionCheckpoint(workspaceId, event.version, executor); +} + +export async function backfillWorkspaceItemsProjection( + workspaceId: string, + executor: DbExecutor = db, +): Promise<{ workspaceId: string; version: number; itemCount: number }> { + const [state, version] = await Promise.all([ + loadWorkspaceState(workspaceId), + getWorkspaceEventVersion(workspaceId, executor), + ]); + + await executor.transaction(async (tx: DbExecutor) => { + await tx + .delete(workspaceItems) + .where(eq(workspaceItems.workspaceId, workspaceId)); + + if (state.items.length > 0) { + await tx.insert(workspaceItems).values( + state.items.map((item) => itemToProjectionRow(workspaceId, item, version)), + ); + } + + await upsertProjectionCheckpoint(workspaceId, version, tx); + }); + + return { + workspaceId, + version, + itemCount: state.items.length, + }; +} + +export async function backfillAllWorkspaceItemsProjection(): Promise<{ + workspaceCount: number; + totalItems: number; +}> { + const rows = await db + .select({ id: workspaces.id }) + .from(workspaces) + .orderBy(asc(workspaces.id)); + + let totalItems = 0; + for (const row of rows) { + const result = await backfillWorkspaceItemsProjection(row.id); + totalItems += result.itemCount; + } + + return { + workspaceCount: rows.length, + totalItems, + }; +} + +export async function reconcileWorkspaceItemsProjection(workspaceId: string): Promise<{ + matches: boolean; + replayedCount: number; + projectedCount: number; + missingItemIds: string[]; + extraItemIds: string[]; + mismatchedItemIds: string[]; +}> { + const [replayedState, projectedItems] = await Promise.all([ + loadWorkspaceState(workspaceId), + loadProjectedWorkspaceItems(workspaceId), + ]); + + const replayedMap = new Map(replayedState.items.map((item) => [item.id, item])); + const projectedMap = new Map(projectedItems.map((item) => [item.id, item])); + + const missingItemIds = replayedState.items + .filter((item) => !projectedMap.has(item.id)) + .map((item) => item.id); + const extraItemIds = projectedItems + .filter((item) => !replayedMap.has(item.id)) + .map((item) => item.id); + const mismatchedItemIds = replayedState.items + .filter((item) => { + const projected = projectedMap.get(item.id); + return projected && !itemsEqual(item, projected); + }) + .map((item) => item.id); + + return { + matches: + missingItemIds.length === 0 && + extraItemIds.length === 0 && + mismatchedItemIds.length === 0, + replayedCount: replayedState.items.length, + projectedCount: projectedItems.length, + missingItemIds, + extraItemIds, + mismatchedItemIds, + }; +} From 64672a7095e43bc23e66e0f61df9b85c44dedd81 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:37:00 -0400 Subject: [PATCH 03/11] refactor(workspace): remove snapshot runtime and legacy workspace events Delete snapshot API routes and snapshot-manager; drop snapshot/metadata event handling from reducer and types. Simplify undo route without snapshot cleanup. Update state-loader, templates, import validation, and bulk-items reducer test. Made-with: Cursor --- .../api/workspaces/[id]/events/undo/route.ts | 31 ++- src/app/api/workspaces/[id]/snapshot/route.ts | 137 ------------- .../api/workspaces/[id]/snapshots/route.ts | 45 ----- .../event-reducer.bulk-items-patched.test.ts | 5 +- src/lib/workspace/event-reducer.ts | 40 +--- src/lib/workspace/events.ts | 56 +----- src/lib/workspace/import-validation.ts | 13 +- src/lib/workspace/snapshot-manager.ts | 185 ------------------ src/lib/workspace/state-loader.ts | 40 +--- src/lib/workspace/templates.ts | 7 +- 10 files changed, 42 insertions(+), 517 deletions(-) delete mode 100644 src/app/api/workspaces/[id]/snapshot/route.ts delete mode 100644 src/app/api/workspaces/[id]/snapshots/route.ts delete mode 100644 src/lib/workspace/snapshot-manager.ts diff --git a/src/app/api/workspaces/[id]/events/undo/route.ts b/src/app/api/workspaces/[id]/events/undo/route.ts index 3b6d9cde..a7367da5 100644 --- a/src/app/api/workspaces/[id]/events/undo/route.ts +++ b/src/app/api/workspaces/[id]/events/undo/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db, workspaceEvents } from "@/lib/db/client"; import { eq, gt, and } from "drizzle-orm"; import { requireAuth, verifyWorkspaceAccess, withErrorHandling } from "@/lib/api/workspace-helpers"; +import { backfillWorkspaceItemsProjection } from "@/lib/workspace/workspace-items-projection"; /** * POST /api/workspaces/[id]/events/undo @@ -29,23 +30,21 @@ async function handlePOST( await verifyWorkspaceAccess(id, userId, "editor"); - // Don't allow reverting past version 1 (WORKSPACE_CREATED) - if (targetVersion < 1) { - return NextResponse.json( - { error: "Cannot revert past the initial workspace creation" }, - { status: 400 } - ); - } - - const result = await db - .delete(workspaceEvents) - .where( - and( - eq(workspaceEvents.workspaceId, id), - gt(workspaceEvents.version, targetVersion) + const result = await db.transaction(async (tx) => { + const deletedEvents = await tx + .delete(workspaceEvents) + .where( + and( + eq(workspaceEvents.workspaceId, id), + gt(workspaceEvents.version, targetVersion) + ) ) - ) - .returning({ version: workspaceEvents.version }); + .returning({ version: workspaceEvents.version }); + + return deletedEvents; + }); + + await backfillWorkspaceItemsProjection(id); return NextResponse.json({ success: true, diff --git a/src/app/api/workspaces/[id]/snapshot/route.ts b/src/app/api/workspaces/[id]/snapshot/route.ts deleted file mode 100644 index c7ece1df..00000000 --- a/src/app/api/workspaces/[id]/snapshot/route.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { - checkNeedsSnapshot, - createSnapshot, - checkAndCreateSnapshot -} from "@/lib/workspace/snapshot-manager"; -import { db, workspaces } from "@/lib/db/client"; -import { eq, sql } from "drizzle-orm"; -import { requireAuth, verifyWorkspaceAccess, withErrorHandling } from "@/lib/api/workspace-helpers"; - -/** - * GET /api/workspaces/[id]/snapshot - * Check snapshot status for a workspace (owner only) - */ -async function handleGET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - // Start independent operations in parallel - const paramsPromise = params; - const authPromise = requireAuth(); - - const { id } = await paramsPromise; - const userId = await authPromise; - - // Check if user has access (owner or collaborator) - const [workspace] = await db - .select() - .from(workspaces) - .where(eq(workspaces.id, id)) - .limit(1); - - if (!workspace) { - return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); - } - - await verifyWorkspaceAccess(id, userId, 'viewer'); - - // Check snapshot status using utility functions - const snapshotStatus = await checkNeedsSnapshot(id); - - // Get latest snapshot info - const latestSnapshot = await db.execute(sql` - SELECT * FROM get_latest_snapshot(${id}::uuid) - `); - - return NextResponse.json({ - workspace: { - id, - title: workspace.name, - }, - snapshot: latestSnapshot[0] || null, - status: snapshotStatus, - }); -} - -export const GET = withErrorHandling(handleGET, "GET /api/workspaces/[id]/snapshot"); - -/** - * POST /api/workspaces/[id]/snapshot - * Manually create a snapshot for a workspace (owner only) - */ -async function handlePOST( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - // Start independent operations in parallel - const paramsPromise = params; - const authPromise = requireAuth(); - - const { id } = await paramsPromise; - const userId = await authPromise; - - // Check if user has editor access (owner or editor collaborator) - await verifyWorkspaceAccess(id, userId, 'editor'); - - // Create snapshot using utility function - const result = await createSnapshot(id); - - if (!result.success) { - return NextResponse.json( - { error: result.error || "Failed to create snapshot" }, - { status: 500 } - ); - } - - return NextResponse.json({ - success: true, - version: result.version, - message: `Snapshot created at version ${result.version}`, - }); -} - -export const POST = withErrorHandling(handlePOST, "POST /api/workspaces/[id]/snapshot"); - -/** - * PUT /api/workspaces/[id]/snapshot - * Check and create snapshot if needed (owner only) - */ -async function handlePUT( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - // Start independent operations in parallel - const paramsPromise = params; - const authPromise = requireAuth(); - - const { id } = await paramsPromise; - const userId = await authPromise; - - // Check if user has editor access (owner or editor collaborator) - await verifyWorkspaceAccess(id, userId, 'editor'); - - // Check and create snapshot if needed - const statusBefore = await checkNeedsSnapshot(id); - - if (!statusBefore.needsSnapshot) { - return NextResponse.json({ - message: "Snapshot not needed yet", - status: statusBefore, - }); - } - - // Create snapshot if needed - await checkAndCreateSnapshot(id); - - const statusAfter = await checkNeedsSnapshot(id); - - return NextResponse.json({ - message: "Snapshot check complete", - created: true, - before: statusBefore, - after: statusAfter, - }); -} - -export const PUT = withErrorHandling(handlePUT, "PUT /api/workspaces/[id]/snapshot"); diff --git a/src/app/api/workspaces/[id]/snapshots/route.ts b/src/app/api/workspaces/[id]/snapshots/route.ts deleted file mode 100644 index 5fab0302..00000000 --- a/src/app/api/workspaces/[id]/snapshots/route.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import type { SnapshotInfo } from "@/lib/workspace/events"; -import { db, workspaceSnapshots } from "@/lib/db/client"; -import { eq, desc } from "drizzle-orm"; -import { requireAuth, verifyWorkspaceAccess, withErrorHandling } from "@/lib/api/workspace-helpers"; - -/** - * GET /api/workspaces/[id]/snapshots - * Fetch all snapshots for a workspace (for version history) - * Note: Owner only (sharing is fork-based) - */ -async function handleGET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - // Start independent operations in parallel - const paramsPromise = params; - const authPromise = requireAuth(); - - const { id } = await paramsPromise; - const userId = await authPromise; - - // Check if user has access (owner or collaborator) - await verifyWorkspaceAccess(id, userId, 'viewer'); - - // Get ALL snapshots for version history - const allSnapshotsData = await db - .select() - .from(workspaceSnapshots) - .where(eq(workspaceSnapshots.workspaceId, id)) - .orderBy(desc(workspaceSnapshots.snapshotVersion)); - - const snapshots: SnapshotInfo[] = allSnapshotsData.map(s => ({ - id: s.id, - version: s.snapshotVersion, - eventCount: s.eventCount, - createdAt: s.createdAt || '', - // Include state for restoration - state: s.state as any, - })); - - return NextResponse.json({ snapshots }); -} - -export const GET = withErrorHandling(handleGET, "GET /api/workspaces/[id]/snapshots"); diff --git a/src/lib/workspace/__tests__/event-reducer.bulk-items-patched.test.ts b/src/lib/workspace/__tests__/event-reducer.bulk-items-patched.test.ts index ae6099d1..c921ebeb 100644 --- a/src/lib/workspace/__tests__/event-reducer.bulk-items-patched.test.ts +++ b/src/lib/workspace/__tests__/event-reducer.bulk-items-patched.test.ts @@ -1,12 +1,11 @@ import { describe, expect, it } from "vitest"; import { eventReducer } from "../event-reducer"; -import type { AgentState, ImageData, Item, PdfData } from "@/lib/workspace-state/types"; +import type { ImageData, Item, PdfData, WorkspaceState } from "@/lib/workspace-state/types"; import type { WorkspaceEvent } from "../events"; describe("eventReducer BULK_ITEMS_PATCHED", () => { it("deep merges OCR changes across multiple items in one event", () => { - const state: AgentState = { - globalTitle: "Test", + const state: WorkspaceState = { workspaceId: "ws-1", items: [ { diff --git a/src/lib/workspace/event-reducer.ts b/src/lib/workspace/event-reducer.ts index 88de84d7..8ab06ebd 100644 --- a/src/lib/workspace/event-reducer.ts +++ b/src/lib/workspace/event-reducer.ts @@ -1,4 +1,4 @@ -import type { AgentState } from "@/lib/workspace-state/types"; +import type { WorkspaceState } from "@/lib/workspace-state/types"; import type { WorkspaceEvent } from "./events"; import { initialState } from "@/lib/workspace-state/state"; @@ -7,16 +7,10 @@ import { initialState } from "@/lib/workspace-state/state"; * This is the heart of event sourcing - state is derived by reducing events */ export function eventReducer( - state: AgentState, + state: WorkspaceState, event: WorkspaceEvent, -): AgentState { +): WorkspaceState { switch (event.type) { - case "WORKSPACE_CREATED": - return { - ...state, - globalTitle: event.payload.title, - }; - case "ITEM_CREATED": { const item = event.payload.item; const now = event.timestamp || Date.now(); @@ -95,24 +89,6 @@ export function eventReducer( }; } - case "GLOBAL_TITLE_SET": - return { - ...state, - globalTitle: event.payload.title, - }; - - case "GLOBAL_DESCRIPTION_SET": - // No-op: globalDescription removed from state - return state; - - case "WORKSPACE_SNAPSHOT": - // Used for migration from old workspace_states table - // Replaces entire state with snapshot - return { - ...event.payload, - workspaceId: state.workspaceId, // Preserve workspace ID - }; - case "BULK_ITEMS_UPDATED": { const p = event.payload; const now = event.timestamp || Date.now(); @@ -283,21 +259,21 @@ export function eventReducer( * * @param events - Events to replay * @param workspaceId - Workspace ID to set in state - * @param snapshotState - Optional snapshot state to start from (optimization) + * @param baseState - Optional starting state for offline utilities that resume from a known checkpoint */ export function replayEvents( events: WorkspaceEvent[], workspaceId?: string, - snapshotState?: AgentState, -): AgentState { + baseState?: WorkspaceState, +): WorkspaceState { const replayStart = performance.now(); - const baseState = snapshotState || { + const initialReplayState = baseState || { ...initialState, workspaceId: workspaceId || initialState.workspaceId, }; - const finalState = events.reduce(eventReducer, baseState); + const finalState = events.reduce(eventReducer, initialReplayState); const replayTime = performance.now() - replayStart; // Only log if replay is slow (>50ms) or if we're replaying many events (>100) diff --git a/src/lib/workspace/events.ts b/src/lib/workspace/events.ts index 35c90fd8..95526497 100644 --- a/src/lib/workspace/events.ts +++ b/src/lib/workspace/events.ts @@ -1,4 +1,4 @@ -import type { Item, AgentState } from "@/lib/workspace-state/types"; +import type { Item } from "@/lib/workspace-state/types"; import type { CardColor } from "@/lib/workspace-state/colors"; /** Payload shape for historical FOLDER_* events (folders are items today; replay still parses these). */ type FolderEventRecord = { @@ -15,14 +15,6 @@ type FolderEventRecord = { */ type WorkspaceEventBase = - | { - type: "WORKSPACE_CREATED"; - payload: { title: string; description: string }; - timestamp: number; - userId: string; - userName?: string; - id: string; - } | { type: "ITEM_CREATED"; payload: { id: string; item: Item }; @@ -61,30 +53,6 @@ type WorkspaceEventBase = userName?: string; id: string; } - | { - type: "GLOBAL_TITLE_SET"; - payload: { title: string }; - timestamp: number; - userId: string; - userName?: string; - id: string; - } - | { - type: "GLOBAL_DESCRIPTION_SET"; - payload: { description: string }; - timestamp: number; - userId: string; - userName?: string; - id: string; - } - | { - type: "WORKSPACE_SNAPSHOT"; - payload: AgentState; - timestamp: number; - userId: string; - userName?: string; - id: string; - } | { type: "BULK_ITEMS_UPDATED"; payload: { @@ -184,23 +152,6 @@ export interface EventLog { workspaceId: string; events: WorkspaceEvent[]; version: number; // Increments with each event - snapshot?: { - version: number; - state: AgentState; - }; - snapshots?: SnapshotInfo[]; // All snapshots for version history -} - -/** - * Snapshot metadata for version history - * Note: Different from WorkspaceSnapshot as it uses 'version' instead of 'snapshotVersion' - */ -export interface SnapshotInfo { - id: string; - version: number; - eventCount: number; - createdAt: string; - state: AgentState; } /** @@ -211,11 +162,6 @@ export interface EventResponse { version: number; conflict?: boolean; currentEvents?: WorkspaceEvent[]; - snapshot?: { - version: number; - state: AgentState; - }; - snapshots?: SnapshotInfo[]; // All snapshots for version history } /** diff --git a/src/lib/workspace/import-validation.ts b/src/lib/workspace/import-validation.ts index 8a948289..195b938e 100644 --- a/src/lib/workspace/import-validation.ts +++ b/src/lib/workspace/import-validation.ts @@ -1,9 +1,9 @@ -import type { AgentState, Item, CardType } from "@/lib/workspace-state/types"; +import type { WorkspaceState, Item, CardType } from "@/lib/workspace-state/types"; export interface ValidationResult { isValid: boolean; error?: string; - data?: AgentState; + data?: WorkspaceState; } const VALID_CARD_TYPES: CardType[] = ["document", "pdf", "flashcard"]; @@ -44,9 +44,8 @@ export function validateImportedJSON(jsonString: string): ValidationResult { } } - const validatedState: AgentState = { + const validatedState: WorkspaceState = { items: parsed.items, - globalTitle: typeof parsed.globalTitle === 'string' ? parsed.globalTitle : "", }; return { @@ -104,7 +103,7 @@ function validateItem(item: any, index: number): string | null { return null; } -export function generateImportPreview(state: AgentState): string { +export function generateImportPreview(state: WorkspaceState): string { const itemCounts = state.items.reduce((acc, item) => { acc[item.type] = (acc[item.type] || 0) + 1; return acc; @@ -112,10 +111,6 @@ export function generateImportPreview(state: AgentState): string { const parts: string[] = []; - if (state.globalTitle) { - parts.push(`Title: "${state.globalTitle}"`); - } - if (state.items.length > 0) { const itemSummary = Object.entries(itemCounts) .map(([type, count]) => `${count} ${type}${count > 1 ? 's' : ''}`) diff --git a/src/lib/workspace/snapshot-manager.ts b/src/lib/workspace/snapshot-manager.ts deleted file mode 100644 index c166ffb6..00000000 --- a/src/lib/workspace/snapshot-manager.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { replayEvents } from "./event-reducer"; -import type { WorkspaceEvent } from "./events"; -import type { AgentState } from "@/lib/workspace-state/types"; -import { db, workspaceEvents, workspaceSnapshots } from "@/lib/db/client"; -import { eq, asc, desc, gt, and, sql } from "drizzle-orm"; - -/** - * Configuration for snapshot creation - */ -export const SNAPSHOT_CONFIG = { - // Create a snapshot every N events - // Snapshot creation is async and non-blocking, so more frequent snapshots - // improve load times without impacting user experience - EVENTS_PER_SNAPSHOT: 20, - // Keep N most recent snapshots per workspace - MAX_SNAPSHOTS_PER_WORKSPACE: 3, -}; - -/** - * Check if a workspace needs a new snapshot - */ -export async function checkNeedsSnapshot( - workspaceId: string, - threshold: number = SNAPSHOT_CONFIG.EVENTS_PER_SNAPSHOT -): Promise<{ - needsSnapshot: boolean; - currentVersion: number; - lastSnapshotVersion: number; - eventsSinceSnapshot: number; -}> { - const result = await db.execute(sql` - SELECT * FROM needs_snapshot(${workspaceId}::uuid, ${threshold}::integer) - `); - - if (!result || result.length === 0) { - // Default to not needing snapshot if there's an error - return { - needsSnapshot: false, - currentVersion: 0, - lastSnapshotVersion: 0, - eventsSinceSnapshot: 0, - }; - } - - const data = result[0] as any; - return { - needsSnapshot: data.needs_snapshot, - currentVersion: data.current_version, - lastSnapshotVersion: data.last_snapshot_version, - eventsSinceSnapshot: data.events_since_snapshot, - }; -} - -/** - * Create a snapshot for a workspace - * This fetches all events, replays them to derive state, and saves the snapshot - */ -export async function createSnapshot( - workspaceId: string -): Promise<{ success: boolean; version?: number; error?: string }> { - try { - // 1. Get the latest snapshot to use as a baseline - const latestSnapshot = await db - .select() - .from(workspaceSnapshots) - .where(eq(workspaceSnapshots.workspaceId, workspaceId)) - .orderBy(desc(workspaceSnapshots.snapshotVersion)) - .limit(1); - - let baseState: AgentState | undefined; - let startVersion = 0; - - if (latestSnapshot && latestSnapshot.length > 0) { - baseState = latestSnapshot[0].state as AgentState; - startVersion = latestSnapshot[0].snapshotVersion; - } - - // 2. Fetch only new events since the last snapshot - // Use keyset pagination (seeking) instead of OFFSET for better performance - const PAGE_SIZE = 1000; - let allEvents: any[] = []; - let hasMore = true; - let lastSeenVersion = startVersion; - - while (hasMore) { - const pageData = await db - .select() - .from(workspaceEvents) - .where( - and( - eq(workspaceEvents.workspaceId, workspaceId), - gt(workspaceEvents.version, lastSeenVersion) - ) - ) - .orderBy(asc(workspaceEvents.version)) - .limit(PAGE_SIZE); - - if (pageData.length > 0) { - allEvents = allEvents.concat(pageData); - lastSeenVersion = pageData[pageData.length - 1].version; - } - - hasMore = pageData.length === PAGE_SIZE; - } - - if (allEvents.length === 0) { - // No new events to snapshot - return { success: true, version: startVersion }; - } - - // 3. Transform to WorkspaceEvent format - const events: WorkspaceEvent[] = allEvents.map((e) => ({ - type: e.eventType as WorkspaceEvent["type"], - payload: e.payload, - timestamp: e.timestamp, - userId: e.userId, - userName: e.userName || undefined, - id: e.eventId, - version: e.version, - })); - - // 4. Replay events to derive current state - // If we have a base state, replayEvents will apply new events on top of it - const state: AgentState = replayEvents(events, workspaceId, baseState); - - // Get max version - const maxVersion = Math.max(...allEvents.map((e) => e.version)); - - // Calculate total event count (previous count + new events) - let totalEventCount = allEvents.length; - if (latestSnapshot && latestSnapshot.length > 0) { - totalEventCount += latestSnapshot[0].eventCount; - } - - // Save snapshot using the PostgreSQL function - // Note: create_workspace_snapshot returns a UUID (snapshot ID) on success - const result = await db.execute(sql` - SELECT create_workspace_snapshot( - ${workspaceId}::uuid, - ${JSON.stringify(state)}::jsonb, - ${maxVersion}::integer, - ${totalEventCount}::integer - ) as result - `); - - if (!result || result.length === 0) { - return { success: false, error: "Failed to create snapshot: no result from database" }; - } - - const snapshotId = result[0].result as string | null | undefined; - - // The function returns a UUID on success, null/undefined on failure - if (!snapshotId || typeof snapshotId !== 'string' || snapshotId.trim() === '') { - console.error("Failed to create snapshot: function returned invalid result:", snapshotId); - return { success: false, error: "Failed to create snapshot: invalid snapshot ID returned" }; - } - - return { success: true, version: maxVersion }; - } catch (error: any) { - console.error("Error creating snapshot:", error); - return { success: false, error: error.message }; - } -} - -/** - * Check and create snapshot if needed - * This is the main function to call after appending events - */ -export async function checkAndCreateSnapshot( - workspaceId: string -): Promise { - try { - const snapshotCheck = await checkNeedsSnapshot(workspaceId); - - if (snapshotCheck.needsSnapshot) { - const result = await createSnapshot(workspaceId); - if (!result.success) { - console.error(`[SNAPSHOT] Failed to create snapshot:`, result.error); - } - } - } catch (error) { - // Don't throw - snapshot creation is an optimization, not critical - console.error(`[SNAPSHOT] Error in checkAndCreateSnapshot for workspace ${workspaceId}:`, error); - } -} diff --git a/src/lib/workspace/state-loader.ts b/src/lib/workspace/state-loader.ts index 1e74a24f..5e14b321 100644 --- a/src/lib/workspace/state-loader.ts +++ b/src/lib/workspace/state-loader.ts @@ -1,40 +1,21 @@ -import { db, workspaceEvents, workspaceSnapshots } from "@/lib/db/client"; -import { eq, asc, desc, gt, and } from "drizzle-orm"; +import { db, workspaceEvents } from "@/lib/db/client"; +import { eq, asc } from "drizzle-orm"; import { replayEvents } from "./event-reducer"; -import type { AgentState } from "@/lib/workspace-state/types"; +import type { WorkspaceState } from "@/lib/workspace-state/types"; import type { WorkspaceEvent } from "./events"; /** - * Load current workspace state by replaying events from the latest snapshot - * This replaces direct reads from workspace_states table + * Load current workspace state by replaying the full event stream. + * Snapshots are deprecated from the normal read path; recovery tooling can still + * use them explicitly when needed. */ -export async function loadWorkspaceState(workspaceId: string): Promise { +export async function loadWorkspaceState(workspaceId: string): Promise { try { - // Get the latest snapshot for this workspace (highest version number) - const latestSnapshot = await db - .select() - .from(workspaceSnapshots) - .where(eq(workspaceSnapshots.workspaceId, workspaceId)) - .orderBy(desc(workspaceSnapshots.snapshotVersion)) - .limit(1); - - let baseState: AgentState | undefined; - let fromVersion = 0; - - if (latestSnapshot[0]) { - baseState = latestSnapshot[0].state as AgentState; - fromVersion = latestSnapshot[0].snapshotVersion; - } - - // Get all events since the snapshot (or all events if no snapshot) + // Replay the authoritative event stream directly. const events = await db .select() .from(workspaceEvents) - .where( - fromVersion > 0 - ? and(eq(workspaceEvents.workspaceId, workspaceId), gt(workspaceEvents.version, fromVersion)) - : eq(workspaceEvents.workspaceId, workspaceId) - ) + .where(eq(workspaceEvents.workspaceId, workspaceId)) .orderBy(asc(workspaceEvents.version)); // Transform to WorkspaceEvent format @@ -49,7 +30,7 @@ export async function loadWorkspaceState(workspaceId: string): Promise { @@ -68,7 +67,6 @@ export const WORKSPACE_TEMPLATES: TemplateDefinition[] = [ layout: { x: 2, y: 0, w: 2, h: 5 }, } ], - globalTitle: "", }, }; })(), @@ -84,10 +82,9 @@ export function getTemplateByType(template: string): TemplateDefinition { /** * Get initial state for a template */ -export function getTemplateInitialState(template: string): AgentState { +export function getTemplateInitialState(template: string): WorkspaceState { const templateDef = getTemplateByType(template); return { items: templateDef.initialState.items || [], - globalTitle: templateDef.initialState.globalTitle || "", }; } From dbbfc5f6d27048845cb2a38b9f826a279757b592 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:37:04 -0400 Subject: [PATCH 04/11] feat(api): projection-backed workspace state and streamlined events Load current state via workspace_items projection; return activity metadata from GET /api/workspaces/[id]. Wire event append through workspace-event-store; remove snapshot checks from events route. Align share, slug, create, and autogen routes. Made-with: Cursor --- src/app/api/share/[id]/route.ts | 12 +- src/app/api/workspaces/[id]/events/route.ts | 182 ++------------------ src/app/api/workspaces/[id]/route.ts | 41 +++-- src/app/api/workspaces/autogen/route.ts | 19 -- src/app/api/workspaces/route.ts | 96 ++--------- src/app/api/workspaces/slug/[slug]/route.ts | 12 +- src/lib/realtime/server-broadcast.ts | 5 +- 7 files changed, 75 insertions(+), 292 deletions(-) diff --git a/src/app/api/share/[id]/route.ts b/src/app/api/share/[id]/route.ts index cf05c2b0..b1dad747 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 { loadWorkspaceCurrentState } from "@/lib/workspace/workspace-items-projection"; /** * GET /api/share/[id] @@ -26,13 +26,9 @@ export async function GET( return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); } - // Get workspace state by replaying events - const state = await loadWorkspaceState(id); - - // Ensure state has workspace metadata if empty - if (!state.globalTitle) { - state.globalTitle = workspace[0].name || ""; - } + const state = await loadWorkspaceCurrentState( + id, + ); // Return workspace data for forking (public access) return NextResponse.json({ diff --git a/src/app/api/workspaces/[id]/events/route.ts b/src/app/api/workspaces/[id]/events/route.ts index dab5ea0b..e983aca5 100644 --- a/src/app/api/workspaces/[id]/events/route.ts +++ b/src/app/api/workspaces/[id]/events/route.ts @@ -1,63 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; import type { WorkspaceEvent, EventResponse } from "@/lib/workspace/events"; -import { checkAndCreateSnapshot } from "@/lib/workspace/snapshot-manager"; - -/** Strip heavy OCR page payloads from OCR-backed items — client refetches item state as needed. */ -function stripOcrPagesFromItem(item: { type?: string; data?: unknown }): void { - if ( - (item?.type === "pdf" || item?.type === "image") && - item.data && - typeof item.data === "object" - ) { - const d = item.data as Record; - delete d.ocrPages; - } -} - -function stripOcrPagesFromState( - state: { items?: Array<{ type?: string; data?: unknown }> } | undefined, -): void { - state?.items?.forEach(stripOcrPagesFromItem); -} - -function stripPdfOcrFromEventPayload(event: WorkspaceEvent): void { - const p = event.payload as Record; - if (event.type === "ITEM_CREATED" && p?.item) - stripOcrPagesFromItem(p.item as { type?: string; data?: unknown }); - if (event.type === "ITEM_UPDATED") { - const changes = p?.changes as Record | undefined; - if (changes?.data && typeof changes.data === "object") { - const d = changes.data as Record; - delete d.ocrPages; - } - } - if (event.type === "BULK_ITEMS_PATCHED") { - const updates = p?.updates as - | Array<{ changes?: { data?: unknown } }> - | undefined; - updates?.forEach((update) => { - if (update?.changes?.data && typeof update.changes.data === "object") { - const d = update.changes.data as Record; - delete d.ocrPages; - } - }); - } - if (event.type === "BULK_ITEMS_UPDATED") { - ( - p?.addedItems as Array<{ type?: string; data?: unknown }> | undefined - )?.forEach(stripOcrPagesFromItem); - (p?.items as Array<{ type?: string; data?: unknown }> | undefined)?.forEach( - stripOcrPagesFromItem, - ); - } - if (event.type === "WORKSPACE_SNAPSHOT" && p?.items) { - (p.items as Array<{ type?: string; data?: unknown }>).forEach( - stripOcrPagesFromItem, - ); - } -} - -import { loadWorkspaceState } from "@/lib/workspace/state-loader"; import { hasDuplicateName } from "@/lib/workspace/unique-name"; import { db, workspaceEvents } from "@/lib/db/client"; import { eq, gt, asc, sql, and } from "drizzle-orm"; @@ -67,6 +9,10 @@ import { withErrorHandling, } from "@/lib/api/workspace-helpers"; import { broadcastWorkspaceEventFromServer } from "@/lib/realtime/server-broadcast"; +import { loadWorkspaceItemsState } from "@/lib/workspace/workspace-items-projection"; +import { + appendWorkspaceEventWithProjection, +} from "@/lib/workspace/workspace-event-store"; /** * GET /api/workspaces/[id]/events @@ -95,49 +41,15 @@ async function handleGET( await verifyWorkspaceAccess(id, userId, "viewer"); timings.workspaceCheck = Date.now() - workspaceCheckStart; - // Get only the latest snapshot (not all snapshots - loaded on demand for version history) - // Use optimized function that bypasses RLS (access already verified above) - const snapshotStart = Date.now(); - const latestSnapshotData = await db.execute(sql` - SELECT - id, - snapshot_version as "snapshotVersion", - state, - event_count as "eventCount", - created_at as "createdAt" - FROM get_latest_snapshot_fast(${id}::uuid) - `); - timings.snapshotFetch = Date.now() - snapshotStart; - - const latestSnapshot = latestSnapshotData[0] as - | { - id?: string; - snapshotVersion?: number; - state?: any; - eventCount?: number; - createdAt?: string; - } - | undefined; - const snapshotVersion = - typeof latestSnapshot?.snapshotVersion === "number" - ? latestSnapshot.snapshotVersion - : 0; - - // Check how many events we need to fetch + // Snapshots are deprecated from the normal event-history path. const countStart = Date.now(); const eventCountResult = await db .select({ count: sql`count(*)::int` }) .from(workspaceEvents) - .where( - and( - eq(workspaceEvents.workspaceId, id), - gt(workspaceEvents.version, snapshotVersion), - ), - ); + .where(eq(workspaceEvents.workspaceId, id)); const eventCount = eventCountResult[0]?.count ?? 0; timings.countQuery = Date.now() - countStart; - // Only fetch events AFTER the snapshot version const PAGE_SIZE = 1000; let eventsData: any[] = []; @@ -147,7 +59,6 @@ async function handleGET( // If we have fewer events than PAGE_SIZE, fetch all at once (no pagination needed) const eventsFetchStart = Date.now(); - // Use optimized function that bypasses RLS (access already verified above) const queryStart = Date.now(); const fastQueryResult = await db.execute(sql` SELECT @@ -160,7 +71,7 @@ async function handleGET( version FROM get_workspace_events_fast( ${id}::uuid, - ${snapshotVersion}::integer, + ${0}::integer, ${PAGE_SIZE}::integer ) `); @@ -198,7 +109,6 @@ async function handleGET( version FROM workspace_events WHERE workspace_id = ${id}::uuid - AND version > ${snapshotVersion} ORDER BY version ASC LIMIT ${PAGE_SIZE} OFFSET ${page * PAGE_SIZE} @@ -245,27 +155,11 @@ async function handleGET( const maxVersion = eventsData && eventsData.length > 0 ? Math.max(...eventsData.map((e) => e.version)) - : snapshotVersion || 0; - - // Strip heavy OCR page payloads — client doesn't need them in event responses. - if (latestSnapshot?.state) - stripOcrPagesFromState( - latestSnapshot.state as { - items?: Array<{ type?: string; data?: unknown }>; - }, - ); - events.forEach(stripPdfOcrFromEventPayload); + : 0; const response: EventResponse = { events, version: maxVersion, - snapshot: - latestSnapshot && typeof latestSnapshot.snapshotVersion === "number" - ? { - version: latestSnapshot.snapshotVersion, - state: latestSnapshot.state as any, - } - : undefined, }; const totalTime = Date.now() - startTime; @@ -323,7 +217,7 @@ async function handlePOST( if (event.type === "ITEM_CREATED") { const item = event.payload?.item; if (item?.name != null && item?.type) { - const state = await loadWorkspaceState(id); + const state = await loadWorkspaceItemsState(id); if ( hasDuplicateName( state.items, @@ -345,7 +239,7 @@ async function handlePOST( const itemId = event.payload?.id; const newName = event.payload.changes.name; if (itemId && newName) { - const state = await loadWorkspaceState(id); + const state = await loadWorkspaceItemsState(id); const existingItem = state.items.find( (i: { id: string }) => i.id === itemId, ); @@ -374,48 +268,13 @@ async function handlePOST( // Use the append function to handle versioning and conflicts const appendStart = Date.now(); - const result = await db.execute(sql` - SELECT append_workspace_event( - ${id}::uuid, - ${event.id}::text, - ${event.type}::text, - ${JSON.stringify(event.payload)}::jsonb, - ${event.timestamp}::bigint, - ${event.userId}::text, - ${baseVersion}::integer, - ${event.userName || null}::text - ) as result - `); + const appendResult = await appendWorkspaceEventWithProjection({ + workspaceId: id, + event, + baseVersion, + }); timings.appendFunction = Date.now() - appendStart; - if (!result || result.length === 0 || !result[0]) { - return NextResponse.json( - { error: "Failed to append event" }, - { status: 500 }, - ); - } - - // PostgreSQL returns result as string like "(6,t)" - need to parse it - const rawResult = result[0].result as string; - - // Parse the PostgreSQL tuple format "(version,conflict)" - const match = rawResult.match(/\((\d+),(t|f)\)/); - if (!match) { - console.error( - `[POST /api/workspaces/${id}/events] Failed to parse PostgreSQL result:`, - rawResult, - ); - return NextResponse.json( - { error: "Invalid database response" }, - { status: 500 }, - ); - } - - const appendResult = { - version: parseInt(match[1], 10), - conflict: match[2] === "t", - }; - // Check for conflict if (appendResult.conflict) { // Fetch current events for client to merge @@ -445,7 +304,6 @@ async function handlePOST( id: e.eventId, }) as WorkspaceEvent, ); - events.forEach(stripPdfOcrFromEventPayload); const totalTime = Date.now() - startTime; timings.total = totalTime; @@ -457,16 +315,6 @@ async function handlePOST( }); } - // Success - no conflict - // Check if we need to create a snapshot (async, non-blocking) - checkAndCreateSnapshot(id).catch((err) => { - console.error( - `[POST /api/workspaces/${id}/events] Failed to create snapshot:`, - err, - ); - // Don't fail the request if snapshot creation fails - }); - await broadcastWorkspaceEventFromServer(id, { ...event, version: appendResult.version, diff --git a/src/app/api/workspaces/[id]/route.ts b/src/app/api/workspaces/[id]/route.ts index 96847343..e950a10f 100644 --- a/src/app/api/workspaces/[id]/route.ts +++ b/src/app/api/workspaces/[id]/route.ts @@ -1,9 +1,10 @@ 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 { db, workspaceEvents, workspaces } from "@/lib/db/client"; +import { eq, and, ne, sql } from "drizzle-orm"; import { requireAuth, verifyWorkspaceOwnership, verifyWorkspaceAccess, withErrorHandling } from "@/lib/api/workspace-helpers"; import { generateSlug } from "@/lib/workspace/slug"; +import { loadWorkspaceCurrentState } from "@/lib/workspace/workspace-items-projection"; +import { EMPTY_WORKSPACE_ACTIVITY_SUMMARY } from "@/lib/workspace/workspace-activity"; /** * GET /api/workspaces/[id] @@ -35,18 +36,36 @@ async function handleGET( return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); } - // Get workspace state by replaying events - const state = await loadWorkspaceState(id); - - // Ensure state has workspace metadata if empty - if (!state.globalTitle) { - state.globalTitle = workspace.name || ""; - } + // Current-state reads now prefer the item projection while workspace name stays canonical in workspaces.name. + const [state, activityRow] = await Promise.all([ + loadWorkspaceCurrentState(id), + db + .select({ + version: sql`coalesce(max(${workspaceEvents.version}), 0)::int`, + eventCount: sql`count(*)::int`, + lastEventAt: sql`max(${workspaceEvents.timestamp})`, + }) + .from(workspaceEvents) + .where(eq(workspaceEvents.workspaceId, id)) + .limit(1), + ]); + + const activity = activityRow[0] + ? { + version: Number(activityRow[0].version ?? 0), + eventCount: Number(activityRow[0].eventCount ?? 0), + lastEventAt: + activityRow[0].lastEventAt == null + ? null + : Number(activityRow[0].lastEventAt), + } + : EMPTY_WORKSPACE_ACTIVITY_SUMMARY; return NextResponse.json({ workspace: { ...workspace, state, + activity, isShared: !accessInfo.isOwner, permissionLevel: accessInfo.permissionLevel, }, @@ -158,7 +177,7 @@ async function handleDELETE( // Check ownership await verifyWorkspaceOwnership(id, userId); - // Delete workspace (cascade will delete events and snapshots) + // Delete workspace (cascade also removes any legacy snapshot rows still in the DB) await db .delete(workspaces) .where(eq(workspaces.id, id)); diff --git a/src/app/api/workspaces/autogen/route.ts b/src/app/api/workspaces/autogen/route.ts index 80800f2f..fff0a28d 100644 --- a/src/app/api/workspaces/autogen/route.ts +++ b/src/app/api/workspaces/autogen/route.ts @@ -3,7 +3,6 @@ import { google } from "@ai-sdk/google"; import { streamText, generateText, Output } from "ai"; import { z } from "zod"; import { executeWebSearch } from "@/lib/ai/tools/web-search"; -import { randomUUID } from "crypto"; import { desc, eq, sql } from "drizzle-orm"; import { requireAuthWithUserInfo } from "@/lib/api/workspace-helpers"; import { db, workspaces } from "@/lib/db/client"; @@ -468,24 +467,6 @@ export async function POST(request: NextRequest) { timings.workspaceCreateMs = Date.now() - phase0Start; logger.info("[AUTOGEN] Workspace created (placeholder)", { ms: timings.workspaceCreateMs, workspaceId }); - // Create WORKSPACE_CREATED event - try { - await db.execute(sql` - SELECT append_workspace_event( - ${workspaceId}::uuid, - ${randomUUID()}::text, - ${"WORKSPACE_CREATED"}::text, - ${JSON.stringify({ title: "New Workspace", description: "" })}::jsonb, - ${Date.now()}::bigint, - ${userId}::text, - 0::integer, - ${user.name || user.email || null}::text - ) - `); - } catch (eventError) { - logger.error("[AUTOGEN] Error creating WORKSPACE_CREATED event:", eventError); - } - // Create PDF and image items immediately so OCR can start (runs in parallel with Phase 1) // Seed with known content-item positions so files are placed around them (matching pre-restructuring layout) // Always reserve YouTube footprint so a later searched YouTube item (AUTOGEN_LAYOUTS.youtube) never overlaps early PDFs/images diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts index dc2509d2..aa7f5969 100644 --- a/src/app/api/workspaces/route.ts +++ b/src/app/api/workspaces/route.ts @@ -6,8 +6,9 @@ import type { CardColor } from "@/lib/workspace-state/colors"; import { randomUUID } from "crypto"; import { db, workspaces } from "@/lib/db/client"; import { workspaceCollaborators } from "@/lib/db/schema"; -import { eq, desc, asc, sql, inArray } from "drizzle-orm"; +import { eq, desc, asc, inArray } from "drizzle-orm"; import { requireAuth, requireAuthWithUserInfo, withErrorHandling } from "@/lib/api/workspace-helpers"; +import { appendWorkspaceEventWithProjection } from "@/lib/workspace/workspace-event-store"; /** * GET /api/workspaces @@ -224,91 +225,30 @@ async function handlePOST(request: NextRequest) { // Use provided initial state (already validated on client side) initialState = customInitialState; initialState.workspaceId = workspace.id; - if (!initialState.globalTitle) { - initialState.globalTitle = name; - } } else { // Use template-based initial state initialState = getTemplateInitialState(effectiveTemplate); initialState.workspaceId = workspace.id; - initialState.globalTitle = name; } - // Note: No need to create workspace_states record - state is now managed via events - - // Determine if we need a WORKSPACE_SNAPSHOT (has items) or just WORKSPACE_CREATED (empty) - const hasItems = initialState.items && initialState.items.length > 0; - - // For workspaces with items (custom or template-based), create WORKSPACE_SNAPSHOT - // This sets the entire state in one event, including title, description, and items - if (customInitialState || hasItems) { - const eventId = randomUUID(); - const timestamp = Date.now(); - - // Use user info from requireAuthWithUserInfo to avoid duplicate session fetch - const userName = user.name || user.email || undefined; - - // Create snapshot event first (starts at version 0, creates version 1) - try { - await db.execute(sql` - SELECT append_workspace_event( - ${workspace.id}::uuid, - ${eventId}::text, - ${'WORKSPACE_SNAPSHOT'}::text, - ${JSON.stringify(initialState)}::jsonb, - ${timestamp}::bigint, - ${userId}::text, - ${0}::integer, - ${userName || null}::text - ) - `); - - // If snapshot creation succeeded, we're done (it includes title/description) - // No need for separate WORKSPACE_CREATED event - } catch (eventError) { - console.error("Error creating WORKSPACE_SNAPSHOT event:", eventError); - // If snapshot fails, create WORKSPACE_CREATED as fallback - try { - const createdEventId = randomUUID(); - const createdTimestamp = Date.now(); - await db.execute(sql` - SELECT append_workspace_event( - ${workspace.id}::uuid, - ${createdEventId}::text, - ${'WORKSPACE_CREATED'}::text, - ${JSON.stringify({ title: name, description: description || "" })}::jsonb, - ${createdTimestamp}::bigint, - ${userId}::text, - ${0}::integer, - ${null}::text - ) - `); - } catch (createdError) { - console.error("Error creating WORKSPACE_CREATED event:", createdError); - } - } - } else { - // For empty workspaces, just create WORKSPACE_CREATED event + // Workspace metadata now lives directly on the `workspaces` row. + // Only seed the event log if the workspace starts with actual items. + if (initialState.items.length > 0) { try { - const eventId = randomUUID(); - const timestamp = Date.now(); - - await db.execute(sql` - SELECT append_workspace_event( - ${workspace.id}::uuid, - ${eventId}::text, - ${'WORKSPACE_CREATED'}::text, - ${JSON.stringify({ title: name, description: description || "" })}::jsonb, - ${timestamp}::bigint, - ${userId}::text, - ${0}::integer, - ${null}::text - ) - `); + await appendWorkspaceEventWithProjection({ + workspaceId: workspace.id, + event: { + id: randomUUID(), + type: "BULK_ITEMS_CREATED", + payload: { items: initialState.items }, + timestamp: Date.now(), + userId, + userName: user.name || user.email || undefined, + }, + baseVersion: 0, + }); } catch (eventError) { - // If event creation fails, continue – workspace exists in DB, - // but event-sourced title/description may be missing - console.error("Error creating WORKSPACE_CREATED event:", eventError); + console.error("Error creating initial BULK_ITEMS_CREATED event:", eventError); } } diff --git a/src/app/api/workspaces/slug/[slug]/route.ts b/src/app/api/workspaces/slug/[slug]/route.ts index 7945148d..1f4b2027 100644 --- a/src/app/api/workspaces/slug/[slug]/route.ts +++ b/src/app/api/workspaces/slug/[slug]/route.ts @@ -2,8 +2,8 @@ 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 { requireAuth, withErrorHandling } from "@/lib/api/workspace-helpers"; +import { loadWorkspaceCurrentState } from "@/lib/workspace/workspace-items-projection"; /** * GET /api/workspaces/slug/[slug] @@ -99,13 +99,9 @@ async function handleGET( }); } - // Get workspace state by replaying events (full mode) - const state = await loadWorkspaceState(workspace.id); - - // Ensure state has workspace metadata if empty - if (!state.globalTitle) { - state.globalTitle = workspace.name || ""; - } + const state = await loadWorkspaceCurrentState( + workspace.id, + ); return NextResponse.json({ workspace: { diff --git a/src/lib/realtime/server-broadcast.ts b/src/lib/realtime/server-broadcast.ts index a4d8bc5d..99087b22 100644 --- a/src/lib/realtime/server-broadcast.ts +++ b/src/lib/realtime/server-broadcast.ts @@ -48,7 +48,10 @@ export async function broadcastWorkspaceEventFromServer( try { // REST broadcast from the server (no WebSocket). See https://supabase.com/docs/guides/realtime/broadcast - const result = await channel.httpSend("workspace_event", event); + const result = await channel.httpSend( + "workspace_event", + event, + ); if (!result.success) { logger.error("[REALTIME] httpSend broadcast failed", { workspaceId, From c3d32a70b931944339f3f7e75a960372cf964ca1 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:37:08 -0400 Subject: [PATCH 05/11] refactor(ui): WorkspaceState hooks, activity metadata, and globalTitle cleanup useWorkspaceState uses projection-backed API and activity for version/last saved. Rename AgentState to WorkspaceState in components; prefer workspace name over globalTitle. Simplify version history and workspace operations cache shape. Made-with: Cursor --- src/app/dashboard/page.tsx | 72 +++--- src/app/share-copy/[id]/layout.tsx | 6 +- src/app/share-copy/[id]/page.tsx | 4 +- .../assistant-ui/AssistantPanel.tsx | 2 +- .../assistant-ui/CreateDocumentToolUI.tsx | 4 +- .../assistant-ui/CreateFlashcardToolUI.tsx | 4 +- .../assistant-ui/CreateQuizToolUI.tsx | 4 +- .../workspace-canvas/WorkspaceContent.tsx | 4 +- .../workspace-canvas/WorkspaceSection.tsx | 4 +- .../workspace/CreateWorkspaceModal.tsx | 4 +- .../workspace/SharedWorkspaceModal.tsx | 4 +- src/components/workspace/SidebarCardList.tsx | 2 +- .../workspace/VersionHistoryModal.tsx | 28 +-- .../ai/use-workspace-context-provider.ts | 6 +- src/hooks/ui/use-reactive-navigation.ts | 4 +- src/hooks/workspace/use-create-workspace.ts | 6 +- src/hooks/workspace/use-workspace-events.ts | 16 +- src/hooks/workspace/use-workspace-history.ts | 9 +- src/hooks/workspace/use-workspace-mutation.ts | 232 ++++++++++++------ .../workspace/use-workspace-operations.ts | 150 ++--------- src/hooks/workspace/use-workspace-realtime.ts | 39 ++- src/hooks/workspace/use-workspace-state.ts | 94 +++---- src/lib/workspace-state/state.ts | 7 +- src/lib/workspace-state/types.ts | 5 +- 24 files changed, 338 insertions(+), 372 deletions(-) diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 6f40da03..e154e470 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -55,6 +55,10 @@ import { getDocumentUploadPartialMessage, getDocumentUploadSuccessMessage, } from "@/lib/uploads/upload-feedback"; +import { + getWorkspaceLastSavedAt, + shouldShowAnonymousSignInPrompt, +} from "@/lib/workspace/workspace-activity"; // Main dashboard content component interface DashboardContentProps { @@ -101,6 +105,8 @@ function DashboardContent({ state, isLoading: isLoadingWorkspace, version, + eventCount, + lastEventAt, } = useWorkspaceState(currentWorkspaceId); // Open audio recorder when landing from home Record flow (store flag set before navigate). @@ -145,7 +151,16 @@ function DashboardContent({ // Version control (history only) const { revertToVersion: revertToVersionRaw } = useWorkspaceHistory(currentWorkspaceId); - const { data: eventLog } = useWorkspaceEvents(currentWorkspaceId); + + // Workspace settings/share modals (lifted so header can open them) + const [showVersionHistory, setShowVersionHistory] = useState(false); + const [showWorkspaceSettings, setShowWorkspaceSettings] = useState(false); + const [showWorkspaceShare, setShowWorkspaceShare] = useState(false); + + const shouldLoadEventLog = !!currentWorkspaceId && showVersionHistory; + const { data: eventLog } = useWorkspaceEvents(currentWorkspaceId, { + enabled: shouldLoadEventLog, + }); // Track sign-in prompt dismissal per workspace for anonymous users. const [ @@ -153,18 +168,13 @@ function DashboardContent({ setDismissedSignInPromptWorkspaceId, ] = useState(null); - // Workspace settings/share modals (lifted so header can open them) - const [showWorkspaceSettings, setShowWorkspaceSettings] = useState(false); - const [showWorkspaceShare, setShowWorkspaceShare] = useState(false); - const [showVersionHistory, setShowVersionHistory] = useState(false); - - const showSignInPrompt = - !!session?.user?.isAnonymous && - !!eventLog && - !isLoadingWorkspace && - !!currentWorkspaceId && - (eventLog.events?.length ?? 0) >= 15 && - dismissedSignInPromptWorkspaceId !== currentWorkspaceId; + const showSignInPrompt = shouldShowAnonymousSignInPrompt({ + isAnonymous: !!session?.user?.isAnonymous, + isLoadingWorkspace, + currentWorkspaceId, + dismissedWorkspaceId: dismissedSignInPromptWorkspaceId, + eventCount, + }); // Get sidebar state and controls const { toggleSidebar } = useSidebar(); @@ -184,43 +194,17 @@ function DashboardContent({ updateLastSaved, ]); - // Mark as saved when workspace is loaded from events + // Mark as saved when workspace state finishes loading. useEffect(() => { if (!isLoadingWorkspace && currentWorkspaceId && state.items) { - // 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); + updateLastSaved(getWorkspaceLastSavedAt(lastEventAt)); updateHasUnsavedChanges(false); } }, [ isLoadingWorkspace, currentWorkspaceId, state.items, - eventLog, + lastEventAt, updateLastSaved, updateHasUnsavedChanges, ]); @@ -395,7 +379,7 @@ function DashboardContent({ isDesktop={isDesktop} isChatExpanded={isChatExpanded} setIsChatExpanded={setIsChatExpanded} - workspaceName={currentWorkspaceTitle || state.globalTitle} + workspaceName={currentWorkspaceTitle || "Workspace"} workspaceIcon={currentWorkspaceIcon} workspaceColor={currentWorkspaceColor} addItem={operations.createItem} @@ -484,7 +468,7 @@ function DashboardContent({ events={eventLog?.events || []} currentVersion={version} onRevertToVersion={revertToVersion} - items={currentWorkspace?.state?.items || []} + items={state.items} /> { }; } - // Fetch full state to get potentially updated title/description - const state = await loadWorkspaceState(id); - - const title = state.globalTitle || workspace[0].name || "Untitled Workspace"; + const title = workspace[0].name || "Untitled Workspace"; const sharedTitle = `Shared Workspace: ${title}`; const description = workspace[0].description || "View and import this shared ThinkEx workspace."; const fullTitle = getPageTitle(sharedTitle); diff --git a/src/app/share-copy/[id]/page.tsx b/src/app/share-copy/[id]/page.tsx index a547b8dc..c691c623 100644 --- a/src/app/share-copy/[id]/page.tsx +++ b/src/app/share-copy/[id]/page.tsx @@ -6,7 +6,7 @@ import { useSession, signIn } from "@/lib/auth-client"; import SharedWorkspaceModal from "@/components/workspace/SharedWorkspaceModal"; import { AuthPageBackground } from "@/components/auth/AuthPageBackground"; import { Loader2 } from "lucide-react"; -import type { AgentState } from "@/lib/workspace-state/types"; +import type { WorkspaceState } from "@/lib/workspace-state/types"; import type { CardColor } from "@/lib/workspace-state/colors"; interface SharedWorkspaceData { @@ -16,7 +16,7 @@ interface SharedWorkspaceData { description: string; icon: string | null; color: CardColor | null; - state: AgentState; + state: WorkspaceState; }; } diff --git a/src/components/assistant-ui/AssistantPanel.tsx b/src/components/assistant-ui/AssistantPanel.tsx index 42f51227..de1bb199 100644 --- a/src/components/assistant-ui/AssistantPanel.tsx +++ b/src/components/assistant-ui/AssistantPanel.tsx @@ -121,7 +121,7 @@ function WorkspaceContextWrapperContent({ // Extract workspace items for context display const items = state?.items || []; - // Workspace name from DB (fallback when state.globalTitle is empty, e.g. after rename in Settings) + // Workspace name comes from the canonical `workspaces` row. const { currentWorkspace } = useWorkspaceContext(); // Inject minimal workspace context (metadata and system instructions only) diff --git a/src/components/assistant-ui/CreateDocumentToolUI.tsx b/src/components/assistant-ui/CreateDocumentToolUI.tsx index 143a9410..413d0bf2 100644 --- a/src/components/assistant-ui/CreateDocumentToolUI.tsx +++ b/src/components/assistant-ui/CreateDocumentToolUI.tsx @@ -222,9 +222,7 @@ function CreateDocumentToolRenderer({ status={status} moveItemToFolder={operations.moveItemToFolder} allItems={(workspaceState?.items ?? []) as Item[]} - workspaceName={ - currentWorkspace?.name || workspaceState?.globalTitle || "Workspace" - } + workspaceName={currentWorkspace?.name || "Workspace"} workspaceIcon={currentWorkspace?.icon} workspaceColor={currentWorkspace?.color} /> diff --git a/src/components/assistant-ui/CreateFlashcardToolUI.tsx b/src/components/assistant-ui/CreateFlashcardToolUI.tsx index fefc6c71..719a890f 100644 --- a/src/components/assistant-ui/CreateFlashcardToolUI.tsx +++ b/src/components/assistant-ui/CreateFlashcardToolUI.tsx @@ -283,9 +283,7 @@ function CreateFlashcardToolRenderer({ status={status} moveItemToFolder={operations.moveItemToFolder} allItems={workspaceState?.items || []} - workspaceName={ - currentWorkspace?.name || workspaceState?.globalTitle || "Workspace" - } + workspaceName={currentWorkspace?.name || "Workspace"} workspaceIcon={currentWorkspace?.icon} workspaceColor={currentWorkspace?.color} /> diff --git a/src/components/assistant-ui/CreateQuizToolUI.tsx b/src/components/assistant-ui/CreateQuizToolUI.tsx index 064d2c88..98dd883d 100644 --- a/src/components/assistant-ui/CreateQuizToolUI.tsx +++ b/src/components/assistant-ui/CreateQuizToolUI.tsx @@ -253,9 +253,7 @@ function CreateQuizToolRenderer({ status={status as ToolStatus} moveItemToFolder={operations.moveItemToFolder} allItems={workspaceState?.items || []} - workspaceName={ - currentWorkspace?.name || workspaceState?.globalTitle || "Workspace" - } + workspaceName={currentWorkspace?.name || "Workspace"} workspaceIcon={currentWorkspace?.icon} workspaceColor={currentWorkspace?.color} /> diff --git a/src/components/workspace-canvas/WorkspaceContent.tsx b/src/components/workspace-canvas/WorkspaceContent.tsx index 268246d6..7202446e 100644 --- a/src/components/workspace-canvas/WorkspaceContent.tsx +++ b/src/components/workspace-canvas/WorkspaceContent.tsx @@ -3,7 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { Plus, Upload } from "lucide-react"; import { EmptyState } from "@/components/empty-state"; -import type { AgentState, Item, CardType } from "@/lib/workspace-state/types"; +import type { WorkspaceState, Item, CardType } from "@/lib/workspace-state/types"; import { filterItemsByFolder } from "@/lib/workspace-state/search"; import { useAutoScroll } from "@/hooks/ui/use-auto-scroll"; import { WorkspaceGrid } from "./WorkspaceGrid"; @@ -17,7 +17,7 @@ import { } from "@/lib/uploads/workspace-upload-config"; interface WorkspaceContentProps { - viewState: AgentState; + viewState: WorkspaceState; addItem: (type: CardType, name?: string, initialData?: Partial) => string; updateItem: (itemId: string, updates: Partial) => void; deleteItem: (itemId: string) => void; diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx index 0b6ff3cc..73e485bf 100644 --- a/src/components/workspace-canvas/WorkspaceSection.tsx +++ b/src/components/workspace-canvas/WorkspaceSection.tsx @@ -1,7 +1,7 @@ import React, { RefObject, useState, useCallback } from "react"; import { useSearchParams } from "next/navigation"; import { toast } from "sonner"; -import type { AgentState, Item, CardType } from "@/lib/workspace-state/types"; +import type { WorkspaceState, Item, CardType } from "@/lib/workspace-state/types"; import { DEFAULT_CARD_DIMENSIONS } from "@/lib/workspace-state/grid-layout-helpers"; import type { WorkspaceOperations } from "@/hooks/workspace/use-workspace-operations"; import WorkspaceContent from "./WorkspaceContent"; @@ -73,7 +73,7 @@ interface WorkspaceSectionProps { // Workspace state currentWorkspaceId: string | null; currentSlug: string | null; - state: AgentState; + state: WorkspaceState; // Operations addItem: (type: CardType, name?: string, initialData?: Partial) => string; diff --git a/src/components/workspace/CreateWorkspaceModal.tsx b/src/components/workspace/CreateWorkspaceModal.tsx index 3487d9f5..2b98e7bd 100644 --- a/src/components/workspace/CreateWorkspaceModal.tsx +++ b/src/components/workspace/CreateWorkspaceModal.tsx @@ -26,7 +26,7 @@ import { SWATCHES_COLOR_GROUPS, type CardColor } from "@/lib/workspace-state/col import { validateImportedJSON, generateImportPreview, type ValidationResult } from "@/lib/workspace/import-validation"; import { Textarea } from "@/components/ui/textarea"; import { Upload } from "lucide-react"; -import type { AgentState } from "@/lib/workspace-state/types"; +import type { WorkspaceState } from "@/lib/workspace-state/types"; interface Workspace { @@ -47,7 +47,7 @@ interface CreateWorkspaceModalProps { description?: string; icon?: string | null; color?: CardColor | null; - initialState?: AgentState; + initialState?: WorkspaceState; }; } diff --git a/src/components/workspace/SharedWorkspaceModal.tsx b/src/components/workspace/SharedWorkspaceModal.tsx index c6bd81e3..f52d9a10 100644 --- a/src/components/workspace/SharedWorkspaceModal.tsx +++ b/src/components/workspace/SharedWorkspaceModal.tsx @@ -14,7 +14,7 @@ import { IconRenderer } from "@/hooks/use-icon-picker"; import { SwatchesPicker, ColorResult } from "react-color"; import { SWATCHES_COLOR_GROUPS, type CardColor } from "@/lib/workspace-state/colors"; import { Skeleton } from "@/components/ui/skeleton"; -import type { AgentState } from "@/lib/workspace-state/types"; +import type { WorkspaceState } from "@/lib/workspace-state/types"; import { useCreateWorkspace } from "@/hooks/workspace/use-create-workspace"; interface SharedWorkspaceData { @@ -24,7 +24,7 @@ interface SharedWorkspaceData { description: string; icon: string | null; color: CardColor | null; - state: AgentState; + state: WorkspaceState; }; } diff --git a/src/components/workspace/SidebarCardList.tsx b/src/components/workspace/SidebarCardList.tsx index b776b63f..57b10733 100644 --- a/src/components/workspace/SidebarCardList.tsx +++ b/src/components/workspace/SidebarCardList.tsx @@ -779,7 +779,7 @@ function SidebarCardList() { }, [workspaces, currentWorkspaceId]); // Get workspace operations for delete functionality - const operations = useWorkspaceOperations(currentWorkspaceId, state || { items: [], workspaceId: currentWorkspaceId || '', globalTitle: '' }); + const operations = useWorkspaceOperations(currentWorkspaceId, state || { items: [], workspaceId: currentWorkspaceId || '' }); const handleDeleteItem = useCallback( async (itemId: string) => { diff --git a/src/components/workspace/VersionHistoryModal.tsx b/src/components/workspace/VersionHistoryModal.tsx index 07825a54..80fe7eb8 100644 --- a/src/components/workspace/VersionHistoryModal.tsx +++ b/src/components/workspace/VersionHistoryModal.tsx @@ -9,7 +9,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Clock, Undo2, Camera, FolderPlus, Plus, Pencil, Trash2, Folder, FolderInput } from "lucide-react"; +import { Clock, Undo2, FolderPlus, Plus, Pencil, Trash2, Folder, FolderInput } from "lucide-react"; import { CgNotes } from "react-icons/cg"; import { BsFillGrid1X2Fill } from "react-icons/bs"; import type { WorkspaceEvent } from "@/lib/workspace/events"; @@ -36,8 +36,6 @@ function formatItemType(type: string): string { function getEventIcon(event: WorkspaceEvent) { const iconClass = "h-5 w-5 shrink-0"; switch (event.type) { - case 'WORKSPACE_CREATED': - return ; case 'ITEM_CREATED': return ; case 'ITEM_UPDATED': @@ -46,9 +44,6 @@ function getEventIcon(event: WorkspaceEvent) { return ; case 'ITEM_DELETED': return ; - case 'GLOBAL_TITLE_SET': - case 'GLOBAL_DESCRIPTION_SET': - return ; case 'BULK_ITEMS_UPDATED': { const p = event.payload as { deletedIds?: string[]; addedItems?: Item[]; items?: Item[]; previousItemCount?: number }; if (p.deletedIds?.length || (p.items && p.previousItemCount !== undefined && p.previousItemCount > p.items.length)) { @@ -58,8 +53,6 @@ function getEventIcon(event: WorkspaceEvent) { } case 'BULK_ITEMS_CREATED': return ; - case 'WORKSPACE_SNAPSHOT': - return ; case 'FOLDER_CREATED': return ; case 'FOLDER_UPDATED': @@ -78,10 +71,6 @@ function getEventIcon(event: WorkspaceEvent) { function getEventDescription(event: WorkspaceEvent, items?: any[]): string { switch (event.type) { - case 'WORKSPACE_CREATED': { - const title = event.payload.title?.trim(); - return title ? `Created workspace "${title}"` : 'Workspace created'; - } case 'ITEM_CREATED': return `Created ${formatItemType(event.payload.item.type)}: "${event.payload.item.name}"`; case 'ITEM_UPDATED': { @@ -113,10 +102,6 @@ function getEventDescription(event: WorkspaceEvent, items?: any[]): string { const itemTitle = event.payload.name ?? `item ${event.payload.id}`; return `Deleted "${itemTitle}"`; } - case 'GLOBAL_TITLE_SET': - return `Set title to "${event.payload.title}"`; - case 'GLOBAL_DESCRIPTION_SET': - return `Set description to "${event.payload.description}"`; case 'BULK_ITEMS_UPDATED': { const p = event.payload as { deletedIds?: string[]; addedItems?: Item[]; items?: Item[]; layoutUpdates?: unknown[]; previousItemCount?: number }; if (p.deletedIds?.length) { @@ -162,8 +147,6 @@ function getEventDescription(event: WorkspaceEvent, items?: any[]): string { } } } - case 'WORKSPACE_SNAPSHOT': - return 'Saved workspace snapshot'; case 'FOLDER_CREATED': { const name = event.payload.folder?.name; return name ? `Created folder "${name}"` : 'Created folder'; @@ -248,7 +231,7 @@ export function VersionHistoryContent({ const handleRevert = async (eventVersion: number) => { // Revert TO the state before this event (undo the event they clicked on) const targetVersion = eventVersion - 1; - if (targetVersion < 1) return; // WORKSPACE_CREATED is v1, can't go before it + if (targetVersion < 0) return; setIsReverting(true); try { await onRevertToVersion(targetVersion); @@ -304,15 +287,12 @@ export function VersionHistoryContent({ {reversedEvents.map((event) => { const eventVersion = event.version ?? 0; - const isWorkspaceCreated = event.type === 'WORKSPACE_CREATED'; - return (
@@ -339,7 +319,7 @@ export function VersionHistoryContent({
- {eventVersion > 1 && !isWorkspaceCreated && ( + {eventVersion > 0 && ( confirmingVersion === eventVersion ? (
diff --git a/src/components/modals/PDFViewerModal.tsx b/src/components/modals/PDFViewerModal.tsx index d0dc22df..fbf2c09a 100644 --- a/src/components/modals/PDFViewerModal.tsx +++ b/src/components/modals/PDFViewerModal.tsx @@ -59,6 +59,7 @@ export function PDFViewerModal({ onUpdateItemData={(updater) => onUpdateItem({ data: updater(item.data) as Item["data"] }) } + onUpdateItemUserStateData={() => {}} /> diff --git a/src/components/workspace-canvas/CardRenderer.tsx b/src/components/workspace-canvas/CardRenderer.tsx index 70ebbb8b..5cae787c 100644 --- a/src/components/workspace-canvas/CardRenderer.tsx +++ b/src/components/workspace-canvas/CardRenderer.tsx @@ -4,9 +4,6 @@ import type { Item, ItemData, PdfData, - FlashcardData, - YouTubeData, - ImageData, DocumentData, } from "@/lib/workspace-state/types"; import { DocumentEditor } from "@/components/editor/DocumentEditor"; @@ -20,10 +17,11 @@ import { QuizContent } from "./QuizContent"; export function CardRenderer(props: { item: Item; onUpdateData: (updater: (prev: ItemData) => ItemData) => void; + onUpdateUserStateData?: (updater: (prev: ItemData) => ItemData) => void; layoutKey?: string | number; quizClassName?: string; // Optional padding/className for quiz when shown in modal }) { - const { item, onUpdateData, quizClassName } = props; + const { item, onUpdateData, onUpdateUserStateData, quizClassName } = props; if (item.type === "pdf") { const pdfData = item.data as PdfData; @@ -47,7 +45,7 @@ export function CardRenderer(props: { return ( ); diff --git a/src/components/workspace-canvas/ItemPanelContent.tsx b/src/components/workspace-canvas/ItemPanelContent.tsx index 8cca3add..e2d8b021 100644 --- a/src/components/workspace-canvas/ItemPanelContent.tsx +++ b/src/components/workspace-canvas/ItemPanelContent.tsx @@ -22,6 +22,7 @@ interface ItemPanelContentProps { onMaximize: () => void; onUpdateItem: (updates: Partial) => void; onUpdateItemData: (updater: (prev: ItemData) => ItemData) => void; + onUpdateItemUserStateData: (updater: (prev: ItemData) => ItemData) => void; } export function ItemPanelContent({ @@ -30,6 +31,7 @@ export function ItemPanelContent({ onMaximize, onUpdateItem, onUpdateItemData, + onUpdateItemUserStateData, }: ItemPanelContentProps) { const isChatExpanded = useUIStore((state) => state.isChatExpanded); const setIsChatExpanded = useUIStore((state) => state.setIsChatExpanded); @@ -161,7 +163,7 @@ export function ItemPanelContent({ ) : isYouTube ? ( ) : isWebsite ? ( )} diff --git a/src/components/workspace-canvas/OpenWorkspaceItemView.tsx b/src/components/workspace-canvas/OpenWorkspaceItemView.tsx index 34588f86..52e68a92 100644 --- a/src/components/workspace-canvas/OpenWorkspaceItemView.tsx +++ b/src/components/workspace-canvas/OpenWorkspaceItemView.tsx @@ -9,6 +9,10 @@ interface OpenWorkspaceItemViewProps { items: Item[]; onUpdateItem: (itemId: string, updates: Partial) => void; onUpdateItemData: (itemId: string, updater: (prev: ItemData) => ItemData) => void; + onUpdateItemUserStateData: ( + itemId: string, + updater: (prev: ItemData) => ItemData, + ) => void; onFlushPendingChanges: (itemId: string) => void; } @@ -20,6 +24,7 @@ export function OpenWorkspaceItemView({ items, onUpdateItem, onUpdateItemData, + onUpdateItemUserStateData, onFlushPendingChanges, }: OpenWorkspaceItemViewProps) { const openItems = useUIStore((state) => state.openItems); @@ -57,7 +62,9 @@ export function OpenWorkspaceItemView({ onClose={() => handleClose(currentItem.id)} onUpdateItem={(updates) => onUpdateItem(currentItem.id, updates)} onUpdateItemData={(updater) => onUpdateItemData(currentItem.id, updater)} - onFlushPendingChanges={onFlushPendingChanges} + onUpdateItemUserStateData={(updater) => + onUpdateItemUserStateData(currentItem.id, updater) + } /> ); } diff --git a/src/components/workspace-canvas/QuizContent.tsx b/src/components/workspace-canvas/QuizContent.tsx index da6693d8..e45a3879 100644 --- a/src/components/workspace-canvas/QuizContent.tsx +++ b/src/components/workspace-canvas/QuizContent.tsx @@ -11,12 +11,17 @@ import { useUIStore } from "@/lib/stores/ui-store"; interface QuizContentProps { item: Item; - onUpdateData: (updater: (prev: ItemData) => ItemData) => void; + onUpdateUserStateData: (updater: (prev: ItemData) => ItemData) => void; isScrollLocked?: boolean; className?: string; // Optional (e.g. padding when in modal) } -export function QuizContent({ item, onUpdateData, isScrollLocked = false, className }: QuizContentProps) { +export function QuizContent({ + item, + onUpdateUserStateData, + isScrollLocked = false, + className, +}: QuizContentProps) { const quizData = item.data as QuizData; const questions = quizData.questions || []; const aui = useAui(); @@ -78,7 +83,7 @@ export function QuizContent({ item, onUpdateData, isScrollLocked = false, classN setShowHint(false); // Clear completedAt since we're no longer complete - onUpdateData((prev) => { + onUpdateUserStateData((prev) => { const current = prev as QuizData; return { ...current, @@ -102,7 +107,7 @@ export function QuizContent({ item, onUpdateData, isScrollLocked = false, classN // Update refs for next comparison prevQuestionCountRef.current = currentCount; prevQuestionIdsRef.current = currentIds; - }, [questions, showResults, answeredQuestions.length, currentIndex, onUpdateData]); + }, [questions, showResults, answeredQuestions.length, currentIndex, onUpdateUserStateData]); // Check if current question was already answered const previousAnswer = useMemo(() => { @@ -123,7 +128,7 @@ export function QuizContent({ item, onUpdateData, isScrollLocked = false, classN // Persist session state const persistSession = useCallback((updates: Partial) => { - onUpdateData((prev) => { + onUpdateUserStateData((prev) => { const current = prev as QuizData; return { ...current, @@ -135,7 +140,7 @@ export function QuizContent({ item, onUpdateData, isScrollLocked = false, classN }, }; }); - }, [onUpdateData, currentIndex, answeredQuestions]); + }, [onUpdateUserStateData, currentIndex, answeredQuestions]); // Handle answer selection const handleSelectAnswer = (index: number) => { @@ -207,7 +212,7 @@ export function QuizContent({ item, onUpdateData, isScrollLocked = false, classN setShowHint(false); setAnsweredQuestions([]); setShowResults(false); - onUpdateData((prev) => { + onUpdateUserStateData((prev) => { const current = prev as QuizData; return { ...current, diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index 781ccd84..149e6e1e 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -30,6 +30,10 @@ interface WorkspaceCardProps { workspaceIcon?: string | null; workspaceColor?: string | null; onUpdateItem: (itemId: string, updates: Partial) => void; + onUpdateItemUserStateData: ( + itemId: string, + updater: (prev: Item["data"]) => Item["data"], + ) => void; onDeleteItem: (itemId: string) => void; onOpenModal: (itemId: string) => void; // NOTE: isSelected is now subscribed directly from the store to prevent @@ -48,6 +52,7 @@ function WorkspaceCard({ workspaceIcon, workspaceColor, onUpdateItem, + onUpdateItemUserStateData, onDeleteItem, onOpenModal, onMoveItem, @@ -494,10 +499,8 @@ function WorkspaceCard({ onSubtitleChange={handleSubtitleChange} onTitleFocus={handleTitleFocus} onTitleBlur={handleTitleBlur} - onUpdateItemData={(updater) => - onUpdateItem(item.id, { - data: updater(item.data) as Item["data"], - }) + onUpdateItemUserStateData={(updater) => + onUpdateItemUserStateData(item.id, updater) } /> diff --git a/src/components/workspace-canvas/WorkspaceCardContent.tsx b/src/components/workspace-canvas/WorkspaceCardContent.tsx index 8d6a6147..508bc3c0 100644 --- a/src/components/workspace-canvas/WorkspaceCardContent.tsx +++ b/src/components/workspace-canvas/WorkspaceCardContent.tsx @@ -37,7 +37,9 @@ interface WorkspaceCardContentProps { onSubtitleChange: (value: string) => void; onTitleFocus: () => void; onTitleBlur: () => void; - onUpdateItemData: (updater: (prev: Item["data"]) => Item["data"]) => void; + onUpdateItemUserStateData: ( + updater: (prev: Item["data"]) => Item["data"], + ) => void; } export function WorkspaceCardContent({ @@ -52,7 +54,7 @@ export function WorkspaceCardContent({ onSubtitleChange, onTitleFocus, onTitleBlur, - onUpdateItemData, + onUpdateItemUserStateData, }: WorkspaceCardContentProps) { const isCompactTextCard = (item.type === "pdf" || @@ -141,7 +143,7 @@ export function WorkspaceCardContent({
diff --git a/src/components/workspace-canvas/WorkspaceContent.tsx b/src/components/workspace-canvas/WorkspaceContent.tsx index 7202446e..991f462b 100644 --- a/src/components/workspace-canvas/WorkspaceContent.tsx +++ b/src/components/workspace-canvas/WorkspaceContent.tsx @@ -20,6 +20,10 @@ interface WorkspaceContentProps { viewState: WorkspaceState; addItem: (type: CardType, name?: string, initialData?: Partial) => string; updateItem: (itemId: string, updates: Partial) => void; + updateItemUserStateData: ( + itemId: string, + updater: (prev: Item["data"]) => Item["data"], + ) => void; deleteItem: (itemId: string) => void; updateAllItems: (items: Item[]) => void; openWorkspaceItem: (itemId: string | null) => void; @@ -40,6 +44,7 @@ export default function WorkspaceContent({ viewState, addItem, updateItem, + updateItemUserStateData, deleteItem, updateAllItems, openWorkspaceItem, @@ -303,6 +308,7 @@ export default function WorkspaceContent({ onDragStart={handleDragStart} onDragStop={handleDragStop} onUpdateItem={handleUpdateItem} + onUpdateItemUserStateData={updateItemUserStateData} onDeleteItem={handleDeleteItem} onUpdateAllItems={handleUpdateAllItems} onOpenModal={handleOpenModal} diff --git a/src/components/workspace-canvas/WorkspaceGrid.tsx b/src/components/workspace-canvas/WorkspaceGrid.tsx index ddfdc8e1..72f97e99 100644 --- a/src/components/workspace-canvas/WorkspaceGrid.tsx +++ b/src/components/workspace-canvas/WorkspaceGrid.tsx @@ -35,6 +35,10 @@ interface WorkspaceGridProps { onDragStart: () => void; onDragStop: (layout: LayoutItem[]) => void; onUpdateItem: (itemId: string, updates: Partial) => void; + onUpdateItemUserStateData: ( + itemId: string, + updater: (prev: Item["data"]) => Item["data"], + ) => void; onDeleteItem: (itemId: string) => void; onUpdateAllItems: (items: Item[]) => void; onOpenModal: (itemId: string) => void; @@ -61,6 +65,7 @@ function WorkspaceGridComponent({ onDragStart, onDragStop, onUpdateItem, + onUpdateItemUserStateData, onDeleteItem, onUpdateAllItems, onOpenModal, @@ -674,6 +679,7 @@ function WorkspaceGridComponent({ workspaceIcon={workspaceIcon} workspaceColor={workspaceColor} onUpdateItem={handleUpdateItem} + onUpdateItemUserStateData={onUpdateItemUserStateData} onDeleteItem={handleDeleteItem} onOpenModal={handleOpenModal} onMoveItem={onMoveItem} @@ -685,6 +691,7 @@ function WorkspaceGridComponent({ allItems, displayItems, handleUpdateItem, + onUpdateItemUserStateData, handleDeleteItem, handleOpenModal, onMoveItem, diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx index 73e485bf..53a70d06 100644 --- a/src/components/workspace-canvas/WorkspaceSection.tsx +++ b/src/components/workspace-canvas/WorkspaceSection.tsx @@ -1,7 +1,7 @@ import React, { RefObject, useState, useCallback } from "react"; import { useSearchParams } from "next/navigation"; import { toast } from "sonner"; -import type { WorkspaceState, Item, CardType } from "@/lib/workspace-state/types"; +import type { WorkspaceState, Item } from "@/lib/workspace-state/types"; import { DEFAULT_CARD_DIMENSIONS } from "@/lib/workspace-state/grid-layout-helpers"; import type { WorkspaceOperations } from "@/hooks/workspace/use-workspace-operations"; import WorkspaceContent from "./WorkspaceContent"; @@ -75,14 +75,8 @@ interface WorkspaceSectionProps { currentSlug: string | null; state: WorkspaceState; - // Operations - addItem: (type: CardType, name?: string, initialData?: Partial) => string; - updateItem: (itemId: string, updates: Partial) => void; - deleteItem: (itemId: string) => void; - updateAllItems: (items: Item[]) => void; - - // Full operations object for advanced functionality - operations?: WorkspaceOperations; + // Workspace operations + operations: WorkspaceOperations; // Layout state isChatMaximized: boolean; @@ -116,10 +110,7 @@ export function WorkspaceSection({ currentWorkspaceId, currentSlug, state, - addItem, - updateItem, - deleteItem, - updateAllItems, + operations, isChatMaximized, isDesktop, isChatExpanded, @@ -130,7 +121,6 @@ export function WorkspaceSection({ workspaceTitle, workspaceIcon, workspaceColor, - operations, openItemView, }: WorkspaceSectionProps) { // Card selection state from UI store @@ -167,13 +157,10 @@ export function WorkspaceSection({ const { handleCreatedItems } = useReactiveNavigation(state); const handleYouTubeCreate = useCallback((url: string, name: string, thumbnail?: string) => { - if (addItem) { - addItem("youtube", name, { url, thumbnail }); - } - }, [addItem]); + operations.createItem("youtube", name, { url, thumbnail }); + }, [operations]); const handleWebsiteCreate = useCallback((url: string, name: string, favicon?: string) => { - if (!operations) return; operations.createItems([{ type: 'website', name, @@ -182,6 +169,13 @@ export function WorkspaceSection({ }]); }, [operations]); + const handleUpdateItemUserStateData = useCallback( + (itemId: string, updater: (prev: Item["data"]) => Item["data"]) => { + operations.updateItemUserStateData(itemId, updater); + }, + [operations], + ); + // Handle delete request (from button or keyboard) const handleDeleteRequest = React.useCallback(() => { if (selectedCardIds.size > 0) { @@ -255,7 +249,7 @@ export function WorkspaceSection({ // Filter out all selected items at once using Set.has() for O(1) lookup const remainingItems = state.items.filter(item => !selectedCardIds.has(item.id)); const deletedCount = selectedCardIds.size; - updateAllItems(remainingItems); + operations.updateAllItems(remainingItems); setShowDeleteDialog(false); clearCardSelection(); if (deletedCount > 0) { @@ -267,7 +261,7 @@ export function WorkspaceSection({ // Handle move selected items to folder const handleMoveSelected = () => { - if (!operations || selectedCardIdsArray.length === 0) { + if (selectedCardIdsArray.length === 0) { return; } setShowMoveDialog(true); @@ -275,7 +269,7 @@ export function WorkspaceSection({ // Handle move confirmation from dialog const handleMoveConfirm = (itemIds: string[], folderId: string | null) => { - if (!operations || itemIds.length === 0) { + if (itemIds.length === 0) { return; } operations.moveItemsToFolder(itemIds, folderId); @@ -287,7 +281,7 @@ export function WorkspaceSection({ // Handle creating a new folder from selected cards const handleCreateFolderFromSelection = () => { - if (!operations || selectedCardIdsArray.length === 0) { + if (selectedCardIdsArray.length === 0) { return; } @@ -315,7 +309,7 @@ export function WorkspaceSection({ // Handle file upload from workspace pickers/empty states const handlePDFUpload = useCallback(async (files: File[]) => { - if (!operations || !currentWorkspaceId) { + if (!currentWorkspaceId) { throw new Error('Workspace operations not available'); } @@ -396,8 +390,6 @@ export function WorkspaceSection({ openFilePicker(); }, [openFilePicker]); const handleAudioReady = useCallback(async (file: File) => { - if (!addItem) return; - const loadingToastId = toast.loading("Uploading audio..."); try { @@ -416,7 +408,7 @@ export function WorkspaceSection({ }); const title = `${dateStr} ${timeStr} Recording`; - const itemId = addItem("audio", title, { + const itemId = operations.createItem("audio", title, { fileUrl, filename: file.name, fileSize: file.size, @@ -446,7 +438,7 @@ export function WorkspaceSection({ error instanceof Error ? error.message : "Failed to upload audio", ); } - }, [addItem, currentWorkspaceId, handleCreatedItems]); + }, [currentWorkspaceId, handleCreatedItems, operations]); // Get search params for invite check const searchParams = useSearchParams(); @@ -495,19 +487,20 @@ export function WorkspaceSection({ () @@ -526,35 +519,33 @@ export function WorkspaceSection({ {/* Right-Click Context Menu */} - {addItem && ( - - {renderWorkspaceMenuItems({ - callbacks: { - onCreateDocument: () => { - if (addItem) { - const itemId = addItem("document"); - if (handleCreatedItems && itemId) { - handleCreatedItems([itemId]); - } - } - }, - onCreateFolder: () => { if (addItem) addItem("folder"); }, - onUpload: () => handleUploadMenuItemClick(), - onAudio: () => openAudioDialog(), - onYouTube: () => setShowYouTubeDialog(true), - onWebsite: () => setShowWebsiteDialog(true), - onFlashcards: () => setShowFlashcardsDialog(true), - onQuiz: () => setShowQuizDialog(true), + + {renderWorkspaceMenuItems({ + callbacks: { + onCreateDocument: () => { + const itemId = operations.createItem("document"); + if (handleCreatedItems && itemId) { + handleCreatedItems([itemId]); + } + }, + onCreateFolder: () => { + operations.createItem("folder"); }, - MenuItem: ContextMenuItem, - MenuSub: ContextMenuSub, - MenuSubTrigger: ContextMenuSubTrigger, - MenuSubContent: ContextMenuSubContent, - MenuLabel: ContextMenuLabel, - showUpload: !!(operations && currentWorkspaceId), - })} - - )} + onUpload: () => handleUploadMenuItemClick(), + onAudio: () => openAudioDialog(), + onYouTube: () => setShowYouTubeDialog(true), + onWebsite: () => setShowWebsiteDialog(true), + onFlashcards: () => setShowFlashcardsDialog(true), + onQuiz: () => setShowQuizDialog(true), + }, + MenuItem: ContextMenuItem, + MenuSub: ContextMenuSub, + MenuSubTrigger: ContextMenuSubTrigger, + MenuSubContent: ContextMenuSubContent, + MenuLabel: ContextMenuLabel, + showUpload: !!currentWorkspaceId, + })} + {/* Selection Action Bar - show when cards are selected */} {(state.items ?? []).length > 0 && !isChatMaximized && selectedCardIds.size > 0 && ( diff --git a/src/components/workspace-canvas/YouTubePanelContent.tsx b/src/components/workspace-canvas/YouTubePanelContent.tsx index e24924f7..93d1c2be 100644 --- a/src/components/workspace-canvas/YouTubePanelContent.tsx +++ b/src/components/workspace-canvas/YouTubePanelContent.tsx @@ -17,34 +17,47 @@ const PROGRESS_SAVE_INTERVAL_MS = 10000; // Save every 10s while playing interface YouTubePanelContentProps { item: Item; - onUpdateItemData: (updater: (prev: ItemData) => ItemData) => void; + onUpdateUserStateData: (updater: (prev: ItemData) => ItemData) => void; } export function YouTubePanelContent({ item, - onUpdateItemData: _onUpdateItemData, + onUpdateUserStateData, }: YouTubePanelContentProps) { const youtubeData = item.data as YouTubeData; const videoId = extractYouTubeVideoId(youtubeData.url); const playlistId = extractYouTubePlaylistId(youtubeData.url); const progressKey = getYouTubeProgressKey(videoId, playlistId); const stored = getYouTubeProgress(progressKey); - const startSeconds = stored?.progress ?? 0; - const savedPlaybackRate = stored?.playbackRate; + const startSeconds = youtubeData.progress ?? stored?.progress ?? 0; + const savedPlaybackRate = youtubeData.playbackRate ?? stored?.playbackRate; const saveStateRef = useRef< (opts: { seconds: number; playbackRate?: number }) => void >(undefined); const progressIntervalRef = useRef | null>(null); + const playerStateRef = useRef(null); const saveState = useCallback( (opts: { seconds: number; playbackRate?: number }) => { setYouTubeProgress(progressKey, opts.seconds, opts.playbackRate); + onUpdateUserStateData((prev) => { + const current = prev as YouTubeData; + return { + ...current, + ...(opts.seconds >= 0 ? { progress: Math.floor(opts.seconds) } : {}), + ...(opts.playbackRate != null && opts.playbackRate > 0 + ? { playbackRate: opts.playbackRate } + : {}), + }; + }); }, - [progressKey] + [onUpdateUserStateData, progressKey] ); - saveStateRef.current = saveState; + useEffect(() => { + saveStateRef.current = saveState; + }, [saveState]); const { containerRef, playerRef, isReady } = useYouTubePlayer({ videoId: videoId ?? null, @@ -60,7 +73,7 @@ export function YouTubePanelContent({ rel: 0, }, onStateChange: useCallback((state: YT.PlayerState) => { - const player = playerRef.current; + const player = playerStateRef.current; if (!player) return; // Clear any existing interval @@ -82,7 +95,7 @@ export function YouTubePanelContent({ // Save progress periodically while playing progressIntervalRef.current = setInterval(() => { try { - const currentPlayer = playerRef.current; + const currentPlayer = playerStateRef.current; if (currentPlayer?.getPlayerState?.() === YT.PlayerState.PLAYING) { saveStateRef.current?.({ seconds: currentPlayer.getCurrentTime(), @@ -97,6 +110,10 @@ export function YouTubePanelContent({ }, []), }); + useEffect(() => { + playerStateRef.current = playerRef.current; + }, [isReady, playerRef]); + // Apply saved playback rate and style iframe once ready useEffect(() => { if (!isReady || !playerRef.current) return; @@ -129,17 +146,17 @@ export function YouTubePanelContent({ } catch { // Player may have been destroyed } - }, [isReady, playerRef]); + }, [isReady, playerRef, savedPlaybackRate]); // Cleanup interval and save final state on unmount (e.g. when closing split view) useEffect(() => { return () => { + const player = playerStateRef.current; if (progressIntervalRef.current) { clearInterval(progressIntervalRef.current); progressIntervalRef.current = null; } try { - const player = playerRef.current; if (player?.getCurrentTime != null) { saveStateRef.current?.({ seconds: player.getCurrentTime(), diff --git a/src/hooks/workspace/use-workspace-operations.ts b/src/hooks/workspace/use-workspace-operations.ts index 7cf4df95..69b502f2 100644 --- a/src/hooks/workspace/use-workspace-operations.ts +++ b/src/hooks/workspace/use-workspace-operations.ts @@ -21,6 +21,7 @@ import { getLayoutForBreakpoint, findNextAvailablePosition, } from "@/lib/workspace-state/grid-layout-helpers"; +import { diffItemDataPatch } from "@/lib/workspace/workspace-item-model"; import { hasDuplicateName, getNextUniqueDefaultName, @@ -67,6 +68,10 @@ export interface WorkspaceOperations { itemId: string, updater: (prev: Item["data"]) => Item["data"], ) => void; + updateItemUserStateData: ( + itemId: string, + updater: (prev: Item["data"]) => Item["data"], + ) => void; deleteItem: (id: string) => void; updateAllItems: (items: Item[]) => void; @@ -113,11 +118,17 @@ export function useWorkspaceOperations( const updateItemDataDebounceRef = useRef>( new Map(), ); + const updateItemUserStateDebounceRef = useRef>( + new Map(), + ); // Store pending changes to merge them const pendingItemChangesRef = useRef>>(new Map()); const pendingItemDataUpdatersRef = useRef< Map Item["data"]> >(new Map()); + const pendingItemUserStateDataRef = useRef>( + new Map(), + ); const getCachedState = useCallback(() => { if (!workspaceId) return null; @@ -165,20 +176,48 @@ export function useWorkspaceOperations( [getLatestItemFromState], ); + const setCachedItemData = useCallback( + (itemId: string, data: Item["data"]) => { + if (!workspaceId) return; + queryClient.setQueryData( + workspaceStateQueryKey(workspaceId), + (current) => { + if (!current) return current; + return { + ...current, + state: { + ...current.state, + items: current.state.items.map((item) => + item.id === itemId ? { ...item, data } : item, + ), + }, + }; + }, + ); + }, + [queryClient, workspaceId], + ); + // Cleanup timeouts on unmount useEffect(() => { const updateItemDebounces = updateItemDebounceRef.current; const updateItemDataDebounces = updateItemDataDebounceRef.current; + const updateItemUserStateDebounces = + updateItemUserStateDebounceRef.current; const pendingItemChanges = pendingItemChangesRef.current; const pendingItemDataUpdaters = pendingItemDataUpdatersRef.current; + const pendingItemUserStateData = pendingItemUserStateDataRef.current; return () => { // Clear all pending timeouts updateItemDebounces.forEach((timeout) => clearTimeout(timeout)); updateItemDataDebounces.forEach((timeout) => clearTimeout(timeout)); + updateItemUserStateDebounces.forEach((timeout) => clearTimeout(timeout)); updateItemDebounces.clear(); updateItemDataDebounces.clear(); + updateItemUserStateDebounces.clear(); pendingItemChanges.clear(); pendingItemDataUpdaters.clear(); + pendingItemUserStateData.clear(); }; }, []); @@ -620,7 +659,9 @@ export function useWorkspaceOperations( "ITEM_UPDATED", { id: itemId, - changes: { data: newData }, + changes: { + data: diffItemDataPatch(latestItem.data, newData) as Item["data"], + }, name: latestItem.name, }, userId, @@ -635,7 +676,92 @@ export function useWorkspaceOperations( updateItemDataDebounceRef.current.set(itemId, timeout); }, - [workspaceId, queryClient, currentState, mutation, userId, userName], + [currentState, getCachedState, mutation, queryClient, userId, userName, workspaceId], + ); + + const persistItemUserStateData = useCallback( + async (itemId: string) => { + if (!workspaceId) return; + + const latestItem = getLatestItemFromState(itemId); + const latestData = + pendingItemUserStateDataRef.current.get(itemId) ?? latestItem?.data; + + if (!latestItem || latestData === undefined) { + pendingItemUserStateDataRef.current.delete(itemId); + updateItemUserStateDebounceRef.current.delete(itemId); + return; + } + + try { + const response = await fetch( + `/api/workspaces/${workspaceId}/item-user-state`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + itemId, + itemType: latestItem.type, + data: latestData, + }), + }, + ); + + if (!response.ok) { + throw new Error(`Failed to persist user state: ${response.status}`); + } + } catch (error) { + logger.error("Failed to persist workspace item user state", { + workspaceId, + itemId, + error, + }); + toast.error("Failed to save personal progress"); + await queryClient.invalidateQueries({ + queryKey: workspaceStateQueryKey(workspaceId), + }); + } finally { + pendingItemUserStateDataRef.current.delete(itemId); + updateItemUserStateDebounceRef.current.delete(itemId); + } + }, + [getLatestItemFromState, queryClient, workspaceId], + ); + + const updateItemUserStateData = useCallback( + (itemId: string, updater: (prev: Item["data"]) => Item["data"]) => { + const latestItem = getLatestItemWithPendingChanges(itemId); + if (!latestItem) { + logger.warn("updateItemUserStateData: item not found", { + workspaceId, + itemId, + }); + return; + } + + const nextData = updater(latestItem.data); + setCachedItemData(itemId, nextData); + pendingItemUserStateDataRef.current.set(itemId, nextData); + + const existingTimeout = updateItemUserStateDebounceRef.current.get(itemId); + if (existingTimeout) { + clearTimeout(existingTimeout); + } + + const timeout = setTimeout(() => { + void persistItemUserStateData(itemId); + }, 500); + + updateItemUserStateDebounceRef.current.set(itemId, timeout); + }, + [ + getLatestItemWithPendingChanges, + persistItemUserStateData, + setCachedItemData, + workspaceId, + ], ); // Update all items at once (used for layout changes, reordering, and bulk delete) @@ -748,7 +874,7 @@ export function useWorkspaceOperations( ); } }, - [workspaceId, queryClient, currentState, mutation, userId, userName], + [currentState, getCachedState, mutation, queryClient, userId, userName, workspaceId], ); // ===================================================== @@ -936,10 +1062,11 @@ export function useWorkspaceOperations( ); }, [ - workspaceId, - queryClient, currentState.items, + getCachedState, + queryClient, updateAllItems, + workspaceId, ], ); @@ -1030,7 +1157,9 @@ export function useWorkspaceOperations( "ITEM_UPDATED", { id: itemId, - changes: { data: newData }, + changes: { + data: diffItemDataPatch(latestItem.data, newData) as Item["data"], + }, name: latestItem.name, }, userId, @@ -1046,8 +1175,15 @@ export function useWorkspaceOperations( } } } + + const pendingUserStateTimeout = + updateItemUserStateDebounceRef.current.get(itemId); + if (pendingUserStateTimeout) { + clearTimeout(pendingUserStateTimeout); + void persistItemUserStateData(itemId); + } }, - [getLatestItemFromState, mutation, userId, userName], + [getLatestItemFromState, mutation, persistItemUserStateData, userId, userName], ); const getDocumentMarkdownForExport = useCallback( @@ -1064,6 +1200,7 @@ export function useWorkspaceOperations( createItems, updateItem, updateItemData, + updateItemUserStateData, deleteItem, updateAllItems, flushPendingChanges,