Skip to content

Commit 29a598e

Browse files
committed
improvement(files): simplify file version history internals
- read metadata and its current version in one statement; drop the retry and 409 - move provenance policy branching into the provenance module - project stored provenance out of list, head, and get reads; revert reads it on demand - chunk storage-cleanup enqueues to the outbox bulk limit in one place - drop the write-only content_updated_at version column (unreleased 0365) - reuse findCause, the shared cleanup batch constants, and the version-number primitives - share the v2 text presenter between the file and version routes
1 parent 5873aff commit 29a598e

36 files changed

Lines changed: 498 additions & 520 deletions

‎apps/docs/openapi-v2-files-audit.json‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -868,7 +868,7 @@
868868
"get": {
869869
"operationId": "listFileVersions",
870870
"summary": "List File Versions",
871-
"description": "List the versions of a file, newest first by default. Each write that changes the bytes records one; identical rewrites do not, and collaborative edits or repeated workflow writes by one author within ten minutes fold into one. Renames and moves are not versions. An empty file is version 1 until its first content replaces it. Retention keeps the newest ten and removes older versions by plan, so numbers can have gaps.\n\nOAuth scope: `api:read`.",
871+
"description": "List the versions of a file, newest first by default. Each write that changes the bytes records one; identical rewrites do not. Collaborative edits within ten minutes fold into one version, as do repeated workflow writes by one author. Renames and moves are not versions. An empty file is version 1 until its first content replaces it. Retention keeps the newest ten and removes older versions by plan, so numbers can have gaps.\n\nOAuth scope: `api:read`.",
872872
"x-sim-operation": "files.versions.list",
873873
"x-oauth-scope": "api:read",
874874
"tags": ["Files"],
@@ -2068,7 +2068,7 @@
20682068
"get": {
20692069
"operationId": "getFile",
20702070
"summary": "Get File Metadata",
2071-
"description": "Get file metadata, its public-share configuration, and the version number of its current content. The `share` field is null when the file has never been shared. `currentVersion` identifies the content in List File Versions and is the precondition Revert File Version accepts. A file rewritten continuously while it is read returns `409`; retry.\n\nOAuth scope: `api:read`.",
2071+
"description": "Get file metadata, its public-share configuration, and the version number of its current content. The `share` field is null when the file has never been shared. `currentVersion` identifies the content in List File Versions and is the precondition Revert File Version accepts.\n\nOAuth scope: `api:read`.",
20722072
"x-sim-operation": "files.read_metadata",
20732073
"x-oauth-scope": "api:read",
20742074
"tags": ["Files"],
@@ -2145,9 +2145,6 @@
21452145
"404": {
21462146
"$ref": "#/components/responses/NotFound"
21472147
},
2148-
"409": {
2149-
"$ref": "#/components/responses/Conflict"
2150-
},
21512148
"429": {
21522149
"$ref": "#/components/responses/RateLimited"
21532150
},

‎apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,8 @@ const callGet = (query: string) =>
8484
const archivedFileUseCase = async ({ input }: { input: { includeDeleted?: boolean } }) => {
8585
if (!input.includeDeleted) throw new OrchestrationError('not_found', 'File not found')
8686
return {
87-
file: { ...buildRecord(), deletedAt: new Date('2024-01-03T00:00:00Z') },
87+
file: { ...buildRecord(), deletedAt: new Date('2024-01-03T00:00:00Z'), currentVersion: 3 },
8888
share: SHARE,
89-
currentVersion: 3,
9089
}
9190
}
9291

@@ -96,7 +95,10 @@ describe('GET /api/v2/files/[fileId]/metadata', () => {
9695
v2RouteMocks.authenticate.mockResolvedValue(auth)
9796
v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED)
9897
v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED)
99-
mocks.readMetadata.mockResolvedValue({ file: buildRecord(), share: SHARE, currentVersion: 3 })
98+
mocks.readMetadata.mockResolvedValue({
99+
file: { ...buildRecord(), currentVersion: 3 },
100+
share: SHARE,
101+
})
100102
mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']]))
101103
})
102104

