Skip to content

Commit 126cf2a

Browse files
committed
feat(files): accept a content revision as the revert precondition and publish it on metadata
1 parent 4663ed9 commit 126cf2a

12 files changed

Lines changed: 121 additions & 11 deletions

File tree

‎apps/docs/content/docs/cli/files.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,8 @@ sim files versions revert <fileId> <version> [options]
299299

300300
| Option | Required | Description |
301301
| --- | --- | --- |
302-
| `--expected-current-version <value>` | No | Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number. |
302+
| `--expected-current-version <value>` | No | Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number, so prefer `expectedRevision` to guard content. |
303+
| `--expected-revision <value>` | No | Revert only while the file still holds the content this revision names, as returned by Get File Metadata or an earlier write; otherwise the request fails with `409`. Unlike a version number, it also catches edits that folded into the current version. |
303304

304305
</CommandTable>
305306

‎apps/docs/content/docs/cli/reference.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1044,7 +1044,8 @@ sim files versions revert <fileId> <version> [options]
10441044

10451045
| Option | Required | Description |
10461046
| --- | --- | --- |
1047-
| `--expected-current-version <value>` | No | Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number. |
1047+
| `--expected-current-version <value>` | No | Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number, so prefer `expectedRevision` to guard content. |
1048+
| `--expected-revision <value>` | No | Revert only while the file still holds the content this revision names, as returned by Get File Metadata or an earlier write; otherwise the request fails with `409`. Unlike a version number, it also catches edits that folded into the current version. |
10481049

10491050
</CommandTable>
10501051

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4988,10 +4988,15 @@
49884988
"description": "Workspace that owns the file."
49894989
},
49904990
"expectedCurrentVersion": {
4991-
"description": "Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number.",
4991+
"description": "Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number, so prefer `expectedRevision` to guard content.",
49924992
"type": "integer",
49934993
"minimum": 1,
49944994
"maximum": 2147483647
4995+
},
4996+
"expectedRevision": {
4997+
"description": "Revert only while the file still holds the content this revision names, as returned by Get File Metadata or an earlier write; otherwise the request fails with `409`. Unlike a version number, it also catches edits that folded into the current version.",
4998+
"type": "string",
4999+
"minLength": 1
49955000
}
49965001
},
49975002
"required": ["workspaceId"],
@@ -5315,6 +5320,17 @@
53155320
],
53165321
"description": "Current public-share state, or null when the file has never been shared."
53175322
},
5323+
"revision": {
5324+
"anyOf": [
5325+
{
5326+
"type": "string"
5327+
},
5328+
{
5329+
"type": "null"
5330+
}
5331+
],
5332+
"description": "Opaque token for the file's current content. Send it back as `expectedRevision` so a write or revert is refused when the content moved on. Null for a file with no recorded content version."
5333+
},
53185334
"currentVersion": {
53195335
"type": "integer",
53205336
"minimum": 1,
@@ -5335,6 +5351,7 @@
53355351
"updatedAt",
53365352
"deletedAt",
53375353
"share",
5354+
"revision",
53385355
"currentVersion"
53395356
],
53405357
"additionalProperties": false,
@@ -5368,7 +5385,8 @@
53685385
"updatedAt": "2026-01-15T10:30:00Z",
53695386
"deletedAt": null,
53705387
"share": null,
5371-
"currentVersion": 1
5388+
"currentVersion": 1,
5389+
"revision": "d2ZfNGtKOW1OMnBRN3JTOjIwMjYtMDEtMTVUMTA6MzA6MDAuMDAwWg"
53725390
}
53735391
},
53745392
{
@@ -5395,7 +5413,8 @@
53955413
"hasPassword": false,
53965414
"allowedEmails": []
53975415
},
5398-
"currentVersion": 3
5416+
"currentVersion": 3,
5417+
"revision": "d2ZfNGtKOW1OMnBRN3JTOjIwMjYtMDEtMTZUMDk6MTI6MDAuMDAwWg"
53995418
}
54005419
}
54015420
]

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => {
148148
deletedAt: null,
149149
share: SHARE,
150150
currentVersion: 3,
151+
revision: expect.any(String),
151152
},
152153
})
153154
expect(mocks.readMetadata).toHaveBeenCalledWith({
@@ -206,6 +207,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => {
206207
deletedAt: '2024-01-03T00:00:00.000Z',
207208
share: SHARE,
208209
currentVersion: 3,
210+
revision: expect.any(String),
209211
},
210212
})
211213
expect(mocks.readMetadata).toHaveBeenCalledWith(

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { v2GetFileContract } from '@/lib/api/contracts/v2/files'
22
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
33
import { v2FileErrorPolicies } from '@/lib/workspace-files/api'
4+
import { workspaceFileRevision } from '@/lib/workspace-files/application/file-revision'
45
import { fileOperations } from '@/lib/workspace-files/application/operations'
56
import { readWorkspaceFileMetadataWithVersion } from '@/lib/workspace-files/application/read-workspace-file-metadata'
67
import { toV2File } from '@/app/api/v2/files/utils'
@@ -30,6 +31,11 @@ export const GET = defineV2JsonRoute({
3031
}),
3132
useCase: readWorkspaceFileMetadataWithVersion,
3233
present: async ({ file, share }) => ({
33-
data: { ...(await toV2File(file)), share, currentVersion: file.currentVersion },
34+
data: {
35+
...(await toV2File(file)),
36+
share,
37+
currentVersion: file.currentVersion,
38+
revision: workspaceFileRevision(file),
39+
},
3440
}),
3541
})

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const POST = defineV2JsonRoute({
2525
assertedWorkspaceId: body.workspaceId,
2626
version: params.version,
2727
expectedCurrentVersion: body.expectedCurrentVersion,
28+
expectedRevision: body.expectedRevision,
2829
}),
2930
useCase: revertWorkspaceFileVersion,
3031
present: async ({ file, version, reverted }) => {

‎apps/sim/lib/api/contracts/v2/file-versions.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,14 @@ export const v2RevertFileVersionBodySchema = z
129129
expectedCurrentVersion: versionNumberSchema
130130
.optional()
131131
.describe(
132-
'Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number.'
132+
'Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number, so prefer `expectedRevision` to guard content.'
133+
),
134+
expectedRevision: z
135+
.string()
136+
.min(1)
137+
.optional()
138+
.describe(
139+
'Revert only while the file still holds the content this revision names, as returned by Get File Metadata or an earlier write; otherwise the request fails with `409`. Unlike a version number, it also catches edits that folded into the current version.'
133140
),
134141
})
135142
.strict()

