diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts index 69848850802..65af6a518cb 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts @@ -155,6 +155,9 @@ const STORAGE_CONTEXT = { customStorageLimitGB: null, } +const POSTGRES_INT4_MAX = 2_147_483_647 +const OVERSIZED_BYTES = 3 * 1024 * 1024 * 1024 + const mothershipRow = { id: 'file-1', key: 'mothership/file-1', @@ -339,6 +342,58 @@ 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. + mockHeadObject.mockResolvedValue({ size: OVERSIZED_BYTES, contentType: 'text/plain' }) + + const result = await executeMaterializeFile( + { fileNames: ['report.txt'], operation: 'save' }, + context + ) + + expect(result.success).toBe(true) + const [updateSet] = dbChainMockFns.set.mock.calls.at(-1) as [Record] + expect(updateSet.size).toBe(POSTGRES_INT4_MAX) + expect(updateSet.sizeBytes).toBe(OVERSIZED_BYTES) + expect(mockCheckStorageQuotaForBillingContext).toHaveBeenCalledWith( + STORAGE_CONTEXT, + OVERSIZED_BYTES + ) + expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith( + expect.anything(), + STORAGE_CONTEXT, + OVERSIZED_BYTES + ) + }) + + 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. + mockHeadObject.mockResolvedValue(null) + mockHasCloudStorage.mockReturnValue(false) + mockFindUpload.mockResolvedValue({ + ...mothershipRow, + size: POSTGRES_INT4_MAX, + sizeBytes: OVERSIZED_BYTES, + }) + + const result = await executeMaterializeFile( + { fileNames: ['report.txt'], operation: 'save' }, + context + ) + + expect(result.success).toBe(true) + const [updateSet] = dbChainMockFns.set.mock.calls.at(-1) as [Record] + expect(updateSet.sizeBytes).toBe(OVERSIZED_BYTES) + expect(updateSet.size).toBe(POSTGRES_INT4_MAX) + expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith( + expect.anything(), + STORAGE_CONTEXT, + OVERSIZED_BYTES + ) + }) + it('materializes with an available root-level copy name', async () => { mockFindUpload.mockResolvedValueOnce({ ...mothershipRow, diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index 25dc2a51685..d6b5e2dece6 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -36,6 +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 { isArchiveFileName } from '@/lib/uploads/utils/file-utils' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -109,7 +110,12 @@ async function executeSave( if (!head && hasCloudStorage()) { return { success: false, error: `Upload object not found: "${fileName}".` } } - const verifiedSize = head?.size ?? row.size + /** + * 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 billingContext = await resolveStorageBillingContext(workspaceId) const quotaCheck = await checkStorageQuotaForBillingContext(billingContext, verifiedSize) if (!quotaCheck.allowed) { @@ -145,7 +151,8 @@ async function executeSave( messageId: null, originalName: materializedName, displayName: materializedName, - size: verifiedSize, + size: legacySize, + sizeBytes: verifiedSize, }) .where( and(