Skip to content

Commit 02311ca

Browse files
authored
perf(db): stop scanning every workspace file on workflow archive, cover the billing period aggregate (#6015)
* perf(files): stop loading every workspace file to archive a workflow's backing files `cleanupWorkflowAliasBacking` runs on every workflow delete. It loaded every `context='workspace'` row for the workspace — active and soft-deleted, all columns — then discarded >99% of them in JS to find the handful of `.changelogs/<workflowId>.md` and `.plans/<workflowId>/**` rows it owns. In production this was the single worst query at 37.83% of total database runtime: p50 6s, p95 9s, max 13.1s, 641 calls/day, 34.1M rows read. It is index-served, so the cost is not a missing index — it is 59,061 rows and ~630MB of cold buffers read to archive a few files. A file's `folderPath` is derived solely from its `folderId`, so matching on folder membership is equivalent to the path comparison it replaces. The load and JS filter become one targeted UPDATE keyed on a handful of folder ids. Folders that are soft-deleted are still included when resolving which files a workflow owns: path resolution ignores `deletedAt`, so a live file parented to an archived folder previously matched and must continue to. Also: - Add `getWorkspaceShares`, replacing an id-list `IN` clause that grew with the file count (59,061 elements on the worst workspace) with one indexed lookup on `workspace_id`. Callers read the map by id, so a superset is equivalent. - Drop `all` from the client-reachable file scope enum. It drops the `deleted_at` predicate and so cannot use the partial index serving the other two. No client requests it; server callers reach that scope directly. - Set `fetch_types: false` on the app and realtime pools. postgres.js otherwise runs a blocking `pg_catalog.pg_type` roundtrip before each new connection's first query — 95,722 of them per day. It builds array parsers only; Drizzle already parses this schema's two `text[]` columns itself. * perf(nav): add route loading boundaries and cover the billing period aggregate Dynamic routes prefetch only down to the nearest loading boundary, and the server stops prefetching at the first one. With no loading.tsx anywhere in the workspace tree and a dynamic layout, Link prefetch was yielding almost nothing and every navigation waited on a full server round trip with no feedback. Add loading.tsx only where the fallback is provably what renders today, so perceived speed improves without changing what users see: - home, chat/[chatId]: reuse HomeFallback, already each page's own Suspense fallback. - integrations, skills: reuse the tab-header chrome, byte-identical to each page's own Suspense fallback. Deliberately not added to workspace root, settings, or w, whose pages are redirect-only or already self-fallbacking — a boundary there would paint a skeleton that does not match the destination. Billing: - Add usage_log_billing_period_cost_idx, trailing the remaining predicate columns and `cost` so the period aggregates resolve index-only. Confirmed against production: the aggregate currently runs as an Index Scan touching 478,559 buffers because `cost` is absent from the existing index. That index is superseded but left in place; dropping it is a separate migration so a planner regression costs nothing to revert. - Collapse the two aggregates behind /api/billing into one scan using SUM(...) FILTER. Besides halving the work on a nav-path query, it removes a latent inconsistency: as separate statements the two sums could observe different snapshots, making the copilot subset exceed the total. Caching was considered and rejected for the usage aggregate. Its callers include usage enforcement, threshold billing, and overage calculation, where a stale-low read permits overspend and a stale-high read double-charges. * test(files): cover cleanupWorkflowAliasBacking and correct a stale share mock cleanupWorkflowAliasBacking had no test coverage, and the rewrite that replaced its load-everything-then-filter body rests on a subtle equivalence: a file's folderPath is derived solely from its folderId, and path resolution ignores deletedAt. The second half is the easy part to get wrong — a live file parented to an archived folder still resolves to a backing path and must still be archived, so folders are filtered by deletedAt only when choosing which folders to archive, never when deciding which files the workflow owns. These tests pin that distinction: they fail if the archived-folder case is dropped from file ownership, and separately assert that archived folders stay out of the folder update and that unrelated workflows are never touched. Also point the workspace files route test at getWorkspaceShares. Its mock still named getSharesForResources, which the route no longer imports. The suite passed regardless because all three cases exercise the upload path, so the GET listing has no coverage — but the stale name would have handed the first GET test an undefined function. * fix(perf): drop the loading boundaries and correct the usage_log index order Adversarial review found real regressions in both. Route loading boundaries — all four removed: The premise in their TSDoc was wrong. Each page's existing `<Suspense>` exists so nuqs can prerender; `useSearchParams` only suspends during SSR, so on a client navigation those fallbacks never painted. Hoisting them to loading.tsx did not "show the same frame" — it made a previously invisible blank frame visible. For chat, Next keys the Suspense boundary by cache key, so a chatId change mounts a fresh suspended boundary and always commits its fallback. Switching chats would have gone from "previous chat stays on screen" to a blank surface for the whole RSC round trip, on the highest-frequency navigation in the product. Partial prefetch only warms the fallback, so no latency was saved to offset it. The integrations and skills fallbacks were correct for their own pages but also became the fallback for four detail routes that render different chrome, inserting a wrong intermediate frame. Fixing that needs per-child boundaries and new skeleton UI that cannot be verified without a browser, for pages whose only server work is `await params`. Not worth it. Every remaining loading.tsx in this app paints real chrome. A blank one was against the grain, and the measurable win was zero. usage_log index: Column order was wrong. The daily-refresh rollup filters entity type, id and period_start but NOT period_end, so putting period_end fourth ended the usable prefix at column three and left user_id and created_at as in-index filters rather than scan boundaries — turning a ~2.2k-entry bitmap scan into a ~266k-entry scan. Verified in production that period_start functionally determines period_end (13,612 groups, zero with more than one end), so the slot bought no selectivity. user_id and created_at now follow the shared prefix; period_end rides as payload. Also corrected the claim that this supersedes usage_log_billing_entity_period_idx. That index deduplicates to 17 MB across 1.42M entries because it has no high-cardinality key column, which is what keeps prefix-only bitmap scans cheap. It is retained deliberately, not pending a drop. Harden cleanupWorkflowAliasBacking: gate the UPDATE on the ownership filter list itself. `and()` and `or()` both drop undefined, so a clause that resolved to nothing would have left a WHERE of workspace + context + not-deleted and archived every file in the workspace. Tests now assert no UPDATE is issued when the workflow owns nothing, plus the filename and context predicates. Note fetch_types also disables array serializers, not just parsers; documented. * docs(db): correct the fetch_types constraint note after differential testing Ran both settings against a real Postgres with the schema's actual text[] columns. Drizzle-typed selects and .returning() are byte-identical either way; only a raw db.execute projecting an array column differs, yielding the wire form. The previous note also claimed a raw JS-array bind fails because the serializers come from the same catalog fetch. It does fail — but under both settings, because Drizzle expands an array into a row constructor before postgres.js ever sees it. That is unrelated to this flag, so the claim is removed rather than left implying a constraint this change introduces. * chore(db): generate the drizzle snapshot for the usage_log index migration 0271 was hand-written, so drizzle-kit's state never learned about the new index — the next `generate` would have re-emitted it as a fresh migration against an already-migrated database. Ran `drizzle-kit generate` to produce the snapshot, then restored the CONCURRENTLY form: drizzle emits a plain CREATE INDEX, which takes an ACCESS EXCLUSIVE lock and would block writes on a 4M-row table for the duration of the build. The generated column order matched the hand-written SQL exactly, which also confirms schema.ts and the migration agree. `generate` is now a no-op, and check:migrations still passes.
1 parent 79b1ed1 commit 02311ca

17 files changed

Lines changed: 18092 additions & 60 deletions

File tree

apps/realtime/src/database/operations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,8 @@ const socketDb = drizzle(
214214
instrumentPoolClient(
215215
postgres(connectionString, {
216216
prepare: false,
217+
// See `packages/db/db.ts` — skips the per-connection pg_type roundtrip.
218+
fetch_types: false,
217219
idle_timeout: 10,
218220
connect_timeout: 20,
219221
max: 10,

apps/sim/app/api/workspaces/[id]/files/route.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import { authMockFns, permissionsMock, permissionsMockFns, posthogServerMock } f
77
import { NextRequest } from 'next/server'
88
import { beforeEach, describe, expect, it, vi } from 'vitest'
99

10-
const { mockUploadWorkspaceFile, mockGetSharesForResources, mockRecordAudit } = vi.hoisted(() => ({
10+
const { mockUploadWorkspaceFile, mockGetWorkspaceShares, mockRecordAudit } = vi.hoisted(() => ({
1111
mockUploadWorkspaceFile: vi.fn(),
12-
mockGetSharesForResources: vi.fn(),
12+
mockGetWorkspaceShares: vi.fn(),
1313
mockRecordAudit: vi.fn(),
1414
}))
1515

@@ -27,7 +27,7 @@ vi.mock('@/lib/uploads/shared/types', async (importOriginal) => {
2727
})
2828

2929
vi.mock('@/lib/public-shares/share-manager', () => ({
30-
getSharesForResources: mockGetSharesForResources,
30+
getWorkspaceShares: mockGetWorkspaceShares,
3131
}))
3232

3333
vi.mock('@/lib/posthog/server', () => posthogServerMock)
@@ -80,7 +80,7 @@ describe('workspace files upload route', () => {
8080
vi.clearAllMocks()
8181
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
8282
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
83-
mockGetSharesForResources.mockResolvedValue(new Map())
83+
mockGetWorkspaceShares.mockResolvedValue(new Map())
8484
mockUploadWorkspaceFile.mockResolvedValue({
8585
id: 'file-1',
8686
name: 'file.txt',

apps/sim/app/api/workspaces/[id]/files/route.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
} from '@/lib/core/utils/stream-limits'
1717
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1818
import { captureServerEvent } from '@/lib/posthog/server'
19-
import { getSharesForResources } from '@/lib/public-shares/share-manager'
19+
import { getWorkspaceShares } from '@/lib/public-shares/share-manager'
2020
import {
2121
FileConflictError,
2222
listWorkspaceFiles,
@@ -74,10 +74,7 @@ export const GET = withRouteHandler(
7474

7575
const files = await listWorkspaceFiles(workspaceId, { scope })
7676

77-
const shares = await getSharesForResources(
78-
'file',
79-
files.map((file) => file.id)
80-
)
77+
const shares = await getWorkspaceShares('file', workspaceId)
8178
const filesWithShares = files.map((file) => ({
8279
...file,
8380
share: shares.get(file.id) ?? null,

apps/sim/hooks/queries/workspace-files.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { useFileContentSource } from '@/hooks/use-file-content-source'
2727

2828
const logger = createLogger('WorkspaceFilesQuery')
2929

30-
type WorkspaceFileQueryScope = 'active' | 'archived' | 'all'
30+
type WorkspaceFileQueryScope = 'active' | 'archived'
3131

3232
/**
3333
* Query key factories for workspace files

apps/sim/lib/api/contracts/workspace-files.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import { inlineFileRefQuerySchema } from '@/lib/api/contracts/primitives'
33
import { shareRecordSchema } from '@/lib/api/contracts/public-shares'
44
import { defineRouteContract } from '@/lib/api/contracts/types'
55

6-
export const workspaceFileScopeSchema = z.enum(['active', 'archived', 'all'])
6+
/**
7+
* Client-reachable listing scopes. `all` is deliberately excluded: it drops the
8+
* `deleted_at` predicate, so it cannot use the partial index that serves the
9+
* other two and degrades to a full workspace scan. No client requests it, and
10+
* server-side callers reach that scope directly rather than over the wire.
11+
*/
12+
export const workspaceFileScopeSchema = z.enum(['active', 'archived'])
713

814
export const workspaceFilesParamsSchema = z.object({
915
id: z.string({ error: 'Workspace ID is required' }).min(1, 'Workspace ID is required'),

apps/sim/lib/billing/core/billing.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ const {
88
mockComputeDailyRefreshConsumed,
99
mockEnsureUserStatsExists,
1010
mockGetBillingPeriodUsageCost,
11+
mockGetBillingPeriodUsageCostWithSourceSubset,
1112
mockGetHighestPriorityPersonalSubscription,
1213
mockGetHighestPrioritySubscription,
1314
mockResolveBillingInterval,
1415
} = vi.hoisted(() => ({
1516
mockComputeDailyRefreshConsumed: vi.fn(),
1617
mockEnsureUserStatsExists: vi.fn(),
1718
mockGetBillingPeriodUsageCost: vi.fn(),
19+
mockGetBillingPeriodUsageCostWithSourceSubset: vi.fn(),
1820
mockGetHighestPriorityPersonalSubscription: vi.fn(),
1921
mockGetHighestPrioritySubscription: vi.fn(),
2022
mockResolveBillingInterval: vi.fn(),
@@ -35,6 +37,7 @@ vi.mock('@/lib/billing/core/usage', () => ({
3537
vi.mock('@/lib/billing/core/usage-log', () => ({
3638
COPILOT_USAGE_SOURCES: ['copilot'],
3739
getBillingPeriodUsageCost: mockGetBillingPeriodUsageCost,
40+
getBillingPeriodUsageCostWithSourceSubset: mockGetBillingPeriodUsageCostWithSourceSubset,
3841
}))
3942

4043
vi.mock('@/lib/billing/credits/daily-refresh', () => ({
@@ -50,7 +53,7 @@ describe('getPersonalBillingSummary', () => {
5053
mockEnsureUserStatsExists.mockResolvedValue(undefined)
5154
mockResolveBillingInterval.mockReturnValue('year')
5255
mockComputeDailyRefreshConsumed.mockResolvedValue(3)
53-
mockGetBillingPeriodUsageCost.mockResolvedValueOnce(2).mockResolvedValueOnce(1)
56+
mockGetBillingPeriodUsageCostWithSourceSubset.mockResolvedValue({ total: 2, subset: 1 })
5457
mockGetHighestPriorityPersonalSubscription.mockResolvedValue({
5558
id: 'personal-sub',
5659
referenceId: 'viewer-a',

apps/sim/lib/billing/core/billing.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import {
77
resolveBillingInterval,
88
} from '@/lib/billing/core/subscription'
99
import { ensureUserStatsExists } from '@/lib/billing/core/usage'
10-
import { COPILOT_USAGE_SOURCES, getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log'
10+
import {
11+
COPILOT_USAGE_SOURCES,
12+
getBillingPeriodUsageCost,
13+
getBillingPeriodUsageCostWithSourceSubset,
14+
} from '@/lib/billing/core/usage-log'
1115
import {
1216
computeDailyRefreshConsumed,
1317
getOrgMemberRefreshBounds,
@@ -429,15 +433,13 @@ export async function getPersonalBillingSummary(userId: string, executor: DbClie
429433
personalSubscription?.periodStart && personalSubscription.periodEnd
430434
? { start: personalSubscription.periodStart, end: personalSubscription.periodEnd }
431435
: defaultBillingPeriod()
432-
const [ledgerUsage, copilotLedgerUsage] = await Promise.all([
433-
getBillingPeriodUsageCost({ type: 'user', id: userId }, billingPeriod, undefined, executor),
434-
getBillingPeriodUsageCost(
436+
const { total: ledgerUsage, subset: copilotLedgerUsage } =
437+
await getBillingPeriodUsageCostWithSourceSubset(
435438
{ type: 'user', id: userId },
436439
billingPeriod,
437440
COPILOT_USAGE_SOURCES,
438441
executor
439-
),
440-
])
442+
)
441443

442444
const hasPersonalUsageSnapshot =
443445
Boolean(personalSubscription) && isPro(plan) && stats.proPeriodCostSnapshotAt !== null

apps/sim/lib/billing/core/usage-log.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,40 @@ export async function getBillingPeriodUsageCost(
207207
return Number.parseFloat(row?.cost ?? '0')
208208
}
209209

210+
/**
211+
* Period total plus the portion attributable to `source`, in a single scan.
212+
*
213+
* Two separate aggregates over the identical row set double the work and, because
214+
* they are separate statements, can observe different snapshots — which makes the
215+
* subset exceeding the total representable. One statement rules that out.
216+
*/
217+
export async function getBillingPeriodUsageCostWithSourceSubset(
218+
billingEntity: BillingEntity,
219+
billingPeriod: { start: Date; end: Date },
220+
source: UsageLogSource[],
221+
executor: DbClient = db
222+
): Promise<{ total: number; subset: number }> {
223+
const [row] = await executor
224+
.select({
225+
total: sql<string>`COALESCE(SUM(${usageLog.cost}), 0)`,
226+
subset: sql<string>`COALESCE(SUM(${usageLog.cost}) FILTER (WHERE ${inArray(usageLog.source, source)}), 0)`,
227+
})
228+
.from(usageLog)
229+
.where(
230+
and(
231+
eq(usageLog.billingEntityType, billingEntity.type),
232+
eq(usageLog.billingEntityId, billingEntity.id),
233+
eq(usageLog.billingPeriodStart, billingPeriod.start),
234+
eq(usageLog.billingPeriodEnd, billingPeriod.end)
235+
)
236+
)
237+
238+
return {
239+
total: Number.parseFloat(row?.total ?? '0'),
240+
subset: Number.parseFloat(row?.subset ?? '0'),
241+
}
242+
}
243+
210244
export async function getBillingPeriodUsageCostByUser(
211245
billingEntity: BillingEntity,
212246
billingPeriod: { start: Date; end: Date },

apps/sim/lib/copilot/vfs/workflow-alias-backing.test.ts

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1+
import { dbChainMockFns, drizzleOrmMock } from '@sim/testing/mocks'
12
import { beforeEach, describe, expect, it, vi } from 'vitest'
23

34
const mocks = vi.hoisted(() => ({
45
ensureWorkspaceFileFolderPath: vi.fn(),
56
listWorkspaceFileFolders: vi.fn(),
67
getWorkspaceFileByName: vi.fn(),
7-
listWorkspaceFiles: vi.fn(),
88
uploadWorkspaceFile: vi.fn(),
99
}))
1010

@@ -15,11 +15,10 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () =>
1515

1616
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
1717
getWorkspaceFileByName: mocks.getWorkspaceFileByName,
18-
listWorkspaceFiles: mocks.listWorkspaceFiles,
1918
uploadWorkspaceFile: mocks.uploadWorkspaceFile,
2019
}))
2120

22-
import { ensureWorkflowAliasBacking } from './workflow-alias-backing'
21+
import { cleanupWorkflowAliasBacking, ensureWorkflowAliasBacking } from './workflow-alias-backing'
2322

2423
describe('workflow alias backing', () => {
2524
beforeEach(() => {
@@ -79,4 +78,98 @@ describe('workflow alias backing', () => {
7978
expect(mocks.uploadWorkspaceFile).not.toHaveBeenCalled()
8079
expect(result.changelogFile).toMatchObject({ id: 'file-existing' })
8180
})
81+
82+
describe('cleanupWorkflowAliasBacking', () => {
83+
/**
84+
* Folder paths resolve independently of `deletedAt`, so a live file parented
85+
* to an archived folder still resolves to a backing path. The archived
86+
* `.changelogs` folder below therefore has to participate in file ownership
87+
* while staying out of the set of folders that get archived.
88+
*/
89+
const folders = [
90+
{ id: 'changelog-live', path: '.changelogs', deletedAt: null },
91+
{ id: 'changelog-archived', path: '.changelogs', deletedAt: new Date() },
92+
{ id: 'plans-wf1', path: '.plans/wf_1', deletedAt: null },
93+
{ id: 'plans-wf1-nested', path: '.plans/wf_1/nested', deletedAt: null },
94+
{ id: 'plans-wf1-archived', path: '.plans/wf_1/old', deletedAt: new Date() },
95+
{ id: 'plans-wf2', path: '.plans/wf_2', deletedAt: null },
96+
{ id: 'unrelated', path: 'documents', deletedAt: null },
97+
]
98+
99+
beforeEach(() => {
100+
mocks.listWorkspaceFileFolders.mockResolvedValue(folders)
101+
})
102+
103+
it('scopes file ownership by folder id, including archived folders', async () => {
104+
await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' })
105+
106+
const inArrayValues = drizzleOrmMock.inArray.mock.calls.map(([, values]) => values)
107+
108+
expect(inArrayValues).toContainEqual(['plans-wf1', 'plans-wf1-nested', 'plans-wf1-archived'])
109+
expect(inArrayValues).toContainEqual(['changelog-live', 'changelog-archived'])
110+
})
111+
112+
it('archives only live folders owned by the workflow', async () => {
113+
await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' })
114+
115+
const inArrayValues = drizzleOrmMock.inArray.mock.calls.map(([, values]) => values)
116+
117+
expect(inArrayValues).toContainEqual(['plans-wf1', 'plans-wf1-nested'])
118+
expect(inArrayValues.flat()).not.toContain('plans-wf2')
119+
expect(inArrayValues.flat()).not.toContain('unrelated')
120+
})
121+
122+
it('matches the changelog by the workflow-scoped filename', async () => {
123+
await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' })
124+
125+
expect(drizzleOrmMock.eq).toHaveBeenCalledWith(expect.anything(), 'wf_1.md')
126+
})
127+
128+
it('restricts the update to workspace-context files', async () => {
129+
await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' })
130+
131+
expect(drizzleOrmMock.eq).toHaveBeenCalledWith(expect.anything(), 'workspace')
132+
})
133+
134+
it('still archives the changelog when the workflow has no plans folder', async () => {
135+
mocks.listWorkspaceFileFolders.mockResolvedValue([
136+
{ id: 'changelog-live', path: '.changelogs', deletedAt: null },
137+
{ id: 'plans-wf2', path: '.plans/wf_2', deletedAt: null },
138+
])
139+
140+
await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' })
141+
142+
const inArrayValues = drizzleOrmMock.inArray.mock.calls.map(([, values]) => values)
143+
expect(inArrayValues).toContainEqual(['changelog-live'])
144+
expect(inArrayValues.flat()).not.toContain('plans-wf2')
145+
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
146+
})
147+
148+
/**
149+
* The decisive guard: `and()` drops `undefined`, so an ownership clause that
150+
* resolved to nothing would leave a WHERE matching every file in the
151+
* workspace. No ownership must mean no UPDATE is issued at all.
152+
*/
153+
it('issues no update at all when the workflow owns no backing folders', async () => {
154+
mocks.listWorkspaceFileFolders.mockResolvedValue([
155+
{ id: 'unrelated', path: 'documents', deletedAt: null },
156+
])
157+
158+
const result = await cleanupWorkflowAliasBacking({
159+
workspaceId: 'workspace-1',
160+
workflowId: 'wf_missing',
161+
})
162+
163+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
164+
expect(drizzleOrmMock.inArray).not.toHaveBeenCalled()
165+
expect(result).toEqual({ files: 0, folders: 0 })
166+
})
167+
168+
it('never archives files belonging to another workflow', async () => {
169+
await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' })
170+
171+
const inArrayValues = drizzleOrmMock.inArray.mock.calls.flatMap(([, values]) => values)
172+
expect(inArrayValues).not.toContain('plans-wf2')
173+
})
174+
})
82175
})

0 commit comments

Comments
 (0)