@@ -271,25 +271,23 @@ export class SeedLoaderService implements ISeedLoaderService {
271271 let lastBatchUncertain = false ;
272272 const isUncertainOutcome = ( e : unknown ) =>
273273 defaultIsTransientError ( e ) || ( e as { code ?: unknown } | null ) ?. code === 'ERR_BULK_RESULT_MISMATCH' ;
274- // Reassemble one record per input row in order: rows already present are
275- // represented by the existing record; the rest are consumed in order from
276- // the freshly-inserted list.
277- const assembleInOrder = ( rows : Record < string , unknown > [ ] , existing : Map < string , any > , freshlyInserted : any [ ] ) : any [ ] => {
278- let k = 0 ;
279- return rows . map ( ( r ) => {
280- const key = extIdOf ( r ) ;
281- if ( key && existing . has ( key ) ) return existing . get ( key ) ;
282- return freshlyInserted [ k ++ ] ;
283- } ) ;
284- } ;
274+ // Partial-success entry (framework#3172): when the engine offers
275+ // insertMany, a row that fails validation is a final per-row verdict from
276+ // ONE batch call — bulkWrite never degrades, so beforeInsert hooks run
277+ // exactly once per row. Feature-detected so a legacy engine (tests, other
278+ // IDataEngine impls) falls back to the whole-array insert + degradation.
279+ const engineInsertMany : ( ( o : string , rows : any [ ] , op : any ) => Promise < Array < { ok : boolean ; record ?: any ; error ?: unknown } > > ) | undefined =
280+ typeof ( this . engine as any ) . insertMany === 'function'
281+ ? ( o , rows , op ) => ( this . engine as any ) . insertMany ( o , rows , op )
282+ : undefined ;
285283 const flushPendingInserts = async ( ) : Promise < void > => {
286284 if ( pendingInserts . length === 0 ) return ;
287285 const batch = pendingInserts . splice ( 0 , pendingInserts . length ) ;
288286 const writeResults : BulkWriteRowResult [ ] = await bulkWrite (
289287 batch . map ( b => b . record ) ,
290288 {
291289 batchSize : SeedLoaderService . BULK_BATCH_SIZE ,
292- writeBatch : async ( rows , { attempt } ) => {
290+ writeBatchPartial : async ( rows , { attempt } ) => {
293291 let toInsert = rows ;
294292 let existing = new Map < string , any > ( ) ;
295293 if ( attempt > 1 ) {
@@ -299,21 +297,42 @@ export class SeedLoaderService implements ISeedLoaderService {
299297 toInsert = rows . filter ( ( r ) => { const k = extIdOf ( r ) ; return ! ( k && existing . has ( k ) ) ; } ) ;
300298 }
301299 try {
302- // A lone row keeps the historical bare-record insert() call shape
303- // (no array wrapping) so single-record datasets are byte-for-byte
304- // unchanged; only a real batch (>1) uses the array/bulk form.
305- const freshlyInserted = toInsert . length === 0
306- ? [ ]
307- : toInsert . length === 1
300+ let freshOutcomes : Array < { ok : boolean ; record ?: any ; error ?: unknown } > ;
301+ if ( toInsert . length === 0 ) {
302+ freshOutcomes = [ ] ;
303+ } else if ( engineInsertMany ) {
304+ // Partial-success batch: per-row verdicts, hooks fire once.
305+ // On ERR_SUMMARY_RECOMPUTE, writeRecoveringSummary hands back
306+ // e.written — which for insertMany IS the outcome array.
307+ freshOutcomes = await this . writeRecoveringSummary ( ( ) => engineInsertMany ( objectName , toInsert , opts ) ) ;
308+ } else {
309+ // Legacy whole-array insert: any bad row throws the batch, and
310+ // bulkWrite's per-row degradation (writeOne) sorts it out. A
311+ // lone row keeps the historical bare-record insert() shape.
312+ const recs = toInsert . length === 1
308313 ? [ await this . writeRecoveringSummary ( ( ) => this . engine . insert ( objectName , toInsert [ 0 ] , opts ) ) ]
309314 : await this . writeRecoveringSummary ( ( ) => this . engine . insert ( objectName , toInsert , opts ) ) ;
315+ freshOutcomes = ( recs as any [ ] ) . map ( ( r ) => ( { ok : true , record : r } ) ) ;
316+ }
310317 lastBatchUncertain = false ;
311- return assembleInOrder ( rows , existing , freshlyInserted as any [ ] ) ;
318+ // Reassemble one outcome per input row in order: recheck hits use
319+ // the existing record; the rest consume freshOutcomes in order.
320+ let k = 0 ;
321+ return rows . map ( ( r ) => {
322+ const key = extIdOf ( r ) ;
323+ if ( key && existing . has ( key ) ) return { ok : true , record : existing . get ( key ) } ;
324+ return freshOutcomes [ k ++ ] ;
325+ } ) ;
312326 } catch ( e ) {
313327 lastBatchUncertain = isUncertainOutcome ( e ) ;
314328 throw e ;
315329 }
316330 } ,
331+ writeBatch : async ( ) => {
332+ // Unreachable — writeBatchPartial above takes precedence — but the
333+ // BulkWriteOptions contract requires it.
334+ throw new Error ( 'seed-loader uses writeBatchPartial' ) ;
335+ } ,
317336 writeOne : async ( row , { attempt } ) => {
318337 if ( attempt > 1 || lastBatchUncertain ) {
319338 const key = extIdOf ( row ) ;
0 commit comments