@@ -5,10 +5,11 @@ import { invalidateResolveSubtree } from "../fs/resolveCache.js";
55import { rm } from "../fs/rm.js" ;
66import { symlink } from "../fs/symlink.js" ;
77import { unlinkDirent } from "../fs/unlink.js" ;
8- import { writeFile , writeFileSync } from "../fs/writeFile.js" ;
8+ import { linkStagedChunksSync } from "../fs/writeFile.js" ;
99import { canonicalizePath } from "../path.js" ;
1010import { incrementRev } from "../rev.js" ;
1111import type { Database } from "../storage.js" ;
12+ import { stageBlob } from "./blobs.js" ;
1213import type { ChangeEntry } from "./changes.js" ;
1314import { computeManifestHash } from "./manifests.js" ;
1415
@@ -289,35 +290,7 @@ export async function applyChanges(
289290 if ( pathsInBatch >= maxPaths ) flush ( ) ;
290291 continue ;
291292 }
292- // file: assemble chunk bytes. First check the in-memory map
293- // (the streaming hand-off); fall back to vfs_blob_bytes (the
294- // staged-via-pushObjects path).
295- const parts : Uint8Array [ ] = [ ] ;
296- let total = 0 ;
297- for ( const c of entry . chunks ) {
298- const k = hex ( c . hash ) ;
299- let bytes = objects . get ( k ) ;
300- if ( bytes === undefined ) {
301- const row = db . one < { bytes : Uint8Array } > (
302- "SELECT bytes FROM vfs_blob_bytes WHERE hash = ?" ,
303- c . hash ,
304- ) ;
305- bytes = row ?. bytes ;
306- }
307- if ( bytes === undefined ) {
308- throw new Error ( `applyChanges: missing object ${ k } for ${ entry . path } ` ) ;
309- }
310- parts . push ( bytes ) ;
311- total += bytes . byteLength ;
312- }
313- const buf = new Uint8Array ( total ) ;
314- let off = 0 ;
315- for ( const p of parts ) {
316- buf . set ( p , off ) ;
317- off += p . byteLength ;
318- }
319- removeReplaceableFinalEntry ( db , entry . path , "file" ) ;
320- await writeFile ( db , entry . path , buf , { mode : entry . mode } , ( ) => entry . mtime ) ;
293+ const total = applyFileEntry ( db , entry , objects ) ;
321294 applied ++ ;
322295 bytesInBatch += total ;
323296 pathsInBatch ++ ;
@@ -408,32 +381,7 @@ export function applyChangesSync(
408381 if ( pathsInBatch >= maxPaths ) flush ( ) ;
409382 continue ;
410383 }
411- const parts : Uint8Array [ ] = [ ] ;
412- let total = 0 ;
413- for ( const c of entry . chunks ) {
414- const k = hex ( c . hash ) ;
415- let bytes = objects . get ( k ) ;
416- if ( bytes === undefined ) {
417- const row = db . one < { bytes : Uint8Array } > (
418- "SELECT bytes FROM vfs_blob_bytes WHERE hash = ?" ,
419- c . hash ,
420- ) ;
421- bytes = row ?. bytes ;
422- }
423- if ( bytes === undefined ) {
424- throw new Error ( `applyChanges: missing object ${ k } for ${ entry . path } ` ) ;
425- }
426- parts . push ( bytes ) ;
427- total += bytes . byteLength ;
428- }
429- const buf = new Uint8Array ( total ) ;
430- let off = 0 ;
431- for ( const p of parts ) {
432- buf . set ( p , off ) ;
433- off += p . byteLength ;
434- }
435- removeReplaceableFinalEntry ( db , entry . path , "file" ) ;
436- writeFileSync ( db , entry . path , buf , { mode : entry . mode } , ( ) => entry . mtime ) ;
384+ const total = applyFileEntry ( db , entry , objects ) ;
437385 applied ++ ;
438386 bytesInBatch += total ;
439387 pathsInBatch ++ ;
@@ -448,6 +396,58 @@ export function applyChangesSync(
448396 return { applied, skipped } ;
449397}
450398
399+ // Link a file entry to staged chunks without loading payload bytes.
400+ // In-memory objects are staged individually before the link.
401+
402+ // Validate declared sizes without loading payloads; chunk hashes remain
403+ // trusted here, matching stageBlob's existing contract.
404+ function applyFileEntry (
405+ db : Database ,
406+ entry : Extract < ChangeEntry , { kind : "file" } > ,
407+ objects : Map < string , Uint8Array > ,
408+ ) : number {
409+ let total = 0 ;
410+ for ( const c of entry . chunks ) {
411+ total += c . size ;
412+ const staged = stagedBlobSize ( db , c . hash ) ;
413+ if ( staged === undefined ) {
414+ const k = hex ( c . hash ) ;
415+ const bytes = objects . get ( k ) ;
416+ if ( bytes === undefined ) {
417+ throw new Error ( `applyChanges: missing object ${ k } for ${ entry . path } ` ) ;
418+ }
419+ assertChunkSize ( bytes . byteLength , c . size , c . hash , entry . path ) ;
420+ stageBlob ( db , c . hash , bytes , entry . mtime ) ;
421+ continue ;
422+ }
423+ assertChunkSize ( staged , c . size , c . hash , entry . path ) ;
424+ }
425+ removeReplaceableFinalEntry ( db , entry . path , "file" ) ;
426+ const { parts, path : canonical } = canonicalizePath ( entry . path ) ;
427+ linkStagedChunksSync ( db , canonical , parts , entry . chunks , { mode : entry . mode } , entry . mtime ) ;
428+ return total ;
429+ }
430+
431+ // Return a staged chunk's size without loading its payload bytes.
432+ // A short byte row is treated as an interrupted write.
433+ function stagedBlobSize ( db : Database , hash : Uint8Array ) : number | undefined {
434+ return db . one < { size : number } > (
435+ `SELECT b.size AS size
436+ FROM vfs_blobs b
437+ JOIN vfs_blob_bytes bb ON bb.hash = b.hash
438+ WHERE b.hash = ?
439+ AND length(bb.bytes) = b.size` ,
440+ hash ,
441+ ) ?. size ;
442+ }
443+
444+ function assertChunkSize ( actual : number , declared : number , hash : Uint8Array , path : string ) : void {
445+ if ( actual === declared ) return ;
446+ throw new Error (
447+ `applyChanges: chunk ${ hex ( hash ) } for ${ path } declares ${ declared } bytes but holds ${ actual } ` ,
448+ ) ;
449+ }
450+
451451// Compare an entry against the local node graph. Returns true when
452452// the entry would be a no-op apply: the manifest hash (files), mode
453453// (dirs), or mode + symlink target (symlinks) already matches.
0 commit comments