@@ -244,9 +246,8 @@ describe('GET /api/v2/files/[fileId]/metadata', () => {
244246

245247
it('returns a null share when the file has no share configuration', async () => {
246248
mocks.readMetadata.mockResolvedValueOnce({
247-
file: buildRecord(),
249+
file: { ...buildRecord(), currentVersion: 3 },
248250
share: null,
249-
currentVersion: 3,
250251
})
251252

252253
const response = await callGet(`workspaceId=${WORKSPACE_ID}`)

‎apps/sim/app/api/v2/files/[fileId]/metadata/route.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const GET = defineV2JsonRoute({
2929
includeDeleted: query.scope === 'archived',
3030
}),
3131
useCase: readWorkspaceFileMetadataWithVersion,
32-
present: async ({ file, share, currentVersion }) => ({
33-
data: { ...(await toV2File(file)), share, currentVersion },
32+
present: async ({ file, share }) => ({
33+
data: { ...(await toV2File(file)), share, currentVersion: file.currentVersion },
3434
}),
3535
})

‎apps/sim/app/api/v2/files/[fileId]/text/route.ts‎

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/
33
import { v2FileErrorPolicies } from '@/lib/workspace-files/api'
44
import { fileOperations } from '@/lib/workspace-files/application/operations'
55
import { readWorkspaceFileText } from '@/lib/workspace-files/application/read-workspace-file-text'
6+
import { toV2FileText } from '@/app/api/v2/files/utils'
67

78
export const dynamic = 'force-dynamic'
89

@@ -39,18 +40,5 @@ export const GET = defineV2JsonRoute({
3940
limit: query.limit,
4041
}),
4142
useCase: readWorkspaceFileText,
42-
present: ({ file, text, truncated, degraded, degradedReason, byteCount, lineRange }) => ({
43-
data: {
44-
fileId: file.id,
45-
name: file.name,
46-
type: file.type,
47-
text,
48-
truncated,
49-
degraded,
50-
degradedReason,
51-
charCount: text.length,
52-
byteCount,
53-
...(lineRange ? { lineRange } : {}),
54-
},
55-
}),
43+
present: (result) => ({ data: toV2FileText(result) }),
5644
})

‎apps/sim/app/api/v2/files/[fileId]/versions/[version]/text/route.ts‎

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/
33
import { v2FileErrorPolicies } from '@/lib/workspace-files/api'
44
import { readWorkspaceFileVersionText } from '@/lib/workspace-files/application/file-versions'
55
import { fileOperations } from '@/lib/workspace-files/application/operations'
6+
import { toV2FileText } from '@/app/api/v2/files/utils'
67

78
export const dynamic = 'force-dynamic'
89

@@ -27,28 +28,5 @@ export const GET = defineV2JsonRoute({
2728
limit: query.limit,
2829
}),
2930
useCase: readWorkspaceFileVersionText,
30-
present: ({
31-
file,
32-
version,
33-
text,
34-
truncated,
35-
degraded,
36-
degradedReason,
37-
byteCount,
38-
lineRange,
39-
}) => ({
40-
data: {
41-
fileId: file.id,
42-
version: version.version,
43-
name: file.name,
44-
type: version.contentType,
45-
text,
46-
truncated,
47-
degraded,
48-
degradedReason,
49-
charCount: text.length,
50-
byteCount,
51-
...(lineRange ? { lineRange } : {}),
52-
},
53-
}),
31+
present: (result) => ({ data: { ...toV2FileText(result), version: result.version.version } }),
5432
})

‎apps/sim/app/api/v2/files/utils.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ const version: WorkspaceFileVersionRecord = {
2727
createdAt: new Date('2026-01-02T00:00:00Z'),
2828
updatedAt: new Date('2026-01-02T00:05:00Z'),
2929
supersededAt: null,
30-
secretProvenance: { status: 'exact', entries: [] },
3130
}
3231

