Skip to content

feat(THU-578): scope all FE DAL methods to active workspaceId#951

Open
raivieiraadriano92 wants to merge 6 commits into
raivieiraadriano92/thu-550-workspaces-data-layer-schema-sync-rules-dal-upload-handlerfrom
raivieiraadriano92/thu-578-workspaces-data-layer-scope-dal-to-active-workspaceid
Open

feat(THU-578): scope all FE DAL methods to active workspaceId#951
raivieiraadriano92 wants to merge 6 commits into
raivieiraadriano92/thu-550-workspaces-data-layer-schema-sync-rules-dal-upload-handlerfrom
raivieiraadriano92/thu-578-workspaces-data-layer-scope-dal-to-active-workspaceid

Conversation

@raivieiraadriano92

@raivieiraadriano92 raivieiraadriano92 commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to THU-550 (PR #944). PR #944 shipped the BE-side enforcement (every workspace-scoped row has workspace_id NOT NULL, sync rules are per-workspace, the upload-handler factory rejects cross-workspace writes) plus a partial FE pattern where workspace-scoped tables' DAL inserts auto-resolve the active workspace via an internal getActiveWorkspaceId(db) call. Reads, updates, and deletes were completely unscoped.

This PR replaces the auto-resolution pattern with explicit workspaceId threading on every DAL function — reads, writes, deletes, creates — across all 11 workspace-scoped DAL files. Day-1 risk is low (v1 ships with personal workspaces only, so every user sees exactly one workspaceId), but the moment shared workspaces ship, every unfiltered query would become a cross-workspace data leak — a chat search from workspace A returning threads from workspace B. The BE handler factory prevents writes from leaking, but reads/updates/deletes are pure FE concerns; the backend can't help.

Convention

Every workspace-scoped DAL function takes workspaceId as its second positional arg:

fn(db, workspaceId, ...rest)
  • Reads / updates / deletes: eq(table.workspaceId, workspaceId) in every WHERE.
  • Inserts: workspaceId merged into the values payload (replaces the auto-resolve call).
  • Updates accepting Partial<TableType>: workspaceId stripped from the SET payload so callers can't reassign a row across workspaces by passing a different value.
  • Zero remaining await getActiveWorkspaceId(db) calls inside src/dal/** — all resolution moved to call sites.

In scope (11 DAL files)

File Notes
src/dal/chat-threads.ts also user-private — keeps user_id = me filter
src/dal/chat-messages.ts same
src/dal/tasks.ts finishes the partial work shipped in PR #944
src/dal/models.ts + threads workspaceId through getDefaultModelForThreadgetLastMessage
src/dal/mcp-servers.ts + new updateMcpServer DAL (replaces a raw db.update(mcpServersTable) in settings/mcp-servers.tsx that bypassed workspace scoping)
src/dal/prompts.ts + threads through runAutomationgetModel / createChatThread
src/dal/skills.ts + workspace-scoped slug uniqueness (same slug allowed across workspaces)
src/dal/triggers.ts
src/dal/modes.ts
src/dal/model-profiles.ts + reset strips seed-default workspaceId: null so the row stays in its workspace
src/dal/agents.ts agents_system + agents_secrets stay local-only (no workspaceId) — only the synced agents table is scoped

Out of scope (user/device-scoped, no workspace filter): settings, devices, integrations, and the workspace-table DAL itself.

New foundation helpers (src/lib/active-workspace.ts)

  • useActiveWorkspaceId(): string | null — React hook. Reads the active user from the trust-domain registry (Zustand store) rather than useAuth()/useSession() so it works in any React tree where <DatabaseProvider> is mounted, not just inside <AuthProvider>. Lives-queries the personal workspace via the new getPersonalWorkspaceByOwnerQuery DAL helper.
  • requireActiveWorkspaceId(db): Promise<string> — non-React entry-point variant. Throws Error('No active workspace') on null. Used by extension tools, AI pipeline, background jobs.

Call-site migration

React (~12 components/hooks): useActiveWorkspaceId() + workspaceId in every queryKey + enabled: !!workspaceId guard. Mutations throw if null.

Non-React (extensions/tasks/tools.ts, ai/fetch.ts, ai/eval/runner.ts, lib/data-migrations/automations-to-skills.ts): resolve once at entry via requireActiveWorkspaceId(db), thread through.

Bootstrap: runDataMigrations(db, workspaceId) now takes the workspace explicitly; post-auth-bootstrap passes the personal workspace id.

Chat plumbing: createChatInstance / createAgentRoutingFetch factories take workspaceId so the customFetch closure can persist ACP session ids back via updateChatThread(db, workspaceId, ...). useChatStore.setSelectedAgent resolves workspaceId from the active session's chatThread.workspaceId.

Shared DAL query for the personal-workspace lookup

src/dal/workspaces.ts gains getPersonalWorkspaceByOwnerQuery(db, userId) returning the Drizzle query (use with toCompilableQuery for live React subscription or await for one-shot). getPersonalWorkspaceByOwner (async) now delegates to it — single source of truth for the personal-workspace WHERE. Both useActiveWorkspaceId and <WorkspaceGate> consume the query helper so they can't drift.

Test infrastructure

  • src/dal/test-utils.ts: exports wsId / otherWsId / testUserId constants; setupTestDatabase / resetTestDatabase seed a personal workspace row owned by testUserId so useActiveWorkspaceId resolves in component tests.
  • src/test-utils/powersync-reactivity-test.tsx: opt-in seedTestTrustDomain() / resetTestTrustDomain() helpers — call from beforeEach in tests that render components consuming useActiveWorkspaceId. Not invoked automatically because the trust-domain registry is a process-global Zustand singleton; auto-seeding would leak across files.
  • Cross-workspace isolation tests added for 7 DAL files (mcp-servers, modes, triggers, model-profiles, tasks, agents, skills): seed rows under wsId + otherWsId, query with one, assert the other's rows don't leak.

Test results

  • bun run type-check green.
  • bun run lint green (0 errors; pre-existing warnings unchanged).
  • DAL tests: 301/301 pass — including new isolation tests.
  • Touched-area suite (1867 tests across 150 files): 1856 pass. The 11 remaining failures are pre-existing flakiness unrelated to this PR (verified by re-running on a clean stash) — all in unrelated areas: proxy-fetch-context, PendingDeviceModal, createAuthenticatedClient device headers, usePowerSyncCredentialsInvalidListener.

Risk if not landed

The moment shared workspaces ship, every unfiltered FE query becomes a cross-workspace data leak. The BE upload handler prevents writes from crossing workspaces but reads/updates/deletes are pure FE concerns; the backend can't help. Day-1 (personal-only) safe; ship-blocker for shared workspaces.

Test plan

  • Sign up → personal workspace materializes → main app renders.
  • Settings → MCP servers: toggle a server, add a server, delete a server — all write through workspace-scoped DAL.
  • Settings → Models: add / toggle / edit / delete / reset-to-default — all scoped.
  • Chat: open a thread, send a message, rename via sidebar, delete via sidebar, "Delete all chats" — all scoped.
  • Skills: create / edit / pin / unpin / disable / delete — all scoped.
  • Tasks (/tasks): create / edit / complete / delete — all scoped + extension tools (addTasks, getTasks, deleteTasks) work via requireActiveWorkspaceId.
  • Agents settings: create / toggle / delete a custom agent — all scoped.
  • AI inference: a chat message flows through ai/fetch.ts (it resolves workspace once at the top and threads it through getModel / getModelProfile / getAllSkills).
  • Trigger scheduler: an enabled automation fires at the right time and creates a new chat thread.
  • Sign out → sign in again: bootstrap creates the personal workspace, defaults reconcile, data migrations run with workspace context.
  • make check and bun test green.

Note

High Risk
Broad refactor of data access across chat, AI inference, automations, and settings; incorrect workspace threading would leak or mutate cross-workspace data once shared workspaces ship.

Overview
This PR threads an explicit workspaceId through every workspace-scoped DAL read/write/update/delete (fn(db, workspaceId, …)), replacing internal getActiveWorkspaceId(db) resolution on inserts only. Queries and mutations now filter with eq(table.workspaceId, workspaceId); partial updates strip workspaceId from SET payloads so rows can’t be reassigned across workspaces.

Call sites resolve workspace once via useActiveWorkspaceId() (React, query keys + enabled guards) or requireActiveWorkspaceId(db) (e.g. ai/fetch.ts, eval runners, trigger scheduler). Chat plumbing passes workspaceId into createChatInstance / createAgentRoutingFetch and stores it on ChatSession; hydration evicts sessions when the active workspace changes and uses getActiveWorkspaceId at save/hydrate time to avoid stale closures.

Supporting changes: shared getPersonalWorkspaceByOwnerQuery for WorkspaceGate and the hook; new updateMcpServer with workspace scoping; eval/test helpers seed trust domain + test wsId; cross-workspace isolation tests on several DAL modules.

Reviewed by Cursor Bugbot for commit b325483. Bugbot is set up for automated code reviews on this repo. Configure here.

@raivieiraadriano92 raivieiraadriano92 self-assigned this Jun 4, 2026
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

Semgrep Security Scan

No security issues found.

@raivieiraadriano92 raivieiraadriano92 marked this pull request as ready for review June 4, 2026 10:57
Comment thread src/ai/fetch.ts
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

PR Metrics

Metric Value
Lines changed (prod code) +1205 / -481
JS bundle size (gzipped) 🟢 726.7 KB → 730.2 KB (+3.6 KB, +0.5%)
Test coverage 🟢 76.08% → 76.17% (+0.1%)
Performance (preview) Preview not ready — Render deploy may have timed out
Accessibility
Best Practices
SEO

Updated Mon, 08 Jun 2026 13:44:38 GMT · run #1814

@raivieiraadriano92 raivieiraadriano92 force-pushed the raivieiraadriano92/thu-550-workspaces-data-layer-schema-sync-rules-dal-upload-handler branch from b2928ae to 2d8f4af Compare June 5, 2026 13:24
@raivieiraadriano92 raivieiraadriano92 force-pushed the raivieiraadriano92/thu-578-workspaces-data-layer-scope-dal-to-active-workspaceid branch from 04c0769 to af7cc1f Compare June 5, 2026 14:28
Comment thread src/dal/model-profiles.ts
Comment thread src/chats/use-hydrate-chat-store.ts
Comment thread src/chats/use-hydrate-chat-store.ts Outdated
Comment thread src/lib/active-workspace.ts
Comment thread src/dal/prompts.ts
@raivieiraadriano92 raivieiraadriano92 force-pushed the raivieiraadriano92/thu-550-workspaces-data-layer-schema-sync-rules-dal-upload-handler branch 2 times, most recently from 442f7d0 to 527dcf9 Compare June 7, 2026 22:12
@raivieiraadriano92 raivieiraadriano92 force-pushed the raivieiraadriano92/thu-578-workspaces-data-layer-scope-dal-to-active-workspaceid branch from 9e7b109 to b325483 Compare June 8, 2026 13:38

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b325483. Configure here.

const nextSessions = new Map(sessions)
nextSessions.delete(id)
useChatStore.setState({ sessions: nextSessions })
// fall through to full create path

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ready flag during workspace rehydrate

Medium Severity

When an existing chat session is removed because the active workspace changed, hydrateChatStore does not set isReady back to false before the async rebuild. The chat UI can stay mounted while the session entry is gone, leading to missing-session errors or a stale chat instance until the new session is created.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b325483. Configure here.

Comment thread src/chats/chat-store.ts
if (session.chatThread) {
await updateChatThread(db, session.chatThread.id, { agentId: agent.id })
if (session.chatThread?.workspaceId) {
await updateChatThread(db, session.chatThread.workspaceId, session.chatThread.id, { agentId: agent.id })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Agent persist checks thread workspace

Low Severity

setSelectedAgent only writes agentId to the database when session.chatThread?.workspaceId is truthy, even though each ChatSession already carries a required workspaceId. If the in-memory thread row lacks workspaceId while the session was hydrated with a valid one, the per-thread agent choice updates in memory but never persists.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b325483. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants