Skip to content

improvement(deletion): refactor to soft deletion of resources#3552

Closed
icecrasher321 wants to merge 1 commit intofeat/mothership-copilotfrom
improvement/soft-deletion
Closed

improvement(deletion): refactor to soft deletion of resources#3552
icecrasher321 wants to merge 1 commit intofeat/mothership-copilotfrom
improvement/soft-deletion

Conversation

@icecrasher321
Copy link
Collaborator

Summary

Soft Deletion of Workspace and Resources: Workflows, KBs, Files, Tables

Type of Change

  • Other: Structural Improvement

Testing

Added specific unit tests + tested manually

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel
Copy link

vercel bot commented Mar 12, 2026

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

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Mar 12, 2026 11:59pm

Request Review

@cursor
Copy link

cursor bot commented Mar 12, 2026

PR Summary

Medium Risk
Touches many API queries and delete paths to respect archivedAt/deletedAt, which can affect visibility and access control across workspaces, workflows, files, tables, MCP, and A2A endpoints. Risk is mitigated by being mostly query-filter changes, but regressions could surface as missing/extra items or broken undeploy/delete flows.

Overview
This PR refactors deletion across the app to soft-archive resources instead of hard deleting them. Many API routes now consistently filter out archived records (archivedAt/deletedAt is NULL) and “delete” endpoints for workflows, workspaces, A2A agents, MCP tools, tables, and files are switched to updates that set archive timestamps (and disable publication/deployment where relevant).

It also adds scope-based listing (active/archived/all) for workspaces, workflows, tables, workspace files, and knowledge bases, updating React Query keys/invalidation accordingly. Admin/workflow deploy routes add a rollback helper to restore the previous active version/webhooks on failure, and logs/notifications are adjusted to tolerate archived/deleted workflows (left-joins + “Deleted Workflow” fallback, skip notifications for archived contexts).

Written by Cursor Bugbot for commit 29e636b. Configure here.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 13, 2026

Greptile Summary

This PR refactors resource deletion across the Sim application from hard deletes to soft deletes ("archiving"), covering workspaces, workflows, knowledge bases, tables, files, webhooks, schedules, chats, forms, MCP tools, and A2A agents. The approach adds archivedAt / deletedAt columns to relevant tables, converts unique indexes to partial indexes conditioned on those columns being NULL, and introduces two new lifecycle modules (lib/workflows/lifecycle.ts, lib/workspaces/lifecycle.ts) that encapsulate all archiving logic. All query helpers are updated to filter soft-deleted records by default, with an opt-in scope parameter for admin/listing use cases.

Key findings:

  • TypeScript compilation errorWorkspaceScope is used as a type assertion in apps/sim/app/api/workspaces/route.ts but is never imported from @/lib/workspaces/utils. This will prevent the file from compiling.
  • Logic bug in useCreateWorkspaceonSuccess accesses variables.workspaceId, but CreateWorkspaceParams only contains name. variables.workspaceId is always undefined, making the four removeQueries calls silently ineffective.
  • Transactional inconsistency in archiveWorkspacearchiveWorkflowsForWorkspace is called outside the workspace-level db.transaction. If that transaction subsequently fails, all workflows will already be soft-deleted while the parent workspace record remains active, leaving an inconsistent state.

Confidence Score: 2/5

  • Not safe to merge — contains a TypeScript compilation error, a logic bug in a React mutation hook, and a transactional consistency gap in the core archiving path.
  • Three distinct issues of varying severity: a missing type import that will break the build, an undefined workspace ID silently passed to cache invalidation, and a multi-step archive operation that is not wrapped in a single atomic transaction, risking partial state on failure.
  • apps/sim/app/api/workspaces/route.ts (missing import), apps/sim/hooks/queries/workspace.ts (wrong variable reference), apps/sim/lib/workspaces/lifecycle.ts (transaction boundary)

Important Files Changed