3332
describe('toV2FileVersions', () => {

‎apps/sim/app/api/v2/files/utils.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { V2FileVersion } from '@/lib/api/contracts/v2/file-versions'
2-
import type { V2File } from '@/lib/api/contracts/v2/files'
2+
import type { V2File, V2FileText } from '@/lib/api/contracts/v2/files'
33
import { getBaseUrl } from '@/lib/core/utils/urls'
44
import { buildFolderPath } from '@/lib/folders/paths'
55
import { workspaceResourceWebUrl } from '@/lib/resources'
@@ -10,6 +10,7 @@ import {
1010
getUserEmailsByIds,
1111
requireResolvedUserEmail,
1212
} from '@/lib/users/queries'
13+
import type { ReadWorkspaceFileTextResult } from '@/lib/workspace-files/application/read-workspace-file-text'
1314
import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path'
1415

1516
/** Shared serialization for the v2 files surface. */
@@ -66,6 +67,30 @@ export async function toV2Files(records: WorkspaceFileRecord[]): Promise<V2File[
6667
)
6768
}
6869

70+
/** Serializes extracted text for the file or version the result was read from. */
71+
export function toV2FileText({
72+
file,
73+
text,
74+
truncated,
75+
degraded,
76+
degradedReason,
77+
byteCount,
78+
lineRange,
79+
}: ReadWorkspaceFileTextResult): V2FileText {
80+
return {
81+
fileId: file.id,
82+
name: file.name,
83+
type: file.type,
84+
text,
85+
truncated,
86+
degraded,
87+
degradedReason,
88+
charCount: text.length,
89+
byteCount,
90+
...(lineRange ? { lineRange } : {}),
91+
}
92+
}
93+
6994
function serializeV2FileVersion(
7095
record: WorkspaceFileVersionRecord,
7196
emailByUserId: Map<string, string>

‎apps/sim/background/cleanup-file-versions.ts‎

Lines changed: 38 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ import { chunkArray } from '@sim/utils/helpers'
55
import { task } from '@trigger.dev/sdk'
66
import { and, count, gt, inArray, isNotNull, lt, min, or, sql } from 'drizzle-orm'
77
import type { CleanupJobPayload } from '@/lib/billing/cleanup-dispatcher'
8-
import type { PlanCategory } from '@/lib/billing/plan-helpers'
9-
import { DEFAULT_DELETE_CHUNK_SIZE } from '@/lib/cleanup/batch-delete'
8+
import {
9+
DEFAULT_DELETE_CHUNK_SIZE,
10+
DEFAULT_MAX_BATCHES_PER_TABLE,
11+
DEFAULT_WORKSPACE_CHUNK_SIZE,
12+
} from '@/lib/cleanup/batch-delete'
1013
import { retentionCleanupQueue } from '@/lib/cleanup/queue'
1114
import { enqueueWorkspaceFileStorageCleanups } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox'
1215
import { MAX_SUPERSEDED_FILE_VERSIONS } from '@/lib/uploads/contexts/workspace/workspace-file-versions'
@@ -16,31 +19,20 @@ const logger = createLogger('CleanupFileVersions')
1619
/** All cleanup queries run on the dedicated cleanup pool. */
1720
const cleanupDb = dbFor('cleanup')
1821

19-
/** Workspaces whose candidate files are found in one query. */
20-
const WORKSPACES_PER_QUERY = 50
2122
/** Candidate files whose histories are ranked in one query. */
2223
const FILES_PER_QUERY = 500
23-
const VERSIONS_PER_BATCH = 1000
24-
/** Bounds one run's work per file chunk; the next daily run continues where this stopped. */
25-
const MAX_BATCHES_PER_CHUNK = 50
2624

2725
/**
28-
* Most superseded versions a file keeps, per plan; any beyond it are pruned whatever their age. Free
29-
* keeps its newest 100 versions (the current one included); every other plan is bounded only by the
30-
* inline write-time ceiling.
26+
* Superseded versions a free file keeps (its newest 100 with the current one); versions beyond it
27+
* are pruned whatever their age. Paid plans are bounded only by the inline write-time ceiling.
3128
*/
32-
const MAX_SUPERSEDED_VERSIONS_BY_PLAN: Record<PlanCategory, number> = {
33-
free: 99,
34-
pro: MAX_SUPERSEDED_FILE_VERSIONS,
35-
team: MAX_SUPERSEDED_FILE_VERSIONS,
36-
enterprise: MAX_SUPERSEDED_FILE_VERSIONS,
37-
}
38-
39-
/** Retention never prunes the newest versions of a file, whatever their age. */
40-
const FILE_VERSION_RETENTION_KEEP_LATEST = 10
29+
const FREE_MAX_SUPERSEDED_VERSIONS = 99
4130

42-
/** Newest superseded versions a file keeps whatever their age (the current version is the tenth). */
43-
const KEEP_SUPERSEDED = FILE_VERSION_RETENTION_KEEP_LATEST - 1
31+
/**
32+
* Newest superseded versions retention never prunes, whatever their age, so a file always keeps its
33+
* newest ten versions with the current one.
34+
*/
35+
const KEEP_SUPERSEDED = 9
4436

4537
/**
4638
* Files in the group that can lose any version: more superseded versions than the keep-latest floor,
@@ -79,7 +71,7 @@ async function selectCandidateFileIds(
7971

8072
/**
8173
* Superseded versions of the given files past retention: older than the cutoff or beyond the plan's
82-
* count, but never among the newest {@link FILE_VERSION_RETENTION_KEEP_LATEST} versions of a file.
74+
* count, but never among the newest {@link KEEP_SUPERSEDED} superseded versions of a file.
8375
*/
8476
function selectExpiredVersions(fileIds: string[], cutoff: Date, maxSuperseded: number) {
8577
const ranked = cleanupDb
@@ -108,38 +100,34 @@ function selectExpiredVersions(fileIds: string[], cutoff: Date, maxSuperseded: n
108100
or(lt(ranked.supersededAt, cutoff), gt(ranked.rank, maxSuperseded))
109101
)
110102
)
111-
.limit(VERSIONS_PER_BATCH)
103+
.limit(DEFAULT_DELETE_CHUNK_SIZE)
112104
}
113105

114106
/**
115107
* Deletes the expired version rows and enqueues their stored objects on the storage-cleanup outbox
116108
* in the same transaction, so a row never outlives its release and every released object is
117109
* deleted durably — the outbox retries failures and treats an already-missing object as done.
118110
*/
119-
async function deleteVersions(rows: Array<{ id: string }>) {
120-
let deleted = 0
121-
for (const batch of chunkArray(rows, DEFAULT_DELETE_CHUNK_SIZE)) {
122-
deleted += await cleanupDb.transaction(async (tx) => {
123-
const removed = await tx
124-
.delete(workspaceFileVersion)
125-
.where(
126-
and(
127-
inArray(
128-
workspaceFileVersion.id,
129-
batch.map((row) => row.id)
130-
),
131-
isNotNull(workspaceFileVersion.supersededAt)
132-
)
111+
function deleteVersions(rows: Array<{ id: string }>): Promise<number> {
112+
return cleanupDb.transaction(async (tx) => {
113+
const removed = await tx
114+
.delete(workspaceFileVersion)
115+
.where(
116+
and(
117+
inArray(
118+
workspaceFileVersion.id,
119+
rows.map((row) => row.id)
120+
),
121+
isNotNull(workspaceFileVersion.supersededAt)
133122
)
134-
.returning({ key: workspaceFileVersion.key })
135-
await enqueueWorkspaceFileStorageCleanups(
136-
tx,
137-
removed.map((row) => row.key)
138123
)
139-
return removed.length
140-
})
141-
}
142-
return deleted
124+
.returning({ key: workspaceFileVersion.key })
125+
await enqueueWorkspaceFileStorageCleanups(
126+
tx,
127+
removed.map((row) => row.key)
128+
)
129+
return removed.length
130+
})
143131
}
144132

145133
export async function runCleanupFileVersions(payload: CleanupJobPayload): Promise<void> {
@@ -151,21 +139,22 @@ export async function runCleanupFileVersions(payload: CleanupJobPayload): Promis
151139
}
152140

153141
const cutoff = new Date(Date.now() - retentionHours * 60 * 60 * 1000)
154-
const maxSuperseded = MAX_SUPERSEDED_VERSIONS_BY_PLAN[plan]
142+
const maxSuperseded =
143+
plan === 'free' ? FREE_MAX_SUPERSEDED_VERSIONS : MAX_SUPERSEDED_FILE_VERSIONS
155144
logger.info(
156145
`[${label}] Processing ${workspaceIds.length} workspaces, cutoff: ${cutoff.toISOString()}`
157146
)
158147

159148
let deleted = 0
160-
for (const group of chunkArray(workspaceIds, WORKSPACES_PER_QUERY)) {
149+
for (const group of chunkArray(workspaceIds, DEFAULT_WORKSPACE_CHUNK_SIZE)) {
161150
const candidates = await selectCandidateFileIds(group, cutoff, maxSuperseded)
162151
for (const fileIds of chunkArray(candidates, FILES_PER_QUERY)) {
163-
for (let batch = 0; batch < MAX_BATCHES_PER_CHUNK; batch++) {
152+
for (let batch = 0; batch < DEFAULT_MAX_BATCHES_PER_TABLE; batch++) {
164153
const expired = await selectExpiredVersions(fileIds, cutoff, maxSuperseded)
165154
if (expired.length === 0) break
166155
const removed = await deleteVersions(expired)
167156
deleted += removed
168-
if (expired.length < VERSIONS_PER_BATCH || removed === 0) break
157+
if (expired.length < DEFAULT_DELETE_CHUNK_SIZE || removed === 0) break
169158
}
170159
}
171160
}

‎apps/sim/lib/api/contracts/deployments.test.ts‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { describe, expect, it } from 'vitest'
22
import {
3-
DEPLOYMENT_VERSION_MAX,
43
deploymentVersionOrActiveParamsSchema,
54
deploymentVersionParamsSchema,
65
} from '@/lib/api/contracts/deployments'
6+
import { INT4_MAX } from '@/lib/api/contracts/primitives'
77

88
describe('deployment version route params', () => {
99
it('coerces numeric path params from the server boundary', () => {
@@ -32,12 +32,10 @@ describe('deployment version route params', () => {
3232
it.each([deploymentVersionParamsSchema, deploymentVersionOrActiveParamsSchema])(
3333
'bounds the path version to the integer column range',
3434
(schema) => {
35-
expect(
36-
schema.safeParse({ id: 'workflow-1', version: String(DEPLOYMENT_VERSION_MAX) }).success
37-
).toBe(true)
38-
expect(
39-
schema.safeParse({ id: 'workflow-1', version: String(DEPLOYMENT_VERSION_MAX + 1) }).success
40-
).toBe(false)
35+
expect(schema.safeParse({ id: 'workflow-1', version: String(INT4_MAX) }).success).toBe(true)
36+
expect(schema.safeParse({ id: 'workflow-1', version: String(INT4_MAX + 1) }).success).toBe(
37+
false
38+
)
4139
}
4240
)
4341
})

‎apps/sim/lib/api/contracts/deployments.ts‎

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
import { z } from 'zod'
2-
import {
3-
INT4_MAX,
4-
versionNumberPathSchema,
5-
versionNumberSchema,
6-
} from '@/lib/api/contracts/primitives'
2+
import { versionNumberPathSchema } from '@/lib/api/contracts/primitives'
73
import { defineRouteContract } from '@/lib/api/contracts/types'
84
import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows'
95
import {
@@ -26,17 +22,6 @@ export const deployedWorkflowStateSchema = z
2622
additionalProperties: true,
2723
})
2824

29-
/**
30-
* Upper bound of `workflow_deployment_version.version`, whose column is a
31-
* Postgres `integer`. A larger value has no row to address and overflows the
32-
* comparison instead of missing, so every schema carrying a deployment version
33-
* — path param, request body, or cursor payload — must be bounded by this.
34-
*/
35-
export const DEPLOYMENT_VERSION_MAX = INT4_MAX
36-
37-
/** A deployment version number, bounded to the range its column can hold. */
38-
export const deploymentVersionNumberSchema = versionNumberSchema
39-
4025
export const deploymentVersionParamsSchema = z.object({
4126
id: z.string().min(1, 'Invalid workflow ID'),
4227
version: versionNumberPathSchema,

0 commit comments

Comments
 (0)