|
1 | 1 | /** Real PostgreSQL transactions and local object storage for workspace file version history. */ |
2 | 2 | import { mkdtempSync } from 'node:fs' |
3 | | -import { access, rm } from 'node:fs/promises' |
| 3 | +import { access, mkdir, rm, writeFile } from 'node:fs/promises' |
4 | 4 | import { tmpdir } from 'node:os' |
5 | 5 | import path from 'node:path' |
6 | 6 | import { db, dbFor } from '@sim/db' |
@@ -39,6 +39,7 @@ import { |
39 | 39 | import { WORKSPACE_FILE_STORAGE_CLEANUP_OUTBOX_EVENT } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' |
40 | 40 | import { |
41 | 41 | getCurrentWorkspaceFileVersion, |
| 42 | + getWorkspaceFileVersion, |
42 | 43 | queryWorkspaceFileVersions, |
43 | 44 | releaseWorkspaceFileVersionsForPurgeInTx, |
44 | 45 | } from '@/lib/uploads/contexts/workspace/workspace-file-versions' |
@@ -288,16 +289,123 @@ describe('workspace file version history in PostgreSQL', () => { |
288 | 289 | ) |
289 | 290 |
|
290 | 291 | await expect(deleteWorkspaceFileVersion(fixture.workspaceId, fixture.fileId, 2)).resolves.toBe( |
291 | | - false |
| 292 | + 'newest' |
292 | 293 | ) |
293 | 294 | await expect(deleteWorkspaceFileVersion(fixture.workspaceId, fixture.fileId, 1)).resolves.toBe( |
294 | | - true |
| 295 | + 'deleted' |
| 296 | + ) |
| 297 | + await expect(deleteWorkspaceFileVersion(fixture.workspaceId, fixture.fileId, 1)).resolves.toBe( |
| 298 | + 'not_found' |
295 | 299 | ) |
296 | 300 |
|
297 | 301 | expect((await versionRows(fixture.fileId)).map((row) => row.version)).toEqual([2]) |
298 | 302 | expect(await objectExists(fixture.firstKey)).toBe(false) |
299 | 303 | }) |
300 | 304 |
|
| 305 | + it('reads bytes a write left unrecorded as the version its next write records them as', async () => { |
| 306 | + const fixture = await seedFile('original') |
| 307 | + const write = { source: 'api', authorUserId: fixture.aliceId } as const |
| 308 | + await updateWorkspaceFileContent( |
| 309 | + fixture.workspaceId, |
| 310 | + fixture.fileId, |
| 311 | + fixture.aliceId, |
| 312 | + Buffer.from('second'), |
| 313 | + undefined, |
| 314 | + { version: write } |
| 315 | + ) |
| 316 | + /** A content write that replaced the bytes without recording a version, as a build predating history would. */ |
| 317 | + 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) |
| 322 | + await db |
| 323 | + .update(workspaceFiles) |
| 324 | + .set({ |
| 325 | + key: unrecordedKey, |
| 326 | + sizeBytes: Buffer.byteLength(unrecordedContent), |
| 327 | + contentUpdatedAt: new Date(), |
| 328 | + }) |
| 329 | + .where(eq(workspaceFiles.id, fixture.fileId)) |
| 330 | + const file = await getWorkspaceFile(fixture.workspaceId, fixture.fileId) |
| 331 | + if (!file) throw new Error('file missing') |
| 332 | + |
| 333 | + await expect( |
| 334 | + getWorkspaceFileWithCurrentVersion(fixture.workspaceId, fixture.fileId) |
| 335 | + ).resolves.toMatchObject({ key: unrecordedKey, currentVersion: 3 }) |
| 336 | + expect(await getCurrentWorkspaceFileVersion(file)).toMatchObject({ |
| 337 | + version: 3, |
| 338 | + key: unrecordedKey, |
| 339 | + isCurrent: true, |
| 340 | + }) |
| 341 | + const listed = await queryWorkspaceFileVersions(file, { sortOrder: 'desc', limit: 10 }) |
| 342 | + expect(listed.versions[0].size).toBe(Buffer.byteLength(unrecordedContent)) |
| 343 | + expect(listed.versions.map((row) => [row.version, row.isCurrent, row.source])).toEqual([ |
| 344 | + [3, true, 'unknown'], |
| 345 | + [2, false, 'api'], |
| 346 | + [1, false, 'upload'], |
| 347 | + ]) |
| 348 | + expect(listed.versions[1].supersededAt).toEqual(file.contentUpdatedAt) |
| 349 | + |
| 350 | + const firstPage = await queryWorkspaceFileVersions(file, { sortOrder: 'asc', limit: 2 }) |
| 351 | + expect(firstPage.versions.map((row) => row.version)).toEqual([1, 2]) |
| 352 | + const lastPage = await queryWorkspaceFileVersions(file, { |
| 353 | + sortOrder: 'asc', |
| 354 | + limit: 2, |
| 355 | + after: firstPage.nextKeys ?? undefined, |
| 356 | + }) |
| 357 | + expect(lastPage.versions.map((row) => row.version)).toEqual([3]) |
| 358 | + expect(lastPage.nextKeys).toBeNull() |
| 359 | + |
| 360 | + await expect(deleteWorkspaceFileVersion(fixture.workspaceId, fixture.fileId, 2)).resolves.toBe( |
| 361 | + 'newest' |
| 362 | + ) |
| 363 | + |
| 364 | + const next = await updateWorkspaceFileContent( |
| 365 | + fixture.workspaceId, |
| 366 | + fixture.fileId, |
| 367 | + fixture.aliceId, |
| 368 | + Buffer.from('fourth'), |
| 369 | + undefined, |
| 370 | + { version: write } |
| 371 | + ) |
| 372 | + expect(next.currentVersion).toBe(4) |
| 373 | + expect((await versionRows(fixture.fileId)).map((row) => [row.version, row.source])).toEqual([ |
| 374 | + [1, 'upload'], |
| 375 | + [2, 'api'], |
| 376 | + [3, 'unknown'], |
| 377 | + [4, 'api'], |
| 378 | + ]) |
| 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() |
| 407 | + }) |
| 408 | + |
301 | 409 | it('never folds deliberate writes, and repoints the head for identical bytes', async () => { |
302 | 410 | const fixture = await seedFile('original') |
303 | 411 | const write = { source: 'api', authorUserId: fixture.aliceId } as const |
|
0 commit comments