@@ -16,6 +16,7 @@ import { Effect } from 'effect';
1616import { noop } from 'foxts/noop' ;
1717import type { AttachmentStore } from '../attachment/attachment-store' ;
1818import type { BlobStore } from '../attachment/blob-store' ;
19+ import { AttachmentIoMutex } from '../attachment/io-mutex' ;
1920import { declaredMimeTypeMatches } from '../attachment/mime-sniff' ;
2021import { OperationError , RequestError } from '../failure' ;
2122import type { FileHostService } from '../preview/file-host-service' ;
@@ -67,6 +68,7 @@ export class ResourceService {
6768 private readonly fileHost : FileHostService ,
6869 private readonly blobs : BlobStore ,
6970 private readonly attachments : AttachmentStore ,
71+ private readonly io : AttachmentIoMutex = new AttachmentIoMutex ( ) ,
7072 ) { }
7173
7274 list ( sessionId : SessionId ) : Effect . Effect < SessionResource [ ] , OperationError > {
@@ -79,7 +81,7 @@ export class ResourceService {
7981 mimeType : string | undefined ,
8082 data : string ,
8183 ) : Effect . Effect < SessionResource , OperationError | RequestError > {
82- const { attachments, blobs, records, transport } = this ;
84+ const { attachments, blobs, io , records, transport } = this ;
8385 return Effect . gen ( { self : this } , function * ( ) {
8486 if ( ! records . has ( sessionId ) ) {
8587 return yield * new RequestError ( { code : 'not_found' , message : 'Session not found' } ) ;
@@ -102,59 +104,69 @@ export class ResourceService {
102104 const sha256 = createHash ( 'sha256' ) . update ( bytes ) . digest ( 'hex' ) ;
103105 const blobId = blobIdFromSha256 ( sha256 ) ;
104106 const now = Date . now ( ) ;
105- // The harness reads the blob's own path: immutable, shared, mode 0444.
106- let resource : SessionResource = {
107- resourceId,
108- sessionId,
109- direction : 'source' ,
110- name,
111- kind : classify ( name , mimeType ) ,
112- status : 'processing' ,
113- locator : { type : 'managed-file' , path : blobs . pathOf ( blobId ) } ,
114- attachmentId,
115- mimeType,
116- sizeBytes : bytes . byteLength ,
117- createdAt : now ,
118- updatedAt : now ,
119- } ;
120- yield * this . run ( 'save' , ( ) => this . store . save ( resource ) ) ;
121- transport . send ( createWireMessage ( { kind : 'resource.changed' , resource } ) ) ;
107+ const kind = classify ( name , mimeType ) ;
108+ const locator = { type : 'managed-file' as const , path : blobs . pathOf ( blobId ) } ;
122109 const written = yield * Effect . tryPromise ( {
123110 async try ( ) {
124- const stage = await blobs . stage ( resourceId ) ;
125- try {
126- await stage . write ( 0 , bytes ) ;
127- await stage . commit ( { sha256, sizeBytes : bytes . byteLength } ) ;
128- } catch ( error ) {
129- await stage . abort ( ) . catch ( noop ) ;
130- throw error ;
131- }
132- await attachments . commitAttachment ( {
133- blob : { blobId, sizeBytes : bytes . byteLength , createdAt : now } ,
134- attachment : {
135- attachmentId,
136- kind : resource . kind ,
137- name,
138- mimeType : mimeType ?? 'application/octet-stream' ,
139- sizeBytes : bytes . byteLength ,
140- metadata : { } ,
141- createdAt : now ,
142- } ,
111+ await io . run ( async ( ) => {
112+ const stage = await blobs . stage ( resourceId ) ;
113+ try {
114+ await stage . write ( 0 , bytes ) ;
115+ await stage . commit ( { sha256, sizeBytes : bytes . byteLength } ) ;
116+ await attachments . commitAttachment ( {
117+ blob : { blobId, sizeBytes : bytes . byteLength , createdAt : now } ,
118+ attachment : {
119+ attachmentId,
120+ kind,
121+ name,
122+ mimeType : mimeType ?? 'application/octet-stream' ,
123+ sizeBytes : bytes . byteLength ,
124+ metadata : { } ,
125+ createdAt : now ,
126+ } ,
127+ } ) ;
128+ } catch ( error ) {
129+ await stage . abort ( ) . catch ( noop ) ;
130+ await blobs . delete ( blobId ) ;
131+ throw error ;
132+ }
143133 } ) ;
144134 } ,
145135 catch : ( cause ) => cause ,
146136 } ) . pipe (
147137 Effect . as ( true ) ,
148138 Effect . catch ( ( ) => Effect . succeed ( false ) ) ,
149139 ) ;
150- resource = written
151- ? { ...resource , status : 'ready' , updatedAt : Date . now ( ) }
152- : {
153- ...resource ,
154- status : 'failed' ,
155- error : 'Failed to persist uploaded resource' ,
156- updatedAt : Date . now ( ) ,
157- } ;
140+ if ( ! written ) {
141+ return {
142+ resourceId,
143+ sessionId,
144+ direction : 'source' ,
145+ name,
146+ kind,
147+ status : 'failed' ,
148+ locator,
149+ error : 'Failed to persist uploaded resource' ,
150+ mimeType,
151+ sizeBytes : bytes . byteLength ,
152+ createdAt : now ,
153+ updatedAt : Date . now ( ) ,
154+ } ;
155+ }
156+ const resource : SessionResource = {
157+ resourceId,
158+ sessionId,
159+ direction : 'source' ,
160+ name,
161+ kind,
162+ status : 'ready' ,
163+ locator,
164+ attachmentId,
165+ mimeType,
166+ sizeBytes : bytes . byteLength ,
167+ createdAt : now ,
168+ updatedAt : Date . now ( ) ,
169+ } ;
158170 yield * this . run ( 'save' , ( ) => this . store . save ( resource ) ) ;
159171 transport . send ( createWireMessage ( { kind : 'resource.changed' , resource } ) ) ;
160172 return resource ;
0 commit comments