Skip to content

Commit 32fbcdc

Browse files
authored
improvement(db): finish workspace file size cutover (#7112)
* improvement(db): finish workspace file size cutover * test(db): update workspace file size fixtures * fix(db): cover dev size cutover * fix(db): fail closed on missing file sizes
1 parent d86fdc1 commit 32fbcdc

35 files changed

Lines changed: 20505 additions & 104 deletions

.github/workflows/migrations.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ jobs:
8080
echo "ERROR: db:push needs an interactive rename decision; land it as a versioned migration instead of relying on push." >&2
8181
exit 1
8282
fi
83+
bun run ./scripts/apply-dev-workspace-file-size-cutover.ts
8384
else
8485
echo "Applying versioned migrations (db:migrate)"
8586
bun run ./scripts/migrate.ts

apps/sim/app/api/files/uploads/finalizers.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
registerUploadedWorkspaceFile,
1515
type WorkspaceFileRecord,
1616
} from '@/lib/uploads/contexts/workspace'
17-
import { type StorageContext, toLegacyWorkspaceFileSize } from '@/lib/uploads/shared/types'
17+
import { getWorkspaceFileSize, type StorageContext } from '@/lib/uploads/shared/types'
1818
import { UploadSessionError, type UploadSessionRecord } from '@/lib/uploads/upload-session/service'
1919
import { toV2File } from '@/app/api/v2/files/utils'
2020

@@ -364,7 +364,6 @@ async function insertOrLoadFileMetadata(
364364
originalName: input.originalName,
365365
displayName: input.originalName,
366366
contentType: input.contentType,
367-
size: toLegacyWorkspaceFileSize(input.size),
368367
sizeBytes: input.size,
369368
deletedAt: null,
370369
uploadedAt: now,
@@ -396,7 +395,7 @@ async function findFileMetadataByKey(key: string): Promise<FileMetadataRecord |
396395
}
397396

398397
function assertMatchingMetadata(existing: FileMetadataRecord, input: FinalizedMetadataInput): void {
399-
const existingSize = existing.sizeBytes ?? existing.size
398+
const existingSize = getWorkspaceFileSize(existing)
400399
if (
401400
existing.key !== input.key ||
402401
existing.userId !== input.userId ||

apps/sim/app/api/mothership/local-files/stage/route.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ vi.mock('@sim/db/schema', () => ({
3939
displayName: 'workspaceFiles.displayName',
4040
originalName: 'workspaceFiles.originalName',
4141
contentType: 'workspaceFiles.contentType',
42-
size: 'workspaceFiles.size',
42+
sizeBytes: 'workspaceFiles.sizeBytes',
4343
deletedAt: 'workspaceFiles.deletedAt',
4444
},
4545
}))
@@ -95,7 +95,7 @@ describe('POST /api/mothership/local-files/stage', () => {
9595
displayName: null,
9696
originalName: 'report.pdf',
9797
contentType: 'application/pdf',
98-
size: 42,
98+
sizeBytes: 42,
9999
},
100100
])
101101
mockWhere.mockReturnValue({ limit: mockLimit })
@@ -139,7 +139,7 @@ describe('POST /api/mothership/local-files/stage', () => {
139139
displayName: 'report (2).pdf',
140140
originalName: 'report.pdf',
141141
contentType: 'application/pdf',
142-
size: 42,
142+
sizeBytes: 42,
143143
},
144144
])
145145
const response = await POST(request())

apps/sim/app/api/mothership/local-files/stage/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
trackChatUpload,
1818
WorkspaceFileKeyOwnershipError,
1919
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
20+
import { getWorkspaceFileSize } from '@/lib/uploads/shared/types'
2021
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
2122

2223
const logger = createLogger('StageLocalFileUploadAPI')
@@ -52,7 +53,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
5253
displayName: workspaceFiles.displayName,
5354
originalName: workspaceFiles.originalName,
5455
contentType: workspaceFiles.contentType,
55-
size: workspaceFiles.size,
56+
sizeBytes: workspaceFiles.sizeBytes,
5657
})
5758
.from(workspaceFiles)
5859
.where(
@@ -87,7 +88,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8788
key,
8889
file.originalName,
8990
file.contentType,
90-
file.size
91+
getWorkspaceFileSize(file)
9192
)
9293
).displayName
9394

