d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
164 lines
5.7 KiB
TypeScript
164 lines
5.7 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const {
|
|
mockBatchDeleteByWorkspaceAndTimestamp,
|
|
mockDeleteFileMetadata,
|
|
mockDeleteFiles,
|
|
mockDeleteRowsById,
|
|
mockIsUsingCloudStorage,
|
|
mockLimit,
|
|
mockOrderBy,
|
|
mockPrepareChatCleanup,
|
|
mockSelect,
|
|
mockSelectRowsByIdChunks,
|
|
mockTask,
|
|
mockWhere,
|
|
} = vi.hoisted(() => {
|
|
const mockLimit = vi.fn(async () => [] as Array<{ key: string }>)
|
|
const mockOrderBy = vi.fn(() => ({ limit: mockLimit }))
|
|
const mockWhere = vi.fn(() => ({ orderBy: mockOrderBy, limit: mockLimit }))
|
|
const mockFrom = vi.fn(() => ({
|
|
where: mockWhere,
|
|
leftJoin: vi.fn(() => ({ where: mockWhere })),
|
|
}))
|
|
const mockSelect = vi.fn(() => ({ from: mockFrom }))
|
|
|
|
return {
|
|
mockBatchDeleteByWorkspaceAndTimestamp: vi.fn(async () => ({ deleted: 0, failed: 0 })),
|
|
mockDeleteFileMetadata: vi.fn(async () => true),
|
|
mockDeleteFiles: vi.fn(async () => ({ deleted: 0, failed: [] as Array<{ key: string }> })),
|
|
mockDeleteRowsById: vi.fn(async () => ({ deleted: 0, failed: 0 })),
|
|
mockIsUsingCloudStorage: vi.fn(() => true),
|
|
mockLimit,
|
|
mockOrderBy,
|
|
mockPrepareChatCleanup: vi.fn(async () => ({ execute: vi.fn(async () => undefined) })),
|
|
mockSelect,
|
|
mockSelectRowsByIdChunks: vi.fn(async () => [] as unknown[]),
|
|
mockTask: vi.fn((config: unknown) => config),
|
|
mockWhere,
|
|
}
|
|
})
|
|
|
|
vi.mock('@sim/db', () => ({ db: { select: mockSelect } }))
|
|
|
|
vi.mock('@sim/db/schema', () => {
|
|
const table = (cols: string[]) =>
|
|
Object.fromEntries(cols.map((c) => [c, `col.${c}`])) as Record<string, string>
|
|
const wsFileCols = ['id', 'key', 'context', 'workspaceId', 'deletedAt', 'uploadedAt']
|
|
const softCols = ['id', 'archivedAt', 'deletedAt', 'workspaceId']
|
|
return {
|
|
copilotChats: table(['id', 'workflowId']),
|
|
document: table(['id', 'storageKey', 'knowledgeBaseId']),
|
|
knowledgeBase: table(softCols),
|
|
mcpServers: table(softCols),
|
|
memory: table(softCols),
|
|
userTableDefinitions: table(softCols),
|
|
workflow: table(softCols),
|
|
workflowFolder: table(softCols),
|
|
workflowMcpServer: table(softCols),
|
|
workspaceFile: table(wsFileCols),
|
|
workspaceFiles: table(wsFileCols),
|
|
}
|
|
})
|
|
|
|
vi.mock('@sim/logger', () => ({
|
|
createLogger: vi.fn(() => ({ error: vi.fn(), info: vi.fn(), warn: vi.fn() })),
|
|
}))
|
|
|
|
vi.mock('@trigger.dev/sdk', () => ({ task: mockTask }))
|
|
|
|
vi.mock('drizzle-orm', () => ({
|
|
and: vi.fn((...args: unknown[]) => ({ op: 'and', args })),
|
|
asc: vi.fn((column: unknown) => ({ op: 'asc', column })),
|
|
eq: vi.fn((...args: unknown[]) => ({ op: 'eq', args })),
|
|
inArray: vi.fn((...args: unknown[]) => ({ op: 'inArray', args })),
|
|
isNotNull: vi.fn((...args: unknown[]) => ({ op: 'isNotNull', args })),
|
|
isNull: vi.fn((...args: unknown[]) => ({ op: 'isNull', args })),
|
|
lt: vi.fn((...args: unknown[]) => ({ op: 'lt', args })),
|
|
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })),
|
|
}))
|
|
|
|
vi.mock('@/lib/cleanup/batch-delete', () => ({
|
|
batchDeleteByWorkspaceAndTimestamp: mockBatchDeleteByWorkspaceAndTimestamp,
|
|
chunkArray: (items: string[], size: number) => {
|
|
const chunks: string[][] = []
|
|
for (let index = 0; index < items.length; index += size) {
|
|
chunks.push(items.slice(index, index + size))
|
|
}
|
|
return chunks
|
|
},
|
|
deleteRowsById: mockDeleteRowsById,
|
|
selectRowsByIdChunks: mockSelectRowsByIdChunks,
|
|
}))
|
|
|
|
vi.mock('@/lib/cleanup/chat-cleanup', () => ({ prepareChatCleanup: mockPrepareChatCleanup }))
|
|
|
|
vi.mock('@/lib/uploads', () => ({
|
|
isUsingCloudStorage: mockIsUsingCloudStorage,
|
|
StorageService: { deleteFiles: mockDeleteFiles },
|
|
}))
|
|
|
|
vi.mock('@/lib/uploads/server/metadata', () => ({ deleteFileMetadata: mockDeleteFileMetadata }))
|
|
|
|
import { runCleanupSoftDeletes } from '@/background/cleanup-soft-deletes'
|
|
|
|
const basePayload = {
|
|
label: 'free/1',
|
|
plan: 'free' as const,
|
|
retentionHours: 720,
|
|
workspaceIds: ['ws-1'],
|
|
}
|
|
|
|
describe('cleanup soft deletes — orphan KB binding sweep', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockIsUsingCloudStorage.mockReturnValue(true)
|
|
mockLimit.mockResolvedValue([])
|
|
})
|
|
|
|
it('soft-deletes abandoned KB bindings and removes their storage objects', async () => {
|
|
mockLimit
|
|
.mockResolvedValueOnce([{ key: 'kb/orphan-1' }, { key: 'kb/orphan-2' }])
|
|
.mockResolvedValueOnce([])
|
|
|
|
await runCleanupSoftDeletes(basePayload)
|
|
|
|
expect(mockDeleteFiles).toHaveBeenCalledWith(['kb/orphan-1', 'kb/orphan-2'], 'knowledge-base')
|
|
expect(mockDeleteFileMetadata).toHaveBeenCalledWith('kb/orphan-1')
|
|
expect(mockDeleteFileMetadata).toHaveBeenCalledWith('kb/orphan-2')
|
|
expect(mockDeleteFileMetadata).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('still removes bindings but skips object deletion without cloud storage', async () => {
|
|
mockIsUsingCloudStorage.mockReturnValue(false)
|
|
mockLimit.mockResolvedValueOnce([{ key: 'kb/orphan-1' }]).mockResolvedValueOnce([])
|
|
|
|
await runCleanupSoftDeletes(basePayload)
|
|
|
|
expect(mockDeleteFiles).not.toHaveBeenCalled()
|
|
expect(mockDeleteFileMetadata).toHaveBeenCalledWith('kb/orphan-1')
|
|
})
|
|
|
|
it('stops the batch loop when binding deletion makes no progress', async () => {
|
|
mockLimit.mockResolvedValue([{ key: 'kb/stuck' }])
|
|
mockDeleteFileMetadata.mockRejectedValue(new Error('db down'))
|
|
|
|
await runCleanupSoftDeletes(basePayload)
|
|
|
|
// One batch attempted, then the no-progress guard breaks the loop.
|
|
expect(mockDeleteFileMetadata).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('does not run the sweep when there are no workspaces', async () => {
|
|
await runCleanupSoftDeletes({ ...basePayload, workspaceIds: [] })
|
|
|
|
expect(mockSelect).not.toHaveBeenCalled()
|
|
expect(mockDeleteFiles).not.toHaveBeenCalled()
|
|
expect(mockDeleteFileMetadata).not.toHaveBeenCalled()
|
|
})
|
|
})
|