Skip to content

Commit e53a76c

Browse files
committed
feat(mothership): erase private memory when a member leaves or an organization is deleted
Account deletion already queues the worker's memory cleanup. Removing a member, removing an external collaborator from an organization's workspaces, moving a member to another organization and deleting an organization now queue it too, in the same transaction, with the worker's new organization_member and organization targets.
1 parent a5fba63 commit e53a76c

5 files changed

Lines changed: 93 additions & 3 deletions

File tree

‎apps/sim/lib/billing/organizations/membership-external-removal.test.ts‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,77 @@
11
import { member } from '@sim/db/schema'
22
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
3+
import { resetEnvMock, setEnv } from '@sim/testing/mocks/env.mock'
34
import {
45
organizationMemberLimitsMock,
56
organizationMemberLimitsMockFns,
67
} from '@sim/testing/mocks/organization-member-limits.mock'
7-
import { beforeEach, describe, expect, it, vi } from 'vitest'
8+
import { outboxServiceMock, outboxServiceMockFns } from '@sim/testing/mocks/outbox-service.mock'
9+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
810

911
vi.mock('@/lib/billing/organizations/member-limits', () => organizationMemberLimitsMock)
12+
vi.mock('@/lib/core/outbox/service', () => outboxServiceMock)
1013

1114
import {
1215
removeExternalUserFromOrganizationWorkspaces,
1316
removeUserFromOrganization,
1417
} from '@/lib/billing/organizations/membership'
18+
import { MEMORY_CLEANUP_EVENT } from '@/lib/mothership/memories/cleanup-event'
1519

1620
const mockSetOrgMemberUsageLimit = organizationMemberLimitsMockFns.mockSetOrgMemberUsageLimit
21+
const { mockEnqueueOutboxEvents } = outboxServiceMockFns
22+
23+
/** The private-memory erasure a removal queues for the departing user (D106 in Mothership). */
24+
function memberErasure(userId: string) {
25+
return [
26+
expect.anything(),
27+
MEMORY_CLEANUP_EVENT,
28+
[
29+
expect.objectContaining({
30+
request: {
31+
version: 1,
32+
target: { kind: 'organization_member', organizationId: 'org-1', userId },
33+
},
34+
}),
35+
],
36+
]
37+
}
1738

1839
describe('external organization access removal', () => {
1940
beforeEach(() => {
2041
resetDbChainMock()
42+
setEnv({ COPILOT_API_KEY: 'test-key' })
43+
})
44+
45+
afterAll(() => {
46+
resetEnvMock()
47+
})
48+
49+
it("erases the departing user's private Mothership memory in the organization", async () => {
50+
await removeExternalUserFromOrganizationWorkspaces({
51+
organizationId: 'org-1',
52+
userId: 'external-user',
53+
})
54+
expect(mockEnqueueOutboxEvents).toHaveBeenCalledWith(...memberErasure('external-user'))
55+
56+
queueTableRows(member, [{ id: 'membership', userId: 'target', role: 'member' }])
57+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'membership' }])
58+
await removeUserFromOrganization({
59+
organizationId: 'org-1',
60+
userId: 'target',
61+
memberId: 'membership',
62+
skipBillingLogic: true,
63+
onError: 'throw',
64+
})
65+
expect(mockEnqueueOutboxEvents).toHaveBeenCalledWith(...memberErasure('target'))
66+
})
67+
68+
it('erases nothing when a removal does not commit', async () => {
69+
queueTableRows(member, [])
70+
queueTableRows(member, [{ id: 'new-membership' }])
71+
await expect(
72+
removeExternalUserFromOrganizationWorkspaces({ organizationId: 'org-1', userId: 'external' })
73+
).rejects.toMatchObject({ code: 'conflict' })
74+
expect(mockEnqueueOutboxEvents).not.toHaveBeenCalled()
2175
})
2276

