Skip to content

Commit 7b93357

Browse files
committed
fix(files): read file versions against one snapshot of the file
1 parent c332b9b commit 7b93357

5 files changed

Lines changed: 139 additions & 46 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
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. Collaborative edits, and repeated workflow writes by one author, fold into the current version while it is under ten minutes old and written in the last five. Renames and moves are not versions. An empty file is version 1 until its first content replaces it. Retention keeps the newest ten, 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, and repeated workflow writes by one author, fold into a version under ten minutes old and written in the last five. Renames and moves are not versions. Retention removes older versions by age and plan but keeps the newest ten, 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"],

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,7 @@ const declaredRoutes = [
447447
operationId: 'listFileVersions',
448448
summary: 'List File Versions',
449449
description:
450-
'List the versions of a file, newest first by default. Each write that changes the bytes records one; identical rewrites do not. Collaborative edits, and repeated workflow writes by one author, fold into the current version while it is under ten minutes old and written in the last five. Renames and moves are not versions. An empty file is version 1 until its first content replaces it. Retention keeps the newest ten, so numbers can have gaps.',
450+
'List the versions of a file, newest first by default. Each write that changes the bytes records one; identical rewrites do not. Collaborative edits, and repeated workflow writes by one author, fold into a version under ten minutes old and written in the last five. Renames and moves are not versions. Retention removes older versions by age and plan but keeps the newest ten, so numbers can have gaps.',
451451
errors: RESOURCE_ERRORS,
452452
success: { description: 'A page of file versions.' },
453453
}),