Filename Overview
packages/db/schema.ts Adds archivedAt / deletedAt columns to 9 tables and converts 7 unique indexes to partial indexes conditioned on the soft-delete column being NULL. No issues found.
packages/db/migrations/0171_modern_wolf_cub.sql Migration correctly drops old unique indexes, adds the new nullable timestamp columns, recreates unique indexes as partial (WHERE archived_at IS NULL / deleted_at IS NULL), and adds btree indexes on the new columns. No issues found.
apps/sim/lib/workflows/lifecycle.ts New file implementing soft-delete for workflows via archiveWorkflow. External webhook cleanup is called outside the DB transaction (similar to the workspace lifecycle issue but less critical since it is intentionally best-effort). Well-structured otherwise.
apps/sim/lib/workspaces/lifecycle.ts New file implementing soft-delete for workspaces. Critical issue: archiveWorkflowsForWorkspace is called outside the workspace-level DB transaction, which can leave the system in a partially archived state if the transaction subsequently fails.
apps/sim/hooks/queries/workspace.ts useCreateWorkspace onSuccess accesses variables.workspaceId which is undefined (CreateWorkspaceParams only has name), making the four removeQueries calls no-ops.
apps/sim/app/api/workspaces/route.ts WorkspaceScope type is used as a type assertion but is never imported — this will cause a TypeScript compilation error.
apps/sim/lib/workflows/active-context.ts New helper that returns workflow + workspace context only when both are non-archived. Clean and well-scoped.
apps/sim/lib/workflows/lifecycle.test.ts Unit tests cover the archive and idempotency cases for workflows. Tests verify the 8 update calls inside the transaction. No issues found.
apps/sim/lib/workspaces/lifecycle.test.ts Unit tests cover the archive and idempotency cases for workspaces. Tests verify 8 update calls and 1 delete call. No issues found.
apps/sim/app/api/workflows/[id]/route.ts DELETE handler correctly delegates to archiveWorkflow and checks the result. The "last workflow in workspace" guard now filters by isNull(workflow.archivedAt). No issues found.
apps/sim/app/api/workspaces/[id]/route.ts DELETE handler delegates to archiveWorkspace, template handling moved outside the transaction. The 404-vs-success logic for already-archived workspaces is correct.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[DELETE /workspaces/:id] --> B[Fetch workspaceRecord - active only]
    B --> C[Handle templates for workflows]
    C --> D[archiveWorkspace]
    D --> E[getWorkspaceWithOwner - incl archived]
    E --> F{Already archived?}
    F -->|Yes| G[Return archived: false]
    F -->|No| H[archiveWorkflowsForWorkspace - OUTSIDE TX]
    H --> I[archiveWorkflow per workflow - each in own TX]
    I --> J[db.transaction - workspace-level resources]
    J --> K[archive knowledgeBase]
    J --> L[archive userTableDefinitions]
    J --> M[archive workspaceFiles]
    J --> N[cancel invitations]
    J --> O[delete apiKeys]
    J --> P[archive workflowSchedules - job type]
    J --> Q[archive workspace record]
    J -->|TX fails here| R[⚠️ Workflows already archived\nWorkspace still active]
    Q --> S[Return archived: true]
    S --> T[recordAudit + 200 OK]

    subgraph archiveWorkflow
        I --> W1[cleanupExternalWebhooks - OUTSIDE TX]
        W1 --> W2[db.transaction]
        W2 --> W3[archive schedules, webhooks, chat, form, mcpTools, a2aAgent, workflow]
    end
Loading

Last reviewed commit: 29e636b

return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const scope = (new URL(request.url).searchParams.get('scope') ?? 'active') as WorkspaceScope
Copy link
Contributor

Choose a reason for hiding this comment

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

WorkspaceScope type used but never imported

WorkspaceScope is referenced as a type assertion on this line, but it is not imported anywhere in the file. The type is exported from @/lib/workspaces/utils, so this will cause a TypeScript compilation error.

Suggested change
const scope = (new URL(request.url).searchParams.get('scope') ?? 'active') as WorkspaceScope
import { listUserWorkspaces, type WorkspaceScope } from '@/lib/workspaces/utils'

And the cast can remain as-is once the type is imported:

const scope = (new URL(request.url).searchParams.get('scope') ?? 'active') as WorkspaceScope

Comment on lines +40 to +42
await archiveWorkflowsForWorkspace(workspaceId, options)

