@@ -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