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
This commit is contained in:
Waleed
2026-08-26 12:27:13 -07:00
committed by GitHub
parent d86fdc152f
commit 32fbcdcfe1
35 changed files with 20505 additions and 104 deletions
+1
View File
@@ -80,6 +80,7 @@ jobs:
echo "ERROR: db:push needs an interactive rename decision; land it as a versioned migration instead of relying on push." >&2
exit 1
fi
bun run ./scripts/apply-dev-workspace-file-size-cutover.ts
else
echo "Applying versioned migrations (db:migrate)"
bun run ./scripts/migrate.ts
+2 -3
View File
@@ -14,7 +14,7 @@ import {
registerUploadedWorkspaceFile,
type WorkspaceFileRecord,
} from '@/lib/uploads/contexts/workspace'
import { type StorageContext, toLegacyWorkspaceFileSize } from '@/lib/uploads/shared/types'
import { getWorkspaceFileSize, type StorageContext } from '@/lib/uploads/shared/types'
import { UploadSessionError, type UploadSessionRecord } from '@/lib/uploads/upload-session/service'
import { toV2File } from '@/app/api/v2/files/utils'
@@ -364,7 +364,6 @@ async function insertOrLoadFileMetadata(
originalName: input.originalName,
displayName: input.originalName,
contentType: input.contentType,
size: toLegacyWorkspaceFileSize(input.size),
sizeBytes: input.size,
deletedAt: null,
uploadedAt: now,
@@ -396,7 +395,7 @@ async function findFileMetadataByKey(key: string): Promise<FileMetadataRecord |
}
function assertMatchingMetadata(existing: FileMetadataRecord, input: FinalizedMetadataInput): void {
const existingSize = existing.sizeBytes ?? existing.size
const existingSize = getWorkspaceFileSize(existing)
if (
existing.key !== input.key ||
existing.userId !== input.userId ||
@@ -39,7 +39,7 @@ vi.mock('@sim/db/schema', () => ({
displayName: 'workspaceFiles.displayName',
originalName: 'workspaceFiles.originalName',
contentType: 'workspaceFiles.contentType',
size: 'workspaceFiles.size',
sizeBytes: 'workspaceFiles.sizeBytes',
deletedAt: 'workspaceFiles.deletedAt',
},
}))
@@ -95,7 +95,7 @@ describe('POST /api/mothership/local-files/stage', () => {
displayName: null,
originalName: 'report.pdf',
contentType: 'application/pdf',
size: 42,
sizeBytes: 42,
},
])
mockWhere.mockReturnValue({ limit: mockLimit })
@@ -139,7 +139,7 @@ describe('POST /api/mothership/local-files/stage', () => {
displayName: 'report (2).pdf',
originalName: 'report.pdf',
contentType: 'application/pdf',
size: 42,
sizeBytes: 42,
},
])
const response = await POST(request())
@@ -17,6 +17,7 @@ import {
trackChatUpload,
WorkspaceFileKeyOwnershipError,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getWorkspaceFileSize } from '@/lib/uploads/shared/types'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('StageLocalFileUploadAPI')
@@ -52,7 +53,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
displayName: workspaceFiles.displayName,
originalName: workspaceFiles.originalName,
contentType: workspaceFiles.contentType,
size: workspaceFiles.size,
sizeBytes: workspaceFiles.sizeBytes,
})
.from(workspaceFiles)
.where(
@@ -87,7 +88,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
key,
file.originalName,
file.contentType,
file.size
getWorkspaceFileSize(file)
)
).displayName
@@ -121,7 +121,7 @@ describe('cleanup soft deletes', () => {
key: 'workspace/ws-1/file-failed',
workspaceId: 'ws-1',
context: 'workspace',
size: 11,
sizeBytes: 11,
},
])
mockDeleteFiles.mockResolvedValueOnce({
@@ -148,14 +148,14 @@ describe('cleanup soft deletes', () => {
key: 'workspace/ws-1/file-deleted',
workspaceId: 'ws-1',
context: 'workspace',
size: 7,
sizeBytes: 7,
},
{
id: 'file-restored',
key: 'workspace/ws-1/file-restored',
workspaceId: 'ws-1',
context: 'workspace',
size: 13,
sizeBytes: 13,
},
])
mockDeleteFiles.mockResolvedValueOnce({ deleted: 2, failed: [] })
@@ -184,7 +184,7 @@ describe('cleanup soft deletes', () => {
key: 'mothership/chat-file',
workspaceId: 'ws-1',
context: 'mothership',
size: 17,
sizeBytes: 17,
},
])
mockDeleteFiles.mockResolvedValueOnce({ deleted: 1, failed: [] })
@@ -198,6 +198,26 @@ describe('cleanup soft deletes', () => {
expect(mockDecrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled()
})
it('fails before deleting storage when canonical size metadata is missing', async () => {
mockSelectRowsByIdChunks
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
id: 'file-missing-size',
key: 'workspace/ws-1/file-missing-size',
workspaceId: 'ws-1',
context: 'workspace',
sizeBytes: null,
},
])
await expect(runCleanupSoftDeletes(basePayload)).rejects.toThrow(
'Workspace file is missing canonical size_bytes metadata'
)
expect(mockDeleteFiles).not.toHaveBeenCalled()
})
it('hard-deletes retained documents before deleting an expired knowledge base', async () => {
mockChunkedBatchDelete.mockImplementationOnce(
async (options: {
+4 -7
View File
@@ -35,6 +35,7 @@ import type { StorageContext } from '@/lib/uploads'
import { isUsingCloudStorage, StorageService } from '@/lib/uploads'
import { allocateUniqueWorkspaceFileName } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { deleteFileMetadata } from '@/lib/uploads/server/metadata'
import { getWorkspaceFileSize } from '@/lib/uploads/shared/types'
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
const logger = createLogger('CleanupSoftDeletes')
@@ -113,9 +114,7 @@ async function selectExpiredWorkspaceFiles(
key: workspaceFiles.key,
workspaceId: workspaceFiles.workspaceId,
context: workspaceFiles.context,
size: sql<number>`coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})`.mapWith(
Number
),
sizeBytes: workspaceFiles.sizeBytes,
})
.from(workspaceFiles)
.where(
@@ -136,7 +135,7 @@ async function selectExpiredWorkspaceFiles(
key: r.key,
workspaceId: r.workspaceId,
context: r.context as StorageContext,
size: r.size,
size: getWorkspaceFileSize(r),
})),
}
}
@@ -329,9 +328,7 @@ async function deleteExpiredBillableWorkspaceFileRows(
)
.returning({
id: workspaceFiles.id,
size: sql<number>`coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})`.mapWith(
Number
),
size: sql<number>`${workspaceFiles.sizeBytes}`.mapWith(Number),
})
if (deletedRows.some(({ size }) => size < 0)) {
throw new Error('Cannot delete workspace files with negative stored-byte metadata')
@@ -549,6 +549,7 @@ describe('planForkFileCopies', () => {
displayName: null,
contentType: 'text/plain',
size: 4321,
sizeBytes: 4321,
deletedAt: null,
uploadedAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
@@ -593,6 +594,7 @@ describe('planForkFileCopies', () => {
displayName: null,
contentType: 'text/plain',
size: 4321,
sizeBytes: 4321,
deletedAt: null,
uploadedAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
@@ -20,7 +20,7 @@ import {
headObject,
uploadFile,
} from '@/lib/uploads/core/storage-service'
import type { StorageContext } from '@/lib/uploads/shared/types'
import { getWorkspaceFileSize, type StorageContext } from '@/lib/uploads/shared/types'
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
import { resolveForkFolderMapping } from '@/ee/workspace-forking/lib/copy/copy-workflows'
import {
@@ -219,7 +219,7 @@ export async function planForkFileCopies(params: {
context: meta.context as StorageContext,
fileName: meta.originalName,
contentType: meta.contentType,
size: meta.size,
size: getWorkspaceFileSize(meta),
targetFileId: childFileId,
displayName: meta.displayName,
userId,
@@ -341,7 +341,7 @@ export async function executeForkFileBlobCopies(
originalName: targetOriginalName,
displayName: targetDisplayName,
contentType: task.contentType,
size: task.size,
sizeBytes: task.size,
deletedAt: null,
uploadedAt: new Date(),
})
@@ -389,7 +389,7 @@ export async function executeForkFileBlobCopies(
originalName: targetOriginalName,
displayName: targetDisplayName,
contentType: task.contentType,
size: task.size,
sizeBytes: task.size,
deletedAt: null,
uploadedAt: new Date(),
})
@@ -49,7 +49,7 @@ import {
} from '@/ee/workspace-forking/lib/copy/storage-quota'
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
function makeExecutor(total: number | string) {
function makeExecutor(total: number | string | null) {
const execute = vi.fn((_query: unknown) => Promise.resolve([{ total }]))
return { executor: { execute } as unknown as DbOrTx, execute }
}
@@ -71,6 +71,8 @@ describe('sumForkCopyBytes', () => {
const compiled = outerQuery.toSQL()
expect(compiled.sql).toBe('SELECT (? + ?)::bigint AS total')
const [fileBytes, kbBytes] = compiled.params
expect(fileBytes.toSQL().sql).toContain('count(*) FILTER')
expect(fileBytes.toSQL().sql).toContain('IS NULL')
expect(fileBytes.toSQL().params).toContainEqual({
type: 'and',
conditions: [
@@ -101,6 +103,17 @@ describe('sumForkCopyBytes', () => {
expect(bytes).toBe(1024)
})
it('fails closed when a selected workspace file lacks canonical size metadata', async () => {
const { executor } = makeExecutor(null)
await expect(
sumForkCopyBytes(executor, 'src-ws', { fileIds: ['wf-missing-size'] })
).rejects.toMatchObject({
message: 'Storage calculation is temporarily unavailable',
statusCode: 503,
})
})
it('runs no query for an empty selection', async () => {
const { executor, execute } = makeExecutor(0)
@@ -25,7 +25,7 @@ export interface ForkCopyBytesSelection {
/**
* Byte total a fork/sync copy selection would duplicate into the target: selected
* workspace-file blobs plus the selected knowledge bases' stored document blobs. Sizes
* come from the metadata rows (`workspace_files.size`, `document.file_size`) - no blob
* come from the metadata rows (`workspace_files.size_bytes`, `document.file_size`) - no blob
* reads. Both sums scope to the source workspace with the same filters the copy itself
* applies, so an id that is not actually copyable can only over-count (block), never
* under-count.
@@ -47,8 +47,11 @@ export async function sumForkCopyBytes(
const fileBytes =
fileSelectors.length === 0
? sql<number>`0`
: sql<number>`(
SELECT coalesce(sum(coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})), 0)
: sql<number | null>`(
SELECT CASE
WHEN count(*) FILTER (WHERE ${workspaceFiles.sizeBytes} IS NULL) > 0 THEN NULL
ELSE coalesce(sum(${workspaceFiles.sizeBytes}), 0)
END
FROM ${workspaceFiles}
WHERE ${and(
fileSelectors.length === 1 ? fileSelectors[0] : or(...fileSelectors),
@@ -74,10 +77,13 @@ export async function sumForkCopyBytes(
isNotNull(document.storageKey)
)}
)`
const [row] = await executor.execute<{ total: number | string }>(
const [row] = await executor.execute<{ total: number | string | null }>(
sql`SELECT (${fileBytes} + ${kbBytes})::bigint AS total`
)
return Number(row?.total ?? 0)
if (row?.total == null) {
throw new ForkError('Storage calculation is temporarily unavailable', 503)
}
return Number(row.total)
}
type ForkCreationPayerPolicy = Pick<
@@ -52,7 +52,7 @@ vi.mock('@sim/db/schema', () => ({
__table: 'workspaceFiles',
context: 'workspaceFiles.context',
deletedAt: 'workspaceFiles.deletedAt',
size: 'workspaceFiles.size',
sizeBytes: 'workspaceFiles.sizeBytes',
workspaceId: 'workspaceFiles.workspaceId',
},
}))
@@ -82,7 +82,7 @@ async function getExactWorkspaceStorageBytes(tx: DbOrTx, workspaceId: string): P
const [row] = await tx.execute<ExactWorkspaceStorageRow>(sql`
SELECT
COALESCE((
SELECT SUM(coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size}::bigint))
SELECT SUM(${workspaceFiles.sizeBytes})
FROM ${workspaceFiles}
WHERE ${workspaceFiles.workspaceId} = ${workspaceId}
AND ${workspaceFiles.context} = 'workspace'
@@ -171,7 +171,7 @@ async function getExactWorkspaceStorageBytesBatch(
FROM (
SELECT
${workspaceFiles.workspaceId} AS workspace_id,
SUM(coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size}::bigint)) AS workspace_file_bytes,
SUM(${workspaceFiles.sizeBytes}) AS workspace_file_bytes,
0::bigint AS document_bytes
FROM ${workspaceFiles}
WHERE ${inArray(workspaceFiles.workspaceId, workspaceIds)}
@@ -51,6 +51,7 @@ function makeRow(overrides: Partial<ForkableChatFileRow> = {}): ForkableChatFile
displayName: 'cat.png',
contentType: 'image/png',
size: 100,
sizeBytes: 100,
deletedAt: null,
uploadedAt: new Date('2026-06-01T00:00:00.000Z'),
updatedAt: new Date('2026-06-01T00:00:00.000Z'),
+4 -2
View File
@@ -2,13 +2,14 @@ import { workspaceFiles } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
import { omit } from '@sim/utils/object'
import { and, eq, isNull } from 'drizzle-orm'
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
import type { DbOrTx, DbTransaction } from '@/lib/db/types'
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { copyWorkspaceFileSecretProvenanceInTx } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service'
import type { StorageContext } from '@/lib/uploads/shared/types'
import { getWorkspaceFileSize, type StorageContext } from '@/lib/uploads/shared/types'
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
const logger = createLogger('ForkChatFiles')
@@ -120,11 +121,12 @@ export async function planChatFileCopies(params: {
const copyId = `wf_${generateShortId()}`
const targetKey = generateWorkspaceFileKey(row.workspaceId, row.originalName)
copyRows.push({
...row,
...omit(row, ['size']),
id: copyId,
key: targetKey,
chatId: newChatId,
userId,
sizeBytes: getWorkspaceFileSize(row),
deletedAt: null,
uploadedAt: now,
updatedAt: now,
@@ -334,7 +334,7 @@ describe('executeMaterializeFile - save storage transition', () => {
null
)
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({ context: 'workspace', chatId: null, size: 250 })
expect.objectContaining({ context: 'workspace', chatId: null, sizeBytes: 250 })
)
expect(mockMaybeNotifyStorageLimitForBillingContext).toHaveBeenCalledWith(
STORAGE_CONTEXT,
@@ -342,9 +342,7 @@ describe('executeMaterializeFile - save storage transition', () => {
)
})
it('writes an int4-representable legacy size and the exact byte count above the int4 ceiling', async () => {
// A row above the int4 ceiling must not be written raw to `size`: Postgres
// raises 22003 and the save becomes unrecoverable.
it('writes the exact byte count above the int4 ceiling without the legacy projection', async () => {
mockHeadObject.mockResolvedValue({ size: OVERSIZED_BYTES, contentType: 'text/plain' })
const result = await executeMaterializeFile(
@@ -354,7 +352,7 @@ describe('executeMaterializeFile - save storage transition', () => {
expect(result.success).toBe(true)
const [updateSet] = dbChainMockFns.set.mock.calls.at(-1) as [Record<string, unknown>]
expect(updateSet.size).toBe(POSTGRES_INT4_MAX)
expect(updateSet).not.toHaveProperty('size')
expect(updateSet.sizeBytes).toBe(OVERSIZED_BYTES)
expect(mockCheckStorageQuotaForBillingContext).toHaveBeenCalledWith(
STORAGE_CONTEXT,
@@ -367,9 +365,7 @@ describe('executeMaterializeFile - save storage transition', () => {
)
})
it('falls back to the exact stored byte count, not the clamped legacy size', async () => {
// Without cloud storage a missing object does not short-circuit, so the row is
// the only size source — and its `size` is already clamped.
it('uses the exact stored byte count when object metadata is unavailable', async () => {
mockHeadObject.mockResolvedValue(null)
mockHasCloudStorage.mockReturnValue(false)
mockFindUpload.mockResolvedValue({
@@ -386,7 +382,7 @@ describe('executeMaterializeFile - save storage transition', () => {
expect(result.success).toBe(true)
const [updateSet] = dbChainMockFns.set.mock.calls.at(-1) as [Record<string, unknown>]
expect(updateSet.sizeBytes).toBe(OVERSIZED_BYTES)
expect(updateSet.size).toBe(POSTGRES_INT4_MAX)
expect(updateSet).not.toHaveProperty('size')
expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith(
expect.anything(),
STORAGE_CONTEXT,
@@ -36,7 +36,7 @@ import {
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
import { hasCloudStorage, headObject } from '@/lib/uploads/core/storage-service'
import { toLegacyWorkspaceFileSize } from '@/lib/uploads/shared/types'
import { getWorkspaceFileSize } from '@/lib/uploads/shared/types'
import { isArchiveFileName } from '@/lib/uploads/utils/file-utils'
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
import { MAX_IMPORT_BODY_BYTES } from '@/lib/workflows/operations/import-workflow'
@@ -111,12 +111,7 @@ async function executeSave(
if (!head && hasCloudStorage()) {
return { success: false, error: `Upload object not found: "${fileName}".` }
}
/**
* The true byte count can exceed the legacy int4 `size` column, so read the exact
* `sizeBytes` first and clamp back down for the legacy projection.
*/
const verifiedSize = head?.size ?? row.sizeBytes ?? row.size
const legacySize = toLegacyWorkspaceFileSize(verifiedSize)
const verifiedSize = head?.size ?? getWorkspaceFileSize(row)
const billingContext = await resolveStorageBillingContext(workspaceId)
const quotaCheck = await checkStorageQuotaForBillingContext(billingContext, verifiedSize)
if (!quotaCheck.allowed) {
@@ -152,7 +147,6 @@ async function executeSave(
messageId: null,
originalName: materializedName,
displayName: materializedName,
size: legacySize,
sizeBytes: verifiedSize,
})
.where(
@@ -14,6 +14,7 @@ import {
getPostgresErrorCode,
} from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
import { omit } from '@sim/utils/object'
import { and, eq, inArray, isNotNull, isNull, or, type SQL, sql } from 'drizzle-orm'
import type { ShareRecord } from '@/lib/api/contracts/public-shares'
import type { V2FileSortBy } from '@/lib/api/contracts/v2/files'
@@ -69,7 +70,7 @@ import {
headObject,
uploadFile,
} from '@/lib/uploads/core/storage-service'
import { MAX_WORKSPACE_FILE_SIZE, toLegacyWorkspaceFileSize } from '@/lib/uploads/shared/types'
import { getWorkspaceFileSize, MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
import { isMarkdownFile } from '@/lib/uploads/utils/file-utils'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
import {
@@ -238,12 +239,6 @@ interface WorkspaceFileMetadataInsert {
size: number
}
function workspaceFileSize(
file: Pick<typeof workspaceFiles.$inferSelect, 'size' | 'sizeBytes'>
): number {
return file.sizeBytes ?? file.size
}
/**
* Attempts one active workspace-file insert and reports the row that this call
* created. Conflict losers receive `undefined` and must inspect the active key
@@ -256,8 +251,7 @@ async function insertWorkspaceFileMetadataInTx(
const [inserted] = await tx
.insert(workspaceFiles)
.values({
...metadata,
size: toLegacyWorkspaceFileSize(metadata.size),
...omit(metadata, ['size']),
sizeBytes: metadata.size,
context: 'workspace',
displayName: metadata.originalName,
@@ -337,7 +331,7 @@ function isSameWorkspaceFileRegistration(
file.userId === params.userId &&
file.context === 'workspace' &&
file.contentType === params.contentType &&
workspaceFileSize(file) === params.size
getWorkspaceFileSize(file) === params.size
)
}
@@ -723,7 +717,7 @@ export async function registerUploadedWorkspaceFile(params: {
file: {
id: existing.id,
name: existing.originalName,
size: workspaceFileSize(existing),
size: getWorkspaceFileSize(existing),
type: existing.contentType,
url: `${pathPrefix}${encodeURIComponent(existing.key)}?context=workspace`,
key: existing.key,
@@ -809,7 +803,7 @@ export async function registerUploadedWorkspaceFile(params: {
file: {
id: finalized.file.id,
name: finalized.file.originalName,
size: workspaceFileSize(finalized.file),
size: getWorkspaceFileSize(finalized.file),
type: finalized.file.contentType,
url: `${pathPrefix}${encodeURIComponent(finalized.file.key)}?context=workspace`,
key: finalized.file.key,
@@ -1057,7 +1051,7 @@ export async function trackChatUpload(
originalName: fileName,
displayName: candidate,
contentType,
size,
sizeBytes: size,
})
.returning({ id: workspaceFiles.id })
@@ -1115,7 +1109,7 @@ function mapWorkspaceFileRecord(
name: file.originalName,
key: file.key,
path: `${pathPrefix}${encodeURIComponent(file.key)}?context=workspace`,
size: workspaceFileSize(file),
size: getWorkspaceFileSize(file),
type: file.contentType,
width: file.width,
height: file.height,
@@ -1253,7 +1247,6 @@ const workspaceFileListColumns = {
folderId: workspaceFiles.folderId,
originalName: workspaceFiles.originalName,
contentType: workspaceFiles.contentType,
size: workspaceFiles.size,
sizeBytes: workspaceFiles.sizeBytes,
width: workspaceFiles.width,
height: workspaceFiles.height,
@@ -1322,10 +1315,7 @@ const fileId = textKey<WorkspaceFileRecord>(workspaceFiles.id, (row) => row.id)
const WORKSPACE_FILE_SORTS = {
name: [textKey(workspaceFiles.originalName, (row) => row.name), fileId],
size: [
numberKey(
sql<number>`coalesce(${workspaceFiles.sizeBytes}, ${workspaceFiles.size})`.mapWith(Number),
(row) => row.size
),
numberKey(sql<number>`${workspaceFiles.sizeBytes}`.mapWith(Number), (row) => row.size),
fileId,
],
uploadedAt: [timestampKey(workspaceFiles.uploadedAt, (row) => row.uploadedAt), fileId],
@@ -1827,7 +1817,7 @@ export async function updateWorkspaceFileContent(
throw new ContentVersionConflictError(fileId)
}
const sizeDiff = content.length - workspaceFileSize(currentFile)
const sizeDiff = content.length - getWorkspaceFileSize(currentFile)
const now = new Date()
// `contentUpdatedAt` is the persist If-Match token, so it MUST be strictly monotonic per file — a
// bare `new Date()` is not: cross-instance clock skew can stamp a later write with an earlier time,
@@ -1842,7 +1832,6 @@ export async function updateWorkspaceFileContent(
.update(workspaceFiles)
.set({
key: uploadResult.key,
size: toLegacyWorkspaceFileSize(content.length),
sizeBytes: content.length,
contentType: nextContentType,
// Replaced bytes: drop the old image's dimensions so the row never describes stale content.
@@ -1957,7 +1946,7 @@ export async function updateWorkspaceFileContent(
name: finalized.file.originalName,
key: finalized.file.key,
path: `${pathPrefix}${encodeURIComponent(finalized.file.key)}?context=workspace`,
size: workspaceFileSize(finalized.file),
size: getWorkspaceFileSize(finalized.file),
type: finalized.file.contentType,
uploadedBy: finalized.file.userId,
folderId: finalized.file.folderId,
@@ -2186,7 +2175,6 @@ export async function purgeCreatedWorkspaceFile(params: {
.select({
id: workspaceFiles.id,
key: workspaceFiles.key,
size: workspaceFiles.size,
sizeBytes: workspaceFiles.sizeBytes,
})
.from(workspaceFiles)
@@ -2204,7 +2192,7 @@ export async function purgeCreatedWorkspaceFile(params: {
await decrementStorageUsageForBillingContextInTx(
tx,
storageBillingContext,
workspaceFileSize(lockedFile)
getWorkspaceFileSize(lockedFile)
)
return enqueueWorkspaceFileStorageCleanup(tx, { key: lockedFile.key })
})
@@ -66,6 +66,7 @@ function buildRow(overrides: Record<string, unknown> = {}) {
originalName: 'data.csv',
contentType: 'text/csv',
size: 1024,
sizeBytes: 1024,
deletedAt: null,
uploadedAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-02T00:00:00Z'),
@@ -141,6 +141,7 @@ const FILE_ROW = {
displayName: 'note.txt',
contentType: 'text/plain',
size: 5,
sizeBytes: 5,
deletedAt: null,
uploadedAt: new Date('2026-07-01T00:00:00.000Z'),
updatedAt: new Date('2026-07-01T00:00:00.000Z'),
@@ -666,12 +667,13 @@ describe('workspace file metadata and storage accounting', () => {
})
it('uploads an overwrite before atomically swapping the locked row and exact delta', async () => {
const concurrentFile = { ...FILE_ROW, size: 7 }
const concurrentFile = { ...FILE_ROW, size: 7, sizeBytes: 7 }
const replacementKey = `${FILE_ROW.key}-replacement`
const updatedFile = {
...concurrentFile,
key: replacementKey,
size: 10,
sizeBytes: 10,
updatedAt: new Date('2026-07-03T00:00:00.000Z'),
}
dbChainMockFns.limit.mockResolvedValueOnce([FILE_ROW]).mockResolvedValueOnce([concurrentFile])
@@ -715,7 +717,7 @@ describe('workspace file metadata and storage accounting', () => {
it('cleans up only the new overwrite object when atomic finalization fails', async () => {
const replacementKey = `${FILE_ROW.key}-replacement`
const updatedFile = { ...FILE_ROW, key: replacementKey, size: 10 }
const updatedFile = { ...FILE_ROW, key: replacementKey, size: 10, sizeBytes: 10 }
dbChainMockFns.limit.mockResolvedValueOnce([FILE_ROW]).mockResolvedValueOnce([FILE_ROW])
dbChainMockFns.returning.mockResolvedValueOnce([updatedFile])
mockUploadFile.mockResolvedValueOnce({ key: replacementKey })
@@ -744,6 +746,7 @@ describe('workspace file metadata and storage accounting', () => {
const updatedFile = {
...MD_ROW,
size: 12,
sizeBytes: 12,
updatedAt: new Date('2026-07-05T00:00:00.000Z'),
contentUpdatedAt: new Date('2026-07-04T00:00:00.000Z'),
}
@@ -764,7 +767,7 @@ describe('workspace file metadata and storage accounting', () => {
})
it('does NOT merge when syncLiveDoc is false (the relay persist / empty-shell opt-out)', async () => {
const updatedFile = { ...MD_ROW, size: 12 }
const updatedFile = { ...MD_ROW, size: 12, sizeBytes: 12 }
dbChainMockFns.limit.mockResolvedValueOnce([MD_ROW]).mockResolvedValueOnce([MD_ROW])
dbChainMockFns.returning.mockResolvedValueOnce([updatedFile])
mockUploadFile.mockResolvedValueOnce({ key: MD_ROW.key })
@@ -782,7 +785,7 @@ describe('workspace file metadata and storage accounting', () => {
})
it('does NOT merge a non-markdown write (the collaborative editor only renders markdown)', async () => {
const updatedFile = { ...FILE_ROW, size: 10 }
const updatedFile = { ...FILE_ROW, size: 10, sizeBytes: 10 }
dbChainMockFns.limit.mockResolvedValueOnce([FILE_ROW]).mockResolvedValueOnce([FILE_ROW])
dbChainMockFns.returning.mockResolvedValueOnce([updatedFile])
mockUploadFile.mockResolvedValueOnce({ key: FILE_ROW.key })
@@ -799,7 +802,7 @@ describe('workspace file metadata and storage accounting', () => {
})
it('writes when the expectedUpdatedAt optimistic-concurrency guard matches', async () => {
const updatedFile = { ...FILE_ROW, size: 12 }
const updatedFile = { ...FILE_ROW, size: 12, sizeBytes: 12 }
dbChainMockFns.limit.mockResolvedValueOnce([FILE_ROW]).mockResolvedValueOnce([FILE_ROW])
dbChainMockFns.returning.mockResolvedValueOnce([updatedFile])
mockUploadFile.mockResolvedValueOnce({ key: FILE_ROW.key })
@@ -88,6 +88,7 @@ describe('workspace file storage attribution', () => {
displayName: 'note.txt',
contentType: 'text/plain',
size: 5,
sizeBytes: 5,
deletedAt: null,
uploadedAt: new Date(),
updatedAt: new Date(),
+14 -6
View File
@@ -66,6 +66,7 @@ describe('recordKnowledgeBaseFileOwnership', () => {
{
id: 'file-1',
...ownership,
sizeBytes: ownership.size,
folderId: null,
context: 'knowledge-base',
deletedAt: null,
@@ -90,6 +91,7 @@ describe('recordKnowledgeBaseFileOwnership', () => {
const active = {
id: 'file-1',
...ownership,
sizeBytes: ownership.size,
folderId: null,
context: 'knowledge-base',
deletedAt: null,
@@ -187,7 +189,7 @@ describe('insertFileMetadata content versions', () => {
context: 'workspace',
originalName: 'file.txt',
contentType: 'text/plain',
size: 12,
sizeBytes: 12,
deletedAt: null,
}
dbChainMockFns.limit.mockResolvedValueOnce([active])
@@ -217,7 +219,7 @@ describe('insertFileMetadata content versions', () => {
context: 'workspace',
originalName: 'file.txt',
contentType: 'text/plain',
size: 12,
sizeBytes: 12,
deletedAt: null,
}
dbChainMockFns.limit.mockResolvedValueOnce([active])
@@ -247,7 +249,7 @@ describe('insertFileMetadata content versions', () => {
context: 'workspace',
originalName: 'file.txt',
contentType: 'text/plain',
size: 12,
sizeBytes: 12,
deletedAt: null,
}
dbChainMockFns.limit.mockResolvedValueOnce([active])
@@ -260,7 +262,7 @@ describe('insertFileMetadata content versions', () => {
context: active.context,
originalName: active.originalName,
contentType: active.contentType,
size: active.size,
size: active.sizeBytes,
})
).resolves.toEqual(active)
@@ -287,7 +289,7 @@ describe('insertFileMetadataMany active-key idempotence', () => {
it('accepts an exact retry after a concurrent insert', async () => {
dbChainMockFns.returning.mockResolvedValueOnce([])
queueTableRows(workspaceFiles, [{ id: 'file-1', ...row, deletedAt: null }])
queueTableRows(workspaceFiles, [{ id: 'file-1', ...row, sizeBytes: row.size, deletedAt: null }])
await expect(insertFileMetadataMany([row])).resolves.toBeUndefined()
})
@@ -295,7 +297,13 @@ describe('insertFileMetadataMany active-key idempotence', () => {
it('rejects a conflicting active row instead of silently adopting it', async () => {
dbChainMockFns.returning.mockResolvedValueOnce([])
queueTableRows(workspaceFiles, [
{ id: 'file-1', ...row, userId: 'different-user', deletedAt: null },
{
id: 'file-1',
...row,
sizeBytes: row.size,
userId: 'different-user',
deletedAt: null,
},
])
await expect(insertFileMetadataMany([row])).rejects.toBeInstanceOf(
+2 -6
View File
@@ -6,9 +6,9 @@ import { and, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm'
import type { DbOrTx, DbTransaction } from '@/lib/db/types'
import { inferContextFromKey } from '@/lib/uploads/utils/file-utils'
import {
getWorkspaceFileSize,
isWorkspaceScopedContext,
type StorageContext,
toLegacyWorkspaceFileSize,
} from '../shared/types'
const logger = createLogger('FileMetadata')
@@ -48,7 +48,7 @@ function isSameFileMetadataInsert(
existing.context === options.context &&
existing.originalName === options.originalName &&
existing.contentType === options.contentType &&
(existing.sizeBytes ?? existing.size) === options.size &&
getWorkspaceFileSize(existing) === options.size &&
existing.deletedAt === null &&
(options.id === undefined || existing.id === options.id)
)
@@ -123,7 +123,6 @@ async function insertFileMetadataWithExecutor(
originalName,
displayName: originalName,
contentType,
size: toLegacyWorkspaceFileSize(size),
sizeBytes: size,
deletedAt: null,
uploadedAt: new Date(),
@@ -152,7 +151,6 @@ async function insertFileMetadataWithExecutor(
originalName,
displayName: originalName,
contentType,
size: toLegacyWorkspaceFileSize(size),
sizeBytes: size,
deletedAt: null,
uploadedAt: new Date(),
@@ -197,7 +195,6 @@ async function insertImmutableFileMetadataWithExecutor(
originalName,
displayName: originalName,
contentType,
size: toLegacyWorkspaceFileSize(size),
sizeBytes: size,
deletedAt: null,
uploadedAt: new Date(),
@@ -275,7 +272,6 @@ export async function insertFileMetadataMany(
originalName: row.originalName,
displayName: row.originalName,
contentType: row.contentType,
size: toLegacyWorkspaceFileSize(row.size),
sizeBytes: row.size,
deletedAt: null,
uploadedAt: new Date(),
+13 -7
View File
@@ -5,15 +5,21 @@
*/
export const MAX_WORKSPACE_FILE_SIZE = 5 * 1024 * 1024 * 1024
const MAX_POSTGRES_INTEGER = 2_147_483_647
/**
* Keeps the legacy int4 metadata projection writable while `size_bytes` stores the exact value.
* Returns the canonical workspace-file byte size after the `size_bytes` cutover.
*
* The migration backfills every existing row before the new application image is
* promoted, and its compatibility trigger fills the column for writes from an old
* image during rollout. A null therefore indicates migration drift, not a legacy row.
*/
export function toLegacyWorkspaceFileSize(size: number): number {
if (!Number.isSafeInteger(size) || size < 0)
throw new Error(`Invalid workspace file size: ${size}`)
return Math.min(size, MAX_POSTGRES_INTEGER)
export function getWorkspaceFileSize(file: { sizeBytes: number | null }): number {
if (file.sizeBytes === null) {
throw new Error('Workspace file is missing canonical size_bytes metadata')
}
if (!Number.isSafeInteger(file.sizeBytes) || file.sizeBytes < 0) {
throw new Error(`Invalid workspace file size: ${file.sizeBytes}`)
}
return file.sizeBytes
}
/**
@@ -0,0 +1,29 @@
-- Expand/cutover half of the size_bytes migration. The default lets the new application omit
-- the legacy NOT NULL column, while the trigger keeps both generations compatible throughout
-- the rolling deploy: old writers supply size, and new writers supply size_bytes.
ALTER TABLE "workspace_files" ALTER COLUMN "size" SET DEFAULT 0;--> statement-breakpoint
CREATE OR REPLACE FUNCTION "sync_workspace_file_size_columns"()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
IF NEW."size_bytes" IS NULL THEN
NEW."size_bytes" := NEW."size";
ELSE
NEW."size" := LEAST(NEW."size_bytes", 2147483647)::integer;
END IF;
ELSIF NEW."size_bytes" IS DISTINCT FROM OLD."size_bytes" THEN
NEW."size" := LEAST(NEW."size_bytes", 2147483647)::integer;
ELSIF NEW."size" IS DISTINCT FROM OLD."size" OR NEW."size_bytes" IS NULL THEN
NEW."size_bytes" := NEW."size";
END IF;
RETURN NEW;
END;
$$;--> statement-breakpoint
DROP TRIGGER IF EXISTS "workspace_files_sync_size_columns" ON "workspace_files";--> statement-breakpoint
CREATE TRIGGER "workspace_files_sync_size_columns"
BEFORE INSERT OR UPDATE OF "size", "size_bytes" ON "workspace_files"
FOR EACH ROW EXECUTE FUNCTION "sync_workspace_file_size_columns"();
File diff suppressed because it is too large Load Diff
@@ -2150,6 +2150,13 @@
"when": 1787722270619,
"tag": "0307_add_subscription_cycle_close_lagging_idx",
"breakpoints": true
},
{
"idx": 308,
"version": "7",
"when": 1787770314977,
"tag": "0308_workspace_file_size_cutover",
"breakpoints": true
}
]
}
+1
View File
@@ -25,6 +25,7 @@
"scripts": {
"db:push": "bunx drizzle-kit push --config=./drizzle.config.ts",
"db:migrate": "bun --env-file=.env run ./scripts/migrate.ts",
"db:apply-dev-workspace-file-size-cutover": "bun --env-file=.env run ./scripts/apply-dev-workspace-file-size-cutover.ts",
"db:reconcile-fork-kb-file-ownership": "bun --env-file=.env run ./scripts/reconcile-fork-kb-file-ownership.ts",
"db:reconcile-workspace-storage": "bun --env-file=.env run ./scripts/reconcile-workspace-storage.ts",
"db:studio": "bunx drizzle-kit studio --config=./drizzle.config.ts",
+3 -3
View File
@@ -2105,9 +2105,9 @@ export const workspaceFiles = pgTable(
*/
displayName: text('display_name'),
contentType: text('content_type').notNull(),
// contract-pending(after #6188 is fully deployed and sizeBytes is backfilled): drop size — new code dual-writes and reads sizeBytes first
size: integer('size').notNull(),
/** Exact byte size for files above PostgreSQL's int4 ceiling; legacy rows fall back to `size`. */
/** contract-pending(after #7112 is fully deployed): drop size, workspace_files_sync_size_columns, and the temporary dev cutover runner */
size: integer('size').notNull().default(0),
/** Exact byte size. The deploy migration backfills existing rows before this release serves traffic. */
sizeBytes: bigint('size_bytes', { mode: 'number' }),
/**
* Intrinsic pixel dimensions of an image file, captured lazily on first view (and stored so later
@@ -444,6 +444,7 @@ describe('script migration registry', () => {
'0005_repair_unknown_table_row_provenance',
'0006_repair_unknown_table_row_provenance_second_pass',
'0007_repair_unknown_workspace_file_provenance',
'0008_backfill_workspace_file_size_bytes',
])
})
})
@@ -133,7 +133,7 @@ export function createPostgresForkKnowledgeBaseOwnershipRepairStore(
original_name,
display_name,
content_type,
size
size_bytes
)
SELECT
gen_random_uuid()::text,
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from 'vitest'
import {
backfillWorkspaceFileSizeBytes,
type WorkspaceFileSizeBytesBackfillStore,
} from './0008_backfill_workspace_file_size_bytes'
describe('backfillWorkspaceFileSizeBytes', () => {
it('processes bounded keyset pages and counts rows changed', async () => {
const listCandidateIds = vi
.fn<WorkspaceFileSizeBytesBackfillStore['listCandidateIds']>()
.mockResolvedValueOnce(['file-a', 'file-b'])
.mockResolvedValueOnce(['file-c'])
.mockResolvedValueOnce([])
const backfillCandidateIds = vi
.fn<WorkspaceFileSizeBytesBackfillStore['backfillCandidateIds']>()
.mockResolvedValueOnce(2)
.mockResolvedValueOnce(1)
await expect(
backfillWorkspaceFileSizeBytes({ listCandidateIds, backfillCandidateIds }, { batchSize: 2 })
).resolves.toBe(3)
expect(listCandidateIds.mock.calls).toEqual([
['', 2],
['file-b', 2],
['file-c', 2],
])
expect(backfillCandidateIds.mock.calls).toEqual([[['file-a', 'file-b']], [['file-c']]])
})
it('rejects non-advancing pages', async () => {
const store: WorkspaceFileSizeBytesBackfillStore = {
listCandidateIds: vi.fn().mockResolvedValue(['file-a']),
backfillCandidateIds: vi.fn().mockResolvedValue(1),
}
await expect(backfillWorkspaceFileSizeBytes(store)).rejects.toThrow('non-advancing page')
})
})
@@ -0,0 +1,86 @@
import { createLogger } from '@sim/logger'
import type { Sql } from 'postgres'
import type { ScriptMigration } from './types'
const logger = createLogger('WorkspaceFileSizeBytesBackfill')
export const WORKSPACE_FILE_SIZE_BYTES_BATCH_SIZE = 1000
export interface WorkspaceFileSizeBytesBackfillStore {
listCandidateIds(afterId: string, limit: number): Promise<string[]>
backfillCandidateIds(ids: readonly string[]): Promise<number>
}
interface WorkspaceFileSizeBytesBackfillOptions {
batchSize?: number
}
/** Backfills null size_bytes rows in bounded, independently committed keyset pages. */
export async function backfillWorkspaceFileSizeBytes(
store: WorkspaceFileSizeBytesBackfillStore,
options: WorkspaceFileSizeBytesBackfillOptions = {}
): Promise<number> {
const batchSize = options.batchSize ?? WORKSPACE_FILE_SIZE_BYTES_BATCH_SIZE
if (!Number.isInteger(batchSize) || batchSize <= 0) {
throw new Error('Workspace file size_bytes backfill batch size must be a positive integer')
}
let afterId = ''
let backfilled = 0
for (;;) {
const ids = await store.listCandidateIds(afterId, batchSize)
if (ids.length === 0) return backfilled
if (ids.length > batchSize) {
throw new Error('Workspace file size_bytes backfill store returned an oversized page')
}
const lastId = ids.at(-1)
if (!lastId || lastId <= afterId) {
throw new Error('Workspace file size_bytes backfill store returned a non-advancing page')
}
backfilled += await store.backfillCandidateIds(ids)
afterId = lastId
}
}
/** Creates the PostgreSQL store used by the deploy-time backfill. */
export function createPostgresWorkspaceFileSizeBytesBackfillStore(
sql: Sql
): WorkspaceFileSizeBytesBackfillStore {
return {
async listCandidateIds(afterId, limit) {
const rows = await sql<Array<{ id: string }>>`
SELECT id
FROM workspace_files
WHERE id > ${afterId}
AND size_bytes IS NULL
ORDER BY id
LIMIT ${limit}
`
return rows.map((row) => row.id)
},
async backfillCandidateIds(ids) {
if (ids.length === 0) return 0
return sql.begin(async (tx) => {
const rows = await tx<Array<{ id: string }>>`
UPDATE workspace_files
SET size_bytes = size
WHERE id = ANY(${ids}::text[])
AND size_bytes IS NULL
RETURNING id
`
return rows.length
})
},
}
}
export const backfillWorkspaceFileSizeBytesMigration: ScriptMigration = {
name: '0008_backfill_workspace_file_size_bytes',
async up(sql) {
const backfilled = await backfillWorkspaceFileSizeBytes(
createPostgresWorkspaceFileSizeBytesBackfillStore(sql)
)
logger.info(`Workspace file size_bytes backfill complete: ${backfilled} file(s) updated.`)
},
}
+2
View File
@@ -6,6 +6,7 @@ import { backfillForkKnowledgeBaseFileOwnership } from './0004_backfill_fork_kb_
import { repairUnknownTableRowProvenance } from './0005_repair_unknown_table_row_provenance'
import { repairUnknownTableRowProvenanceSecondPass } from './0006_repair_unknown_table_row_provenance_second_pass'
import { repairUnknownWorkspaceFileProvenance } from './0007_repair_unknown_workspace_file_provenance'
import { backfillWorkspaceFileSizeBytesMigration } from './0008_backfill_workspace_file_size_bytes'
import type { ScriptMigration } from './types'
export type { ScriptMigration } from './types'
@@ -23,6 +24,7 @@ export const scriptMigrations: readonly ScriptMigration[] = [
repairUnknownTableRowProvenance,
repairUnknownTableRowProvenanceSecondPass,
repairUnknownWorkspaceFileProvenance,
backfillWorkspaceFileSizeBytesMigration,
]
/**
@@ -0,0 +1,46 @@
import { createLogger } from '@sim/logger'
import postgres from 'postgres'
import {
backfillWorkspaceFileSizeBytes,
createPostgresWorkspaceFileSizeBytesBackfillStore,
} from '../script-migrations/0008_backfill_workspace_file_size_bytes'
const logger = createLogger('DevWorkspaceFileSizeCutover')
const MIGRATION_NAME = '0008_backfill_workspace_file_size_bytes'
const MIGRATION_SQL_URL = new URL(
'../migrations/0308_workspace_file_size_cutover.sql',
import.meta.url
)
const url = process.env.MIGRATION_DATABASE_URL || process.env.DATABASE_URL
if (!url) {
throw new Error('Missing MIGRATION_DATABASE_URL or DATABASE_URL')
}
const sql = postgres(url, {
max: 1,
connect_timeout: 10,
max_lifetime: null,
connection: { application_name: 'sim-dev-workspace-file-size-cutover' },
})
try {
const migrationSql = await Bun.file(MIGRATION_SQL_URL).text()
await sql.unsafe(migrationSql)
const backfilled = await backfillWorkspaceFileSizeBytes(
createPostgresWorkspaceFileSizeBytesBackfillStore(sql)
)
await sql`
CREATE TABLE IF NOT EXISTS script_migrations (
name text PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()
)
`
await sql`
INSERT INTO script_migrations (name) VALUES (${MIGRATION_NAME})
ON CONFLICT (name) DO NOTHING
`
logger.info('Dev workspace file size cutover completed', { backfilled })
} finally {
await sql.end()
}
+1 -1
View File
@@ -649,7 +649,7 @@ export const schemaMock = {
chatId: 'workspaceFiles.chatId',
originalName: 'workspaceFiles.originalName',
contentType: 'workspaceFiles.contentType',
size: 'workspaceFiles.size',
sizeBytes: 'workspaceFiles.sizeBytes',
deletedAt: 'workspaceFiles.deletedAt',
uploadedAt: 'workspaceFiles.uploadedAt',
updatedAt: 'workspaceFiles.updatedAt',