Skip to content

Feat: Zero Sync — real-time workspace state#398

Merged
urjitc merged 17 commits into
mainfrom
staging
Apr 19, 2026
Merged

Feat: Zero Sync — real-time workspace state#398
urjitc merged 17 commits into
mainfrom
staging

Conversation

@urjitc

@urjitc urjitc commented Apr 18, 2026

Copy link
Copy Markdown
Member

Zero Sync Migration

Migrates workspace state management from event-sourcing to Zero real-time sync.

What changed

  • Zero infrastructure: zero-cache server (EC2 us-west-2), Caddy SSL, cookie-based auth with Better Auth cross-subdomain cookies
  • Reads: useWorkspaceState hook reads from Zero's local replica via @rocicorp/zero/react useQuery — no more API polling
  • Writes: All workspace mutations (create, update, delete, move, layout) go through Zero mutators with server-side auth + extracted row sync
  • Shell-only optimization: Layout drags and move operations write only to workspace_items — skip workspace_item_content entirely
  • AI tools / OCR / Audio: Write directly to Postgres via Drizzle; zero-cache replicates changes to clients automatically via WAL
  • Deleted code: Event-sourcing infrastructure, workspace events, snapshot tables, projection state, user state, polling hooks — net -2,667 lines

PRs included

Deployment

  • zero-cache running on EC2 us-west-2 at zero.thinkex.app
  • Vercel env vars needed: NEXT_PUBLIC_ZERO_SERVER, ZERO_COOKIE_DOMAIN
  • Postgres publication zero_pub on workspace_items + workspace_item_content

Summary by CodeRabbit

Release Notes

  • New Features

    • Integrated Zero real-time synchronization framework for improved data consistency and collaboration
    • Added /api/zero/query and /api/zero/mutate endpoints for Zero operations
  • Refactor

    • Migrated from event-based persistence to direct database writes
    • Consolidated workspace state management to use Zero sync instead of Supabase realtime
    • Simplified database schema by removing event and user state tables
  • Chores

    • Added environment variables for Zero server configuration
    • Removed legacy API endpoints (/api/workspaces/[id]/events and /api/workspaces/[id]/state)

urjitc and others added 16 commits April 10, 2026 05:39
* Wire ZeroProvider and replace event-sourced reads with Zero useQuery

* Fix ZeroProvider unauthenticated render

* Fix ZeroProvider token initialization