‎apps/sim/lib/api/contracts/v2/files.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,12 @@ export const v2FileMetadataSchema = v2FileSchema
156156
share: v2FileShareSchema
157157
.nullable()
158158
.describe('Current public-share state, or null when the file has never been shared.'),
159+
revision: z
160+
.string()
161+
.nullable()
162+
.describe(
163+
"Opaque token for the file's current content. Send it back as `expectedRevision` so a write or revert is refused when the content moved on. Null for a file with no recorded content version."
164+
),
159165
currentVersion: versionNumberSchema.describe(
160166
'Version number of the current content. List File Versions returns the history; pass this as `expectedCurrentVersion` to revert only if nothing changed since.'
161167
),

‎apps/sim/lib/api/contracts/v2/openapi/files-audit.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -871,8 +871,22 @@ const declaredRoutes = [
871871
'File metadata response',
872872
'File metadata enriched with its current nullable public-share state.',
873873
[
874-
{ data: { ...FILE_EXAMPLE, share: null, currentVersion: 1 } },
875-
{ data: { ...FILE_EXAMPLE, share: SHARE_EXAMPLE, currentVersion: 3 } },
874+
{
875+
data: {
876+
...FILE_EXAMPLE,
877+
share: null,
878+
currentVersion: 1,
879+
revision: 'd2ZfNGtKOW1OMnBRN3JTOjIwMjYtMDEtMTVUMTA6MzA6MDAuMDAwWg',
880+
},
881+
},
882+
{
883+
data: {
884+
...FILE_EXAMPLE,
885+
share: SHARE_EXAMPLE,
886+
currentVersion: 3,
887+
revision: 'd2ZfNGtKOW1OMnBRN3JTOjIwMjYtMDEtMTZUMDk6MTI6MDAuMDAwWg',
888+
},
889+
},
876890
]
877891
),
878892
}

‎apps/sim/lib/workspace-files/application/file-versions.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ vi.mock('@/lib/workspace-files/application/read-workspace-file-text', () => ({
6161
vi.mock('@/lib/uploads', () => ({ getServePathPrefix: () => '/api/files/serve/' }))
6262

6363
import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types'
64+
import { workspaceFileRevision } from '@/lib/workspace-files/application/file-revision'
6465
import {
6566
deleteWorkspaceFileVersion,
6667
downloadWorkspaceFileVersion,
@@ -209,6 +210,43 @@ describe('file version use cases', () => {
209210
expect(mocks.notify).not.toHaveBeenCalled()
210211
})
211212

213+
/** The revision guards content, so it catches an edit that folded into the current version. */
214+
it('guards the revert with the revision the caller read', async () => {
215+
const callerRevision = new Date('2026-01-02T00:00:00Z')
216+
mocks.getVersion.mockImplementation(async (_file: unknown, number: number) =>
217+
number === 2 ? version(2) : number === 4 ? version(4, { isCurrent: true }) : current
218+
)
219+
220+
await revertWorkspaceFileVersion.execute({
221+
principal,
222+
input: {
223+
fileId: 'file-1',
224+
assertedWorkspaceId: 'workspace-1',
225+
version: 2,
226+
expectedRevision: workspaceFileRevision({ ...file, contentUpdatedAt: callerRevision }),
227+
},
228+
})
229+
230+
expect(mocks.updateContent.mock.calls[0][5]).toMatchObject({
231+
expectedUpdatedAt: callerRevision,
232+
})
233+
})
234+
235+
it('refuses a revision issued for a different file', async () => {
236+
await expect(
237+
revertWorkspaceFileVersion.execute({
238+
principal,
239+
input: {
240+
fileId: 'file-1',
241+
assertedWorkspaceId: 'workspace-1',
242+
version: 2,
243+
expectedRevision: workspaceFileRevision({ ...file, id: 'file-2' }),
244+
},
245+
})
246+
).rejects.toMatchObject({ code: 'validation' })
247+
expect(mocks.updateContent).not.toHaveBeenCalled()
248+
})
249+
212250
it('refuses a stale expected current version without writing', async () => {
213251
await expect(
214252
revertWorkspaceFileVersion.execute({

0 commit comments

Comments
 (0)