mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.`)
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user