@@ -5,6 +5,7 @@ import { dirname, join } from 'node:path'
55import type { ResourceDelegatedPrincipal } from '@sim/auth/principal'
66import { db } from '@sim/db'
77import {
8+ fileSearchDispatchQueue,
89 folder,
910 idempotencyKey,
1011 member,
@@ -14,6 +15,7 @@ import {
1415 subscription,
1516 user,
1617 workspace,
18+ workspaceFileSearchRevision,
1719 workspaceFiles,
1820} from '@sim/db/schema'
1921import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
@@ -44,11 +46,13 @@ import {
4446} from '@/lib/projects/files/application/previews'
4547import { searchProjectFileContent } from '@/lib/projects/files/application/search'
4648import { revertProjectFileVersion } from '@/lib/projects/files/application/versions'
49+ import { lockProjectShared } from '@/lib/projects/membership'
4750import * as storageCleanup from '@/lib/uploads/contexts/workspace/file-storage-cleanup-outbox'
4851import * as storage from '@/lib/uploads/core/storage-service'
4952import { compileFileDocument } from '@/lib/uploads/documents/compile'
5053import * as heic from '@/lib/uploads/server/heic'
5154import { observeWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer'
55+ import { FILE_SEARCH_ARTIFACT_READY_LOCK_TIMEOUT_MS } from '@/lib/workspace-files/search/constants'
5256import * as searchDelivery from '@/lib/workspace-files/search/delivery'
5357import * as searchRepository from '@/lib/workspace-files/search/repository'
5458
@@ -139,6 +143,19 @@ async function fixture() {
139143 return { ...ids, principal: createSessionPrincipal({ userId: editorId }) }
140144}
141145
146+ /** File creation marks a revision pending, so a requeue check starts from no revision at all. */
147+ async function clearSearchRevision(fileId: string) {
148+ await db.delete(workspaceFileSearchRevision).where(eq(workspaceFileSearchRevision.fileId, fileId))
149+ }
150+
151+ async function searchRevision(fileId: string) {
152+ const [revision] = await db
153+ .select()
154+ .from(workspaceFileSearchRevision)
155+ .where(eq(workspaceFileSearchRevision.fileId, fileId))
156+ return revision
157+ }
158+
142159function createInput(projectId: string, content = 'Project architecture') {
143160 return {
144161 projectId,
@@ -306,6 +323,7 @@ describe('Project delivery admission', () => {
306323 }
307324 vi.spyOn(searchRepository, 'searchFileIndex').mockResolvedValue(prepared)
308325 vi.spyOn(searchDelivery, 'loadFileSearchDelivery').mockResolvedValue({
326+ current: new Set(),
309327 provenance: new Map(),
310328 outOfScope: new Set(['moved']),
311329 inputs: [],
@@ -671,6 +689,125 @@ describe('Project rendered artifacts against PostgreSQL and private storage', ()
671689 }
672690 )
673691
692+ check(
693+ 'a generated artifact read beside a concurrent Project reader completes and requeues search',
694+ async () => {
695+ const f = await fixture()
696+ const source = await createProjectFile.execute({
697+ principal: f.principal,
698+ input: {
699+ ...createInput(f.projectId, 'const title = "concurrent"'),
700+ name: 'deck.pptx',
701+ contentType: 'text/x-pptxgenjs',
702+ },
703+ })
704+ const artifact = Buffer.from('PK\u0003\u0004document')
705+ vi.spyOn(sandboxTask, 'runSandboxTask').mockResolvedValue(artifact)
706+ let markHeld = () => {}
707+ const held = new Promise<void>((resolve) => {
708+ markHeld = resolve
709+ })
710+ let release = () => {}
711+ const released = new Promise<void>((resolve) => {
712+ release = resolve
713+ })
714+ const concurrentReader = db.transaction(async (tx) => {
715+ await lockProjectShared(tx, f.projectId)
716+ markHeld()
717+ await released
718+ })
719+ await held
720+ await clearSearchRevision(source.file.id)
721+ try {
722+ const rendered = await observeWorkspaceFileDelivery(
723+ async () => release(),
724+ () =>
725+ readProjectFileArtifact.execute({
726+ principal: f.principal,
727+ input: { projectId: f.projectId, fileId: source.file.id, maxBytes: 1_000_000 },
728+ })
729+ )
730+ expect(rendered.buffer).toEqual(artifact)
731+ } finally {
732+ release()
733+ await concurrentReader
734+ }
735+ const revision = await searchRevision(source.file.id)
736+ expect(revision?.status).toBe('pending')
737+ expect(revision?.sourceContentUpdatedAt).toEqual(source.file.contentUpdatedAt)
738+ }
739+ )
740+
741+ check(
742+ 'a contended search requeue gives up within its short bound without failing the read',
743+ async () => {
744+ const f = await fixture()
745+ const source = await createProjectFile.execute({
746+ principal: f.principal,
747+ input: {
748+ ...createInput(f.projectId, 'const title = "contended"'),
749+ name: 'deck.pptx',
750+ contentType: 'text/x-pptxgenjs',
751+ },
752+ })
753+ const artifact = Buffer.from('PK\u0003\u0004document')
754+ vi.spyOn(sandboxTask, 'runSandboxTask').mockResolvedValue(artifact)
755+ let markHeld = () => {}
756+ const held = new Promise<void>((resolve) => {
757+ markHeld = resolve
758+ })
759+ let release = () => {}
760+ const released = new Promise<void>((resolve) => {
761+ release = resolve
762+ })
763+ const concurrentReader = db.transaction(async (tx) => {
764+ await lockProjectShared(tx, f.projectId)
765+ markHeld()
766+ await released
767+ })
768+ await held
769+ await clearSearchRevision(source.file.id)
770+ try {
771+ const started = performance.now()
772+ const rendered = await readProjectFileArtifact.execute({
773+ principal: f.principal,
774+ input: { projectId: f.projectId, fileId: source.file.id, maxBytes: 1_000_000 },
775+ })
776+ expect(rendered.buffer).toEqual(artifact)
777+ expect(performance.now() - started).toBeLessThan(
778+ FILE_SEARCH_ARTIFACT_READY_LOCK_TIMEOUT_MS + 2_000
779+ )
780+ } finally {
781+ release()
782+ await concurrentReader
783+ }
784+ expect(await searchRevision(source.file.id)).toBeUndefined()
785+ }
786+ )
787+
788+ check("a Project page embedding another owner's image is a missing input", async () => {
789+ const f = await fixture()
790+ for (const owner of [`workspace=${f.workspaceId}`, `project=${generateId()}`]) {
791+ const page = await createProjectFile.execute({
792+ principal: f.principal,
793+ input: {
794+ ...createInput(
795+ f.projectId,
796+ `---\ntitle: Design\n---\n\n}?${owner})`
797+ ),
798+ name: `${generateId()}.html`,
799+ contentType: 'text/x-sim-page',
800+ },
801+ })
802+ await expect(
803+ readProjectFileArtifact.execute({
804+ principal: f.principal,
805+ input: { projectId: f.projectId, fileId: page.file.id, maxBytes: 1_000_000 },
806+ })
807+ ).rejects.toMatchObject({ code: 'not_found' })
808+ }
809+ })
810+
674811 check(
675812 'artifact source cannot import a foreign Project asset through a valid source-file capability',
676813 async () => {
@@ -846,6 +983,9 @@ afterAll(async () => {
846983 .where(
847984 sql`${outboxEvent.payload}::jsonb ->> 'key' LIKE ${`project/${f.projectId}/%`} OR ${outboxEvent.payload}::jsonb -> 'owner' ->> 'entityId' = ${f.projectId}`
848985 )
986+ await db
987+ .delete(fileSearchDispatchQueue)
988+ .where(eq(fileSearchDispatchQueue.entityId, f.projectId))
849989 await db.delete(workspaceFiles).where(eq(workspaceFiles.projectId, f.projectId))
850990 await db.delete(folder).where(eq(folder.projectId, f.projectId))
851991 await deleteWorkspaceFixture(db, eq(workspace.id, f.workspaceId))
0 commit comments