Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/docs/openapi-v2-files-audit.json
Original file line number Diff line number Diff line change
Expand Up @@ -4923,6 +4923,10 @@
"version": {
"description": "The current version of the file after the revert: a new `revert` version; the requested version when it was already current; or the unchanged current version when its content already matched the requested one.",
"$ref": "#/components/schemas/V2FileVersion"
},
"revision": {
"description": "Opaque token for the content the file holds after the revert — the one it just wrote, or the unchanged current content when `reverted` is false. Send it back as `expectedRevision` on the next write.",
"type": "string"
}
},
"required": ["reverted", "file", "version"],
Expand Down Expand Up @@ -4976,7 +4980,8 @@
"createdAt": "2026-01-15T10:30:00Z",
"updatedAt": "2026-01-15T10:30:00Z",
"supersededAt": null
}
},
"revision": "d2ZfVjFTdEdYUjh6NWpkSGk2Qm15VDkxOjIwMjYtMDEtMTVUMTA6MzA6MDAuMDAwWg"
}
}
]
Expand Down
18 changes: 7 additions & 11 deletions apps/sim/app/api/v2/files/[fileId]/content/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
import { v2FileErrorPolicies } from '@/lib/workspace-files/api'
import { editWorkspaceFileContent } from '@/lib/workspace-files/application/edit-workspace-file-content'
import { workspaceFileRevision } from '@/lib/workspace-files/application/file-revision'
import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision'
import { fileOperations } from '@/lib/workspace-files/application/operations'
import {
admitUpdateWorkspaceFileContent,
Expand Down Expand Up @@ -40,10 +40,9 @@ export const PUT = defineV2JsonRoute({
expectedRevision: body.expectedRevision,
}),
useCase: updateWorkspaceFileContent,
present: async ({ file }) => {
const revision = workspaceFileRevision(file)
return { data: { ...(await toV2File(file)), ...(revision === null ? {} : { revision }) } }
},
present: async ({ file }) => ({
data: { ...(await toV2File(file)), ...workspaceFileRevisionField(file) },
}),
})

/**
Expand Down Expand Up @@ -78,10 +77,7 @@ export const PATCH = defineV2JsonRoute({
expectedRevision: body.expectedRevision,
}),
useCase: editWorkspaceFileContent,
present: async ({ file, lineCount }) => {
const revision = workspaceFileRevision(file)
return {
data: { file: await toV2File(file), lineCount, ...(revision === null ? {} : { revision }) },
}
},
present: async ({ file, lineCount }) => ({
data: { file: await toV2File(file), lineCount, ...workspaceFileRevisionField(file) },
}),
})
21 changes: 9 additions & 12 deletions apps/sim/app/api/v2/files/[fileId]/metadata/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { v2GetFileContract } from '@/lib/api/contracts/v2/files'
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
import { v2FileErrorPolicies } from '@/lib/workspace-files/api'
import { workspaceFileRevision } from '@/lib/workspace-files/application/file-revision'
import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision'
import { fileOperations } from '@/lib/workspace-files/application/operations'
import { readWorkspaceFileMetadataWithVersion } from '@/lib/workspace-files/application/read-workspace-file-metadata'
import { toV2File } from '@/app/api/v2/files/utils'
Expand Down Expand Up @@ -30,15 +30,12 @@ export const GET = defineV2JsonRoute({
includeDeleted: query.scope === 'archived',
}),
useCase: readWorkspaceFileMetadataWithVersion,
present: async ({ file, share }) => {
const revision = workspaceFileRevision(file)
return {
data: {
...(await toV2File(file)),
share,
currentVersion: file.currentVersion,
...(revision === null ? {} : { revision }),
},
}
},
present: async ({ file, share }) => ({
data: {
...(await toV2File(file)),
share,
currentVersion: file.currentVersion,
...workspaceFileRevisionField(file),
},
}),
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* @vitest-environment node
*/
import {
V2_OPERATION_RATE_LIMIT_ALLOWED,
V2_PREAUTH_RATE_LIMIT_ALLOWED,
v2ApiKeyAuthModuleMock,
v2RateLimiterModuleMock,
v2RouteMocks,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
revertVersion: vi.fn(),
getUserEmailsByIds: vi.fn(),
findUserEmailsByIds: vi.fn(),
}))

vi.mock('@/lib/workspace-files/application/file-versions', () => ({
revertWorkspaceFileVersion: {
operation: { id: 'files.versions.revert', minimumRole: 'write', workspaceApiKey: 'allow' },
execute: mocks.revertVersion,
},
}))

vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock)
vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock)

vi.mock('@/lib/users/queries', () => ({
getUserEmailsByIds: mocks.getUserEmailsByIds,
findUserEmailsByIds: mocks.findUserEmailsByIds,
requireResolvedUserEmail: (emails: Map<string, string>, userId: string) => emails.get(userId)!,
}))

import { workspaceFileRevision } from '@/lib/workspace-files/application/file-revision'
import { POST } from '@/app/api/v2/files/[fileId]/versions/[version]/revert/route'

const WORKSPACE_ID = 'workspace-1'
const FILE_ID = 'wf_1'
const auth = {
principal: {
kind: 'workspace_api_key' as const,
workspaceId: WORKSPACE_ID,
keyId: 'key-1',
},
rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const,
rateLimitSubscription: null,
keyType: 'workspace' as const,
}

const record = {
id: FILE_ID,
workspaceId: WORKSPACE_ID,
name: 'data.csv',
key: 'workspace/ws/1-x-data.csv',
path: '/api/files/serve/x',
size: 8,
type: 'text/csv',
uploadedBy: 'user-1',
folderId: null,
uploadedAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-03T00:00:00Z'),
contentUpdatedAt: new Date('2024-01-04T00:00:00Z'),
}

const versionRecord = {
fileId: FILE_ID,
version: 4,
isCurrent: true,
size: 8,
contentType: 'text/csv',
source: 'revert' as const,
Comment thread
waleedlatif1 marked this conversation as resolved.
authorUserIds: ['user-1'],
restoredFromVersion: 2,
createdAt: new Date('2024-01-04T00:00:00Z'),
updatedAt: new Date('2024-01-04T00:00:00Z'),
supersededAt: null,
}

