Skip to content

Commit eef9d29

Browse files
feat(projects): enforce Project membership and retire the connector (#8590)
* feat(projects): add project identity and lifecycle foundation * feat(projects): create projects with their initial environment * docs(projects): record project files follow-up * docs(projects): explain project and workspace creation flows * fix(projects): stage activation after compatible writers deploy * feat(projects): enforce membership after the staged backfill * fix(projects): retry writes after concurrent membership changes * fix(projects): align integration fixtures with membership constraints * refactor(projects): prepare compatible writers for the SQL backfill * refactor(projects): backfill through bounded SQL migration batches * fix(projects): clean up automatically created fixture Projects * fix(projects): backfill only families missing membership * fix(projects): bound the trigger installation lock window * fix(workflows): guard restore against concurrent workspace archive * fix(projects): keep workflow lifecycle guards at workspace scope * fix(workflows): guard restore against concurrent workspace archive * fix(projects): close lifecycle races and surface rollout conflicts * fix(workflows): return not found when import loses archive race * fix(projects): tighten rollout checks and deduplicate lifecycle validation * test(projects): detect missing lifecycle validation calls * test(projects): verify concurrent lifecycle lock contention * test(projects): update new workspace fixtures for enforcement * fix(tests): create Project membership in desktop and CLI fixtures * fix(tests): restore Project enforcement fixtures after migration renumbering * fix(tests): scope migration probes and align remaining Project fixtures * feat(projects): prepare workspace project column compatibility * test(projects): clean up compatibility fixtures atomically * feat(projects): move membership to workspace column with rollout bridge * test(projects): align transfer mocks with workspace membership * test(projects): provision required membership in transfer unit fixtures * improvement(workspaces): present API responses explicitly * feat(projects): prepare legacy membership with a resumable operator tool * fix(projects): support PostgreSQL 16 column migration * fix(projects): allow multi-batch copies to finish * fix(projects): support PostgreSQL 16 preparation and current fixtures * fix(projects): reconcile memberships during schema push * fix(projects): enforce safe schema push provisioning * test(projects): provision newly merged workspace fixtures * fix(projects): use the direct connection for push reconciliation * test(billing): wait for parked transactions before racing writers * test(billing): freeze the clock for exact deadline assertions * fix(projects): close operator verification and contention gaps * test(billing): stabilize member ledger timeout checks * fix(projects): await cleanup and bound backfill artifacts * fix(projects): honor operator cancellation and status exits * fix(projects): reject verification of conflicted plans * fix(projects): defer archive repair on database contention * fix(projects): coordinate read-only maintenance commands * ci(projects): use the standard PostgreSQL test coverage * fix(projects): support trigger-free column rollout * fix(projects): run bounded membership backfill before enforcement * test(projects): verify column-only archive repair assignment * fix(projects): retain healthy indexes during schema push * ci(projects): use standard integration coverage for enforcement * fix(projects): correct rollout fixtures and schema-push index * fix(projects): reconcile assigned environments before enforcement * fix(projects): preserve backfill repair and resume guarantees Lock and verify effective legacy memberships before materializing reviewed assignments and applying the shared detach policy. Reject resume reports whose code hash differs before changing their checkpoint. Isolate local pubsub subscriber failures so healthy archive subscribers still receive notifications. Extend existing CLI and real-database integration coverage for mixed memberships, concurrent and stale connector changes, unchanged mismatched reports, replay, and local subscriber delivery. * test(projects): align fixture lifecycle with enforced project ownership * test(projects): skip archive repair suite when Redis is unset * chore(projects): normalize enforcement SQL whitespace * feat(projects): commit membership authority before reconciliation * fix(projects): switch membership authority after compatible rollout * test(projects): guard optional authority fixtures explicitly * test(projects): seed authority in billing lock-order mock * fix(projects): bound authority barriers and verify push replay * fix(projects): pin operator schema and pace detach repairs * docs(projects): record native constraint rollout decisions * refactor(projects): replace integrity triggers with native foreign keys * fix(projects): recognize completed rollout without its marker * fix(projects): retire rollout marker with legacy membership * test(projects): model expansion authority in workspace move fixtures * fix(projects): keep repair reads compatible with expansion schema * fix(projects): retain project name for workspace archival * docs(projects): consolidate permanent storage rules in README * fix(tests): seed Project membership in new staging fixtures * Update packages/db/scripts/reconcile-project-membership.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
1 parent caa925a commit eef9d29

178 files changed

Lines changed: 36297 additions & 1792 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
#!/usr/bin/env python3
2+
"""Read-only, fail-closed ECS retirement check for the Project column contract migration.
3+
4+
The expected digest acknowledges fully deployed #8830 transaction-barrier and durable-authority support
5+
and retirement of every incompatible server and relevant old worker job before switching. Every live and supported
6+
rollback application must tolerate project_workspace being absent.
7+
AWS checks below independently verify ECS retirement, not worker drainage.
8+
"""
9+
import argparse
10+
import datetime
11+
import json
12+
import re
13+
import subprocess
14+
import sys
15+
16+
17+
def aws(region, *args):
18+
result = subprocess.run(
19+
['aws', '--region', region, '--no-cli-pager', '--cli-connect-timeout', '10', '--cli-read-timeout', '30', *args, '--output', 'json'],
20+
capture_output=True, text=True, timeout=90, check=False,
21+
)
22+
if result.returncode:
23+
raise RuntimeError('AWS preflight read failed; check deployment-read permissions')
24+
return json.loads(result.stdout)
25+
26+
27+
def latest_execution(region, pipeline):
28+
executions = aws(region, 'codepipeline', 'list-pipeline-executions', '--pipeline-name', pipeline).get('pipelineExecutionSummaries', [])
29+
def epoch(execution):
30+
value = execution['startTime']
31+
return value if isinstance(value, (int, float)) else datetime.datetime.fromisoformat(value.replace('Z', '+00:00')).timestamp()
32+
if not executions:
33+
raise RuntimeError('No application deployment execution was found')
34+
return max(executions, key=epoch)
35+
36+
37+
def matches_release(execution, digest):
38+
return execution.get('status') == 'Succeeded' and any(
39+
revision.get('actionName') == 'ECR_Source' and revision.get('revisionId') == digest
40+
for revision in execution.get('sourceRevisions', [])
41+
)
42+
43+
44+
def verify(environment, region, digest):
45+
if not re.fullmatch(r'sha256:[0-9a-f]{64}', digest):
46+
raise RuntimeError('Set the environment-specific PROJECT_COLUMN_ENFORCEMENT_READY_IMAGE_DIGEST after verifying #8830 authority-aware readers/writers, transaction barriers and all incompatible server/worker drainage')
47+
pipeline = f'sim-{environment}-{region}-app-deployment'
48+
execution = latest_execution(region, pipeline)
49+
if not matches_release(execution, digest):
50+
raise RuntimeError('The latest app pipeline has not completed for the acknowledged image; traffic cutover alone is insufficient')
51+
execution_id = execution['pipelineExecutionId']
52+
group = aws(region, 'deploy', 'get-deployment-group', '--application-name', f'sim-{environment}-{region}-ecs-app',
53+
'--deployment-group-name', f'sim-{environment}-{region}-app-dg')['deploymentGroupInfo']
54+
services = group.get('ecsServices', [])
55+
if len(services) != 1:
56+
raise RuntimeError('Expected exactly one application ECS service')
57+
cluster, service = services[0]['clusterName'], services[0]['serviceName']
58+
description = aws(region, 'ecs', 'describe-services', '--cluster', cluster, '--services', service)
59+
if description.get('failures') or len(description.get('services', [])) != 1:
60+
raise RuntimeError('Cannot inspect the application ECS service')
61+
record = description['services'][0]
62+
if record.get('desiredCount', 0) < 1 or record.get('runningCount') != record['desiredCount'] or record.get('pendingCount') != 0:
63+
raise RuntimeError('Application ECS service is not stable')
64+
arns = set()
65+
for status in ('RUNNING', 'STOPPED'):
66+
arns.update(aws(region, 'ecs', 'list-tasks', '--cluster', cluster, '--service-name', service,
67+
'--desired-status', status).get('taskArns', []))
68+
live = []
69+
ordered = sorted(arns)
70+
for start in range(0, len(ordered), 100):
71+
response = aws(region, 'ecs', 'describe-tasks', '--cluster', cluster, '--tasks', *ordered[start:start + 100])
72+
if response.get('failures'):
73+
raise RuntimeError('Cannot account for every ECS task')
74+
if len(response.get('tasks', [])) != len(ordered[start:start + 100]):
75+
raise RuntimeError('Incomplete ECS task response')
76+
live.extend(task for task in response['tasks'] if task.get('lastStatus') != 'STOPPED')
77+
if len(live) != record['desiredCount']:
78+
raise RuntimeError('Old, stopping, or pending ECS tasks remain')
79+
for task in live:
80+
app = [container for container in task.get('containers', []) if container.get('name') == 'app']
81+
if task.get('lastStatus') != 'RUNNING' or task.get('desiredStatus') != 'RUNNING' or len(app) != 1 or app[0].get('imageDigest') != digest:
82+
raise RuntimeError('A live ECS task does not match the acknowledged compatible release')
83+
latest = latest_execution(region, pipeline)
84+
if latest.get('pipelineExecutionId') != execution_id or not matches_release(latest, digest):
85+
raise RuntimeError('Application deployment changed during preflight')
86+
print(json.dumps({'ecsRetired': True, 'expectedImageDigest': digest, 'pipelineExecutionId': execution_id,
87+
'operatorAcknowledgedColumnWritersAndWorkers': True}))
88+
89+
90+
if __name__ == '__main__':
91+
parser = argparse.ArgumentParser(description=__doc__)
92+
parser.add_argument('--environment', required=True, choices=['production', 'staging'])
93+
parser.add_argument('--region', required=True)
94+
parser.add_argument('--expected-image-digest', required=True)
95+
args = parser.parse_args()
96+
try:
97+
verify(args.environment, args.region, args.expected_image_digest)
98+
except (RuntimeError, ValueError, KeyError, TypeError, subprocess.TimeoutExpired) as error:
99+
print(f'Project column rollout preflight refused: {error}', file=sys.stderr)
100+
sys.exit(1)

‎.github/workflows/ci.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ jobs:
112112
# in place before the new app version deploys (replaces the removed ECS
113113
# migration sidecar)
114114
migrate:
115+
permissions:
116+
contents: read
117+
id-token: write
115118
name: migrate
116119
needs: [checks]
117120
# Explicit need results instead of the implicit success(): a skipped job
@@ -131,9 +134,16 @@ jobs:
131134
STAGING_DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}
132135
MIGRATION_DATABASE_URL: ${{ secrets.MIGRATION_DATABASE_URL }}
133136
STAGING_MIGRATION_DATABASE_URL: ${{ secrets.STAGING_MIGRATION_DATABASE_URL }}
137+
AWS_ROLE_TO_ASSUME: ${{ secrets.AWS_ROLE_TO_ASSUME }}
138+
STAGING_AWS_ROLE_TO_ASSUME: ${{ secrets.STAGING_AWS_ROLE_TO_ASSUME }}
139+
AWS_REGION: ${{ secrets.AWS_REGION }}
140+
STAGING_AWS_REGION: ${{ secrets.STAGING_AWS_REGION }}
134141