2377
it('clears a stale per-user organization usage cap in the removal transaction', async () => {

‎apps/sim/lib/billing/organizations/membership.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import { isRetryableTransactionError } from '@/lib/db/transaction'
6161
import type { DbOrTx, DbTransaction } from '@/lib/db/types'
6262
import { acquireInvitationMutationLocks } from '@/lib/invitations/locks'
6363
import { type StoppedRun, stopLiveRunsTx } from '@/lib/mothership/async-runs/repository'
64+
import { enqueueMemoryCleanup } from '@/lib/mothership/memories/cleanup-outbox'
6465
import { requireMemberManagementAuthority } from '@/lib/organizations/members/authority'
6566
import {
6667
revokePersonalApiKeysTx,
@@ -1192,6 +1193,13 @@ export async function transferUserBetweenOrganizations(
11921193
if (deleted.length === 0) {
11931194
throw new Error('Member could not be transferred because their role changed')
11941195
}
1196+
await enqueueMemoryCleanup(tx, [
1197+
{
1198+
kind: 'organization_member',
1199+
organizationId: params.sourceOrganizationId,
1200+
userId: params.userId,
1201+
},
1202+
])
11951203

11961204
const cancelledInvitations = invitationIds.length
11971205
? await tx
@@ -1407,6 +1415,7 @@ export async function removeUserFromOrganization(
14071415
'The membership changed before removal. Refresh and try again.'
14081416
)
14091417
}
1418+
await enqueueMemoryCleanup(tx, [{ kind: 'organization_member', organizationId, userId }])
14101419

14111420
if (!skipBillingLogic) {
14121421
await enqueueOutboxEvent(tx, MEMBER_BILLING_RECONCILIATION_EVENT_TYPE, {
@@ -1679,6 +1688,7 @@ export async function removeExternalUserFromOrganizationWorkspaces(params: {
16791688
)
16801689

16811690
await setOrgMemberUsageLimit(organizationId, userId, null, undefined, tx)
1691+
await enqueueMemoryCleanup(tx, [{ kind: 'organization_member', organizationId, userId }])
16821692

16831693
const cancelledInvitations = invitationIds.length
16841694
? await tx

‎apps/sim/lib/mothership/generated/memory-notes-access.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ export const MemoryCleanupInput = z.strictObject({
5353
z.strictObject({ kind: z.literal("private_owner"), userId: z.string().min(1).max(200) }),
5454
z.strictObject({ kind: z.literal("workspace"), workspaceId: z.uuid() }),
5555
z.strictObject({ kind: z.literal("project"), projectId: z.uuid() }),
56+
// Erase private memory held in an organization: all of it when the organization is
57+
// deleted, one person's when they leave it (D106).
58+
z.strictObject({ kind: z.literal("organization"), organizationId: z.string().min(1).max(200) }),
59+
z.strictObject({
60+
kind: z.literal("organization_member"),
61+
organizationId: z.string().min(1).max(200),
62+
userId: z.string().min(1).max(200),
63+
}),
5664
]),
5765
});
5866
export type MemoryCleanupInput = z.infer<typeof MemoryCleanupInput>;

‎apps/sim/lib/organizations/resource-cleanup.test.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ vi.mock('@/lib/cleanup/chat-cleanup', () => chatCleanupMock)
1212
vi.mock('@/lib/core/outbox/service', () => outboxServiceMock)
1313

1414
import type { OutboxEventContext } from '@/lib/core/outbox/service'
15+
import { MEMORY_CLEANUP_EVENT } from '@/lib/mothership/memories/cleanup-event'
1516
import {
1617
enqueueOrganizationResourceCleanup,
1718
organizationResourceCleanupOutboxHandlers,
@@ -20,7 +21,7 @@ import { ORGANIZATION_RESOURCE_CLEANUP_EVENT } from '@/lib/organizations/resourc
2021

2122
const { mockDeleteFile } = storageServiceMockFns
2223
const { mockCleanupCopilotBackend } = chatCleanupMockFns
23-
const { mockEnqueueOutboxEvent } = outboxServiceMockFns
24+
const { mockEnqueueOutboxEvent, mockEnqueueOutboxEvents } = outboxServiceMockFns
2425

2526
const ORG_ID = 'org-1'
2627
const CHAT_ID = '10000000-0000-4000-8000-000000000001'
@@ -60,6 +61,19 @@ afterAll(() => {
6061
})
6162

6263
describe('enqueueOrganizationResourceCleanup', () => {
64+
it("erases the organization's private Mothership memory in the delete transaction", async () => {
65+
queueTableRows(organization, [{ id: ORG_ID }])
66+
67+
await db.transaction(async (tx) => {
68+
await enqueueOrganizationResourceCleanup(tx, ORG_ID)
69+
expect(mockEnqueueOutboxEvents).toHaveBeenCalledWith(tx, MEMORY_CLEANUP_EVENT, [
70+
expect.objectContaining({
71+
request: { version: 1, target: { kind: 'organization', organizationId: ORG_ID } },
72+
}),
73+
])
74+
})
75+
})
76+
6377
it('locks the organization and snapshots only canonical direct org resources inside the caller transaction', async () => {
6478
queueTableRows(organization, [{ id: ORG_ID }])
6579
queueTableRows(workspaceFiles, [

‎apps/sim/lib/organizations/resource-cleanup.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { env } from '@/lib/core/config/env'
88
import { enqueueOutboxEvent, type OutboxHandlerRegistry } from '@/lib/core/outbox/service'
99
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
1010
import type { DbTransaction } from '@/lib/db/types'
11+
import { enqueueMemoryCleanup } from '@/lib/mothership/memories/cleanup-outbox'
1112
import { ORGANIZATION_RESOURCE_CLEANUP_EVENT } from '@/lib/organizations/resource-cleanup-event'
1213
import { deleteFile } from '@/lib/uploads/core/storage-service'
1314

@@ -35,7 +36,8 @@ const organizationResourceCleanupSchema = z.discriminatedUnion('kind', [
3536
* Captures canonical bindings before the org FK cascade removes them. The org
3637
* row lock excludes new direct resources; a rebound resource is checked again
3738
* by the worker. Each keyset page becomes its own bounded outbox event in the
38-
* caller's delete transaction, so rollback cannot leave cleanup work behind.
39+
* caller's delete transaction, so rollback cannot leave cleanup work behind. The
40+
* members' private Mothership memory in the organization is erased the same way.
3941
*/
4042
export async function enqueueOrganizationResourceCleanup(
4143
tx: DbTransaction,
@@ -105,6 +107,8 @@ export async function enqueueOrganizationResourceCleanup(
105107
chatCursor = chats[chats.length - 1].id
106108
if (chats.length < CLEANUP_BATCH_SIZE) break
107109
}
110+
111+
await enqueueMemoryCleanup(tx, [{ kind: 'organization', organizationId }])
108112
}
109113

110114
export const organizationResourceCleanupOutboxHandlers = {

0 commit comments

Comments
 (0)