‎apps/sim/lib/api/mcp/generated/v2-operations.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1425,7 +1425,7 @@ export const V2_MCP_OPERATIONS = {
14251425
contract: v2ListFileVersionsContract,
14261426
summary: 'List File Versions',
14271427
description:
1428-
'List the versions of a file, newest first by default. Each write that changes the bytes records one; identical rewrites do not. Collaborative edits, and repeated workflow writes by one author, fold into the current version while it is under ten minutes old and written in the last five. Renames and moves are not versions. An empty file is version 1 until its first content replaces it. Retention keeps the newest ten, so numbers can have gaps.\n\nOAuth scope: `api:read`.',
1428+
'List the versions of a file, newest first by default. Each write that changes the bytes records one; identical rewrites do not. Collaborative edits, and repeated workflow writes by one author, fold into a version under ten minutes old and written in the last five. Renames and moves are not versions. Retention removes older versions by age and plan but keeps the newest ten, so numbers can have gaps.\n\nOAuth scope: `api:read`.',
14291429
handler: () => import('@/app/api/v2/files/[fileId]/versions/route').then((route) => route.GET),
14301430
},
14311431
listKnowledgeBases: {

‎apps/sim/lib/uploads/contexts/workspace/__integration__/file-versions.integration.ts‎

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/** Real PostgreSQL transactions and local object storage for workspace file version history. */
22
import { mkdtempSync } from 'node:fs'
3-
import { access, rm } from 'node:fs/promises'
3+
import { access, mkdir, rm, writeFile } from 'node:fs/promises'
44
import { tmpdir } from 'node:os'
55
import path from 'node:path'
66
import { db, dbFor } from '@sim/db'
@@ -39,6 +39,7 @@ import {
3939
import { WORKSPACE_FILE_STORAGE_CLEANUP_OUTBOX_EVENT } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox'
4040
import {
4141
getCurrentWorkspaceFileVersion,
42+
getWorkspaceFileVersion,
4243
queryWorkspaceFileVersions,
4344
releaseWorkspaceFileVersionsForPurgeInTx,
4445
} from '@/lib/uploads/contexts/workspace/workspace-file-versions'
@@ -314,9 +315,17 @@ describe('workspace file version history in PostgreSQL', () => {
314315
)
315316
/** A content write that replaced the bytes without recording a version, as a build predating history would. */
316317
const unrecordedKey = `${fixture.firstKey}-unrecorded`
318+
const unrecordedContent = 'third, never recorded'
319+
const unrecordedPath = path.join(fixtureStorage.root, unrecordedKey)
320+
await mkdir(path.dirname(unrecordedPath), { recursive: true })
321+
await writeFile(unrecordedPath, unrecordedContent)
317322
await db
318323
.update(workspaceFiles)
319-
.set({ key: unrecordedKey, contentUpdatedAt: new Date() })
324+
.set({
325+
key: unrecordedKey,
326+
sizeBytes: Buffer.byteLength(unrecordedContent),
327+
contentUpdatedAt: new Date(),
328+
})
320329
.where(eq(workspaceFiles.id, fixture.fileId))
321330
const file = await getWorkspaceFile(fixture.workspaceId, fixture.fileId)
322331
if (!file) throw new Error('file missing')
@@ -330,6 +339,7 @@ describe('workspace file version history in PostgreSQL', () => {
330339
isCurrent: true,
331340
})
332341
const listed = await queryWorkspaceFileVersions(file, { sortOrder: 'desc', limit: 10 })
342+
expect(listed.versions[0].size).toBe(Buffer.byteLength(unrecordedContent))
333343
expect(listed.versions.map((row) => [row.version, row.isCurrent, row.source])).toEqual([
334344
[3, true, 'unknown'],
335345
[2, false, 'api'],
@@ -366,7 +376,34 @@ describe('workspace file version history in PostgreSQL', () => {
366376
[3, 'unknown'],
367377
[4, 'api'],
368378
])
369-
expect((await versionRows(fixture.fileId))[2].key).toBe(unrecordedKey)
379+
const materialized = (await versionRows(fixture.fileId))[2]
380+
expect(materialized.key).toBe(unrecordedKey)
381+
expect(materialized.sizeBytes).toBe(Buffer.byteLength(unrecordedContent))
382+
expect(await readVersionBytes(fixture.workspaceId, fixture.fileId, materialized.key)).toBe(
383+
unrecordedContent
384+
)
385+
})
386+
387+
it('reads versions against the file as committed, not a record loaded before a write', async () => {
388+
const fixture = await seedFile('original')
389+
const stale = await getWorkspaceFile(fixture.workspaceId, fixture.fileId)
390+
if (!stale) throw new Error('file missing')
391+
await updateWorkspaceFileContent(
392+
fixture.workspaceId,
393+
fixture.fileId,
394+
fixture.aliceId,
395+
Buffer.from('second'),
396+
undefined,
397+
{ version: { source: 'api', authorUserId: fixture.aliceId } }
398+
)
399+
400+
const listed = await queryWorkspaceFileVersions(stale, { sortOrder: 'desc', limit: 10 })
401+
expect(listed.versions.map((row) => [row.version, row.isCurrent])).toEqual([
402+
[2, true],
403+
[1, false],
404+
])
405+
expect((await getCurrentWorkspaceFileVersion(stale)).version).toBe(2)
406+
await expect(getWorkspaceFileVersion(stale, 3)).resolves.toBeNull()
370407
})
371408

372409
it('never folds deliberate writes, and repoints the head for identical bytes', async () => {

‎apps/sim/lib/uploads/contexts/workspace/workspace-file-versions.ts‎

Lines changed: 96 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,59 @@ function implicitCurrentVersion(
485485
}
486486
}
487487

488+
/**
489+
* Runs `read` in one read-only snapshot that also re-reads the file's content columns, so a content
490+
* write committing mid-read can never pair one write's file record with another write's version
491+
* rows. A file row deleted since the caller loaded it keeps the caller's record.
492+
*/
493+
function withVersionSnapshot<T>(
494+
file: WorkspaceFileVersionSubject,
495+
read: (tx: DbTransaction, file: WorkspaceFileVersionSubject) => Promise<T>
496+
): Promise<T> {
497+
return db.transaction(
498+
async (tx) => {
499+
const [row] = await tx
500+
.select({
501+
key: workspaceFiles.key,
502+
sizeBytes: workspaceFiles.sizeBytes,
503+
contentType: workspaceFiles.contentType,
504+
userId: workspaceFiles.userId,
505+
uploadedAt: workspaceFiles.uploadedAt,
506+
updatedAt: workspaceFiles.updatedAt,
507+
contentUpdatedAt: workspaceFiles.contentUpdatedAt,
508+
})
509+
.from(workspaceFiles)
510+
.where(eq(workspaceFiles.id, file.id))
511+
.limit(1)
512+
const snapshot: WorkspaceFileVersionSubject = row
513+
? {
514+
id: file.id,
515+
key: row.key,
516+
size: getWorkspaceFileSize(row),
517+
type: row.contentType,
518+
uploadedBy: row.userId,
519+
uploadedAt: row.uploadedAt,
520+
updatedAt: row.updatedAt,
521+
contentUpdatedAt: row.contentUpdatedAt,
522+
}
523+
: file
524+
return read(tx, snapshot)
525+
},
526+
{ isolationLevel: 'repeatable read', accessMode: 'read only' }
527+
)
528+
}
529+
530+
/** The version holding the file's current bytes, recorded or implicit, within a snapshot. */
531+
async function currentVersionInSnapshot(
532+
tx: DbTransaction,
533+
file: WorkspaceFileVersionSubject
534+
): Promise<WorkspaceFileVersionRecord> {
535+
const head = await loadWorkspaceFileVersionHead(file.id, tx)
536+
return head && isVersionHeadCurrent(head, file)
537+
? toVersionRecord(head, file)
538+
: implicitCurrentVersion(file, head)
539+
}
540+
488541
const VERSION_KEYSET: readonly KeysetKey<{ version: number }>[] = [
489542
numberKey(workspaceFileVersion.version, (row) => row.version),
490543
]
@@ -495,35 +548,37 @@ export async function queryWorkspaceFileVersions(
495548
options: { sortOrder: ListSortOrder; limit: number; after?: CursorKey[] }
496549
): Promise<{ versions: WorkspaceFileVersionRecord[]; nextKeys: CursorKey[] | null }> {
497550
const resume = resumeKeyset(VERSION_KEYSET, options.after, options.sortOrder)
498-
const [rows, head] = await Promise.all([
499-
db
551+
return withVersionSnapshot(file, async (tx, current) => {
552+
const rows = await tx
500553
.select(versionSummaryColumns)
501554
.from(workspaceFileVersion)
502-
.where(and(eq(workspaceFileVersion.fileId, file.id), resume))
555+
.where(and(eq(workspaceFileVersion.fileId, current.id), resume))
503556
.orderBy(...listOrderBy(keysetColumns(VERSION_KEYSET), options.sortOrder))
504-
.limit(options.limit + 1),
505-
loadWorkspaceFileVersionHead(file.id),
506-
])
507-
const records = rows.map((row) => toVersionRecord(row, file))
508-
/**
509-
* The implicit current version numbers above every row, so it leads a descending list and ends an
510-
* ascending one; a cursor already past it leaves it out. Over-fetching by one row still decides
511-
* whether another page follows, since the cut keeps the first `limit` records either way.
512-
*/
513-
const implicit = isVersionHeadCurrent(head, file) ? null : implicitCurrentVersion(file, head)
514-
const resumeAfter = options.after?.[0]
515-
if (
516-
implicit &&
517-
(typeof resumeAfter !== 'number' ||
518-
(options.sortOrder === 'desc'
519-
? implicit.version < resumeAfter
520-
: implicit.version > resumeAfter))
521-
) {
522-
if (options.sortOrder === 'desc') records.unshift(implicit)
523-
else records.push(implicit)
524-
}
525-
const page = keysetPage(VERSION_KEYSET, records, options.limit)
526-
return { versions: page.data, nextKeys: page.nextCursorKeys }
557+
.limit(options.limit + 1)
558+
const head = await loadWorkspaceFileVersionHead(current.id, tx)
559+
const records = rows.map((row) => toVersionRecord(row, current))
560+
/**
561+
* The implicit current version numbers above every row, so it leads a descending list and ends
562+
* an ascending one; a cursor already past it leaves it out. Over-fetching by one row still
563+
* decides whether another page follows, since the cut keeps the first `limit` records either way.
564+
*/
565+
const implicit = isVersionHeadCurrent(head, current)
566+
? null
567+
: implicitCurrentVersion(current, head)
568+
const resumeAfter = options.after?.[0]
569+
if (
570+
implicit &&
571+
(typeof resumeAfter !== 'number' ||
572+
(options.sortOrder === 'desc'
573+
? implicit.version < resumeAfter
574+
: implicit.version > resumeAfter))
575+
) {
576+
if (options.sortOrder === 'desc') records.unshift(implicit)
577+
else records.push(implicit)
578+
}
579+
const page = keysetPage(VERSION_KEYSET, records, options.limit)
580+
return { versions: page.data, nextKeys: page.nextCursorKeys }
581+
})
527582
}
528583

529584
/**
@@ -542,13 +597,10 @@ export async function findWorkspaceFileVersionKeys(keys: readonly string[]): Pro
542597
}
543598

544599
/** The version holding the file's current bytes, recorded or implicit. */
545-
export async function getCurrentWorkspaceFileVersion(
600+
export function getCurrentWorkspaceFileVersion(
546601
file: WorkspaceFileVersionSubject
547602
): Promise<WorkspaceFileVersionRecord> {
548-
const head = await loadWorkspaceFileVersionHead(file.id)
549-
return head && isVersionHeadCurrent(head, file)
550-
? toVersionRecord(head, file)
551-
: implicitCurrentVersion(file, head)
603+
return withVersionSnapshot(file, currentVersionInSnapshot)
552604
}
553605

554606
/**
@@ -582,18 +634,22 @@ export function currentWorkspaceFileVersionNumberSql() {
582634
}
583635

584636
/** One version of a file, or null when it never existed or retention removed it. */
585-
export async function getWorkspaceFileVersion(
637+
export function getWorkspaceFileVersion(
586638
file: WorkspaceFileVersionSubject,
587639
version: number
588640
): Promise<WorkspaceFileVersionRecord | null> {
589-
const [row] = await db
590-
.select(versionSummaryColumns)
591-
.from(workspaceFileVersion)
592-
.where(and(eq(workspaceFileVersion.fileId, file.id), eq(workspaceFileVersion.version, version)))
593-
.limit(1)
594-
if (row) return toVersionRecord(row, file)
595-
const current = await getCurrentWorkspaceFileVersion(file)
596-
return current.version === version ? current : null
641+
return withVersionSnapshot(file, async (tx, current) => {
642+
const [row] = await tx
643+
.select(versionSummaryColumns)
644+
.from(workspaceFileVersion)
645+
.where(
646+
and(eq(workspaceFileVersion.fileId, current.id), eq(workspaceFileVersion.version, version))
647+
)
648+
.limit(1)
649+
if (row) return toVersionRecord(row, current)
650+
const latest = await currentVersionInSnapshot(tx, current)
651+
return latest.version === version ? latest : null
652+
})
597653
}
598654

599655
/**

0 commit comments

Comments
 (0)