135142
# Same ordering for dev (schema push before the dev image lands in ECR)
136143
migrate-dev:
144+
permissions:
145+
contents: read
146+
id-token: write
137147
name: migrate-dev
138148
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
139149
uses: ./.github/workflows/migrate.yml

‎.github/workflows/migrate.yml‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ on:
1818
required: false
1919
STAGING_MIGRATION_DATABASE_URL:
2020
required: false
21+
AWS_ROLE_TO_ASSUME:
22+
required: false
23+
STAGING_AWS_ROLE_TO_ASSUME:
24+
required: false
25+
AWS_REGION:
26+
required: false
27+
STAGING_AWS_REGION:
28+
required: false
2129
workflow_dispatch:
2230
inputs:
2331
environment:
@@ -31,6 +39,7 @@ on:
3139

3240
permissions:
3341
contents: read
42+
id-token: write
3443

3544
jobs:
3645
migrate:
@@ -51,6 +60,33 @@ jobs:
5160
provider: ${{ vars.CI_PROVIDER }}
5261
node-version: ''
5362

63+
- name: Check Project column expansion and whether enforcement needs rollout evidence
64+
id: project-contract
65+
if: inputs.environment != 'dev'
66+
working-directory: ./packages/db
67+
env:
68+
DATABASE_URL: ${{ inputs.environment == 'production' && secrets.DATABASE_URL || inputs.environment == 'staging' && secrets.STAGING_DATABASE_URL || '' }}
69+
MIGRATION_DATABASE_URL: ${{ inputs.environment == 'production' && secrets.MIGRATION_DATABASE_URL || inputs.environment == 'staging' && secrets.STAGING_MIGRATION_DATABASE_URL || '' }}
70+
run: bun --no-env-file scripts/project-contract-required.ts >> "$GITHUB_OUTPUT"
71+
72+
- name: Configure AWS for Project connector retirement verification
73+
if: steps.project-contract.outputs.required == 'true'
74+
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b
75+
with:
76+
role-to-assume: ${{ inputs.environment == 'production' && secrets.AWS_ROLE_TO_ASSUME || secrets.STAGING_AWS_ROLE_TO_ASSUME }}
77+
aws-region: ${{ inputs.environment == 'production' && secrets.AWS_REGION || secrets.STAGING_AWS_REGION }}
78+
79+
# Acknowledge #8830 only after authority-aware readers/writers deploy and all incompatible
80+
# servers and relevant old Trigger.dev runs drain. The runner commits the column-authority
81+
# switch before backfill, validation, and connector retirement; retries never revert it.
82+
# The preflight independently checks ECS retirement; it does not inspect worker runs.
83+
- name: Require completed authority-aware rollout before Project cutover
84+
if: steps.project-contract.outputs.required == 'true'
85+
env:
86+
ENVIRONMENT: ${{ inputs.environment }}
87+
EXPECTED_IMAGE_DIGEST: ${{ inputs.environment == 'production' && vars.PROJECT_COLUMN_ENFORCEMENT_READY_IMAGE_DIGEST_PRODUCTION || inputs.environment == 'staging' && vars.PROJECT_COLUMN_ENFORCEMENT_READY_IMAGE_DIGEST_STAGING || '' }}
88+
run: python3 .github/scripts/check-project-rollout.py --environment "$ENVIRONMENT" --region "$AWS_REGION" --expected-image-digest "$EXPECTED_IMAGE_DIGEST"
89+
5490
# The expression maps the explicit environment input to exactly one repo
5591
# secret, so the job never holds another environment's database URL. An
5692
# unknown environment resolves to empty and the guard below fails the job.

‎apps/desktop/e2e/fixtures/live-sim.ts‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -651,9 +651,16 @@ export class SimDatabase {
651651
}
652652

653653
async close(): Promise<void> {
654-
if (this.userIds.length > 0)
655-
await this.sql`delete from "user" where id in ${this.sql(this.userIds)}`.catch(() => {})
656-
await this.sql.end({ timeout: 5 })
654+
try {
655+
if (this.userIds.length > 0)
656+
await this.sql.begin(async (tx) => {
657+
await tx`delete from workspace where owner_id in ${tx(this.userIds)}`
658+
await tx`delete from project where owner_id in ${tx(this.userIds)}`
659+
await tx`delete from "user" where id in ${tx(this.userIds)}`
660+
})
661+
} finally {
662+
await this.sql.end({ timeout: 5 })
663+
}
657664
}
658665

659666
/** A user with a workspace, a session as the desktop sign-in creates, and one chat per title. */
@@ -669,8 +676,10 @@ export class SimDatabase {
669676
await tx`insert into "user" (id, name, email, normalized_email, email_verified, created_at, updated_at)
670677
values (${userId}, ${name}, ${email}, ${email}, true, now(), now())`
671678
await tx`insert into user_stats (id, user_id) values (${generateId()}, ${userId})`
672-
await tx`insert into workspace (id, name, owner_id, billed_account_user_id)
673-
values (${workspaceId}, 'Desktop tools E2E', ${userId}, ${userId})`
679+
await tx`insert into project (id, name, owner_id)
680+
values (${workspaceId}, 'E2E fixture project', ${userId})`
681+
await tx`insert into workspace (id, project_id, name, owner_id, billed_account_user_id)
682+
values (${workspaceId}, ${workspaceId}, 'Desktop tools E2E', ${userId}, ${userId})`
674683
await tx`insert into permissions (id, user_id, entity_type, entity_id, permission_type)
675684
values (${generateId()}, ${userId}, 'workspace', ${workspaceId}, 'admin')`
676685
await tx`insert into session (id, token, user_id, user_agent, expires_at, created_at, updated_at)

‎apps/sim/app/api/copilot/chat/stream/desktop-executor.integration.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* Redis through the production reconnect route, for the live tail and the replay batch a view
55
* attaches with after a reload.
66
*/
7+
import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
78
import { authMock, authMockFns } from '@sim/testing/mocks/auth.mock'
89
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
910

@@ -72,7 +73,7 @@ describe.runIf(Boolean(redisUrl))('desktop executor binding on a chat stream', (
7273
createdAt: now,
7374
updatedAt: now,
7475
})
75-
await db.insert(workspace).values({
76+
await insertWorkspaceFixture(db, {
7677
id: workspaceId,
7778
name: 'Desktop stream fixture',
7879
ownerId: userId,
@@ -105,7 +106,7 @@ describe.runIf(Boolean(redisUrl))('desktop executor binding on a chat stream', (
105106
await db.delete(copilotChats).where(eq(copilotChats.id, chatId))
106107
await db.delete(desktopDevices).where(eq(desktopDevices.id, deviceId))
107108
await db.delete(permissions).where(eq(permissions.userId, userId))
108-
await db.delete(workspace).where(eq(workspace.id, workspaceId))
109+
await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId))
109110
await db.delete(user).where(eq(user.id, userId))
110111
await closeRedisConnection()
111112
})

‎apps/sim/app/api/knowledge/connectors/member-sync/route.integration.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
12
/**
23
* The member sync scheduler's reclaim against real PostgreSQL: a members-mode connector whose
34
* member lease went stale is put back on the failure ladder, and a connector in any other access
@@ -51,7 +52,7 @@ describe('member sync reclaim in PostgreSQL', () => {
5152
})
5253

5354
afterAll(async () => {
54-
await db.delete(workspace).where(eq(workspace.id, ids.workspaceId))
55+
await deleteWorkspaceFixture(db, eq(workspace.id, ids.workspaceId))
5556
await db.delete(organization).where(eq(organization.id, ids.organizationId))
5657
await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId]))
5758
await db.$client.end()

‎apps/sim/app/api/v1/knowledge/route.integration.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import type { Principal } from '@sim/auth/principal'
88
import { db } from '@sim/db'
99
import { document, organization, user, workspace } from '@sim/db/schema'
10+
import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
1011
import { authMock, authMockFns, createMockRequest } from '@sim/testing'
1112
import { generateId } from '@sim/utils/id'
1213
import { eq, inArray } from 'drizzle-orm'
@@ -85,7 +86,7 @@ describe('knowledge-base document totals in PostgreSQL', () => {
8586
})
8687

8788
afterAll(async () => {
88-
await db.delete(workspace).where(eq(workspace.id, ids.workspaceId))
89+
await deleteWorkspaceFixture(db, eq(workspace.id, ids.workspaceId))
8990
await db.delete(organization).where(eq(organization.id, ids.organizationId))
9091
await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId]))
9192
vi.unstubAllGlobals()

‎apps/sim/app/api/webhooks/agentmail/thread-scope.integration.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
12
/**
23
* An inbox reply continues its parent task's chat, and the parent is found by the
34
* `In-Reply-To` header — a value every recipient of the agent's reply holds. Against
@@ -97,7 +98,7 @@ async function seedWorkspace(ws: InboxWorkspace): Promise<void> {
9798
createdAt: now,
9899
updatedAt: now,
99100
})
100-
await db.insert(workspace).values({
101+
await insertWorkspaceFixture(db, {
101102
id: ws.id,
102103
name: 'Inbox thread scope fixture',
103104
ownerId: ws.ownerId,
@@ -224,7 +225,7 @@ describe('inbox reply threading stays inside the receiving workspace', () => {
224225
afterAll(async () => {
225226
const ownerIds = [victim.ownerId, attacker.ownerId]
226227
await db.delete(permissions).where(inArray(permissions.userId, ownerIds))
227-
await db.delete(workspace).where(inArray(workspace.id, [victim.id, attacker.id]))
228+
await deleteWorkspaceFixture(db, inArray(workspace.id, [victim.id, attacker.id]))
228229
await db.delete(user).where(inArray(user.id, ownerIds))
229230
})
230231

‎apps/sim/background/cleanup-soft-deletes.integration.ts‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,28 @@ async function seedRelease(deletedAt: string | null, sizeBytes: number, patch: n
4343

4444
describe('Retention cleanup of deleted owned bodies', () => {
4545
beforeAll(async () => {
46-
await control`INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
47-
VALUES (${userId}, 'Test cleanup fixture', ${`${userId}@example.test`}, true, now(), now())`
48-
await control`INSERT INTO user_stats (id, user_id, storage_used_bytes)
49-
VALUES (${generateId()}, ${userId}, 100)`
50-
await control`INSERT INTO workspace (id, name, owner_id, billed_account_user_id, storage_used_bytes)
51-
VALUES (${workspaceId}, 'Test cleanup fixtures', ${userId}, ${userId}, 100)`
46+
await control.begin(async (tx) => {
47+
await tx`INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
48+
VALUES (${userId}, 'Test cleanup fixture', ${`${userId}@example.test`}, true, now(), now())`
49+
await tx`INSERT INTO user_stats (id, user_id, storage_used_bytes)
50+
VALUES (${generateId()}, ${userId}, 100)`
51+
await tx`INSERT INTO project (id, name, owner_id)
52+
VALUES (${workspaceId}, 'Test cleanup project', ${userId})`
53+
await tx`INSERT INTO workspace (id, project_id, name, owner_id, billed_account_user_id, storage_used_bytes)
54+
VALUES (${workspaceId}, ${workspaceId}, 'Test cleanup fixtures', ${userId}, ${userId}, 100)`
55+
})
5256
})
5357

5458
afterAll(async () => {
55-
await control`DELETE FROM workspace WHERE id = ${workspaceId}`
56-
await control`DELETE FROM "user" WHERE id = ${userId}`
57-
await control.end()
59+
try {
60+
await control.begin(async (tx) => {
61+
await tx`DELETE FROM workspace WHERE id = ${workspaceId}`
62+
await tx`DELETE FROM project WHERE id = ${workspaceId} AND owner_id = ${userId}`
63+
await tx`DELETE FROM "user" WHERE id = ${userId}`
64+
})
65+
} finally {
66+
await control.end()
67+
}
5868
})
5969

6070
it('purges an expired test with its source and releases the source bytes', async () => {

‎apps/sim/background/cleanup-table-row-ttl.integration.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,11 @@ describe.skipIf(!migrated)('Expiration with real PostgreSQL transactions', () =>
109109
beforeAll(async () => {
110110
await control`INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
111111
VALUES (${userId}, 'Expiration integration fixture', ${`${userId}@example.test`}, true, now(), now())`
112-
await control`INSERT INTO workspace (id, name, owner_id, billed_account_user_id)
113-
VALUES (${workspaceId}, 'Expiration integration fixtures', ${userId}, ${userId})`
112+
await control.begin(async (tx) => {
113+
await tx`INSERT INTO project (id, name, owner_id) VALUES (${workspaceId}, 'Fixture project', ${userId})`
114+
await tx`INSERT INTO workspace (id, project_id, name, owner_id, billed_account_user_id)
115+
VALUES (${workspaceId}, ${workspaceId}, 'Expiration integration fixtures', ${userId}, ${userId})`
116+
})
114117
})
115118

116119
beforeEach(async () => {
@@ -124,7 +127,10 @@ describe.skipIf(!migrated)('Expiration with real PostgreSQL transactions', () =>
124127
})
125128

126129
afterAll(async () => {
127-
await control`DELETE FROM workspace WHERE id = ${workspaceId}`
130+
await control.begin(async (tx) => {
131+
await tx`DELETE FROM workspace WHERE id = ${workspaceId}`
132+
await tx`DELETE FROM project WHERE id = ${workspaceId}`
133+
})
128134
await control`DELETE FROM "user" WHERE id = ${userId}`
129135
writeFileSync(
130136
join(tmpdir(), 'expiration-qa-measurements.json'),

0 commit comments

Comments
 (0)