apps/sim/background/cleanup-soft-deletes.test.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ describe('cleanup soft deletes', () => {
121121
key: 'workspace/ws-1/file-failed',
122122
workspaceId: 'ws-1',
123123
context: 'workspace',
124-
size: 11,
124+
sizeBytes: 11,
125125
},
126126
])
127127
mockDeleteFiles.mockResolvedValueOnce({
@@ -148,14 +148,14 @@ describe('cleanup soft deletes', () => {
148148
key: 'workspace/ws-1/file-deleted',
149149
workspaceId: 'ws-1',
150150
context: 'workspace',
151-
size: 7,
151+
sizeBytes: 7,
152152
},
153153
{
154154
id: 'file-restored',
155155
key: 'workspace/ws-1/file-restored',
156156
workspaceId: 'ws-1',
157157
context: 'workspace',
158-
size: 13,
158+
sizeBytes: 13,
159159
},
160160
])
161161
mockDeleteFiles.mockResolvedValueOnce({ deleted: 2, failed: [] })
@@ -184,7 +184,7 @@ describe('cleanup soft deletes', () => {
184184
key: 'mothership/chat-file',
185185
workspaceId: 'ws-1',
186186
context: 'mothership',
187-
size: 17,
187+
sizeBytes: 17,
188188
},
189189
])
190190
mockDeleteFiles.mockResolvedValueOnce({ deleted: 1, failed: [] })
@@ -198,6 +198,26 @@ describe('cleanup soft deletes', () => {
198198
expect(mockDecrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled()
199199
})
200200

201+
it('fails before deleting storage when canonical size metadata is missing', async () => {
202+
mockSelectRowsByIdChunks
203+
.mockResolvedValueOnce([])
204+
.mockResolvedValueOnce([])
205+
.mockResolvedValueOnce([
206+
{
207+
id: 'file-missing-size',
208+
key: 'workspace/ws-1/file-missing-size',
209+
workspaceId: 'ws-1',
210+
context: 'workspace',
211+
sizeBytes: null,
212+
},
213+
])
214+
215+
await expect(runCleanupSoftDeletes(basePayload)).rejects.toThrow(
216+
'Workspace file is missing canonical size_bytes metadata'
217+
)
218+
expect(mockDeleteFiles).not.toHaveBeenCalled()
219+
})
220+
201221
it('hard-deletes retained documents before deleting an expired knowledge base', async () => {
202222
mockChunkedBatchDelete.mockImplementationOnce(
203223
async (options: {

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

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import type { StorageContext } from '@/lib/uploads'
3535
import { isUsingCloudStorage, StorageService } from '@/lib/uploads'
3636
import { allocateUniqueWorkspaceFileName } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
3737
import { deleteFileMetadata } from '@/lib/uploads/server/metadata'
38+
import { getWorkspaceFileSize } from '@/lib/uploads/shared/types'
3839
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
3940

4041
const logger = createLogger('CleanupSoftDeletes')
@@ -113,9 +114,7 @@ async function selectExpiredWorkspaceFiles(
113114
key: workspaceFiles.key,
114115
workspaceId: workspaceFiles.workspaceId,
115116
context: workspaceFiles.context,
116-
size: sql<number>`coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})`.mapWith(
117-
Number
118-
),
117+
sizeBytes: workspaceFiles.sizeBytes,
119118
})
120119
.from(workspaceFiles)
121120
.where(
@@ -136,7 +135,7 @@ async function selectExpiredWorkspaceFiles(
136135
key: r.key,
137136
workspaceId: r.workspaceId,
138137
context: r.context as StorageContext,
139-
size: r.size,
138+
size: getWorkspaceFileSize(r),
140139
})),
141140
}
142141
}
@@ -329,9 +328,7 @@ async function deleteExpiredBillableWorkspaceFileRows(
329328
)
330329
.returning({
331330
id: workspaceFiles.id,
332-
size: sql<number>`coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})`.mapWith(
333-
Number
334-
),
331+
size: sql<number>`${workspaceFiles.sizeBytes}`.mapWith(Number),
335332
})
336333
if (deletedRows.some(({ size }) => size < 0)) {
337334
throw new Error('Cannot delete workspace files with negative stored-byte metadata')

apps/sim/ee/workspace-forking/lib/copy/copy-files.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,7 @@ describe('planForkFileCopies', () => {
549549
displayName: null,
550550
contentType: 'text/plain',
551551
size: 4321,
552+
sizeBytes: 4321,
552553
deletedAt: null,
553554
uploadedAt: new Date('2026-01-01'),
554555
updatedAt: new Date('2026-01-01'),
@@ -593,6 +594,7 @@ describe('planForkFileCopies', () => {
593594
displayName: null,
594595
contentType: 'text/plain',
595596
size: 4321,
597+
sizeBytes: 4321,
596598
deletedAt: null,
597599
uploadedAt: new Date('2026-01-01'),
598600
updatedAt: new Date('2026-01-01'),

apps/sim/ee/workspace-forking/lib/copy/copy-files.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
headObject,
2121
uploadFile,
2222
} from '@/lib/uploads/core/storage-service'
23-
import type { StorageContext } from '@/lib/uploads/shared/types'
23+
import { getWorkspaceFileSize, type StorageContext } from '@/lib/uploads/shared/types'
2424
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
2525
import { resolveForkFolderMapping } from '@/ee/workspace-forking/lib/copy/copy-workflows'
2626
import {
@@ -219,7 +219,7 @@ export async function planForkFileCopies(params: {
219219
context: meta.context as StorageContext,
220220
fileName: meta.originalName,
221221
contentType: meta.contentType,
222-
size: meta.size,
222+
size: getWorkspaceFileSize(meta),
223223
targetFileId: childFileId,
224224
displayName: meta.displayName,
225225
userId,
@@ -341,7 +341,7 @@ export async function executeForkFileBlobCopies(
341341
originalName: targetOriginalName,
342342
displayName: targetDisplayName,
343343
contentType: task.contentType,
344-
size: task.size,
344+
sizeBytes: task.size,
345345
deletedAt: null,
346346
uploadedAt: new Date(),
347347
})
@@ -389,7 +389,7 @@ export async function executeForkFileBlobCopies(
389389
originalName: targetOriginalName,
390390
displayName: targetDisplayName,
391391
contentType: task.contentType,
392-
size: task.size,
392+
sizeBytes: task.size,
393393
deletedAt: null,
394394
uploadedAt: new Date(),
395395
})

apps/sim/ee/workspace-forking/lib/copy/storage-quota.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import {
4949
} from '@/ee/workspace-forking/lib/copy/storage-quota'
5050
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
5151

52-
function makeExecutor(total: number | string) {
52+
function makeExecutor(total: number | string | null) {
5353
const execute = vi.fn((_query: unknown) => Promise.resolve([{ total }]))
5454
return { executor: { execute } as unknown as DbOrTx, execute }
5555
}
@@ -71,6 +71,8 @@ describe('sumForkCopyBytes', () => {
7171
const compiled = outerQuery.toSQL()
7272
expect(compiled.sql).toBe('SELECT (? + ?)::bigint AS total')
7373
const [fileBytes, kbBytes] = compiled.params
74+
expect(fileBytes.toSQL().sql).toContain('count(*) FILTER')
75+
expect(fileBytes.toSQL().sql).toContain('IS NULL')
7476
expect(fileBytes.toSQL().params).toContainEqual({
7577
type: 'and',
7678
conditions: [
@@ -101,6 +103,17 @@ describe('sumForkCopyBytes', () => {
101103
expect(bytes).toBe(1024)
102104
})
103105

106+
it('fails closed when a selected workspace file lacks canonical size metadata', async () => {
107+
const { executor } = makeExecutor(null)
108+
109+
await expect(
110+
sumForkCopyBytes(executor, 'src-ws', { fileIds: ['wf-missing-size'] })
111+
).rejects.toMatchObject({
112+
message: 'Storage calculation is temporarily unavailable',
113+
statusCode: 503,
114+
})
115+
})
116+
104117
it('runs no query for an empty selection', async () => {
105118
const { executor, execute } = makeExecutor(0)
106119

apps/sim/ee/workspace-forking/lib/copy/storage-quota.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export interface ForkCopyBytesSelection {
2525
/**
2626
* Byte total a fork/sync copy selection would duplicate into the target: selected
2727
* workspace-file blobs plus the selected knowledge bases' stored document blobs. Sizes
28-
* come from the metadata rows (`workspace_files.size`, `document.file_size`) - no blob
28+
* come from the metadata rows (`workspace_files.size_bytes`, `document.file_size`) - no blob
2929
* reads. Both sums scope to the source workspace with the same filters the copy itself
3030
* applies, so an id that is not actually copyable can only over-count (block), never
3131
* under-count.
@@ -47,8 +47,11 @@ export async function sumForkCopyBytes(
4747
const fileBytes =
4848
fileSelectors.length === 0
4949
? sql<number>`0`
50-
: sql<number>`(
51-
SELECT coalesce(sum(coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})), 0)
50+
: sql<number | null>`(
51+
SELECT CASE
52+
WHEN count(*) FILTER (WHERE ${workspaceFiles.sizeBytes} IS NULL) > 0 THEN NULL
53+
ELSE coalesce(sum(${workspaceFiles.sizeBytes}), 0)
54+
END
5255
FROM ${workspaceFiles}
5356
WHERE ${and(
5457
fileSelectors.length === 1 ? fileSelectors[0] : or(...fileSelectors),
@@ -74,10 +77,13 @@ export async function sumForkCopyBytes(
7477
isNotNull(document.storageKey)
7578
)}
7679
)`
77-
const [row] = await executor.execute<{ total: number | string }>(
80+
const [row] = await executor.execute<{ total: number | string | null }>(
7881
sql`SELECT (${fileBytes} + ${kbBytes})::bigint AS total`
7982
)
80-
return Number(row?.total ?? 0)
83+
if (row?.total == null) {
84+
throw new ForkError('Storage calculation is temporarily unavailable', 503)
85+
}
86+
return Number(row.total)
8187
}
8288

8389
type ForkCreationPayerPolicy = Pick<

0 commit comments

Comments
 (0)