* Refactor: workspace writes to Zero mutators (#336)

* Replace event-sourcing writes with Zero mutators

* Fix audio duration persistence

* Make workflow persistence atomic

* Refactor: remove event sourcing and user state infrastructure (#339)

* Delete event-sourcing infrastructure and remove per-user state persistence

* Remove auth secret fallback

* Preserve legacy workspace tables

* Remove unused workspace table migration

* Fix Zero createdAt column type

* Migrate Zero integration to 1.x query/mutator architecture

* Refactor Zero auth to cookie-based + dedup server mutators

* Fix Zero query auth: fail-closed + rename hasWorkspaceReadAccess (#385)

* Enforce authentication and workspace read access checks on Zero query endpoint

* Fix Zero query workspace context checks

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
Feat: Zero Sync — real-time workspace state via zero-cache
@urjitc urjitc added the capy Generated by capy.ai label Apr 18, 2026
@github-project-automation github-project-automation Bot moved this to Backlog in Dev Board Apr 18, 2026
@vercel

vercel Bot commented Apr 18, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
thinkex Ready Ready Preview, Comment Apr 18, 2026 11:38pm
thinkex (staging) Ready Ready Preview, Comment Apr 18, 2026 11:38pm

Request Review

@coderabbitai

coderabbitai Bot commented Apr 18, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR migrates the application from event-sourced workspace state with Supabase realtime to Zero Sync-based state synchronization. Changes include removing event-store infrastructure, replacing projection-based state derivation with direct item persistence, adding Zero client/server mutators and queries, and updating UI components to use Zero connection state instead of event logs.

Changes

Cohort / File(s) Summary
Environment & Dependencies
.env.example, package.json, src/lib/auth.ts
Added NEXT_PUBLIC_ZERO_SERVER and ZERO_COOKIE_DOMAIN env vars; added @rocicorp/zero and drizzle-zero packages; configured cross-subdomain cookies and auth secret.
Database Schema & Migrations
drizzle/0002_zero_publication.sql, drizzle/meta/_journal.json, src/lib/db/schema.ts, src/lib/db/types.ts, src/lib/db/relations.ts
Created logical replication publication for workspace items; removed event-store tables (workspaceEvents, workspaceSnapshots), projection tables (workspaceItemProjectionState), and user-state tables; removed related types and relations.
Zero Infrastructure
src/lib/zero/zero-schema.gen.ts, src/lib/zero/client.ts, src/lib/zero/provider.tsx, src/lib/zero/queries.ts, src/lib/zero/mutators.ts, src/lib/zero/server-mutators.ts
Added complete Zero Sync implementation: schema generation with workspace items and content tables, lazy client instantiation, React provider with session integration, query definitions for workspace items, client/server mutators for item CRUD/folder operations, and server-side authorization/extracted-content sync.
API Routes - Removed Event/State Endpoints
src/app/api/workspaces/[id]/events/route.ts, src/app/api/workspaces/[id]/state/route.ts
Removed /api/workspaces/[id]/events (GET/POST) and /api/workspaces/[id]/state (GET) endpoints that fetched event logs and computed state; replaced with Zero sync infrastructure.
API Routes - New Zero Endpoints
src/app/api/zero/mutate/route.ts, src/app/api/zero/query/route.ts
Added /api/zero/mutate (POST) for executing Zero mutations with auth/context and /api/zero/query (POST) for executing Zero queries with workspace access verification and error mapping.
API Routes - Import Updates
src/app/api/share/[id]/route.ts, src/app/api/workspaces/[id]/route.ts, src/app/api/workspaces/slug/[slug]/route.ts
Updated import source for loadWorkspaceState from state-loader to new workspace-state-read module (no logic changes).
Workspace Item Write Layer
src/lib/workspace/workspace-item-write.ts
New module providing transactional database write operations: load/insert/upsert/delete workspace items across shell, content, and extracted tables.
Workspace State Read & Removal
src/lib/workspace/workspace-state-read.ts, src/lib/workspace/state-loader.ts (deleted), src/lib/workspace/workspace-items-projector.ts (deleted), src/lib/workspace/workspace-state-cache.ts (deleted)
Added new workspace-state-read.ts for direct item loading from tables; removed event-sourcing-based state derivation, projection, and cache utilities.
Event System - Removed
src/lib/workspace/events.ts, src/lib/workspace/event-reducer.ts, src/lib/workspace/workspace-event-store.ts, src/lib/workspace/workspace-event-client-payload.ts, src/lib/workspace/version-helpers.ts
Deleted entire workspace event type system, event-store persistence, projector, version conflict handling, and client-side event sanitization.
Workspace Operations Migration
src/hooks/workspace/use-workspace-operations.ts
Replaced React Query + event-emission mutation pattern with Zero-based mutations (zeroRef.current.mutate); item updates/creates/deletes now call Zero mutators with sanitized data instead of emitting events.
Workspace State & Mutation Hooks - Removed
src/hooks/workspace/use-workspace-events.ts, src/hooks/workspace/use-workspace-mutation.ts, src/hooks/workspace/use-workspace-realtime.ts
Deleted hooks for event queries, optimistic event appending with retry/conflict handling, and Supabase realtime subscriptions.
Workspace State Hook Refactor
src/hooks/workspace/use-workspace-state.ts
Replaced event-log + projection-based state derivation with Zero query (queries.workspace.items); return shape simplified with hardcoded version: 0 and no-op refetch.
Item Data Schemas & Types
src/lib/workspace-state/item-data-schemas.ts, src/lib/workspace-state/types.ts, src/lib/workspace-state/item-helpers.ts, src/lib/workspace-state/state.ts
Removed user-state fields (currentIndex from flashcard, session from quiz, progress/playbackRate from youtube) and deleted quizSessionSchema.
Workspace Item Model - User State Removal
src/lib/workspace/workspace-item-model-types.ts, src/lib/workspace/workspace-item-model-shared.ts, src/lib/workspace/workspace-item-model.ts, src/lib/workspace/workspace-item-model-schemas.ts
Removed user-state projection types, extraction logic, and model incorporation; buildWorkspaceItemTableRows and rehydrateWorkspaceItem no longer handle per-user fields.
Workspace Item Sanitization
src/lib/workspace/workspace-item-sanitize.ts
New module with placeholder sanitization functions for item persistence and partial updates.
Realtime & Broadcasting - Removed
src/lib/realtime/server-broadcast.ts, src/contexts/RealtimeContext.tsx
Deleted Supabase broadcast module; updated realtime provider to derive connection state from useConnectionState() instead of workspace realtime subscriptions.
Workspace Store Simplification
src/lib/stores/workspace-store.ts
Removed save-status fields (isSaving, lastSavedAt, hasUnsavedChanges) and related actions; store now only tracks currentWorkspaceId.
Dashboard & UI Updates
src/app/dashboard/page.tsx, src/contexts/WorkspaceContext.tsx, src/components/workspace-canvas/FlashcardWorkspaceCard.tsx, src/components/workspace-canvas/QuizContent.tsx, src/components/workspace-canvas/WorkspaceContent.tsx
Wrapped dashboard in ZeroProvider; replaced event-log based state/save-status tracking with Zero connection state; removed session persistence from quiz/flashcard components; removed event query invalidation in favor of Zero sync; updated comment documenting workspace data source from "Event sourcing" to "Zero sync".
Workspace Worker & Persistence
src/lib/ai/workers/workspace-worker.ts, src/workflows/audio-transcribe/steps/persist-result.ts, src/workflows/ocr-dispatch/steps/persist-results.ts, src/lib/ai/tools/tool-utils.ts, src/lib/ai/tools/workspace-tools.ts, src/lib/ai/workers/common.ts, src/lib/workspace/mutation-helpers.ts
Replaced event creation/appending with direct item insertion/upsert via insertWorkspaceItem/upsertWorkspaceItem; changed ID generation to crypto.randomUUID(); removed createEvent and broadcast calls; updated import paths for loadWorkspaceState.
Utility Functions
src/lib/utils/format-workspace-context.ts
Removed session progress serialization from quiz formatting; now outputs only the questions JSON payload.
Test Removals
src/lib/ai/workers/__tests__/workspace-worker.edit.test.ts, src/hooks/workspace/workspace-state-cache.test.ts, src/lib/workspace/__tests__/*.test.ts (event-reducer, version-helpers, workspace-event-store, workspace-items-projector, etc.)
Deleted comprehensive test suites covering event-sourcing, projections, version conflict handling, realtime synchronization, and OCR/bulk-patch scenarios.
Build Configuration
tsconfig.json
Excluded additional test source patterns from TypeScript compilation (**/__tests__/**, **/*.test.ts, **/*.test.tsx).

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client<br/>(Browser)
    participant Provider as ZeroProvider<br/>(React)
    participant Zero as Zero Client<br/>(Sync Engine)
    participant API as /api/zero/*<br/>(Routes)
    participant Auth as Auth<br/>(Session)
    participant DB as Database<br/>(Drizzle)

    rect rgba(200, 150, 255, 0.5)
    Note over Client,DB: Initial Setup
    Client->>Provider: Mount with session
    Provider->>Auth: Get user session
    Auth-->>Provider: userId
    Provider->>Zero: Create instance (userId)
    Zero-->>Provider: Ready
    Provider-->>Client: Render with Zero context
    end

    rect rgba(150, 200, 255, 0.5)
    Note over Client,DB: Query Workspace Items
    Client->>Zero: useQuery(workspace.items)
    Zero->>API: POST /api/zero/query
    API->>Auth: Verify session
    API->>DB: Check workspace access
    DB-->>API: Access verified
    API->>DB: SELECT workspace_items
    DB-->>API: Item rows
    API-->>Zero: Query result
    Zero-->>Client: state[] (cached)
    end

    rect rgba(255, 200, 150, 0.5)
    Note over Client,DB: Mutate Item
    Client->>Zero: mutate('item.update', args)
    Zero->>API: POST /api/zero/mutate
    API->>Auth: Verify session
    Auth-->>API: userId
    API->>DB: Transaction begin
    DB->>DB: Update workspace_items
    DB->>DB: Upsert workspace_item_content
    DB->>DB: Upsert workspace_item_extracted
    DB-->>API: Success
    API-->>Zero: Mutation result
    Zero->>Zero: Apply to local cache
    Zero-->>Client: Updated state
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • Feat: Zero Sync — real-time workspace state via zero-cache #395: Implements identical Zero Sync integration (schema, client, provider, mutators, queries) alongside event-sourcing removal; directly parallel effort.
  • Main chrome ext fix #268: Modifies workspace event persistence and realtime broadcast paths (persistOcrResults, workspace realtime hooks) in conflicting directions—main PR removes event-based flow while related PR augments it.
  • Feat/improvements #322: Touches overlapping workspace state/event/mutation subsystems (state-loader, version-helpers, event persistence, audio/OCR workflows) with potentially conflicting architectural approaches.

Poem

🐰 Hoppy hops with glee so bright,
Event streams fade from sight!
Zero sync now leads the way,
Real-time state, hooray, hooray!
Direct writes make items dance,
Fresh mutations get their chance!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Feat: Zero Sync — real-time workspace state' accurately summarizes the main change: migrating workspace state management to Zero real-time sync. It is concise, clear, and directly reflects the primary objective.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch staging

Comment thread src/lib/ai/tools/workspace-tools.ts Fixed
Comment thread src/lib/zero/mutators.ts Fixed
@greptile-apps

greptile-apps Bot commented Apr 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces the event-sourcing workspace state system with Zero real-time sync, removing ~2,600 lines of projection, snapshot, and event infrastructure and replacing client reads with useQuery from @rocicorp/zero/react and writes with server-authenticated Zero mutators.

  • P1: src/app/api/zero/query/route.ts — workspace read-access is enforced by parsing the Zero protocol body; if parsing yields no workspace IDs (malformed or protocol-drift request), the auth check is skipped entirely and handleQueryRequest runs unguarded. The ZQL query has no user-ownership predicate, so any authenticated user who knows a workspace UUID can read its items.

Confidence Score: 3/5

Not safe to merge until the query authorization bypass is resolved; all other findings are P2.

One P1 security finding: authenticated users can bypass workspace read-access checks on the Zero query endpoint by crafting a request that prevents workspace ID extraction. This is on the primary data-read path for the new architecture.

src/app/api/zero/query/route.ts (auth bypass), src/lib/zero/queries.ts (no server-side ownership predicate in ZQL)

Security Review

  • Authorization bypass (src/app/api/zero/query/route.ts): Workspace read-access checks rely on parsing the Zero protocol body to extract workspaceId from args[0]. If the body doesn't match the expected structure, workspaceIds is empty, the auth loop is skipped, and handleQueryRequest executes the ZQL query unguarded. The ZQL query has no user-ownership predicate, so any authenticated user who knows a workspace UUID can read its items.
  • Wildcard trusted origin (src/lib/auth.ts): \"https://*.thinkex.app\" in trustedOrigins may be silently ignored if Better Auth does not support glob matching, potentially breaking cross-subdomain cookie auth for zero.thinkex.app.

Important Files Changed

Filename Overview
src/app/api/zero/query/route.ts New Zero query endpoint with workspace access checks; auth can be bypassed if the Zero protocol body doesn't match the expected parsing format
src/app/api/zero/mutate/route.ts New Zero mutate endpoint; session auth checked before dispatching to serverMutators which enforce workspace write access internally
src/lib/zero/server-mutators.ts Server-side Zero mutators wrap shared mutators with workspace write-access checks and extracted-row sync; auth applied consistently across all mutation paths
src/lib/zero/mutators.ts Client-side optimistic mutators with Zod validation; sanitize functions called are currently no-ops
src/lib/zero/zero-schema.gen.ts Zero schema defines workspace_items and workspace_item_content tables with relationships; workspace_item_extracted intentionally excluded
src/lib/zero/client.ts Module-level Zero singleton with user-scoped instance management; correctly recreates instance on user change
src/lib/zero/provider.tsx ZeroProvider wraps base provider, blocks render during session load, and destroys Zero instance on logout
src/hooks/workspace/use-workspace-state.ts Replaced polling hook with Zero useQuery; always passes extracted: null — OCR/transcript data not available client-side
src/lib/workspace/workspace-item-write.ts New direct Drizzle write helpers used by AI/OCR/audio workflows; DbExecutor typed as any, weakening type safety
src/lib/workspace/workspace-item-sanitize.ts Both sanitize functions are identity stubs providing no actual sanitization
src/lib/auth.ts Adds cross-subdomain cookie support for Zero auth; wildcard trusted origin may not function as intended depending on Better Auth version
drizzle/0002_zero_publication.sql Creates zero_pub publication for WAL replication; does not drop removed tables, causing Drizzle schema drift
src/workflows/audio-transcribe/steps/persist-result.ts Migrated from event-sourcing to direct Drizzle updates within a transaction; structuredData fully replaced on each write
src/workflows/ocr-dispatch/steps/persist-results.ts Migrated from event-sourcing to direct Drizzle updates; each OCR result wrapped in its own transaction
src/lib/db/schema.ts Removes workspaceEvents, workspaceSnapshots, workspaceItemUserState, and workspaceItemProjectionState table definitions; tables still exist in DB

Fix All in Cursor

Reviews (1): Last reviewed commit: "Merge pull request #396 from ThinkEx-OSS..." | Re-trigger Greptile

Comment on lines +119 to +135
// for any that require args.
for (const workspaceId of workspaceIds) {
const hasAccess = await hasWorkspaceReadAccess(workspaceId, userId);
if (!hasAccess) {
throw new Error(WORKSPACE_ACCESS_DENIED_ERROR);
}
}

const result = await handleQueryRequest(
(name, args) => mustGetQuery(queries, name).fn({ args, ctx }),
schema,
body as ReadonlyJSONValue,
);

return NextResponse.json(result);
} catch (error) {
if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Auth check bypassed when workspace ID parsing fails

If the Zero request body doesn't match the expected [tag, [{args: [{workspaceId}...]}]] format — or if Zero's internal protocol ever changes — workspaceIds will be empty, the for loop is skipped, and handleQueryRequest executes the query anyway. Because the ZQL query in queries.ts only filters rows by workspaceId (no user ownership predicate), any authenticated user who knows a workspace UUID can read its items by crafting a request where args[0].workspaceId is absent or in a different position.

At minimum, add a server-side ownership predicate to the ZQL query in queries.ts so that the Zero server itself enforces access, making the pre-parse check a defense-in-depth layer rather than the only gate.

Fix in Cursor

Comment on lines +1 to +11
import type { Item } from "@/lib/workspace-state/types";

export function sanitizeWorkspaceItemForPersistence(item: Item): Item {
return item;
}

export function sanitizeWorkspaceItemChanges(
changes: Partial<Item>,
): Partial<Item> {
return changes;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Sanitize functions are empty stubs

Both sanitizeWorkspaceItemForPersistence and sanitizeWorkspaceItemChanges are identity functions that return their input unchanged. They are called extensively in mutators.ts and server-mutators.ts but provide no protection. If sanitization is intentionally deferred, a comment explaining that would prevent future callers from assuming safety guarantees that don't exist.

Fix in Cursor

buildWorkspaceItemTableRows,
rehydrateWorkspaceItem,
} from "./workspace-item-model";
import { sanitizeWorkspaceItemForPersistence } from "./workspace-item-sanitize";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 DbExecutor typed as typeof db | any defeats type safety

type DbExecutor = typeof db | any collapses to any, making the entire type annotation a no-op. The same pattern appears in workspace-state-read.ts. Consider using the actual Drizzle transaction type or a narrow union so TypeScript can catch incorrect executor usage at compile time.

Fix in Cursor

Comment on lines +63 to +64
extracted: null,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 extracted data is always null — OCR/transcript fields unavailable to clients

workspace_item_extracted is not included in the Zero schema and is not part of the queries.ts query, so extracted is always null. Any component that previously rendered ocrText, transcriptText, contentPreview, or searchText from workspace state will silently receive nothing. If this is intentional (extracted data is search-only), it's worth documenting.

Fix in Cursor

Comment on lines +1 to +3
CREATE PUBLICATION zero_pub FOR TABLE
workspace_items,
workspace_item_content;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Removed tables absent from migration — Drizzle schema drift

workspaceEvents, workspaceSnapshots, workspaceItemUserState, and workspaceItemProjectionState have been removed from schema.ts but are not dropped in this migration. drizzle-kit will detect drift on the next run. The PR description mentions retaining tables for rollback safety — a comment or follow-up migration stub would make this intent explicit and prevent accidental auto-generated drops.

Fix in Cursor

Comment thread src/lib/auth.ts
},
},
secret: process.env.BETTER_AUTH_SECRET || process.env.AUTH_SECRET,
secret: authSecret,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Wildcard in trustedOrigins may not be supported by Better Auth

"https://*.thinkex.app" is added to trustedOrigins. Better Auth's origin validation typically does exact-string matching rather than glob/wildcard matching. If wildcards are not supported, this entry is silently ignored and zero.thinkex.app would need to be listed explicitly for cross-subdomain cookie auth to function.

Fix in Cursor

@@ -0,0 +1,3 @@
CREATE PUBLICATION zero_pub FOR TABLE

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

@src/lib/db/schema.ts deletes workspace_events, workspace_snapshots, workspace_item_user_state, and workspace_item_projection_state, but the only SQL added for migration 0002 is:

-- drizzle/0002_zero_publication.sql
CREATE PUBLICATION zero_pub FOR TABLE
  workspace_items,
  workspace_item_content;

@package.json still defines pnpm db:migrate, and applying 0000/0001/0002 to a fresh database will therefore continue to create all four legacy tables (plus their policies/trigger objects) even though the checked-in schema no longer models them. That leaves migrated databases permanently out of sync with the repo schema. Either add explicit DROP TABLE ... CASCADE / cleanup statements in this migration, or keep the legacy tables in schema.ts until you intentionally retire them.

@@ -116,47 +108,21 @@ async function handlePOST(request: NextRequest) {
? normalizeWorkspaceItems(customInitialItems)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

initialItems is still treated as an unchecked cast, but this diff now persists each item directly into workspace_items/workspace_item_content inside the request path. In @src/lib/workspace-state/state.ts, normalizeWorkspaceItems() only returns the incoming array as-is; it does not validate that each entry has the required id, type, name, and data fields. That means a bad client payload now reaches insertWorkspaceItem(), hits NOT NULL / PK constraints, and withServerObservability converts the thrown DB error into a generic 500 instead of a 400. This is a regression in API behavior for untrusted request data and can break share/import flows if they ever pass a malformed item. Validate each item before starting the transaction and reject invalid payloads with a client error.

// src/app/api/workspaces/route.ts
const initialItems = customInitialItems
  ? normalizeWorkspaceItems(customInitialItems)
  : getTemplateInitialItems(effectiveTemplate);

await db.transaction(async (tx) => {

conflictMessage: "Workspace was modified by another user, please try again",
async function createWorkspaceItem(workspaceId: string, item: Item) {
await db.transaction(async (tx) => {
await insertWorkspaceItem(tx, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The new direct-write helpers persist the item exactly as passed in, but they never stamp lastModified. ts // src/lib/ai/workers/workspace-worker.ts await insertWorkspaceItem(tx, { workspaceId, item: sanitizeWorkspaceItemForPersistence(item), sourceVersion: 0, }); Before this migration, the removed projector set lastModified from the event timestamp for creates/updates, and the canonical Zero mutators still do lastModified: now on every create/update. With this change, AI-created items are stored with NULL last_modified, and later AI edits preserve the old timestamp instead of marking the item fresh. That makes the AI worker the only writer that violates the workspace item shell contract. Set lastModified: Date.now() before insertWorkspaceItem() and before upsertWorkspaceItem() in updateWorkspaceItem() so this path matches the rest of the Zero write surface.


mutation.mutate(event);
if (workspaceIdRef.current) {
zeroRef.current.mutate(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

createItem() now persists explicit names through Zero without the duplicate-name guard that still exists in the batched create path, so single-card flows can create two sibling items of the same type with the same name. The old /api/workspaces/[id]/events POST handler rejected this for ITEM_CREATED, and the new server wrapper in server-mutators.ts just delegates to the shared item.create mutator, so there is no authoritative backstop anymore. This is reachable from changed callers such as the YouTube/website/audio create flows in WorkspaceHeader and YouTubeSearchToolUI, and it breaks the existing name invariant that hasDuplicateName/checkDuplicateName still enforce elsewhere. Reintroduce the duplicate check before mutators.item.create(...) (or, better, enforce it in the server mutator) so single-item creates stay consistent with createItems() and updateItem().

// src/hooks/workspace/use-workspace-operations.ts
const finalName =
  name ||
  getNextUniqueDefaultName(currentItemsRef.current, validType, folderId);

if (workspaceIdRef.current) {
  zeroRef.current.mutate(
// src/hooks/workspace/use-workspace-operations.ts
if (
  name &&
  hasDuplicateName(allItemsSoFar, finalName, validType, folderId)
) {
  return null;
}

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

12 issues found across 72 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/app/api/zero/query/route.ts">

<violation number="1" location="src/app/api/zero/query/route.ts:73">
P0: The request parser is using the wrong Zero query body shape, so `workspaceIds` can remain empty and the ownership gate is skipped before executing the query. Parse the request as an array of query objects (or fail closed) so access checks always run.</violation>

<violation number="2" location="src/app/api/zero/query/route.ts:142">
P2: Log the error before returning the 500 response. Without this, production failures in this route will be invisible — no stack trace, no error message, nothing to diagnose issues with Zero query handling or DB connectivity.</violation>
</file>

<file name="src/contexts/RealtimeContext.tsx">

<violation number="1" location="src/contexts/RealtimeContext.tsx:50">
P2: `error` connection states are being downgraded to `disconnected`, which hides actual sync failures from realtime context consumers.</violation>
</file>

<file name="src/lib/zero/client.ts">

<violation number="1" location="src/lib/zero/client.ts:10">
P1: Avoid defaulting `NEXT_PUBLIC_APP_URL` to localhost for production-facing Zero endpoints; this can route client mutate/query calls to an invalid host and break sync.</violation>
</file>

<file name=".env.example">

<violation number="1" location=".env.example:68">
P2: The default `ZERO_COOKIE_DOMAIN` points to a production domain, which breaks localhost cookie auth when developers copy `.env.example` for local setup.</violation>
</file>

<file name="src/hooks/workspace/use-workspace-state.ts">

<violation number="1" location="src/hooks/workspace/use-workspace-state.ts:63">
P2: This always rehydrates items with `extracted: null`, so OCR/transcript/contentPreview/search-derived fields cannot be populated in workspace state.</violation>

<violation number="2" location="src/hooks/workspace/use-workspace-state.ts:81">
P2: Custom agent: **Flag AI Slop and Fabricated Changes**

A functional `refetch` API was replaced with a production no-op stub, which matches the rule’s prohibition on shipping temporary/stub code in real application paths without explicit rationale.</violation>
</file>

<file name="src/workflows/audio-transcribe/steps/persist-result.ts">

<violation number="1" location="src/workflows/audio-transcribe/steps/persist-result.ts:56">
P2: `hasTranscript` is set to `true` unconditionally, even when transcription returns no segments/text.</violation>
</file>

<file name="src/lib/zero/mutators.ts">

<violation number="1" location="src/lib/zero/mutators.ts:241">
P1: Converting `null` to `undefined` prevents clearing the `layout` field on updates. In Zero, `null` means "set to null" while `undefined` means "leave unchanged". When `upsertItem` calls `tx.mutate.workspace_items.update(...)`, a null layout (meaning "clear it") becomes undefined and the old value is silently preserved.

Use `null` directly instead of coalescing to `undefined`, and cast to `JsonValue | null | undefined`.</violation>

<violation number="2" location="src/lib/zero/mutators.ts:250">
P2: Same `null` → `undefined` coalescing pattern as `toMutateShellRow`. For `upsert`, `undefined` means "use column default" while `null` means "set to null". These happen to align today if defaults are null, but the semantics are wrong and will break if these columns gain non-null defaults or this helper is reused with `update`.</violation>
</file>

<file name="src/lib/ai/workers/__tests__/workspace-worker.edit.test.ts">

<violation number="1" location="src/lib/ai/workers/__tests__/workspace-worker.edit.test.ts:1">
P2: Custom agent: **Flag AI Slop and Fabricated Changes**

Deleted regression coverage for the workspace edit workflow in a behavior-change PR.</violation>
</file>

<file name="src/lib/workspace/__tests__/workspace-items-projector.test.ts">

<violation number="1" location="src/lib/workspace/__tests__/workspace-items-projector.test.ts:1">
P2: Custom agent: **Flag AI Slop and Fabricated Changes**

Deleted the projector regression tests instead of preserving coverage for workspace-state behavior.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

*/
const body = (await request.json()) as unknown;
const queryRequests =
Array.isArray(body) && body.length > 1 && Array.isArray(body[1])

@cubic-dev-ai cubic-dev-ai Bot Apr 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: The request parser is using the wrong Zero query body shape, so workspaceIds can remain empty and the ownership gate is skipped before executing the query. Parse the request as an array of query objects (or fail closed) so access checks always run.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/api/zero/query/route.ts, line 73:

<comment>The request parser is using the wrong Zero query body shape, so `workspaceIds` can remain empty and the ownership gate is skipped before executing the query. Parse the request as an array of query objects (or fail closed) so access checks always run.</comment>

<file context>
@@ -0,0 +1,147 @@
+     */
+    const body = (await request.json()) as unknown;
+    const queryRequests =
+      Array.isArray(body) && body.length > 1 && Array.isArray(body[1])
+        ? (body[1] as QueryRequest[])
+        : [];
</file context>
Fix with Cubic

Comment thread src/lib/zero/client.ts
}

const appURL =
process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";

@cubic-dev-ai cubic-dev-ai Bot Apr 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Avoid defaulting NEXT_PUBLIC_APP_URL to localhost for production-facing Zero endpoints; this can route client mutate/query calls to an invalid host and break sync.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib/zero/client.ts, line 10:

<comment>Avoid defaulting `NEXT_PUBLIC_APP_URL` to localhost for production-facing Zero endpoints; this can route client mutate/query calls to an invalid host and break sync.</comment>

<file context>
@@ -0,0 +1,50 @@
+}
+
+const appURL =
+  process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
+
+function createZeroInstance(params: { userId: string }) {
</file context>
Fix with Cubic

Comment thread src/lib/zero/mutators.ts
) {
return {
...row,
layout: (row.layout ?? undefined) as JsonValue | undefined,

@cubic-dev-ai cubic-dev-ai Bot Apr 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Converting null to undefined prevents clearing the layout field on updates. In Zero, null means "set to null" while undefined means "leave unchanged". When upsertItem calls tx.mutate.workspace_items.update(...), a null layout (meaning "clear it") becomes undefined and the old value is silently preserved.

Use null directly instead of coalescing to undefined, and cast to JsonValue | null | undefined.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib/zero/mutators.ts, line 241:

<comment>Converting `null` to `undefined` prevents clearing the `layout` field on updates. In Zero, `null` means "set to null" while `undefined` means "leave unchanged". When `upsertItem` calls `tx.mutate.workspace_items.update(...)`, a null layout (meaning "clear it") becomes undefined and the old value is silently preserved.

Use `null` directly instead of coalescing to `undefined`, and cast to `JsonValue | null | undefined`.</comment>

<file context>
@@ -0,0 +1,642 @@
+) {
+  return {
+    ...row,
+    layout: (row.layout ?? undefined) as JsonValue | undefined,
+  };
+}
</file context>
Suggested change
layout: (row.layout ?? undefined) as JsonValue | undefined,
layout: (row.layout === undefined ? undefined : row.layout === null ? null : row.layout) as JsonValue | null | undefined,
Fix with Cubic

Comment thread src/app/api/zero/query/route.ts
case "connecting":
return "connecting";
default:
return "disconnected";

@cubic-dev-ai cubic-dev-ai Bot Apr 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: error connection states are being downgraded to disconnected, which hides actual sync failures from realtime context consumers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/contexts/RealtimeContext.tsx, line 50:

<comment>`error` connection states are being downgraded to `disconnected`, which hides actual sync failures from realtime context consumers.</comment>

<file context>
@@ -37,10 +37,21 @@ export function RealtimeProvider({
+        case "connecting":
+          return "connecting";
+        default:
+          return "disconnected";
+      }
+    },
</file context>
Suggested change
return "disconnected";
return zeroConnectionState.name === "error" ? "error" : "disconnected";
Fix with Cubic

.update(workspaceItems)
.set({
processingStatus: "complete",
hasTranscript: true,

@cubic-dev-ai cubic-dev-ai Bot Apr 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: hasTranscript is set to true unconditionally, even when transcription returns no segments/text.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/workflows/audio-transcribe/steps/persist-result.ts, line 56:

<comment>`hasTranscript` is set to `true` unconditionally, even when transcription returns no segments/text.</comment>

<file context>
@@ -1,95 +1,144 @@
+      .update(workspaceItems)
+      .set({
+        processingStatus: "complete",
+        hasTranscript: true,
+        lastModified: Date.now(),
+      })
</file context>
Fix with Cubic

Comment thread src/lib/zero/mutators.ts
) {
return {
...row,
structuredData: (row.structuredData ?? undefined) as JsonValue | undefined,

@cubic-dev-ai cubic-dev-ai Bot Apr 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Same nullundefined coalescing pattern as toMutateShellRow. For upsert, undefined means "use column default" while null means "set to null". These happen to align today if defaults are null, but the semantics are wrong and will break if these columns gain non-null defaults or this helper is reused with update.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib/zero/mutators.ts, line 250:

<comment>Same `null` → `undefined` coalescing pattern as `toMutateShellRow`. For `upsert`, `undefined` means "use column default" while `null` means "set to null". These happen to align today if defaults are null, but the semantics are wrong and will break if these columns gain non-null defaults or this helper is reused with `update`.</comment>

<file context>
@@ -0,0 +1,642 @@
+) {
+  return {
+    ...row,
+    structuredData: (row.structuredData ?? undefined) as JsonValue | undefined,
+    assetData: (row.assetData ?? undefined) as JsonValue | undefined,
+    embedData: (row.embedData ?? undefined) as JsonValue | undefined,
</file context>
Fix with Cubic

@@ -1,434 +0,0 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";

@cubic-dev-ai cubic-dev-ai Bot Apr 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Flag AI Slop and Fabricated Changes

Deleted regression coverage for the workspace edit workflow in a behavior-change PR.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib/ai/workers/__tests__/workspace-worker.edit.test.ts, line 1:

<comment>Deleted regression coverage for the workspace edit workflow in a behavior-change PR.</comment>

<file context>
@@ -1,434 +0,0 @@
-import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
-
-const mockGetSession = vi.fn();
-const mockHeaders = vi.fn();
-const mockLoadWorkspaceState = vi.fn();
-const mockCreateEvent = vi.fn();
-const mockExecute = vi.fn();
-const mockTransaction = vi.fn();
-const mockBroadcastWorkspaceEventFromServer = vi.fn();
</file context>
Fix with Cubic

isLoading: Boolean(workspaceId) && status.type === "unknown",
error,
version: 0,
refetch: async () => {},

@cubic-dev-ai cubic-dev-ai Bot Apr 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Flag AI Slop and Fabricated Changes

A functional refetch API was replaced with a production no-op stub, which matches the rule’s prohibition on shipping temporary/stub code in real application paths without explicit rationale.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/hooks/workspace/use-workspace-state.ts, line 81:

<comment>A functional `refetch` API was replaced with a production no-op stub, which matches the rule’s prohibition on shipping temporary/stub code in real application paths without explicit rationale.</comment>

<file context>
@@ -1,52 +1,83 @@
+    isLoading: Boolean(workspaceId) && status.type === "unknown",
+    error,
+    version: 0,
+    refetch: async () => {},
   };
 }
</file context>
Fix with Cubic

@@ -1,188 +0,0 @@
import { describe, expect, it } from "vitest";

@cubic-dev-ai cubic-dev-ai Bot Apr 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Flag AI Slop and Fabricated Changes

Deleted the projector regression tests instead of preserving coverage for workspace-state behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib/workspace/__tests__/workspace-items-projector.test.ts, line 1:

<comment>Deleted the projector regression tests instead of preserving coverage for workspace-state behavior.</comment>

<file context>
@@ -1,188 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { Item } from "@/lib/workspace-state/types";
-import { eventReducer } from "@/lib/workspace/event-reducer";
-import type { WorkspaceEvent } from "@/lib/workspace/events";
-import { deriveWorkspaceProjectionChangeSet } from "@/lib/workspace/workspace-items-projector";
-import { buildWorkspaceItemTableRows } from "@/lib/workspace/workspace-item-model";
-
-function applyEvents(events: WorkspaceEvent[], baseState: Item[] = []) {
-  let state = baseState;
</file context>
Fix with Cubic

.values({
workspaceId,
itemId,
searchText: transcriptText ?? "",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

Both workflow steps now hand-write workspace_item_extracted rows with only raw transcript/OCR text and never refresh contentPreview, but the rest of the Zero stack treats extracted rows as a canonical projection built by buildWorkspaceItemTableRows: getItemSearchIndex() lowercases and prefixes name, subtitle, and type, and getItemContentPreview() derives a truncated preview from the full item body. The shared server mutator path explicitly calls that canonical projector to keep extracted rows in sync; these workflow writes bypass it. As a result, audio/OCR items written by workflows now have narrower/stale extracted metadata than items written through mutators (for example, audio summaries and item titles disappear from searchText, and previews never update). Recompute extracted rows through the shared projector after updating shell/content instead of hard-coding searchText here.

// src/workflows/audio-transcribe/steps/persist-result.ts
.values({
  workspaceId,
  itemId,
  searchText: transcriptText ?? "",
  transcriptText,

tx: DbExecutor,
workspaceId: string,
): Promise<Item[]> {
const shellRows = await tx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The new read path reconstructs each item with separate statements for workspace_items, workspace_item_content, and workspace_item_extracted. In PostgreSQL's default READ COMMITTED isolation, each statement gets its own snapshot, so a writer that commits between these selects can make one API response combine the old shell row with the new content/extracted row (or vice versa). The same pattern exists in loadWorkspaceItemRecord, so server-side update/delete helpers can also read a torn item image. That produces impossible Item objects during active OCR/audio updates or normal item edits. Fix by reading the slices in a single joined statement (preferred) or by explicitly using a repeatable-read snapshot for the transaction.

// src/lib/workspace/workspace-state-read.ts
const shellRows = await tx
  .select()
  .from(workspaceItems)
  .where(eq(workspaceItems.workspaceId, workspaceId))
  .orderBy(asc(workspaceItems.createdAt), asc(workspaceItems.itemId));

"tag": "0001_stormy_rawhide_kid",
"breakpoints": true
},
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The journal records a new migration:

{
  "idx": 2,
  "version": "7",
  "when": 1775793600000,
  "tag": "0002_zero_publication",
  "breakpoints": true
}

but @drizzle/meta still contains only 0000_snapshot.json and 0001_snapshot.json, and 0001_snapshot.json still models the legacy tables deleted in this PR. That leaves Drizzle's metadata out of sync with the post-PR schema, so the next drizzle-kit generate will diff against stale state and can emit incorrect follow-up migrations. Commit the generated 0002_snapshot.json for the new schema state alongside the journal entry.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 6 files (changes from recent commits).

Requires human review: Auto-approval blocked by 10 unresolved issues from previous reviews.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/components/workspace-canvas/QuizContent.tsx (2)

58-108: ⚠️ Potential issue | 🟠 Major

Spurious "new questions added" toast on initial Zero hydration and remount.

prevQuestionIdsRef/prevQuestionCountRef are initialized from the first render. With Zero sync, the first render frequently sees questions = [] (pre-hydration or before workspace_item_content replicates), and the next render sees the real questions. The effect then computes questionsAdded > 0 && currentCount > prevCount as true and fires a success toast every time a user opens a quiz card. Similarly, any remount (navigation, modal open/close) will surface the same false toast if the initial data arrives asynchronously.

Guard the "newly added" detection so it only fires after the first non-empty render, e.g., track a hasHydrated ref or skip the toast when prevCount === 0.

💡 Suggested guard
+  const hasHydratedRef = useRef(false);
   useEffect(() => {
     const prevCount = prevQuestionCountRef.current;
     const currentCount = questions.length;
     const prevIds = prevQuestionIdsRef.current;

     const currentIds = new Set(questions.map((q) => q.id));
     const questionsAdded = questions.filter((q) => !prevIds.has(q.id)).length;

     const hasUnansweredQuestions = answeredQuestions.length < currentCount;

-    if (questionsAdded > 0 && currentCount > prevCount) {
+    if (
+      hasHydratedRef.current &&
+      questionsAdded > 0 &&
+      currentCount > prevCount
+    ) {
       ...
     } else if (showResults && hasUnansweredQuestions) {
       setShowResults(false);
     }

     prevQuestionCountRef.current = currentCount;
     prevQuestionIdsRef.current = currentIds;
+    if (currentCount > 0) hasHydratedRef.current = true;
   }, [questions, showResults, answeredQuestions.length, currentIndex]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/workspace-canvas/QuizContent.tsx` around lines 58 - 108, The
toast fires on first async hydration because
prevQuestionCountRef/prevQuestionIdsRef are initialized from the first render
(often empty); add a guard to skip the "new questions added" branch when
prevQuestionCountRef.current === 0 (or before a first non-empty render) by
introducing a hasHydratedRef or checking prevCount === 0 and currentCount > 0 to
suppress the toast and only update refs/state (prevQuestionCountRef,
prevQuestionIdsRef) without calling toast, setShowResults, setCurrentIndex,
setSelectedAnswer or setIsSubmitted; keep the existing logic for subsequent
changes when prevCount > 0.

28-40: ⚠️ Potential issue | 🟡 Minor

Quiz progress is now lost on remount — confirm this is the intended UX.

With session persistence removed, currentIndex, answeredQuestions, and showResults live purely in local component state. Navigating away from the workspace, closing/reopening the modal, or any unmount resets the quiz. If that's the intentional tradeoff per the Zero migration, consider persisting to sessionStorage keyed by item.id to avoid regressing the "resume where I left off" flow.

Additionally, onUpdateData is kept in QuizContentProps but is now dead (_onUpdateData). Parent components (CardRenderer, WorkspaceCardContent) still construct and pass this callback needlessly — drop the prop from the interface and clean up call sites to avoid misleading future readers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/workspace-canvas/QuizContent.tsx` around lines 28 - 40, Quiz
progress state (currentIndex, answeredQuestions, showResults) is lost on unmount
because it's only kept in component state; persist these to sessionStorage keyed
by item.id (read on mount to restore and write on updates) to preserve resume
behavior, and remove the now-unused onUpdateData prop from the QuizContentProps
interface and its parameter (_onUpdateData) while also cleaning up call sites
that still pass onUpdateData (e.g., CardRenderer, WorkspaceCardContent) so the
prop is not misleading.
src/app/api/workspaces/route.ts (1)

73-99: ⚠️ Potential issue | 🟠 Major

Regenerate the slug on uniqueness retries.

The retry loop catches 23505, but each attempt uses the same generateSlug(name) value, so slug conflicts will fail five times instead of resolving.

🔧 Proposed retry fix
   while (attempts < MAX_ATTEMPTS) {
     try {
-      const slug = generateSlug(name);
+      const baseSlug = generateSlug(name);
+      const slug = attempts === 0 ? baseSlug : `${baseSlug}-${attempts}`;
 
       [workspace] = await db
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/workspaces/route.ts` around lines 73 - 99, The insert retries
currently reuse the same slug on uniqueness failures; update the loop so a fresh
slug is generated on each attempt by calling generateSlug(name) for every
iteration (either move "const slug = generateSlug(name)" to the top of the while
loop so it runs before each try, or call generateSlug(name) again inside the
23505 catch branch before continuing), ensuring the slug variable is in scope
for the db.insert(workspaces).values(...).returning() call and preserving the
existing attempts / MAX_ATTEMPTS logic.
♻️ Duplicate comments (2)
src/lib/ai/workers/workspace-worker.ts (1)

185-224: ⚠️ Potential issue | 🟠 Major

lastModified still not stamped on AI-worker writes.

buildItemFromCreateParams (lines 173-182) doesn't set lastModified, and neither createWorkspaceItem / createWorkspaceItems nor updateWorkspaceItem stamps it before calling insertWorkspaceItem / upsertWorkspaceItem. As a result, AI-created items land with NULL last_modified, and AI edits preserve the stale value — inconsistent with Zero client mutators (src/lib/zero/mutators.ts) which set lastModified: Date.now() on every create/update. This affects "recent activity" UI, sorts by last modified, and optimistic-update reconciliation.

🐛 Proposed fix
 async function createWorkspaceItem(workspaceId: string, item: Item) {
   await db.transaction(async (tx) => {
     await insertWorkspaceItem(tx, {
       workspaceId,
-      item: sanitizeWorkspaceItemForPersistence(item),
+      item: sanitizeWorkspaceItemForPersistence({
+        ...item,
+        lastModified: Date.now(),
+      }),
       sourceVersion: 0,
     });
   });
 }

 async function createWorkspaceItems(workspaceId: string, items: Item[]) {
   await db.transaction(async (tx) => {
+    const now = Date.now();
     for (const item of items) {
       await insertWorkspaceItem(tx, {
         workspaceId,
-        item: sanitizeWorkspaceItemForPersistence(item),
+        item: sanitizeWorkspaceItemForPersistence({ ...item, lastModified: now }),
         sourceVersion: 0,
       });
     }
   });
 }

 async function updateWorkspaceItem(
   workspaceId: string,
   itemId: string,
   updater: (item: Item) => Item,
 ) {
   await db.transaction(async (tx) => {
     const existing = await loadWorkspaceItemRecord(tx, { workspaceId, itemId });
     if (!existing) {
       throw new Error(`Item not found with ID: ${itemId}`);
     }

     await upsertWorkspaceItem(tx, {
       workspaceId,
       sourceVersion: existing.sourceVersion,
-      item: sanitizeWorkspaceItemForPersistence(updater(existing.item)),
+      item: sanitizeWorkspaceItemForPersistence({
+        ...updater(existing.item),
+        lastModified: Date.now(),
+      }),
     });
   });
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/ai/workers/workspace-worker.ts` around lines 185 - 224, AI-worker
persistence paths are not stamping lastModified, causing NULL or stale
last_modified values; update buildItemFromCreateParams to set lastModified:
Date.now() for new items and modify createWorkspaceItem, createWorkspaceItems
(before calling insertWorkspaceItem) to ensure
sanitizeWorkspaceItemForPersistence receives an item with lastModified set, and
change updateWorkspaceItem to set lastModified: Date.now() on the updated object
before calling upsertWorkspaceItem (use existing.item as the base, apply
updater, then overwrite lastModified), so all
insertWorkspaceItem/upsertWorkspaceItem calls persist a current lastModified
like Zero mutators do.
src/hooks/workspace/use-workspace-operations.ts (1)

389-425: ⚠️ Potential issue | 🟠 Major

Restore the duplicate-name guard on single-create paths.

createItems() still skips explicit duplicates, but createItem(), createFolder(), and createFolderWithItems() can now send duplicate sibling names through item.create / folder.createWithItems. Please apply the same hasDuplicateName(...) check before these single-create mutations, or enforce it in the server mutator so all write paths share the invariant.

Also applies to: 747-759, 798-810

🧹 Nitpick comments (7)
src/lib/auth.ts (3)

58-75: Wildcard subdomains are supported by Better Auth. No action needed for https://*.thinkex.app.

Separate note: The NODE_ENV === "development" branch adding "*" to trusted origins (line 71) is very permissive; audit to ensure this wildcard cannot leak into preview/staging builds.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/auth.ts` around lines 58 - 75, The trustedOrigins array currently
adds a wildcard ("*") when process.env.NODE_ENV === "development" (the spread
that creates ["*"]); change this to only allow the wildcard in true local
development environments (avoid letting it into preview/staging/vercel builds)
by tightening the condition — for example, require an additional signal such as
process.env.VERCEL === undefined or process.env.VERCEL_ENV === "development"
(i.e., use process.env.NODE_ENV === "development" && !process.env.VERCEL or
check process.env.VERCEL_ENV) so that the wildcard cannot be injected in
preview/staging; update the spread expression that currently uses
(process.env.NODE_ENV === "development" ? ["*"] : []) accordingly.

107-110: Remove leading dot from cookie domain to align with Better Auth best practices.

The .thinkex.app domain format deviates from Better Auth's documented guidance, which recommends domains without leading dots (e.g., thinkex.app instead of .thinkex.app). While modern browsers handle both formats equivalently per RFC 6265, Better Auth explicitly advises the simpler format for security and compatibility. Change to thinkex.app.

The coupling of ZERO_COOKIE_DOMAIN to auth cookie configuration is intentional (documented in .env.example under "Zero Sync"), but consider adding a comment in the code clarifying this design decision to prevent accidental changes by future operators.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/auth.ts` around lines 107 - 110, Update the
crossSubDomainCookies.domain default to remove the leading dot (use
"thinkex.app" instead of ".thinkex.app") in the crossSubDomainCookies block,
keeping the override via process.env.ZERO_COOKIE_DOMAIN intact; also add a short
inline comment next to ZERO_COOKIE_DOMAIN/crossSubDomainCookies explaining that
this env var is intentionally shared with Zero Sync (refer to .env.example) so
future maintainers don’t remove or change the coupling.

25-25: Adding explicit validation for authSecret is good practice, but Better Auth already prevents silent failures in production.

While the code passes authSecret (which could be undefined) to betterAuth({ secret }), Better Auth's built-in validation will throw an error in production if neither BETTER_AUTH_SECRET nor AUTH_SECRET is set, preventing silent fallback to an insecure default. The middleware in this codebase uses getSessionCookie(), which only checks cookie presence and does not depend on the secret.

That said, an explicit validation check remains a good defensive measure to fail immediately and clearly during initialization rather than relying on Better Auth's deferred validation.

Optional: Add explicit validation
const authSecret = process.env.BETTER_AUTH_SECRET || process.env.AUTH_SECRET;
+if (!authSecret && process.env.NODE_ENV === "production") {
+  throw new Error(
+    "[auth] BETTER_AUTH_SECRET (or AUTH_SECRET) must be set in production",
+  );
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/auth.ts` at line 25, The code currently derives authSecret via the
authSecret constant and passes it to betterAuth({ secret }) without an explicit
check; add a defensive validation right after the authSecret assignment to throw
a clear, early error if authSecret is falsy (e.g., throw new Error('Missing auth
secret: set BETTER_AUTH_SECRET or AUTH_SECRET')), so initialization fails fast
and provides a clear message; keep betterAuth({ secret: authSecret }) and leave
getSessionCookie() behavior unchanged.
src/lib/workspace/workspace-state-read.ts (1)

96-97: Avoid as any on JSONB columns.

ocrPages/transcriptSegments are typed unknown from Drizzle's jsonb(). Casting to any discards type safety and silently unblocks invalid shapes. Prefer the same narrowing style used for structuredData above (as Record<string, unknown> | null or the actual projection types).

🔧 Proposed fix
-            ocrPages: (extracted.ocrPages as any) ?? null,
-            transcriptSegments: (extracted.transcriptSegments as any) ?? null,
+            ocrPages:
+              (extracted.ocrPages as Record<string, unknown> | null) ?? null,
+            transcriptSegments:
+              (extracted.transcriptSegments as Record<string, unknown> | null) ??
+              null,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/workspace/workspace-state-read.ts` around lines 96 - 97, The ocrPages
and transcriptSegments fields are being cast with "as any", which discards type
safety; update the assignments in workspace-state-read.ts to use the same
narrowing style as structuredData instead of "as any" — e.g., narrow
extracted.ocrPages and extracted.transcriptSegments to a safe JSON shape such as
Record<string, unknown> | null or the concrete projection types (e.g., OcrPage[]
| null, TranscriptSegment[] | null) so their types remain checked and invalid
shapes are not silently accepted; change the two expressions that currently use
(extracted.ocrPages as any) and (extracted.transcriptSegments as any) to the
appropriate narrowed types.
src/lib/zero/mutators.ts (2)

432-449: create silently overrides args.item.id with args.id.

itemSchema requires id: z.string() on the nested item, and the mutator then spreads ...args.item, id: args.id — making the nested id meaningless (the outer args.id always wins). This is confusing for callers and invites bugs where a caller sets only args.item.id and the top-level args.id is stale/mismatched.

Consider either (a) dropping id from itemSchema here so the outer args.id is the single source of truth, or (b) validating that args.id === args.item.id when both are present.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/zero/mutators.ts` around lines 432 - 449, The create mutator
currently overwrites args.item.id with args.id which makes the nested id
meaningless; update the create handler (the defineMutator for
zeroMutatorSchemas.item.create, where toItem(...) and insertItem(...) are
called) to validate that if args.item.id is present it must equal args.id and
otherwise throw a clear validation error (or reject the request), so the
top-level args.id remains the single source of truth; reference args.id and
args.item.id in this check and keep the subsequent toItem(...) call unchanged so
insertItem(...) still receives the canonical id.

313-353: Dead userId parameter on insertItem/upsertItem.

Both helpers take userId: string | null but never use it. Since these mutators are intentionally auth-agnostic (auth happens in server-mutators.ts), dropping the parameter avoids misleading callers into thinking they're providing auth context.

♻️ Proposed cleanup
 async function insertItem(
   tx: ZeroTx,
   params: {
     workspaceId: string;
     item: Item;
     sourceVersion?: number;
-    userId: string | null;
   },
 ) {
@@
 async function upsertItem(
   tx: ZeroTx,
   params: {
     workspaceId: string;
     item: Item;
     sourceVersion: number;
-    userId: string | null;
   },
 ) {

Call sites in mutators.item.{create,update,createMany,patchMany,updateMany} and folder.createWithItems drop the userId: field accordingly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/zero/mutators.ts` around lines 313 - 353, Remove the dead userId
parameter from the insertItem and upsertItem function signatures and all call
sites; specifically update async function insertItem(tx: ZeroTx, params: {
workspaceId: string; item: Item; sourceVersion?: number; }) and async function
upsertItem(tx: ZeroTx, params: { workspaceId: string; item: Item; sourceVersion:
number; }) and then delete any userId: ... being passed from callers (e.g.,
mutators.item.create/update/createMany/patchMany/updateMany and
folder.createWithItems). Keep the existing logic that builds rows via
buildWorkspaceItemTableRows and calls to toMutateShellRow/toMutateContentRow and
tx.mutate.workspace_items / workspace_item_content unchanged.
src/lib/ai/workers/workspace-worker.ts (1)

309-316: Remove unused event and version fields from the return type.

The return type declares event?: unknown and version?: number, but the implementation never populates these fields in any code path. The only caller that reads them (quiz-tools.ts lines 81-82) receives undefined values, which it then passes downstream. These dead fields create a misleading API surface. Remove them along with their usages in quiz-tools.ts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/ai/workers/workspace-worker.ts` around lines 309 - 316, The return
type of the function in workspace-worker.ts declares unused optional fields
event?: unknown and version?: number—remove these fields from the Promise return
type and any related type annotations in that function (the signature that
currently returns { success, message, itemId?, cardCount?, event?, version? }),
then update any call sites (notably the consumer in quiz-tools.ts where the
result is destructured or accessed around lines that read event/version) to stop
referencing event and version; ensure only success, message, itemId, and
cardCount remain in the returned shape and used downstream (adjust any
interface/type aliases if present to match the new shape).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.env.example:
- Around line 66-68: The Zero Sync env block (NEXT_PUBLIC_ZERO_SERVER and
ZERO_COOKIE_DOMAIN) is out of order and triggers dotenv-linter's UnorderedKey;
move the two lines for NEXT_PUBLIC_ZERO_SERVER and ZERO_COOKIE_DOMAIN so they
appear before the SUPERMEMORY_ENABLED entry (or reorder the surrounding env keys
to match the linter's expected alphabetical/grouping), ensuring the variable
names remain unchanged and the block stays contiguous.

In `@drizzle/0002_zero_publication.sql`:
- Around line 1-3: The CREATE PUBLICATION zero_pub statement is not idempotent
and will fail if zero_pub already exists; update the migration by replacing the
plain CREATE PUBLICATION zero_pub FOR TABLE ... with a DO block that checks
pg_catalog.pg_publication (or pg_publication_rel) for an existing publication
named 'zero_pub' and only executes CREATE PUBLICATION zero_pub FOR TABLE
workspace_items, workspace_item_content when it does not exist; also confirm
that the referenced tables workspace_items and workspace_item_content have
primary keys or unique indexes (Zero requirement) before creating the
publication.

In `@src/app/api/workspaces/route.ts`:
- Around line 111-123: The workspace row is currently inserted before the
item-insert transaction, so failures can leave an empty workspace; move the
workspace insert into the same db.transaction that inserts initialItems.
Specifically, start a single db.transaction (use the same tx passed into
insertWorkspaceItem) and perform the workspace creation and then loop to call
insertWorkspaceItem(tx, {...}) for each initial item inside that transaction,
then remove the compensating db.delete(workspaces)... throw insertError cleanup
since the transaction will roll back on error. Ensure you reference the existing
functions/objects: db.transaction, insertWorkspaceItem, and the
workspaces/workspace creation logic so both operations run atomically.

In `@src/app/dashboard/page.tsx`:
- Around line 86-87: isSaving is incorrectly derived from
zeroConnectionState.name === "connecting", causing false positives on reconnect
and false negatives when local mutations are queued; update the logic to reflect
pending local mutations instead of transport state by reading Zero's
pending-mutation metric (e.g. zero.pendingMutationCount or
zeroConnectionState.pendingMutations) and set isSaving = pendingCount > 0 (or
similar), or if the Zero API doesn't expose a pending count, remove the save
indicator entirely; locate the usage around zeroConnectionState and isSaving in
page.tsx and replace the connection-state-based check with a
pending-mutation-based check or drop the indicator.

In `@src/contexts/RealtimeContext.tsx`:
- Around line 42-54: Update the useMemo that computes connectionStatus so it
maps zeroConnectionState.name values "error" and "needs-auth" to the
RealtimeContextType "error" status instead of returning "disconnected", and
change the memo dependency from [zeroConnectionState] to
[zeroConnectionState.name]; locate the memo block that defines connectionStatus
(the useMemo referencing zeroConnectionState) and adjust the switch to include
cases "error" and "needs-auth" returning "error" while leaving "connected" and
"connecting" as-is.

In `@src/hooks/workspace/use-workspace-operations.ts`:
- Around line 988-990: The hook currently hardcodes isPending: false, isError:
false, error: null which masks real mutator failures; update the
useWorkspaceOperations hook so that any mutator invocation (e.g.,
save/delete/update workspace functions inside the hook) sets isPending=true
while the request runs, then on success sets isPending=false and clears error,
and on rejection captures the thrown ApplicationError (or network error), sets
isPending=false, isError=true, and populates error with the caught error
object/message so callers can observe and react to failed saves/deletes or
authorization errors.
- Around line 589-627: The current deleteItem flow deletes the PDF file before
the authoritative server-side deletion (see deleteItem, currentItemsRef,
pdfData.fileUrl, zeroRef.current.mutate and mutators.item.delete); change the
flow so the DB/server delete is confirmed first and only then remove the file
asset. Concretely: call zeroRef.current.mutate(mutators.item.delete(...)) and
await/confirm success (or handle the outbox/server-side file cleanup) before
invoking the fetch DELETE for pdfData.fileUrl; if the mutator fails, skip file
deletion and surface/log the error.
- Around line 651-740: The update logic in updateAllItems currently only handles
deletes when items.length < previousItemCount and adds when >, missing cases
where a delete and add occur but lengths match; change the flow to always
compute deletedIds (items in currentItemsRef.current not in newIds), addedItems
(items in items not in currentIds, passed through
sanitizeWorkspaceItemForPersistence), and layoutUpdates (as currently computed
with getLayoutForBreakpoint), then if any of deletedIds, addedItems, or
layoutUpdates are non-empty call
zeroRef.current.mutate(mutators.item.updateMany({ workspaceId:
currentWorkspaceId, deletedIds, addedItems: addedItems?.map(...), layoutUpdates,
previousItemCount })) so a single updateMany applies all changes atomically;
keep existing mapping shape for addedItems and reuse
currentItemsRef/currentIds/newIds variables to locate items.

In `@src/hooks/workspace/use-workspace-state.ts`:
- Around line 36-64: The Zero query for items must include the related
workspaceItemExtracted and the hook must pass that data into
rehydrateWorkspaceItem instead of hardcoding extracted: null; update
queries.workspace.items to add .related("workspaceItemExtracted") (alongside
workspaceItemContent) and in use-workspace-state.ts map the returned related
workspaceItemExtracted row to the extracted argument of rehydrateWorkspaceItem
so PDF/image OCR pages and audio transcriptSegments are populated (identify the
mapping near the rehydrateWorkspaceItem call in use-workspace-state.ts and the
query definition in queries.workspace.items).

In `@src/lib/workspace/workspace-item-sanitize.ts`:
- Around line 1-11: The two sanitizer functions
sanitizeWorkspaceItemForPersistence and sanitizeWorkspaceItemChanges are
currently no-ops, which prevents dropping transient/client-only fields and
breaks the "empty-changes short-circuit"; either implement sanitization to
remove derived fields (e.g., lastModified, ephemeral UI state, any fields not
persisted to workspace_items/workspace_item_content) or remove these functions
and their call sites so updates go directly to mutators.item.update. Locate
sanitizeWorkspaceItemForPersistence and sanitizeWorkspaceItemChanges and either
(A) implement a whitelist/blacklist that removes non-persistent keys from Item
and Partial<Item> before returning (ensuring sanitizeWorkspaceItemChanges can
return an empty object when nothing remains), or (B) delete these functions and
refactor call sites (such as use-workspace-operations.ts) to stop invoking them.

In `@src/lib/workspace/workspace-item-write.ts`:
- Around line 151-177: The current upsertWorkspaceItem() only performs an UPDATE
(the tx.update(...).set(...) block) so if the shell row is missing it never
INSERTs and later content/extracted upserts produce orphan rows; change
upsertWorkspaceItem() to perform an upsert: after the UPDATE attempt, check
whether any row was affected and if zero, INSERT a shell row into workspaceItems
with at minimum workspaceId, itemId, createdAt/updatedAt and any required
non-null columns (type/name/folderId as appropriate) or use a single INSERT ...
ON CONFLICT (workspaceId, itemId) DO UPDATE to atomically create-or-update the
shell; ensure loadWorkspaceItemRecord() will then find the shell row.

In `@src/lib/zero/client.ts`:
- Around line 9-24: The createZeroInstance function currently uses a non-null
assertion for process.env.NEXT_PUBLIC_ZERO_SERVER and a localhost fallback for
appURL; change it to validate required env vars up-front: throw a clear Error if
NEXT_PUBLIC_ZERO_SERVER is missing, and if NODE_ENV === "production" (or when
deploying) also require NEXT_PUBLIC_APP_URL instead of falling back to
"http://localhost:3000"; use those validated values for Zero({ cacheURL,
mutateURL, queryURL, ... }) so cacheURL, mutateURL and queryURL are never
undefined and produce an explicit configuration error instead of downstream
cryptic failures.

In `@src/lib/zero/zero-schema.gen.ts`:
- Around line 14-37: The createdAt column in the workspaceItems table schema is
marked optional but the DB column is NOT NULL with a default, so update the
workspaceItems table definition to remove .optional() and use
number().from("created_at") for the createdAt field (modify the createdAt
property in the workspaceItems .columns call).

In `@src/workflows/audio-transcribe/steps/persist-result.ts`:
- Around line 66-103: The code performs a non-atomic read-modify-write: you call
tx.select(...) to read workspaceItemContent.assetData into currentAssetData then
tx.update(workspaceItemContent).set(...) to write back merged assetData, which
can lose concurrent updates; instead remove the SELECT and perform a single
UPDATE that merges JSONB on the DB side (use PostgreSQL jsonb
concatenation/merge via an sql template or query builder to set assetData =
workspace_item_content.asset_data || '{"duration": ...}'::jsonb when
result.duration is present), keep setting structuredData.summary and updatedAt
in the same UPDATE, and only conditionally include the jsonb merge when
result.duration > 0; also fix the incorrect cast of result.segments (don’t cast
segments to Record<string, unknown> — use a truthful type such as unknown or the
actual array type) so the value stored in assetData/columns matches its real
type.

In `@src/workflows/ocr-dispatch/steps/persist-results.ts`:
- Around line 18-100: The loop currently does per-result db.transaction calls
and reads workspaceItemContent.assetData without locking, causing a lost-update
race; change to use a single outer db.transaction for the whole results array
(replace per-result db.transaction(...) with one transaction enclosing the loop)
and inside that transaction either (A) change the SELECT from
workspaceItemContent to acquire a row lock (use the query builder's
.for("update") on the select of workspaceItemContent.assetData) before computing
nextAssetData, or (B) avoid read-modify-write entirely by performing the merge
in SQL (use jsonb_set or the jsonb '-' operator to remove/add the ocrError key
on workspaceItemContent.assetData in the UPDATE statement); reference the
symbols workspaceItemContent, assetData, db.transaction, workspaceItemExtracted,
workspaceItems, and use jsonb_set / '-' if choosing the SQL-merge approach.

---

Outside diff comments:
In `@src/app/api/workspaces/route.ts`:
- Around line 73-99: The insert retries currently reuse the same slug on
uniqueness failures; update the loop so a fresh slug is generated on each
attempt by calling generateSlug(name) for every iteration (either move "const
slug = generateSlug(name)" to the top of the while loop so it runs before each
try, or call generateSlug(name) again inside the 23505 catch branch before
continuing), ensuring the slug variable is in scope for the
db.insert(workspaces).values(...).returning() call and preserving the existing
attempts / MAX_ATTEMPTS logic.

In `@src/components/workspace-canvas/QuizContent.tsx`:
- Around line 58-108: The toast fires on first async hydration because
prevQuestionCountRef/prevQuestionIdsRef are initialized from the first render
(often empty); add a guard to skip the "new questions added" branch when
prevQuestionCountRef.current === 0 (or before a first non-empty render) by
introducing a hasHydratedRef or checking prevCount === 0 and currentCount > 0 to
suppress the toast and only update refs/state (prevQuestionCountRef,
prevQuestionIdsRef) without calling toast, setShowResults, setCurrentIndex,
setSelectedAnswer or setIsSubmitted; keep the existing logic for subsequent
changes when prevCount > 0.
- Around line 28-40: Quiz progress state (currentIndex, answeredQuestions,
showResults) is lost on unmount because it's only kept in component state;
persist these to sessionStorage keyed by item.id (read on mount to restore and
write on updates) to preserve resume behavior, and remove the now-unused
onUpdateData prop from the QuizContentProps interface and its parameter
(_onUpdateData) while also cleaning up call sites that still pass onUpdateData
(e.g., CardRenderer, WorkspaceCardContent) so the prop is not misleading.

---

Duplicate comments:
In `@src/lib/ai/workers/workspace-worker.ts`:
- Around line 185-224: AI-worker persistence paths are not stamping
lastModified, causing NULL or stale last_modified values; update
buildItemFromCreateParams to set lastModified: Date.now() for new items and
modify createWorkspaceItem, createWorkspaceItems (before calling
insertWorkspaceItem) to ensure sanitizeWorkspaceItemForPersistence receives an
item with lastModified set, and change updateWorkspaceItem to set lastModified:
Date.now() on the updated object before calling upsertWorkspaceItem (use
existing.item as the base, apply updater, then overwrite lastModified), so all
insertWorkspaceItem/upsertWorkspaceItem calls persist a current lastModified
like Zero mutators do.

---

Nitpick comments:
In `@src/lib/ai/workers/workspace-worker.ts`:
- Around line 309-316: The return type of the function in workspace-worker.ts
declares unused optional fields event?: unknown and version?: number—remove
these fields from the Promise return type and any related type annotations in
that function (the signature that currently returns { success, message, itemId?,
cardCount?, event?, version? }), then update any call sites (notably the
consumer in quiz-tools.ts where the result is destructured or accessed around
lines that read event/version) to stop referencing event and version; ensure
only success, message, itemId, and cardCount remain in the returned shape and
used downstream (adjust any interface/type aliases if present to match the new
shape).

In `@src/lib/auth.ts`:
- Around line 58-75: The trustedOrigins array currently adds a wildcard ("*")
when process.env.NODE_ENV === "development" (the spread that creates ["*"]);
change this to only allow the wildcard in true local development environments
(avoid letting it into preview/staging/vercel builds) by tightening the
condition — for example, require an additional signal such as process.env.VERCEL
=== undefined or process.env.VERCEL_ENV === "development" (i.e., use
process.env.NODE_ENV === "development" && !process.env.VERCEL or check
process.env.VERCEL_ENV) so that the wildcard cannot be injected in
preview/staging; update the spread expression that currently uses
(process.env.NODE_ENV === "development" ? ["*"] : []) accordingly.
- Around line 107-110: Update the crossSubDomainCookies.domain default to remove
the leading dot (use "thinkex.app" instead of ".thinkex.app") in the
crossSubDomainCookies block, keeping the override via
process.env.ZERO_COOKIE_DOMAIN intact; also add a short inline comment next to
ZERO_COOKIE_DOMAIN/crossSubDomainCookies explaining that this env var is
intentionally shared with Zero Sync (refer to .env.example) so future
maintainers don’t remove or change the coupling.
- Line 25: The code currently derives authSecret via the authSecret constant and
passes it to betterAuth({ secret }) without an explicit check; add a defensive
validation right after the authSecret assignment to throw a clear, early error
if authSecret is falsy (e.g., throw new Error('Missing auth secret: set
BETTER_AUTH_SECRET or AUTH_SECRET')), so initialization fails fast and provides
a clear message; keep betterAuth({ secret: authSecret }) and leave
getSessionCookie() behavior unchanged.

In `@src/lib/workspace/workspace-state-read.ts`:
- Around line 96-97: The ocrPages and transcriptSegments fields are being cast
with "as any", which discards type safety; update the assignments in
workspace-state-read.ts to use the same narrowing style as structuredData
instead of "as any" — e.g., narrow extracted.ocrPages and
extracted.transcriptSegments to a safe JSON shape such as Record<string,
unknown> | null or the concrete projection types (e.g., OcrPage[] | null,
TranscriptSegment[] | null) so their types remain checked and invalid shapes are
not silently accepted; change the two expressions that currently use
(extracted.ocrPages as any) and (extracted.transcriptSegments as any) to the
appropriate narrowed types.

In `@src/lib/zero/mutators.ts`:
- Around line 432-449: The create mutator currently overwrites args.item.id with
args.id which makes the nested id meaningless; update the create handler (the
defineMutator for zeroMutatorSchemas.item.create, where toItem(...) and
insertItem(...) are called) to validate that if args.item.id is present it must
equal args.id and otherwise throw a clear validation error (or reject the
request), so the top-level args.id remains the single source of truth; reference
args.id and args.item.id in this check and keep the subsequent toItem(...) call
unchanged so insertItem(...) still receives the canonical id.
- Around line 313-353: Remove the dead userId parameter from the insertItem and
upsertItem function signatures and all call sites; specifically update async
function insertItem(tx: ZeroTx, params: { workspaceId: string; item: Item;
sourceVersion?: number; }) and async function upsertItem(tx: ZeroTx, params: {
workspaceId: string; item: Item; sourceVersion: number; }) and then delete any
userId: ... being passed from callers (e.g.,
mutators.item.create/update/createMany/patchMany/updateMany and
folder.createWithItems). Keep the existing logic that builds rows via
buildWorkspaceItemTableRows and calls to toMutateShellRow/toMutateContentRow and
tx.mutate.workspace_items / workspace_item_content unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 795a902b-0c75-4ff1-ad3e-84b9b6513cb4

📥 Commits

Reviewing files that changed from the base of the PR and between 348b81a and 4a7d265.

📒 Files selected for processing (72)
  • .env.example
  • drizzle/0002_zero_publication.sql
  • drizzle/meta/_journal.json
  • package.json
  • src/app/api/share/[id]/route.ts
  • src/app/api/workspaces/[id]/events/route.ts
  • src/app/api/workspaces/[id]/route.ts
  • src/app/api/workspaces/[id]/state/route.ts
  • src/app/api/workspaces/route.ts
  • src/app/api/workspaces/slug/[slug]/route.ts
  • src/app/api/zero/mutate/route.ts
  • src/app/api/zero/query/route.ts
  • src/app/dashboard/page.tsx
  • src/components/workspace-canvas/FlashcardWorkspaceCard.tsx
  • src/components/workspace-canvas/QuizContent.tsx
  • src/components/workspace-canvas/WorkspaceContent.tsx
  • src/contexts/RealtimeContext.tsx
  • src/contexts/WorkspaceContext.tsx
  • src/hooks/workspace/use-workspace-events.ts
  • src/hooks/workspace/use-workspace-mutation.ts
  • src/hooks/workspace/use-workspace-operations.ts
  • src/hooks/workspace/use-workspace-realtime.ts
  • src/hooks/workspace/use-workspace-state.ts
  • src/hooks/workspace/workspace-state-cache.test.ts
  • src/hooks/workspace/workspace-state-cache.ts
  • src/lib/ai/tools/tool-utils.ts
  • src/lib/ai/tools/workspace-tools.ts
  • src/lib/ai/workers/__tests__/workspace-worker.edit.test.ts
  • src/lib/ai/workers/common.ts
  • src/lib/ai/workers/workspace-worker.ts
  • src/lib/auth.ts
  • src/lib/db/relations.ts
  • src/lib/db/schema.ts
  • src/lib/db/types.ts
  • src/lib/realtime/server-broadcast.ts
  • src/lib/stores/workspace-store.ts
  • src/lib/utils/format-workspace-context.ts
  • src/lib/workspace-state/item-data-schemas.ts
  • src/lib/workspace-state/item-helpers.ts
  • src/lib/workspace-state/state.ts
  • src/lib/workspace-state/types.ts
  • src/lib/workspace/__tests__/event-reducer.bulk-items-patched.test.ts
  • src/lib/workspace/__tests__/event-reducer.lightweight-ocr.test.ts
  • src/lib/workspace/__tests__/version-helpers.test.ts
  • src/lib/workspace/__tests__/workspace-event-client-payload.test.ts
  • src/lib/workspace/__tests__/workspace-event-store.test.ts
  • src/lib/workspace/__tests__/workspace-item-model.test.ts
  • src/lib/workspace/__tests__/workspace-items-projector.test.ts
  • src/lib/workspace/event-reducer.ts
  • src/lib/workspace/events.ts
  • src/lib/workspace/mutation-helpers.ts
  • src/lib/workspace/state-loader.ts
  • src/lib/workspace/version-helpers.ts
  • src/lib/workspace/workspace-event-client-payload.ts
  • src/lib/workspace/workspace-event-store.ts
  • src/lib/workspace/workspace-item-model-schemas.ts
  • src/lib/workspace/workspace-item-model-shared.ts
  • src/lib/workspace/workspace-item-model-types.ts
  • src/lib/workspace/workspace-item-model.ts
  • src/lib/workspace/workspace-item-sanitize.ts
  • src/lib/workspace/workspace-item-write.ts
  • src/lib/workspace/workspace-items-projector.ts
  • src/lib/workspace/workspace-state-read.ts
  • src/lib/zero/client.ts
  • src/lib/zero/mutators.ts
  • src/lib/zero/provider.tsx
  • src/lib/zero/queries.ts
  • src/lib/zero/server-mutators.ts
  • src/lib/zero/zero-schema.gen.ts
  • src/workflows/audio-transcribe/steps/persist-result.ts
  • src/workflows/ocr-dispatch/steps/persist-results.ts
  • tsconfig.json
💤 Files with no reviewable changes (27)
  • src/lib/ai/tools/workspace-tools.ts
  • src/lib/workspace-state/state.ts
  • src/lib/workspace/tests/event-reducer.bulk-items-patched.test.ts
  • src/lib/workspace/tests/version-helpers.test.ts
  • src/lib/workspace/tests/workspace-event-client-payload.test.ts
  • src/lib/workspace/tests/event-reducer.lightweight-ocr.test.ts
  • src/lib/workspace-state/item-helpers.ts
  • src/lib/workspace/tests/workspace-items-projector.test.ts
  • src/lib/workspace/event-reducer.ts
  • src/lib/workspace/workspace-event-client-payload.ts
  • src/lib/db/schema.ts
  • src/lib/workspace-state/item-data-schemas.ts
  • src/hooks/workspace/workspace-state-cache.test.ts
  • src/lib/workspace/events.ts
  • src/hooks/workspace/use-workspace-events.ts
  • src/lib/workspace/state-loader.ts
  • src/hooks/workspace/use-workspace-mutation.ts
  • src/lib/ai/workers/tests/workspace-worker.edit.test.ts
  • src/hooks/workspace/use-workspace-realtime.ts
  • src/lib/workspace/version-helpers.ts
  • src/lib/workspace/workspace-event-store.ts
  • src/lib/realtime/server-broadcast.ts
  • src/hooks/workspace/workspace-state-cache.ts
  • src/app/api/workspaces/[id]/state/route.ts
  • src/app/api/workspaces/[id]/events/route.ts
  • src/lib/workspace/workspace-items-projector.ts
  • src/lib/workspace/tests/workspace-event-store.test.ts

Comment thread .env.example
Comment on lines +66 to +68
# Zero Sync
NEXT_PUBLIC_ZERO_SERVER=http://localhost:4848
ZERO_COOKIE_DOMAIN=localhost

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Move the Zero env block to satisfy dotenv-linter.

NEXT_PUBLIC_ZERO_SERVER currently triggers UnorderedKey; move the Zero Sync block before SUPERMEMORY_ENABLED or adjust the ordering expected by the linter.

🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 67-67: [UnorderedKey] The NEXT_PUBLIC_ZERO_SERVER key should go before the SUPERMEMORY_ENABLED key

(UnorderedKey)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.env.example around lines 66 - 68, The Zero Sync env block
(NEXT_PUBLIC_ZERO_SERVER and ZERO_COOKIE_DOMAIN) is out of order and triggers
dotenv-linter's UnorderedKey; move the two lines for NEXT_PUBLIC_ZERO_SERVER and
ZERO_COOKIE_DOMAIN so they appear before the SUPERMEMORY_ENABLED entry (or
reorder the surrounding env keys to match the linter's expected
alphabetical/grouping), ensuring the variable names remain unchanged and the
block stays contiguous.

Comment on lines +1 to +3
CREATE PUBLICATION zero_pub FOR TABLE
workspace_items,
workspace_item_content;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does @rocicorp/zero zero-cache require REPLICA IDENTITY FULL on published Postgres tables?

💡 Result:

No, @rocicorp/zero zero-cache does not require REPLICA IDENTITY FULL on published Postgres tables by default. Official documentation on supported Postgres features, configuration, and replication (sources 1,2,5,9) does not mention any such requirement. zero-cache uses standard logical replication via Postgres publications, which defaults to using the primary key or unique index for row identification (source 2 explicitly requires tables to have a primary key or unique index for sync). REPLICA IDENTITY FULL was added as an explicit support feature in Zero 0.17 (source 9), implying it handles tables needing it (e.g., those without PK/unique index or with non-PK updates), but it is not a general requirement for all tables—only when necessary for correct replication of updates/deletes.

Citations:


Make the migration idempotent to handle replayed runs.

CREATE PUBLICATION zero_pub will fail if the publication already exists (e.g., during migration replay). Postgres lacks CREATE PUBLICATION IF NOT EXISTS, so wrap the creation in a DO block to check for existence first.

Suggested idempotent migration
-CREATE PUBLICATION zero_pub FOR TABLE
-  workspace_items,
-  workspace_item_content;
+DO $$
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'zero_pub') THEN
+    CREATE PUBLICATION zero_pub FOR TABLE
+      workspace_items,
+      workspace_item_content;
+  END IF;
+END $$;

Note: Verify that both workspace_items and workspace_item_content have primary keys or unique indices, as Zero requires them for logical replication. REPLICA IDENTITY FULL is only needed in edge cases (e.g., tables without primary keys), not as a general requirement.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
CREATE PUBLICATION zero_pub FOR TABLE
workspace_items,
workspace_item_content;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'zero_pub') THEN
CREATE PUBLICATION zero_pub FOR TABLE
workspace_items,
workspace_item_content;
END IF;
END $$;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@drizzle/0002_zero_publication.sql` around lines 1 - 3, The CREATE PUBLICATION
zero_pub statement is not idempotent and will fail if zero_pub already exists;
update the migration by replacing the plain CREATE PUBLICATION zero_pub FOR
TABLE ... with a DO block that checks pg_catalog.pg_publication (or
pg_publication_rel) for an existing publication named 'zero_pub' and only
executes CREATE PUBLICATION zero_pub FOR TABLE workspace_items,
workspace_item_content when it does not exist; also confirm that the referenced
tables workspace_items and workspace_item_content have primary keys or unique
indexes (Zero requirement) before creating the publication.

Comment on lines +111 to +123
try {
await db.transaction(async (tx) => {
for (const item of initialItems) {
await insertWorkspaceItem(tx, {
workspaceId: workspace.id,
item,
sourceVersion: 0,
});
}
});
} catch (insertError) {
await db.delete(workspaces).where(eq(workspaces.id, workspace.id));
throw insertError;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make workspace creation and initial item inserts atomic.

The workspace row is committed before this item transaction starts, then a compensating delete tries to clean up failures. If the process dies or the delete fails after an item insert error, an empty workspace can remain and Zero may replicate the transient row. Move the workspace insert and item inserts into the same db.transaction.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/workspaces/route.ts` around lines 111 - 123, The workspace row is
currently inserted before the item-insert transaction, so failures can leave an
empty workspace; move the workspace insert into the same db.transaction that
inserts initialItems. Specifically, start a single db.transaction (use the same
tx passed into insertWorkspaceItem) and perform the workspace creation and then
loop to call insertWorkspaceItem(tx, {...}) for each initial item inside that
transaction, then remove the compensating db.delete(workspaces)... throw
insertError cleanup since the transaction will roll back on error. Ensure you
reference the existing functions/objects: db.transaction, insertWorkspaceItem,
and the workspaces/workspace creation logic so both operations run atomically.

Comment on lines +86 to +87
const zeroConnectionState = useConnectionState();
const isSaving = zeroConnectionState.name === "connecting";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

isSaving no longer reflects actual save progress.

Deriving isSaving from zeroConnectionState.name === "connecting" conflates transport state with mutation state:

  • Initial page load and every reconnect will briefly flash the "saving" indicator even when nothing is dirty.
  • While offline (disconnected/error), local mutations are queued by Zero but the UI will display "not saving", which is the opposite of the user expectation.

Consider wiring this to pending mutation count from Zero (or simply dropping the save indicator if Zero's local-first model makes it meaningless) rather than to connection state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/dashboard/page.tsx` around lines 86 - 87, isSaving is incorrectly
derived from zeroConnectionState.name === "connecting", causing false positives
on reconnect and false negatives when local mutations are queued; update the
logic to reflect pending local mutations instead of transport state by reading
Zero's pending-mutation metric (e.g. zero.pendingMutationCount or
zeroConnectionState.pendingMutations) and set isSaving = pendingCount > 0 (or
similar), or if the Zero API doesn't expose a pending count, remove the save
indicator entirely; locate the usage around zeroConnectionState and isSaving in
page.tsx and replace the connection-state-based check with a
pending-mutation-based check or drop the indicator.

Comment on lines +42 to +54
const connectionStatus = useMemo<RealtimeContextType["connectionStatus"]>(
() => {
switch (zeroConnectionState.name) {
case "connected":
return "connected";
case "connecting":
return "connecting";
default:
return "disconnected";
}
},
[zeroConnectionState],
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

What connection state names can @rocicorp/zero/react useConnectionState return, and what do error and needs-auth mean in the official Rocicorp Zero docs?

💡 Result:

The @rocicorp/zero/react hook useConnectionState returns the current connection state of the Zero instance, with possible state names: 'connecting', 'connected', 'disconnected', 'error', 'needs-auth', and 'closed'. In the official Rocicorp Zero docs: - 'error' occurs if zero-cache crashes or if the mutate or query endpoints return a network or HTTP error (excluding 401/403). Reads are allowed, but writes are rejected. The state includes a 'reason' string. - 'needs-auth' occurs if the mutate or query endpoints return a 401 or 403 status code (indicating unauthorized access). Reads are allowed, but writes are rejected. The state includes a 'reason' object. Re-authenticate the user and call zero.connection.connect (optionally with new auth token) to reconnect.

Citations:


🏁 Script executed:

# First, find and inspect the RealtimeContext.tsx file
find . -name "RealtimeContext.tsx" -type f

Repository: ThinkEx-OSS/thinkex

Length of output: 97


🏁 Script executed:

# Read the specific file to see the full context including type definitions
cat -n src/contexts/RealtimeContext.tsx | head -80

Repository: ThinkEx-OSS/thinkex

Length of output: 2725


Map Zero's error and needs-auth states to the "error" status instead of collapsing them to "disconnected".

Zero's useConnectionState returns error and needs-auth as distinct states that both reject writes. Mapping them to "disconnected" hides actionable failures from the UI: needs-auth requires explicit user re-authentication, while error indicates a transient zero-cache or backend failure. The RealtimeContextType type already defines an "error" status for this purpose, but the current switch statement never returns it.

Also, the dependency array should be [zeroConnectionState.name] instead of [zeroConnectionState] to avoid unnecessary re-memoization when the state object instance changes.

🔧 Proposed fix
   const connectionStatus = useMemo<RealtimeContextType["connectionStatus"]>(
     () => {
       switch (zeroConnectionState.name) {
         case "connected":
           return "connected";
         case "connecting":
           return "connecting";
+        case "disconnected":
+          return "disconnected";
+        case "error":
+        case "needs-auth":
+          return "error";
         default:
           return "disconnected";
       }
     },
-    [zeroConnectionState],
+    [zeroConnectionState.name],
   );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/contexts/RealtimeContext.tsx` around lines 42 - 54, Update the useMemo
that computes connectionStatus so it maps zeroConnectionState.name values
"error" and "needs-auth" to the RealtimeContextType "error" status instead of
returning "disconnected", and change the memo dependency from
[zeroConnectionState] to [zeroConnectionState.name]; locate the memo block that
defines connectionStatus (the useMemo referencing zeroConnectionState) and
adjust the switch to include cases "error" and "needs-auth" returning "error"
while leaving "connected" and "connecting" as-is.

Comment on lines +151 to +177
await tx
.update(workspaceItems)
.set({
type: rows.item.type,
name: rows.item.name,
subtitle: rows.item.subtitle,
color: rows.item.color,
folderId: rows.item.folderId,
layout: rows.item.layout,
lastModified: rows.item.lastModified,
sourceVersion: rows.item.sourceVersion,
dataSchemaVersion: rows.item.dataSchemaVersion,
contentHash: rows.item.contentHash,
processingStatus: rows.item.processingStatus,
hasOcr: rows.item.hasOcr,
ocrStatus: rows.item.ocrStatus,
ocrPageCount: rows.item.ocrPageCount,
hasTranscript: rows.item.hasTranscript,
sourceCount: rows.item.sourceCount,
updatedAt: new Date().toISOString(),
})
.where(
and(
eq(workspaceItems.workspaceId, params.workspaceId),
eq(workspaceItems.itemId, rows.item.itemId),
),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Make upsertWorkspaceItem() upsert the shell row too.

This updates workspaceItems but never inserts it when the shell row is missing. The following content/extracted upserts can then create orphan rows that workspace reads ignore because loadWorkspaceItemRecord() returns null without a shell.

🐛 Proposed fix
-  await tx
-    .update(workspaceItems)
-    .set({
+  await tx
+    .insert(workspaceItems)
+    .values(rows.item)
+    .onConflictDoUpdate({
+      target: [workspaceItems.workspaceId, workspaceItems.itemId],
+      set: {
       type: rows.item.type,
       name: rows.item.name,
       subtitle: rows.item.subtitle,
       color: rows.item.color,
       folderId: rows.item.folderId,
       layout: rows.item.layout,
       lastModified: rows.item.lastModified,
       sourceVersion: rows.item.sourceVersion,
       dataSchemaVersion: rows.item.dataSchemaVersion,
       contentHash: rows.item.contentHash,
       processingStatus: rows.item.processingStatus,
       hasOcr: rows.item.hasOcr,
       ocrStatus: rows.item.ocrStatus,
       ocrPageCount: rows.item.ocrPageCount,
       hasTranscript: rows.item.hasTranscript,
       sourceCount: rows.item.sourceCount,
       updatedAt: new Date().toISOString(),
-    })
-    .where(
-      and(
-        eq(workspaceItems.workspaceId, params.workspaceId),
-        eq(workspaceItems.itemId, rows.item.itemId),
-      ),
-    );
+      },
+    });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await tx
.update(workspaceItems)
.set({
type: rows.item.type,
name: rows.item.name,
subtitle: rows.item.subtitle,
color: rows.item.color,
folderId: rows.item.folderId,
layout: rows.item.layout,
lastModified: rows.item.lastModified,
sourceVersion: rows.item.sourceVersion,
dataSchemaVersion: rows.item.dataSchemaVersion,
contentHash: rows.item.contentHash,
processingStatus: rows.item.processingStatus,
hasOcr: rows.item.hasOcr,
ocrStatus: rows.item.ocrStatus,
ocrPageCount: rows.item.ocrPageCount,
hasTranscript: rows.item.hasTranscript,
sourceCount: rows.item.sourceCount,
updatedAt: new Date().toISOString(),
})
.where(
and(
eq(workspaceItems.workspaceId, params.workspaceId),
eq(workspaceItems.itemId, rows.item.itemId),
),
);
await tx
.insert(workspaceItems)
.values(rows.item)
.onConflictDoUpdate({
target: [workspaceItems.workspaceId, workspaceItems.itemId],
set: {
type: rows.item.type,
name: rows.item.name,
subtitle: rows.item.subtitle,
color: rows.item.color,
folderId: rows.item.folderId,
layout: rows.item.layout,
lastModified: rows.item.lastModified,
sourceVersion: rows.item.sourceVersion,
dataSchemaVersion: rows.item.dataSchemaVersion,
contentHash: rows.item.contentHash,
processingStatus: rows.item.processingStatus,
hasOcr: rows.item.hasOcr,
ocrStatus: rows.item.ocrStatus,
ocrPageCount: rows.item.ocrPageCount,
hasTranscript: rows.item.hasTranscript,
sourceCount: rows.item.sourceCount,
updatedAt: new Date().toISOString(),
},
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/workspace/workspace-item-write.ts` around lines 151 - 177, The
current upsertWorkspaceItem() only performs an UPDATE (the
tx.update(...).set(...) block) so if the shell row is missing it never INSERTs
and later content/extracted upserts produce orphan rows; change
upsertWorkspaceItem() to perform an upsert: after the UPDATE attempt, check
whether any row was affected and if zero, INSERT a shell row into workspaceItems
with at minimum workspaceId, itemId, createdAt/updatedAt and any required
non-null columns (type/name/folderId as appropriate) or use a single INSERT ...
ON CONFLICT (workspaceId, itemId) DO UPDATE to atomically create-or-update the
shell; ensure loadWorkspaceItemRecord() will then find the shell row.

Comment thread src/lib/zero/client.ts
Comment on lines +9 to +24
const appURL =
process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";

function createZeroInstance(params: { userId: string }) {
return new Zero({
schema,
cacheURL: process.env.NEXT_PUBLIC_ZERO_SERVER!,
mutateURL: `${appURL}/api/zero/mutate`,
queryURL: `${appURL}/api/zero/query`,
userID: params.userId,
context: {
userId: params.userId,
},
mutators,
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail fast on missing NEXT_PUBLIC_ZERO_SERVER and avoid localhost fallback in production.

  • Line 15 uses a non-null assertion on process.env.NEXT_PUBLIC_ZERO_SERVER. If the env var is absent (e.g., misconfigured Vercel deploy), cacheURL becomes undefined and you'll get a cryptic downstream error from @rocicorp/zero instead of a clear configuration failure.
  • The appURL localhost fallback on line 10 will silently produce broken mutate/query URLs in a production build if NEXT_PUBLIC_APP_URL isn't baked in. Given the PR's deployment notes list these as required, a hard check protects against regressions.
🛡️ Proposed fix
-const appURL =
-  process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
+const appURL =
+  process.env.NEXT_PUBLIC_APP_URL ??
+  (process.env.NODE_ENV === "production"
+    ? (() => {
+        throw new Error("NEXT_PUBLIC_APP_URL is required in production");
+      })()
+    : "http://localhost:3000");
+
+const cacheURL = process.env.NEXT_PUBLIC_ZERO_SERVER;
+if (!cacheURL) {
+  throw new Error("NEXT_PUBLIC_ZERO_SERVER is required");
+}

 function createZeroInstance(params: { userId: string }) {
   return new Zero({
     schema,
-    cacheURL: process.env.NEXT_PUBLIC_ZERO_SERVER!,
+    cacheURL,
     mutateURL: `${appURL}/api/zero/mutate`,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const appURL =
process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
function createZeroInstance(params: { userId: string }) {
return new Zero({
schema,
cacheURL: process.env.NEXT_PUBLIC_ZERO_SERVER!,
mutateURL: `${appURL}/api/zero/mutate`,
queryURL: `${appURL}/api/zero/query`,
userID: params.userId,
context: {
userId: params.userId,
},
mutators,
});
}
const appURL =
process.env.NEXT_PUBLIC_APP_URL ??
(process.env.NODE_ENV === "production"
? (() => {
throw new Error("NEXT_PUBLIC_APP_URL is required in production");
})()
: "http://localhost:3000");
const cacheURL = process.env.NEXT_PUBLIC_ZERO_SERVER;
if (!cacheURL) {
throw new Error("NEXT_PUBLIC_ZERO_SERVER is required");
}
function createZeroInstance(params: { userId: string }) {
return new Zero({
schema,
cacheURL,
mutateURL: `${appURL}/api/zero/mutate`,
queryURL: `${appURL}/api/zero/query`,
userID: params.userId,
context: {
userId: params.userId,
},
mutators,
});
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/zero/client.ts` around lines 9 - 24, The createZeroInstance function
currently uses a non-null assertion for process.env.NEXT_PUBLIC_ZERO_SERVER and
a localhost fallback for appURL; change it to validate required env vars
up-front: throw a clear Error if NEXT_PUBLIC_ZERO_SERVER is missing, and if
NODE_ENV === "production" (or when deploying) also require NEXT_PUBLIC_APP_URL
instead of falling back to "http://localhost:3000"; use those validated values
for Zero({ cacheURL, mutateURL, queryURL, ... }) so cacheURL, mutateURL and
queryURL are never undefined and produce an explicit configuration error instead
of downstream cryptic failures.

Comment on lines +14 to +37
const workspaceItems = table("workspace_items")
.from("workspace_items")
.columns({
workspaceId: string().from("workspace_id"),
itemId: string().from("item_id"),
type: string(),
name: string(),
subtitle: string(),
color: string().optional(),
folderId: string().from("folder_id").optional(),
layout: json().optional(),
lastModified: number().from("last_modified").optional(),
sourceVersion: number().from("source_version"),
dataSchemaVersion: number().from("data_schema_version"),
contentHash: string().from("content_hash"),
processingStatus: string().from("processing_status").optional(),
hasOcr: boolean().from("has_ocr"),
ocrStatus: string().from("ocr_status").optional(),
ocrPageCount: number().from("ocr_page_count"),
hasTranscript: boolean().from("has_transcript"),
sourceCount: number().from("source_count"),
createdAt: number().from("created_at").optional(),
})
.primaryKey("workspaceId", "itemId");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify the database column type and find all Zero/client usages of createdAt/updatedAt.

set -euo pipefail

echo "Database timestamp columns:"
fd '\.sql$' drizzle -x rg -n '"created_at"|"updated_at"' {}

echo
echo "Zero schema/client timestamp usages:"
rg -n -C3 'createdAt|updatedAt|created_at|updated_at|number\(\)\.from\("created_at"\)' --type=ts --type=tsx

Repository: ThinkEx-OSS/thinkex

Length of output: 2094


🌐 Web query:

Rocicorp Zero schema PostgreSQL timestamp with time zone column type mapping number string created_at

💡 Result:

In Rocicorp Zero, a PostgreSQL timestamp with time zone (timestamptz) column maps to the number type in the Zero schema definition. The resulting JavaScript/TypeScript type is number. This applies to date, timestamp, timestamptz, time, and timetz Postgres types. For a created_at column of type timestamptz, define it in your Zero schema as created_at: number. The value is a numeric timestamp, likely milliseconds since Unix epoch.

Citations:


🏁 Script executed:

# Find the migration file that defines workspace_items table
fd '\.sql$' drizzle -x grep -l 'workspace_items' {} | head -5

Repository: ThinkEx-OSS/thinkex

Length of output: 132


🏁 Script executed:

# Once found, show the exact column definitions for workspace_items
fd '\.sql$' drizzle -x grep -A 30 'CREATE TABLE.*workspace_items' {}

Repository: ThinkEx-OSS/thinkex

Length of output: 2377


Remove .optional() from createdAt schema definition.

Line 35 maps createdAt as number().optional(), but the database defines created_at as timestamp with time zone DEFAULT now() NOT NULL. The number() type is correct for this column, but .optional() contradicts the NOT NULL constraint and weakens the generated row typing. Use number().from("created_at") instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/zero/zero-schema.gen.ts` around lines 14 - 37, The createdAt column
in the workspaceItems table schema is marked optional but the DB column is NOT
NULL with a default, so update the workspaceItems table definition to remove
.optional() and use number().from("created_at") for the createdAt field (modify
the createdAt property in the workspaceItems .columns call).

Comment on lines +66 to +103
const [contentRow] = await tx
.select({
assetData: workspaceItemContent.assetData,
})
.from(workspaceItemContent)
.where(
and(
eq(workspaceItemContent.workspaceId, workspaceId),
eq(workspaceItemContent.itemId, itemId),
),
)
.limit(1);

const currentAssetData =
(contentRow?.assetData as Record<string, unknown> | null) ?? {};

await tx
.update(workspaceItemContent)
.set({
structuredData: {
summary: result.summary ?? "",
},
...(typeof result.duration === "number" && result.duration > 0
? {
assetData: {
...currentAssetData,
duration: result.duration,
},
}
: {}),
updatedAt,
})
.where(
and(
eq(workspaceItemContent.workspaceId, workspaceId),
eq(workspaceItemContent.itemId, itemId),
),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Read-modify-write on assetData is not atomic — prefer JSONB merge in SQL.

The SELECT at lines 66-77 then UPDATE at lines 82-103 form a non-atomic read-modify-write on workspace_item_content.assetData. Under PostgreSQL's default READ COMMITTED isolation, another transaction committing between the SELECT and UPDATE can have its assetData keys silently clobbered. Wrapping in db.transaction does not lock the row on a plain SELECT.

Audio transcription today may be the sole writer to this row's assetData, but if any other workflow (e.g., an "update duration" worker, user rename that touches metadata, re-transcribe) writes concurrently, keys will be lost. Using JSONB's concat operator does the merge in one statement and is race-free:

🛡️ Proposed fix using `sql` template
-    const [contentRow] = await tx
-      .select({
-        assetData: workspaceItemContent.assetData,
-      })
-      .from(workspaceItemContent)
-      .where(
-        and(
-          eq(workspaceItemContent.workspaceId, workspaceId),
-          eq(workspaceItemContent.itemId, itemId),
-        ),
-      )
-      .limit(1);
-
-    const currentAssetData =
-      (contentRow?.assetData as Record<string, unknown> | null) ?? {};
-
     await tx
       .update(workspaceItemContent)
       .set({
         structuredData: {
           summary: result.summary ?? "",
         },
         ...(typeof result.duration === "number" && result.duration > 0
           ? {
-              assetData: {
-                ...currentAssetData,
-                duration: result.duration,
-              },
+              assetData: sql`COALESCE(${workspaceItemContent.assetData}, '{}'::jsonb) || ${JSON.stringify({ duration: result.duration })}::jsonb`,
             }
           : {}),
         updatedAt,
       })

Also: the result.segments as unknown as Record<string, unknown> cast on lines 30-33 and 43-46 is a type lie (segments is an array). JSONB accepts the array fine at runtime, but consider a truthful cast (e.g. to unknown) or typing the column with a matching interface.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [contentRow] = await tx
.select({
assetData: workspaceItemContent.assetData,
})
.from(workspaceItemContent)
.where(
and(
eq(workspaceItemContent.workspaceId, workspaceId),
eq(workspaceItemContent.itemId, itemId),
),
)
.limit(1);
const currentAssetData =
(contentRow?.assetData as Record<string, unknown> | null) ?? {};
await tx
.update(workspaceItemContent)
.set({
structuredData: {
summary: result.summary ?? "",
},
...(typeof result.duration === "number" && result.duration > 0
? {
assetData: {
...currentAssetData,
duration: result.duration,
},
}
: {}),
updatedAt,
})
.where(
and(
eq(workspaceItemContent.workspaceId, workspaceId),
eq(workspaceItemContent.itemId, itemId),
),
);
await tx
.update(workspaceItemContent)
.set({
structuredData: {
summary: result.summary ?? "",
},
...(typeof result.duration === "number" && result.duration > 0
? {
assetData: sql`COALESCE(${workspaceItemContent.assetData}, '{}'::jsonb) || ${JSON.stringify({ duration: result.duration })}::jsonb`,
}
: {}),
updatedAt,
})
.where(
and(
eq(workspaceItemContent.workspaceId, workspaceId),
eq(workspaceItemContent.itemId, itemId),
),
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/workflows/audio-transcribe/steps/persist-result.ts` around lines 66 -
103, The code performs a non-atomic read-modify-write: you call tx.select(...)
to read workspaceItemContent.assetData into currentAssetData then
tx.update(workspaceItemContent).set(...) to write back merged assetData, which
can lose concurrent updates; instead remove the SELECT and perform a single
UPDATE that merges JSONB on the DB side (use PostgreSQL jsonb
concatenation/merge via an sql template or query builder to set assetData =
workspace_item_content.asset_data || '{"duration": ...}'::jsonb when
result.duration is present), keep setting structuredData.summary and updatedAt
in the same UPDATE, and only conditionally include the jsonb merge when
result.duration > 0; also fix the incorrect cast of result.segments (don’t cast
segments to Record<string, unknown> — use a truthful type such as unknown or the
actual array type) so the value stored in assetData/columns matches its real
type.

Comment on lines 18 to +100
for (const result of results) {
const ocrPages = result.ok ? result.pages : [];
const ocrText = getOcrPagesTextContent(ocrPages) || null;
const updatedAt = new Date().toISOString();

await db
.insert(workspaceItemExtracted)
.values({
workspaceId,
itemId: result.itemId,
searchText: ocrText ?? "",
ocrText,
ocrPages: ocrPages as unknown as Record<string, unknown>,
updatedAt: new Date().toISOString(),
})
.onConflictDoUpdate({
target: [
workspaceItemExtracted.workspaceId,
workspaceItemExtracted.itemId,
],
set: {
await db.transaction(async (tx) => {
await tx
.insert(workspaceItemExtracted)
.values({
workspaceId,
itemId: result.itemId,
searchText: ocrText ?? "",
ocrText,
ocrPages: ocrPages as unknown as Record<string, unknown>,
searchText: ocrText ?? "",
updatedAt: new Date().toISOString(),
},
});
}
updatedAt,
})
.onConflictDoUpdate({
target: [
workspaceItemExtracted.workspaceId,
workspaceItemExtracted.itemId,
],
set: {
ocrText,
ocrPages: ocrPages as unknown as Record<string, unknown>,
searchText: ocrText ?? "",
updatedAt,
},
});

const event = createEvent(
"BULK_ITEMS_PATCHED",
{
updates: results.map((result) => {
const statusPatch = (
result.ok
? {
ocrStatus: "complete" as const,
ocrError: undefined,
}
: {
ocrStatus: "failed" as const,
ocrError: result.error,
}
) satisfies Partial<PdfData> | Partial<ImageData>;
await tx
.update(workspaceItems)
.set({
ocrStatus: result.ok ? "complete" : "failed",
hasOcr: result.ok && ocrPages.length > 0,
ocrPageCount: ocrPages.length,
lastModified: Date.now(),
})
.where(
and(
eq(workspaceItems.workspaceId, workspaceId),
eq(workspaceItems.itemId, result.itemId),
),
);

return {
id: result.itemId,
changes: {
data: statusPatch as Item["data"],
},
};
}),
},
userId,
);
const [contentRow] = await tx
.select({
assetData: workspaceItemContent.assetData,
})
.from(workspaceItemContent)
.where(
and(
eq(workspaceItemContent.workspaceId, workspaceId),
eq(workspaceItemContent.itemId, result.itemId),
),
)
.limit(1);

const currentAssetData =
(contentRow?.assetData as Record<string, unknown> | null) ?? {};
const nextAssetData = result.ok
? (() => {
const { ocrError: _ocrError, ...rest } = currentAssetData;
return rest;
})()
: {
...currentAssetData,
ocrError: result.error,
};

await appendWorkspaceEventOrThrow({
workspaceId,
event,
conflictMessage: `Version conflict appending event ${event.id} to workspace ${workspaceId}. Workflow will retry automatically.`,
});
await tx
.update(workspaceItemContent)
.set({
assetData: nextAssetData,
updatedAt,
})
.where(
and(
eq(workspaceItemContent.workspaceId, workspaceId),
eq(workspaceItemContent.itemId, result.itemId),
),
);
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Lost-update race on assetData and N sequential transactions.

Two related issues in this loop:

  1. Read-modify-write race on assetData. The SELECT at Lines 62–73 doesn't lock the row (default READ COMMITTED), so a concurrent writer touching workspaceItemContent.assetData for the same (workspaceId, itemId) between the read and the UPDATE at Lines 87–98 will have its change silently clobbered. This is reachable because AI tools / audio / re-OCR flows also write directly to workspaceItemContent (per PR objectives). Either add .for("update") on the select, or perform the merge at the SQL level with jsonb_set / the - operator so the write is self-contained.

  2. Per-result transaction and non-atomic batch. Wrapping each result in its own db.transaction(...) means a partial failure leaves some items persisted and others not. It also multiplies round-trip cost for large OCR batches. Consider a single outer transaction for the batch (or, if per-item isolation is desired, document that explicitly and make the workflow idempotent on retry).

🔧 Sketch for point 1 (row-level lock)
-      const [contentRow] = await tx
-        .select({
-          assetData: workspaceItemContent.assetData,
-        })
-        .from(workspaceItemContent)
-        .where(
-          and(
-            eq(workspaceItemContent.workspaceId, workspaceId),
-            eq(workspaceItemContent.itemId, result.itemId),
-          ),
-        )
-        .limit(1);
+      const [contentRow] = await tx
+        .select({
+          assetData: workspaceItemContent.assetData,
+        })
+        .from(workspaceItemContent)
+        .where(
+          and(
+            eq(workspaceItemContent.workspaceId, workspaceId),
+            eq(workspaceItemContent.itemId, result.itemId),
+          ),
+        )
+        .limit(1)
+        .for("update");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const result of results) {
const ocrPages = result.ok ? result.pages : [];
const ocrText = getOcrPagesTextContent(ocrPages) || null;
const updatedAt = new Date().toISOString();
await db
.insert(workspaceItemExtracted)
.values({
workspaceId,
itemId: result.itemId,
searchText: ocrText ?? "",
ocrText,
ocrPages: ocrPages as unknown as Record<string, unknown>,
updatedAt: new Date().toISOString(),
})
.onConflictDoUpdate({
target: [
workspaceItemExtracted.workspaceId,
workspaceItemExtracted.itemId,
],
set: {
await db.transaction(async (tx) => {
await tx
.insert(workspaceItemExtracted)
.values({
workspaceId,
itemId: result.itemId,
searchText: ocrText ?? "",
ocrText,
ocrPages: ocrPages as unknown as Record<string, unknown>,
searchText: ocrText ?? "",
updatedAt: new Date().toISOString(),
},
});
}
updatedAt,
})
.onConflictDoUpdate({
target: [
workspaceItemExtracted.workspaceId,
workspaceItemExtracted.itemId,
],
set: {
ocrText,
ocrPages: ocrPages as unknown as Record<string, unknown>,
searchText: ocrText ?? "",
updatedAt,
},
});
const event = createEvent(
"BULK_ITEMS_PATCHED",
{
updates: results.map((result) => {
const statusPatch = (
result.ok
? {
ocrStatus: "complete" as const,
ocrError: undefined,
}
: {
ocrStatus: "failed" as const,
ocrError: result.error,
}
) satisfies Partial<PdfData> | Partial<ImageData>;
await tx
.update(workspaceItems)
.set({
ocrStatus: result.ok ? "complete" : "failed",
hasOcr: result.ok && ocrPages.length > 0,
ocrPageCount: ocrPages.length,
lastModified: Date.now(),
})
.where(
and(
eq(workspaceItems.workspaceId, workspaceId),
eq(workspaceItems.itemId, result.itemId),
),
);
return {
id: result.itemId,
changes: {
data: statusPatch as Item["data"],
},
};
}),
},
userId,
);
const [contentRow] = await tx
.select({
assetData: workspaceItemContent.assetData,
})
.from(workspaceItemContent)
.where(
and(
eq(workspaceItemContent.workspaceId, workspaceId),
eq(workspaceItemContent.itemId, result.itemId),
),
)
.limit(1);
const currentAssetData =
(contentRow?.assetData as Record<string, unknown> | null) ?? {};
const nextAssetData = result.ok
? (() => {
const { ocrError: _ocrError, ...rest } = currentAssetData;
return rest;
})()
: {
...currentAssetData,
ocrError: result.error,
};
await appendWorkspaceEventOrThrow({
workspaceId,
event,
conflictMessage: `Version conflict appending event ${event.id} to workspace ${workspaceId}. Workflow will retry automatically.`,
});
await tx
.update(workspaceItemContent)
.set({
assetData: nextAssetData,
updatedAt,
})
.where(
and(
eq(workspaceItemContent.workspaceId, workspaceId),
eq(workspaceItemContent.itemId, result.itemId),
),
);
});
}
for (const result of results) {
const ocrPages = result.ok ? result.pages : [];
const ocrText = getOcrPagesTextContent(ocrPages) || null;
const updatedAt = new Date().toISOString();
await db.transaction(async (tx) => {
await tx
.insert(workspaceItemExtracted)
.values({
workspaceId,
itemId: result.itemId,
searchText: ocrText ?? "",
ocrText,
ocrPages: ocrPages as unknown as Record<string, unknown>,
updatedAt,
})
.onConflictDoUpdate({
target: [
workspaceItemExtracted.workspaceId,
workspaceItemExtracted.itemId,
],
set: {
ocrText,
ocrPages: ocrPages as unknown as Record<string, unknown>,
searchText: ocrText ?? "",
updatedAt,
},
});
await tx
.update(workspaceItems)
.set({
ocrStatus: result.ok ? "complete" : "failed",
hasOcr: result.ok && ocrPages.length > 0,
ocrPageCount: ocrPages.length,
lastModified: Date.now(),
})
.where(
and(
eq(workspaceItems.workspaceId, workspaceId),
eq(workspaceItems.itemId, result.itemId),
),
);
const [contentRow] = await tx
.select({
assetData: workspaceItemContent.assetData,
})
.from(workspaceItemContent)
.where(
and(
eq(workspaceItemContent.workspaceId, workspaceId),
eq(workspaceItemContent.itemId, result.itemId),
),
)
.limit(1)
.for("update");
const currentAssetData =
(contentRow?.assetData as Record<string, unknown> | null) ?? {};
const nextAssetData = result.ok
? (() => {
const { ocrError: _ocrError, ...rest } = currentAssetData;
return rest;
})()
: {
...currentAssetData,
ocrError: result.error,
};
await tx
.update(workspaceItemContent)
.set({
assetData: nextAssetData,
updatedAt,
})
.where(
and(
eq(workspaceItemContent.workspaceId, workspaceId),
eq(workspaceItemContent.itemId, result.itemId),
),
);
});
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/workflows/ocr-dispatch/steps/persist-results.ts` around lines 18 - 100,
The loop currently does per-result db.transaction calls and reads
workspaceItemContent.assetData without locking, causing a lost-update race;
change to use a single outer db.transaction for the whole results array (replace
per-result db.transaction(...) with one transaction enclosing the loop) and
inside that transaction either (A) change the SELECT from workspaceItemContent
to acquire a row lock (use the query builder's .for("update") on the select of
workspaceItemContent.assetData) before computing nextAssetData, or (B) avoid
read-modify-write entirely by performing the merge in SQL (use jsonb_set or the
jsonb '-' operator to remove/add the ocrError key on
workspaceItemContent.assetData in the UPDATE statement); reference the symbols
workspaceItemContent, assetData, db.transaction, workspaceItemExtracted,
workspaceItems, and use jsonb_set / '-' if choosing the SQL-merge approach.

@urjitc
urjitc merged commit 8db7881 into main Apr 19, 2026
8 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in Dev Board Apr 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

capy Generated by capy.ai

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant