feat(project): move a project to another workspace - #1525
Conversation
Adds PUT /project/:id/move, which repoints a project (and everything hanging off it) at a different workspace. The caller needs project update + delete in the source workspace and project create in the target, since the move both removes the project from one workspace and adds it to another. The target is authorized separately from the source, as the access middleware only resolves one workspace per request. The move runs in a single transaction and refuses when the target workspace already uses the project's key, which would make short ids like KAN-12 ambiguous. Tasks assigned to people who aren't members of the target are unassigned, each with a task.unassigned event. Rows that denormalize the workspace (assets, labels) are rewritten, and project notification subscriptions are dropped, since their rule stays behind. Closes usekaneo#1328 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a project move workflow between workspaces. The API validates access and updates related records transactionally. The web settings page provides workspace selection, confirmation, feedback, and navigation. Localization and integration tests cover the workflow. ChangesProject move workflow
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant ProjectSettings
participant MoveFetcher
participant ProjectRoute
participant moveProject
participant Database
User->>ProjectSettings: Select target workspace and confirm
ProjectSettings->>MoveFetcher: Submit project and workspace IDs
MoveFetcher->>ProjectRoute: PUT /:id/move
ProjectRoute->>moveProject: Validate authorization and execute move
moveProject->>Database: Update project-related records transactionally
Database-->>moveProject: Commit move
moveProject-->>ProjectRoute: Return project and unassignedTaskCount
ProjectRoute-->>MoveFetcher: Return response
MoveFetcher-->>ProjectSettings: Return move result
ProjectSettings-->>User: Show status and navigate to target workspace
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
PR Summary by QodoMove a project between workspaces (API + UI + transactional data rewrite)
AI Description
Diagram
High-Level Assessment
Files changed (25)
|
Code Review by Qodo
1.
|
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
apps/api/src/project/index.ts (1)
242-261: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider documenting the non-200 responses.
The route can return 400, 402, 403, 404, and 409. Clients must handle 409 specifically, because a project-key collision is a recoverable, user-correctable state. Only the 200 response is documented now. Adding the error responses makes the generated OpenAPI contract actionable for API consumers.
This matches an existing gap in the other routes in this file, so it is optional.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/api/src/project/index.ts` around lines 242 - 261, Extend the moveProject describeRoute response schema to document the route’s 400, 402, 403, 404, and 409 outcomes alongside the existing 200 response, including the 409 project-key collision response so clients can handle it explicitly. Reuse the established error response schemas or conventions already used by nearby routes in this file.apps/api/src/project/controllers/move-project.ts (1)
50-66: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚖️ Poor tradeoffThe key-conflict check is advisory only; concurrent writes can still create a duplicate key.
This check reads the target workspace, then the move proceeds. No unique constraint on
(workspace_id, lower(slug))backs it. Two concurrent requests that move or create projects with the same key into the same target workspace can both pass this check and both commit. The result is the ambiguous short-id state the comment above describes.The window is narrow and requires concurrent authorized writes, so this does not block the feature. The durable fix is a partial unique index on
lower(slug)per workspace, plus handling the resulting constraint violation as the same 409. That change needs a backfill, because existing workspaces may already hold duplicate keys.Consider tracking this separately.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/api/src/project/controllers/move-project.ts` around lines 50 - 66, Track the advisory key-conflict check in the move flow separately and add durable enforcement with a backfilled partial unique index on workspace and lowercased slug. Update the move operation’s constraint-error handling to translate violations of this index into the existing HTTP 409 conflict response, while preserving the current pre-check and message for detected conflicts.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/api/src/project/controllers/move-project.ts`:
- Around line 139-144: Replace the taskIds-based predicate in the label update
within moveProject with a subquery selecting taskTable.id for the current
project, and remove the now-unused taskIds declaration. At the sibling update
site in apps/api/src/project/controllers/move-project.ts lines 114-124, retain
the row list for event titles but chunk its ids into batches below the
PostgreSQL parameter limit before issuing updates.
In `@apps/web/src/hooks/mutations/project/use-move-project.ts`:
- Around line 4-8: Update useMoveProject to add the standard mutation onSuccess
and onError handlers alongside mutationFn: invalidate the related project and
workspace queries after a successful move, and display Sonner success or error
feedback through the established toast pattern. Reuse existing query keys and
notification conventions from nearby mutation hooks.
In
`@apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/`$projectId/general.tsx:
- Around line 616-620: Update the move UI condition in the project settings
component to require both canEdit and canDelete before rendering. Also filter
moveTargets to only include workspaces where project:create permission is
available, ensuring the move dialog cannot offer unauthorized targets.
---
Nitpick comments:
In `@apps/api/src/project/controllers/move-project.ts`:
- Around line 50-66: Track the advisory key-conflict check in the move flow
separately and add durable enforcement with a backfilled partial unique index on
workspace and lowercased slug. Update the move operation’s constraint-error
handling to translate violations of this index into the existing HTTP 409
conflict response, while preserving the current pre-check and message for
detected conflicts.
In `@apps/api/src/project/index.ts`:
- Around line 242-261: Extend the moveProject describeRoute response schema to
document the route’s 400, 402, 403, 404, and 409 outcomes alongside the existing
200 response, including the 409 project-key collision response so clients can
handle it explicitly. Reuse the established error response schemas or
conventions already used by nearby routes in this file.
🪄 Autofix
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: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: e313e61b-b79d-4bc8-855e-0ec1b33f4335
📒 Files selected for processing (25)
apps/api/src/project/controllers/move-project.tsapps/api/src/project/index.tsapps/api/src/utils/require-workspace-permission.tsapps/web/src/fetchers/project/move-project.tsapps/web/src/hooks/mutations/project/use-move-project.tsapps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/$projectId/general.tsxi18n/de-DE.jsoni18n/el-GR.jsoni18n/en-US.jsoni18n/es-ES.jsoni18n/fr-FR.jsoni18n/hi-IN.jsoni18n/id-ID.jsoni18n/it-IT.jsoni18n/ko-KR.jsoni18n/mk-MK.jsoni18n/nl-NL.jsoni18n/pt-BR.jsoni18n/ru-RU.jsoni18n/schema.jsoni18n/tr-TR.jsoni18n/uk-UA.jsoni18n/vi-VN.jsoni18n/zh-CN.jsontests/api-integration/project-move.test.ts
| function useMoveProject() { | ||
| return useMutation({ | ||
| mutationFn: moveProject, | ||
| }); | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Add mutation side effects to useMoveProject.
This hook only sets mutationFn. Add the established success and error handlers to invalidate related project and workspace queries and show Sonner feedback. Otherwise, consumers can keep stale cache data or omit user feedback after a move.
As per coding guidelines, mutation hooks must invalidate related queries and use toast notifications in mutation success and error handlers.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/hooks/mutations/project/use-move-project.ts` around lines 4 - 8,
Update useMoveProject to add the standard mutation onSuccess and onError
handlers alongside mutationFn: invalidate the related project and workspace
queries after a successful move, and display Sonner success or error feedback
through the established toast pattern. Reuse existing query keys and
notification conventions from nearby mutation hooks.
Source: Coding guidelines
… move Addresses review feedback on usekaneo#1525: - Lock the project row and predicate the move UPDATE on the source workspace, so a concurrent move can't invalidate the authorization basis after it was checked. Scope the notification-subscription delete to the source workspace as well. - Replace the materialized task-id lists with set-based predicates: a subquery for the label rewrite, and the target's member set for the unassignment. Both previously scaled with task count and could exceed Postgres' 65535 bind-parameter cap on a large project. - Offer only workspaces the caller can create projects in as move targets, matching what the endpoint requires. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/$projectId/general.tsx (1)
628-628: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winCheck
project:updatein the project source workspace.
useWorkspacePermission()checks the active workspace, but the source project can belong to a different workspace. In that case, this gate can hide the move action from an authorized user or show it to a user that the API rejects.Query source permissions with
project.workspaceIdbefore rendering this action.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/`$projectId/general.tsx at line 628, Update the move-action gate near the canEdit condition to use permissions queried for the source project's workspaceId, rather than the active workspace from useWorkspacePermission(). Check project:update for project.workspaceId before rendering the action, preserving the existing moveTargets.length requirement.
🧹 Nitpick comments (2)
apps/web/src/hooks/queries/workspace/use-project-create-workspaces.ts (2)
15-15: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRemove the redundant return type.
TypeScript infers
Promise<string[]>from the filtered results.As per coding guidelines, "Do not add explicit type annotations that can be inferred by TypeScript."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/hooks/queries/workspace/use-project-create-workspaces.ts` at line 15, Remove the explicit Promise<string[]> return type from the queryFn callback in the workspace creation query, allowing TypeScript to infer the return type from the filtered results.Source: Coding guidelines
40-40: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUse a named export for
useProjectCreateWorkspaces.Update the import at
apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/$projectId/general.tsxLine 53 to use the named export.As per coding guidelines, "Use named imports when possible instead of default imports."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/hooks/queries/workspace/use-project-create-workspaces.ts` at line 40, Change useProjectCreateWorkspaces to a named export, then update its import in the general project settings module to use the named-import syntax. Remove the default-export usage while preserving the hook’s existing behavior.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In
`@apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/`$projectId/general.tsx:
- Line 628: Update the move-action gate near the canEdit condition to use
permissions queried for the source project's workspaceId, rather than the active
workspace from useWorkspacePermission(). Check project:update for
project.workspaceId before rendering the action, preserving the existing
moveTargets.length requirement.
---
Nitpick comments:
In `@apps/web/src/hooks/queries/workspace/use-project-create-workspaces.ts`:
- Line 15: Remove the explicit Promise<string[]> return type from the queryFn
callback in the workspace creation query, allowing TypeScript to infer the
return type from the filtered results.
- Line 40: Change useProjectCreateWorkspaces to a named export, then update its
import in the general project settings module to use the named-import syntax.
Remove the default-export usage while preserving the hook’s existing behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: ad6c502e-df7a-4d20-bc53-059917f89c6b
📒 Files selected for processing (4)
apps/api/src/project/controllers/move-project.tsapps/web/src/hooks/queries/workspace/use-project-create-workspaces.tsapps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/$projectId/general.tsxtests/api-integration/project-move.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/api/src/project/controllers/move-project.ts
The move action was gated on `canEdit`, which `useWorkspacePermission` answers for the *active* workspace. This route has no `workspaceId` param, so the active workspace can differ from the one the project actually lives in (deep link, bookmark, back after a workspace switch) — the same reason the move candidates already filter against `project.workspaceId`. The gate could therefore hide the action from an authorized user, or show it to one the API rejects. Generalize `useProjectCreateWorkspaces` into `useWorkspacesWithPermission`, which takes the permission set as an argument, and use it twice: `project:create` over the candidate targets as before, and `project:update` over the project's own workspace for the gate. Export it by name and drop the inferable return type annotation. Notes on the rest of the review: - The API findings (move TOCTOU race, unbounded `inArray` parameter lists) were already fixed in 7c40f98: the controller locks the source workspace with SELECT ... FOR UPDATE, predicates the unassign on assignee ids, and uses a subquery for the label update. - Moving invalidation and toasts into `useMoveProject` was skipped on purpose. Those side effects already live in `handleMoveProject` next to the post-move `navigate()`, which is inherently route-specific; splitting them across two files would read worse. - `canEdit` and `canDelete` have the same active-vs-source workspace flaw, but that predates this PR and is left for a separate fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/$projectId/general.tsx (3)
356-363: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftPersist the latest project edits before moving the project.
If the user changes
slugand confirms before the 800 ms autosave runs, this request uses the old persisted slug. The API collision check inapps/api/src/project/controllers/move-project.tsthen evaluates the old value. The unmount cleanup can save the new slug only after navigation.Serialize the move after the latest valid autosave, or combine the edit and move in one server transaction.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/`$projectId/general.tsx around lines 356 - 363, Update handleMoveProject to persist the latest valid project edits before calling moveProject, ensuring pending slug changes are saved rather than relying on unmount cleanup; serialize the autosave completion ahead of the move request while preserving the existing move flow.
356-363: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winGate Move and Delete on the route-owned project.
moveProjectanddeleteProjectuse the global store value (project.id), while this route’sprojectIdcomes from URL params. The store is updated only when the query for that param returns, so a route transition can still carry the previous project. Only allow the mutation ifproject?.id === projectId, or clear and gate the store until the current project loads.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/`$projectId/general.tsx around lines 356 - 363, Update handleMoveProject and the corresponding delete handler to verify that the store project belongs to the current route before mutating: require project?.id === projectId alongside the existing guards, and continue using the validated project ID for moveProject and deleteProject. Ensure route transitions cannot operate on a stale store project while the current project query is loading.
739-764: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winRender custom dialog buttons without nesting primitives.
AlertDialogCloserenders Base UI’sClosecomponent, so wrapping child<Button>elements creates nested interactive elements. Move each closing action’s props onto a single button that replaces the primitive instead (render={<Button ...>}), and attachonClickanddisabledto the rendered button.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/`$projectId/general.tsx around lines 739 - 764, Update the AlertDialogClose elements in the move dialog around isMoveModalOpen to render a single Button via the primitive’s render prop instead of nesting Button children. Preserve the cancel button’s outline styling and the confirm button’s isMoving onClick/disabled behavior by applying those props to the rendered buttons.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/`$projectId/general.tsx:
- Around line 175-181: Update the permission checks around canMoveFromSource and
the move action to query PROJECT_DELETE for sourceWorkspaceIds, then require
both source PROJECT_UPDATE and PROJECT_DELETE permissions when determining move
availability. Do not use canDeleteProjects() for this gate, since it evaluates
the active workspace.
---
Outside diff comments:
In
`@apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/`$projectId/general.tsx:
- Around line 356-363: Update handleMoveProject to persist the latest valid
project edits before calling moveProject, ensuring pending slug changes are
saved rather than relying on unmount cleanup; serialize the autosave completion
ahead of the move request while preserving the existing move flow.
- Around line 356-363: Update handleMoveProject and the corresponding delete
handler to verify that the store project belongs to the current route before
mutating: require project?.id === projectId alongside the existing guards, and
continue using the validated project ID for moveProject and deleteProject.
Ensure route transitions cannot operate on a stale store project while the
current project query is loading.
- Around line 739-764: Update the AlertDialogClose elements in the move dialog
around isMoveModalOpen to render a single Button via the primitive’s render prop
instead of nesting Button children. Preserve the cancel button’s outline styling
and the confirm button’s isMoving onClick/disabled behavior by applying those
props to the rendered buttons.
🪄 Autofix
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: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: d93de07b-d904-4f1e-92d9-3fe794a2014b
📒 Files selected for processing (2)
apps/web/src/hooks/queries/workspace/use-workspaces-with-permission.tsapps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/$projectId/general.tsx
The move endpoint authorizes against the project's own workspace with `project: ["update", "delete"]`, since a move takes the project out of that workspace. The UI gate only queried `project:update` there. A custom role granting update without delete would therefore see the Move action and get a 403 on submit. Query `project:delete` over the same source workspace and require both in `canMoveFromSource`, so the section's existing condition needs no change. `canDelete` stays as-is: it answers for the active workspace and correctly gates the delete section, so it can't stand in for the source-workspace check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ween-workspaces # Conflicts: # apps/api/src/project/index.ts
|
@rdlugs thank you for your contribution! Please take a look at the coderabbit suggestions when you have a chance! Thank you! |
|
Thanks! I take a look at the coderabbit suggestions and address them accordingly, appreciate the feedback |
The move landed the project on whatever position it held in the source workspace, which is a slot the target almost always already gave to another project. Two projects then shared an ordering position and the board's order became arbitrary. The move now appends at max(position) + 1 in the target, the same place createProject puts a new one, and takes the advisory lock createProject and reorderProjects already share on the target workspace, so the key-collision check and the position read stop being read-then-write races. Only the target is locked, so two moves in opposite directions cannot deadlock; the source is left with a gap exactly as a delete leaves one. Unassigning no longer emits one task.unassigned event per task. That set is not a client-supplied batch but every assigned task in the project, and publishEvent is a synchronous emit into detached subscribers, so a project-sized loop fired that many activity inserts, webhook deliveries and board broadcasts in a single tick. History is written instead as chunked bulk inserts, staying under the 65535 bind-parameter cap, and clients get a single project-scoped task.bulk_unassigned refresh. Plugin and webhook consumers no longer see per-task unassign events for a move. The permission probe for candidate targets stopped swallowing failures. Reporting a failed check as "no permission" is indistinguishable from a real denial, so a transient error silently hid the action; the hook now surfaces the error and the move section explains it rather than showing an empty workspace list, which answers a different question. Moving on a debounced edit checked the stale project key against the target and then persisted the new one after navigation, so the flush is now awaited first and reports whether the write actually landed. Move and delete are also gated on the project store matching the route, which it does not during a transition, and cache invalidation for the move moved into useMoveProject where the other project mutations keep theirs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #1328
Problem
There's no way to reassign a project to a different workspace. Teams that reorganize are stuck either recreating the project by hand or editing the database directly, which is what the issue reporter was resorting to.
What this adds
PUT /project/:id/moveplus a Move to workspace control in project general settings.Permissions. The caller needs
project:updateandproject:deletein the source workspace — a move removes the project from there — andproject:createin the target. Every built-in role that grantsupdatealso grantsdelete, so the pair only matters for custom roles, whereupdatealone would otherwise let someone move a project out of a workspace they can't delete from. The target is authorized separately (membership, permission, entitlement) because the access middleware only resolves one workspace per request.Key collisions. The project key doubles as the ticket-id prefix (
KAN-12), and short-id lookup resolves it per workspace with a limit of 1. If the target workspace already uses the key, the move is refused with a 409 naming the incumbent project, rather than silently making ticket ids ambiguous. Compared case-insensitively, matching the lookup.Data that moves with it. All in one transaction:
project.workspaceIdis repointed; tasks, columns, and comments follow by project id.assetandlabelrows denormalize the workspace, so they're rewritten.(workspace_id, project_id)key, and the cascade from the workspace update would break the rule side, since the rule itself stays behind in the source workspace.task.unassignedevent so the change lands on the task timeline. Events publish after commit, so a rollback can't leave activity rows behind for a move that never happened.The confirm dialog spells out the unassignment and subscription clearing before the user commits, and the resulting toast reports how many tasks lost their assignee.
Testing
tests/api-integration/project-move.test.ts— 8 integration tests covering: non-member target, target withoutproject:create, key collision (including differing case), same key left behind in the source not blocking, source withoutproject:delete, moving into the current workspace, the full side-data rewrite, and assignee dropping with its activity row.Full suite green:
pnpm typecheck,pnpm test,pnpm test:integration(117 tests),pnpm lint,pnpm build.Notes
i18n/schema.json.updateandarchivebehave — an expired source workspace can still move projects out.workspace/<id>/…prefix after a move. Serving and cleanup both use the stored key verbatim and authorize against the asset row's workspace, so this is cosmetic; rewriting the keys would mean copying objects.🤖 Generated with Claude Code
Summary by CodeRabbit