await db.transaction(async (tx) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Workflow archiving outside transaction risks partial state

archiveWorkflowsForWorkspace is awaited on line 40 before the db.transaction block begins on line 42. Each workflow is archived in its own internal transaction inside archiveWorkflows. If the subsequent workspace-level db.transaction fails (e.g. a DB error while updating knowledgeBase, workspace, etc.), all workflows in the workspace will already be permanently soft-deleted while the workspace record itself remains active. Re-running the delete will then find zero non-archived workflows and still fail if the underlying DB issue persists, leaving the workspace permanently in a broken half-archived state.

Consider wrapping the workflow archiving step within the same transaction, or at a minimum applying a compensating step / idempotency guard to ensure these two phases always commit together.

Comment on lines +89 to 95
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: workspaceKeys.all })
queryClient.removeQueries({ queryKey: workspaceKeys.detail(variables.workspaceId) })
queryClient.removeQueries({ queryKey: workspaceKeys.settings(variables.workspaceId) })
queryClient.removeQueries({ queryKey: workspaceKeys.permissions(variables.workspaceId) })
queryClient.removeQueries({ queryKey: workspaceKeys.members(variables.workspaceId) })
},
Copy link
Contributor

Choose a reason for hiding this comment

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

variables.workspaceId is always undefined

The onSuccess callback accesses variables.workspaceId, but variables has type CreateWorkspaceParams which only contains name. This means the four removeQueries calls are silently no-ops because they receive undefined as the workspace ID.

The correct workspace ID is available on the first parameter (data) returned by mutationFn. The callback signature should be changed to onSuccess: (data) => { ... } and data.id used in place of variables.workspaceId.

Copy link

@cursor cursor bot left a comment

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 4 potential issues.

Fix All in Cursor

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

logger.info(`Deleted templates for workflows in workspace ${workspaceId}`)
} else {
// Set workflowId to null for templates to create "orphaned" templates
// This allows templates to remain without source workflows
Copy link

Choose a reason for hiding this comment

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

Workspace deletion lost atomicity for template handling

Medium Severity

Template deletion/orphaning (lines 256–264) now executes outside any transaction, separately from archiveWorkspace (which has its own internal transaction). If archiveWorkspace fails after templates have already been deleted or orphaned, the database is left in an inconsistent state — templates are gone or detached but the workspace and its workflows remain active. The old code performed both operations within a single transaction, guaranteeing atomicity.

Fix in Cursor Fix in Web

.from(workflow)
.where(eq(workflow.workspaceId, workspaceId))
.orderBy(...orderByClause)
workflows = await listWorkflows(workspaceId, { scope })
Copy link

Choose a reason for hiding this comment

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

Workflow listing loses ordering tiebreaker via listWorkflows

Low Severity

When workspaceId is provided, the code now delegates to listWorkflows() which orders by (sortOrder, createdAt). The original inline query and the still-defined orderByClause on line 78 include asc(workflow.id) as a final tiebreaker for deterministic ordering. The else branch (no workspaceId) still uses orderByClause with the id tiebreaker, creating an inconsistency between the two code paths.

Fix in Cursor Fix in Web

)
if (!permission) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
Copy link

Choose a reason for hiding this comment

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

API endpoint now leaks workflow existence to unauthorized users

Low Severity

The refactored code separates the workflow lookup from the permission check. The old code used a single innerJoin with permissions, returning 404 for both "not found" and "no access." The new code returns 404 when the workflow doesn't exist and 403 when the user lacks permission, allowing an attacker to enumerate valid workflow IDs through this public API endpoint.

Fix in Cursor Fix in Web

})
return false
}

Copy link

Choose a reason for hiding this comment

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

Double database lookup in workspace file access verification

Low Severity

verifyWorkspaceFileAccess now calls getFileMetadataByKey with includeDeleted: true to check for archived files, and then lookupWorkspaceFileByKey internally calls getFileMetadataByKey again (with includeDeleted: false) for the same key. This results in two sequential database queries against the workspaceFiles table for every workspace file access check, when a single query with includeDeleted: true would suffice.

Fix in Cursor Fix in Web

@icecrasher321 icecrasher321 deleted the improvement/soft-deletion branch March 13, 2026 02:00
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.

1 participant