const callRevert = (body: unknown) =>
POST(
new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/versions/2/revert`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
{ params: Promise.resolve({ fileId: FILE_ID, version: '2' }) }
)

describe('POST /api/v2/files/[fileId]/versions/[version]/revert', () => {
beforeEach(() => {
vi.clearAllMocks()
v2RouteMocks.authenticate.mockResolvedValue(auth)
v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED)
v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED)
mocks.revertVersion.mockResolvedValue({
file: record,
version: versionRecord,
reverted: true,
revertedFrom: 3,
})
mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']]))
mocks.findUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']]))
})

/**
* A revert consumes the caller's revision, so the response has to issue its replacement —
* otherwise chaining a second conditional write needs a metadata re-read, and the window
* between the two is exactly what the revision is meant to close.
*/
it('returns the revision naming the content the revert produced', async () => {
const expectedRevision = workspaceFileRevision(record)!

const response = await callRevert({ workspaceId: WORKSPACE_ID, expectedRevision })

expect(response.status).toBe(200)
const body = await response.json()
expect(body.data.reverted).toBe(true)
expect(body.data.revision).toBe(expectedRevision)
expect(mocks.revertVersion).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
fileId: FILE_ID,
assertedWorkspaceId: WORKSPACE_ID,
version: 2,
expectedRevision,
}),
})
)
})

it('returns the current content revision when the version was already current', async () => {
mocks.revertVersion.mockResolvedValue({
file: record,
version: { ...versionRecord, version: 3, source: 'api', restoredFromVersion: null },
reverted: false,
revertedFrom: 3,
})

const body = await (await callRevert({ workspaceId: WORKSPACE_ID })).json()

expect(body.data.reverted).toBe(false)
expect(body.data.revision).toBe(workspaceFileRevision(record))
})

it('forwards the caller revision precondition to the use case', async () => {
const expectedRevision = workspaceFileRevision(record)!

await callRevert({ workspaceId: WORKSPACE_ID, expectedRevision })

expect(mocks.revertVersion).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
fileId: FILE_ID,
assertedWorkspaceId: WORKSPACE_ID,
version: 2,
expectedRevision,
}),
})
)
})
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { v2RevertFileVersionContract } from '@/lib/api/contracts/v2/file-versions'
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
import { v2FileErrorPolicies } from '@/lib/workspace-files/api'
import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision'
import { revertWorkspaceFileVersion } from '@/lib/workspace-files/application/file-versions'
import { fileOperations } from '@/lib/workspace-files/application/operations'
import { toV2File, toV2FileVersion } from '@/app/api/v2/files/utils'
Expand All @@ -13,6 +14,9 @@ export const revalidate = 0
*
* Writes the version's bytes as a new version, so the revert can itself be reverted. Reverting to
* the current version is a no-op that reports `reverted: false`.
*
* A revert invalidates the revision the caller guarded it with, so the response carries the one
* naming the content the file now holds.
*/
export const POST = defineV2JsonRoute({
contract: v2RevertFileVersionContract,
Expand All @@ -30,6 +34,13 @@ export const POST = defineV2JsonRoute({
useCase: revertWorkspaceFileVersion,
present: async ({ file, version, reverted }) => {
const [v2File, v2Version] = await Promise.all([toV2File(file), toV2FileVersion(version)])
return { data: { reverted, file: v2File, version: v2Version } }
return {
data: {
reverted,
file: v2File,
version: v2Version,
...workspaceFileRevisionField(file),
},
}
},
})
60 changes: 60 additions & 0 deletions apps/sim/app/workspace/providers/socket-presence-merge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { mergePresenceRoster } from '@/app/workspace/providers/socket-presence-merge'
import type { PresenceUser } from '@/stores/presence/types'

function peer(overrides: Partial<PresenceUser> = {}): PresenceUser {
return {
socketId: 'socket-1',
userId: 'user-1',
userName: 'Ada',
...overrides,
}
}

describe('mergePresenceRoster', () => {
it('clears the pointer when the roster carries an explicit null cursor', () => {
const previous = [peer({ cursor: { x: 10, y: 20 } })]

const merged = mergePresenceRoster(previous, [peer({ cursor: null })])

expect(merged[0].cursor).toBeNull()
})

it('keeps the known pointer when the roster omits the cursor', () => {
const previous = [peer({ cursor: { x: 10, y: 20 } })]

const merged = mergePresenceRoster(previous, [peer()])

expect(merged[0].cursor).toEqual({ x: 10, y: 20 })
})

it('keeps the known selection when the roster omits it', () => {
const previous = [peer({ selection: { type: 'block', id: 'block-1' } })]

const merged = mergePresenceRoster(previous, [peer()])

expect(merged[0].selection).toEqual({ type: 'block', id: 'block-1' })
})

it('applies a cleared selection, which the wire spells as type none', () => {
const previous = [peer({ selection: { type: 'block', id: 'block-1' } })]

const merged = mergePresenceRoster(previous, [peer({ selection: { type: 'none' } })])

expect(merged[0].selection).toEqual({ type: 'none' })
})

it('passes through a peer it has no previous presence for', () => {
const joining = peer({ socketId: 'socket-2', userId: 'user-2', cursor: { x: 1, y: 2 } })

expect(mergePresenceRoster([], [joining])).toEqual([joining])
})

it('drops peers the roster no longer lists', () => {
const previous = [peer(), peer({ socketId: 'socket-2', userId: 'user-2' })]

const merged = mergePresenceRoster(previous, [peer()])

expect(merged.map((user) => user.socketId)).toEqual(['socket-1'])
})
})
32 changes: 32 additions & 0 deletions apps/sim/app/workspace/providers/socket-presence-merge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { PresenceUser } from '@/stores/presence/types'

/**
* Folds a `presence-update` roster over the presence already held for each socket.
*
* The server rebuilds a socket's presence record from scratch when it joins a room, so a re-join
* of the same workflow broadcasts a roster whose `cursor` and `selection` are simply absent. The
* fields are carried over rather than blanked, which is what keeps a peer's pointer from
* flickering on every re-join.
*
* A `null` `cursor` is therefore not the same as an absent one: it is a pointer the peer
* explicitly cleared on leaving the canvas. Coalescing the two with `??` would resurrect a stale
* pointer whenever the clearing `cursor-update` was missed — dropped by the visibility gate
* during a join, or by a rejoin that never refreshed the roster. `selection` needs no such
* split: a cleared selection is `{ type: 'none' }`, and the wire type admits no `null`.
*/
export function mergePresenceRoster(
previous: PresenceUser[],
incoming: PresenceUser[]
): PresenceUser[] {
const previousBySocketId = new Map(previous.map((user) => [user.socketId, user]))

return incoming.map((user) => {
const existing = previousBySocketId.get(user.socketId)
if (!existing) return user
return {
...user,
cursor: user.cursor === undefined ? existing.cursor : user.cursor,
selection: user.selection ?? existing.selection,
}
})
}
Loading
Loading