@@ -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
@@ -306,6 +310,7 @@ describe('Project delivery admission', () => {
306310 }
307311 vi . spyOn ( searchRepository , 'searchFileIndex' ) . mockResolvedValue ( prepared )
308312 vi . spyOn ( searchDelivery , 'loadFileSearchDelivery' ) . mockResolvedValue ( {
313+ current : new Set ( ) ,
309314 provenance : new Map ( ) ,
310315 outOfScope : new Set ( [ 'moved' ] ) ,
311316 inputs : [ ] ,
@@ -671,6 +676,130 @@ describe('Project rendered artifacts against PostgreSQL and private storage', ()
671676 }
672677 )
673678
679+ check (
680+ 'a generated artifact read beside a concurrent Project reader completes and requeues search' ,
681+ async ( ) => {
682+ const f = await fixture ( )
683+ const source = await createProjectFile . execute ( {
684+ principal : f . principal ,
685+ input : {
686+ ...createInput ( f . projectId , 'const title = "concurrent"' ) ,
687+ name : 'deck.pptx' ,
688+ contentType : 'text/x-pptxgenjs' ,
689+ } ,
690+ } )
691+ const artifact = Buffer . from ( 'PK\u0003\u0004document' )
692+ vi . spyOn ( sandboxTask , 'runSandboxTask' ) . mockResolvedValue ( artifact )
693+ let markHeld = ( ) => { }
694+ const held = new Promise < void > ( ( resolve ) => {
695+ markHeld = resolve
696+ } )
697+ let release = ( ) => { }
698+ const released = new Promise < void > ( ( resolve ) => {
699+ release = resolve
700+ } )
701+ const concurrentReader = db . transaction ( async ( tx ) => {
702+ await lockProjectShared ( tx , f . projectId )
703+ markHeld ( )
704+ await released
705+ } )
706+ await held
707+ try {
708+ const rendered = await observeWorkspaceFileDelivery (
709+ async ( ) => release ( ) ,
710+ ( ) =>
711+ readProjectFileArtifact . execute ( {
712+ principal : f . principal ,
713+ input : { projectId : f . projectId , fileId : source . file . id , maxBytes : 1_000_000 } ,
714+ } )
715+ )
716+ expect ( rendered . buffer ) . toEqual ( artifact )
717+ } finally {
718+ release ( )
719+ await concurrentReader
720+ }
721+ const [ revision ] = await db
722+ . select ( )
723+ . from ( workspaceFileSearchRevision )
724+ . where ( eq ( workspaceFileSearchRevision . fileId , source . file . id ) )
725+ expect ( revision ?. status ) . toBe ( 'pending' )
726+ expect ( revision ?. sourceContentUpdatedAt ) . toEqual ( source . file . contentUpdatedAt )
727+ }
728+ )
729+
730+ check (
731+ 'a contended search requeue gives up within its short bound without failing the read' ,
732+ async ( ) => {
733+ const f = await fixture ( )
734+ const source = await createProjectFile . execute ( {
735+ principal : f . principal ,
736+ input : {
737+ ...createInput ( f . projectId , 'const title = "contended"' ) ,
738+ name : 'deck.pptx' ,
739+ contentType : 'text/x-pptxgenjs' ,
740+ } ,
741+ } )
742+ const artifact = Buffer . from ( 'PK\u0003\u0004document' )
743+ vi . spyOn ( sandboxTask , 'runSandboxTask' ) . mockResolvedValue ( artifact )
744+ let markHeld = ( ) => { }
745+ const held = new Promise < void > ( ( resolve ) => {
746+ markHeld = resolve
747+ } )
748+ let release = ( ) => { }
749+ const released = new Promise < void > ( ( resolve ) => {
750+ release = resolve
751+ } )
752+ const concurrentReader = db . transaction ( async ( tx ) => {
753+ await lockProjectShared ( tx , f . projectId )
754+ markHeld ( )
755+ await released
756+ } )
757+ await held
758+ try {
759+ const started = performance . now ( )
760+ const rendered = await readProjectFileArtifact . execute ( {
761+ principal : f . principal ,
762+ input : { projectId : f . projectId , fileId : source . file . id , maxBytes : 1_000_000 } ,
763+ } )
764+ expect ( rendered . buffer ) . toEqual ( artifact )
765+ expect ( performance . now ( ) - started ) . toBeLessThan (
766+ FILE_SEARCH_ARTIFACT_READY_LOCK_TIMEOUT_MS + 2_000
767+ )
768+ } finally {
769+ release ( )
770+ await concurrentReader
771+ }
772+ const revisions = await db
773+ . select ( )
774+ . from ( workspaceFileSearchRevision )
775+ . where ( eq ( workspaceFileSearchRevision . fileId , source . file . id ) )
776+ expect ( revisions ) . toEqual ( [ ] )
777+ }
778+ )
779+
780+ check ( "a Project page embedding another owner's image is a missing input" , async ( ) => {
781+ const f = await fixture ( )
782+ for ( const owner of [ `workspace=${ f . workspaceId } ` , `project=${ generateId ( ) } ` ] ) {
783+ const page = await createProjectFile . execute ( {
784+ principal : f . principal ,
785+ input : {
786+ ...createInput (
787+ f . projectId ,
788+ `---\ntitle: Design\n---\n\n } ?${ owner } )`
789+ ) ,
790+ name : `${ generateId ( ) } .html` ,
791+ contentType : 'text/x-sim-page' ,
792+ } ,
793+ } )
794+ await expect (
795+ readProjectFileArtifact . execute ( {
796+ principal : f . principal ,
797+ input : { projectId : f . projectId , fileId : page . file . id , maxBytes : 1_000_000 } ,
798+ } )
799+ ) . rejects . toMatchObject ( { code : 'not_found' } )
800+ }
801+ } )
802+
674803 check (
675804 'artifact source cannot import a foreign Project asset through a valid source-file capability' ,
676805 async ( ) => {
@@ -846,6 +975,9 @@ afterAll(async () => {
846975 . where (
847976 sql `${ outboxEvent . payload } ::jsonb ->> 'key' LIKE ${ `project/${ f . projectId } /%` } OR ${ outboxEvent . payload } ::jsonb -> 'owner' ->> 'entityId' = ${ f . projectId } `
848977 )
978+ await db
979+ . delete ( fileSearchDispatchQueue )
980+ . where ( eq ( fileSearchDispatchQueue . entityId , f . projectId ) )
849981 await db . delete ( workspaceFiles ) . where ( eq ( workspaceFiles . projectId , f . projectId ) )
850982 await db . delete ( folder ) . where ( eq ( folder . projectId , f . projectId ) )
851983 await deleteWorkspaceFixture ( db , eq ( workspace . id , f . workspaceId ) )
0 commit comments