11// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22
3+ import { randomUUID } from 'node:crypto' ;
34import { coerceRow , firstMissingRequiredField , type RefResolver , type RefMatch } from './import-coerce.js' ;
45import type { ExportFieldMeta } from './export-format.js' ;
56import { bulkWrite , withTransientRetry , defaultIsTransientError , type BulkWriteRowResult } from '@objectstack/core' ;
@@ -76,6 +77,14 @@ export interface ImportProtocolLike {
7677 * record per input row, in the same order.
7778 */
7879 createManyData ?( args : { object : string ; records : any [ ] ; context ?: any ; environmentId ?: string } ) : Promise < { records : any [ ] } > ;
80+ /**
81+ * Optional partial-success bulk create (framework#3172). When present it is
82+ * preferred over `createManyData`: one outcome per input row, in order — a
83+ * row that fails validation is a per-row verdict, so a bad row never forces
84+ * the whole-batch degradation that re-runs beforeInsert hooks on the good
85+ * rows.
86+ */
87+ insertManyData ?( args : { object : string ; records : any [ ] ; context ?: any ; environmentId ?: string } ) : Promise < { outcomes : Array < { ok : boolean ; record ?: any ; error ?: unknown } > } > ;
7988}
8089
8190export interface RunImportOptions {
@@ -272,12 +281,19 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
272281 // without `createManyData` never buffers — `canBulkCreate` is false and
273282 // creates fall back to the original inline per-row `createData` call.
274283 const canBulkCreate = typeof p . createManyData === 'function' ;
284+ // Partial-success flush (framework#3172): preferred when the protocol
285+ // offers it — a row that fails validation is a per-row verdict from one
286+ // batch call, so a bad row never forces the whole-batch degradation that
287+ // re-runs beforeInsert hooks on its siblings.
288+ const canPartialCreate = typeof p . insertManyData === 'function' ;
275289 const pendingCreates : Array < { index : number ; rowNo : number ; data : Record < string , any > } > = [ ] ;
276290 // bulkWrite is at-least-once: a retry (or a mismatch-driven degradation) may
277- // re-run a create whose prior attempt already committed. When the import has
278- // natural keys (matchFields), recheck before re-creating so a retry can't
279- // duplicate a row (framework#3149). A pure-insert import (no matchFields) has
280- // no natural key to recheck against and stays at-least-once by contract.
291+ // re-run a create whose prior attempt already committed. Every buffered
292+ // CREATE row is therefore pre-assigned a client-generated id at flush time
293+ // (framework#3173) — stable across attempts — so a retry can recheck by id
294+ // ($in) and re-insert only the rows that truly did not land. This is exact
295+ // for EVERY write mode (including pure insert with legitimate duplicate
296+ // rows, where a natural-key recheck could not distinguish copies).
281297 let lastBatchUncertain = false ;
282298 // Set when a flush's write succeeded but its post-write roll-up summary
283299 // recompute exhausted retries (framework#3147). The rows ARE written; we mark
@@ -296,15 +312,42 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
296312 }
297313 return null ;
298314 } ;
299- const existingByMatch = async ( data : Record < string , any > ) : Promise < Record < string , any > | null > => {
300- if ( matchFields . length === 0 ) return null ;
301- const found = await findExisting ( data ) ;
302- return found && typeof found === 'object' ? found : null ; // ignore 'blank'/'none'/'ambiguous'
315+ // Exact idempotency recheck (framework#3173): buffered CREATE rows carry a
316+ // pre-assigned id, so "did the lost-response attempt actually commit?" is
317+ // answered precisely by an id $in query — no natural key, no clocks, and
318+ // legitimate duplicate rows (pure insert mode) resolve correctly because
319+ // each copy has its own id.
320+ const recheckByIds = async ( chunk : Array < Record < string , any > > ) : Promise < Map < string , any > > => {
321+ const ids = chunk . map ( ( r ) => r . id ) . filter ( ( v ) => v != null && v !== '' ) ;
322+ if ( ids . length === 0 ) return new Map ( ) ;
323+ const r = await p . findData ( {
324+ ...findArgsBase ( { $filter : { id : { $in : ids } } , $top : ids . length } ) ,
325+ object : objectName ,
326+ } ) ;
327+ return new Map ( findRows ( r ) . map ( ( rec : any ) => [ String ( rec . id ) , rec ] ) ) ;
303328 } ;
304329 const flushPendingCreates = async ( ) : Promise < void > => {
305330 if ( pendingCreates . length === 0 ) return ;
306331 flushSummaryStale = false ;
307332 const batch = pendingCreates . splice ( 0 , pendingCreates . length ) ;
333+ // Pre-assign ids once per row (framework#3173) — the closures below see
334+ // the same row objects on every retry attempt, so the ids are stable. An
335+ // id the user supplied explicitly is respected.
336+ for ( const b of batch ) {
337+ if ( b . data . id == null || b . data . id === '' ) b . data . id = randomUUID ( ) ;
338+ }
339+ // Recheck helper shared by both write paths: on attempt > 1 split the
340+ // chunk into rows that already landed (by id) and rows still to create.
341+ const splitByExisting = async ( chunk : Array < Record < string , any > > ) => {
342+ const existingByIdx = new Map < number , Record < string , any > > ( ) ;
343+ const toCreate : Array < Record < string , any > > = [ ] ;
344+ const found = await recheckByIds ( chunk ) ;
345+ chunk . forEach ( ( row , i ) => {
346+ const hit = row . id != null ? found . get ( String ( row . id ) ) : undefined ;
347+ if ( hit ) existingByIdx . set ( i , hit ) ; else toCreate . push ( row ) ;
348+ } ) ;
349+ return { existingByIdx, toCreate } ;
350+ } ;
308351 const writeResults : BulkWriteRowResult [ ] = await bulkWrite (
309352 batch . map ( b => b . data ) ,
310353 {
@@ -313,17 +356,57 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
313356 // the issue's suggested 100-500 rows/batch must not translate into
314357 // one oversized multi-row INSERT statement.
315358 batchSize : Math . min ( progressEvery , MAX_CREATE_BATCH_SIZE ) ,
359+ // Partial-success path (framework#3172): per-row verdicts from one
360+ // call; a bad row never degrades the batch.
361+ ...( canPartialCreate ? {
362+ writeBatchPartial : async ( chunk : Array < Record < string , any > > , { attempt } : { attempt : number } ) => {
363+ let toCreate = chunk ;
364+ let existingByIdx = new Map < number , Record < string , any > > ( ) ;
365+ if ( attempt > 1 ) {
366+ ( { existingByIdx, toCreate } = await splitByExisting ( chunk ) ) ;
367+ }
368+ try {
369+ let freshOutcomes : Array < { ok : boolean ; record ?: any ; error ?: unknown } > ;
370+ if ( toCreate . length === 0 ) {
371+ freshOutcomes = [ ] ;
372+ } else {
373+ try {
374+ freshOutcomes = ( await p . insertManyData ! ( {
375+ object : objectName , records : toCreate , context : writeCtx ,
376+ ...( environmentId ? { environmentId } : { } ) ,
377+ } ) ) . outcomes ;
378+ } catch ( e ) {
379+ // Rows written but summary recompute failed: recover the
380+ // outcome array carried on the error (framework#3147).
381+ const recovered = recoverSummaryStale ( e ) ;
382+ if ( ! recovered ) throw e ;
383+ freshOutcomes = recovered as Array < { ok : boolean ; record ?: any ; error ?: unknown } > ;
384+ }
385+ }
386+ if ( ! Array . isArray ( freshOutcomes ) || freshOutcomes . length !== toCreate . length ) {
387+ throw Object . assign (
388+ new Error ( `insertManyData returned ${ Array . isArray ( freshOutcomes ) ? `${ freshOutcomes . length } outcome(s)` : String ( typeof freshOutcomes ) } for ${ toCreate . length } row(s)` ) ,
389+ { code : 'ERR_BULK_RESULT_MISMATCH' } ,
390+ ) ;
391+ }
392+ lastBatchUncertain = false ;
393+ let k = 0 ;
394+ return chunk . map ( ( _row , i ) => existingByIdx . has ( i )
395+ ? { ok : true , record : existingByIdx . get ( i ) ! }
396+ : freshOutcomes [ k ++ ] ) ;
397+ } catch ( e ) {
398+ lastBatchUncertain = isUncertainOutcome ( e ) ;
399+ throw e ;
400+ }
401+ } ,
402+ } : { } ) ,
316403 writeBatch : async ( chunk , { attempt } ) => {
317404 let toCreate = chunk ;
318- const existingByIdx = new Map < number , Record < string , any > > ( ) ;
319- if ( attempt > 1 && matchFields . length > 0 ) {
405+ let existingByIdx = new Map < number , Record < string , any > > ( ) ;
406+ if ( attempt > 1 ) {
320407 // A prior attempt may have committed before its response was lost:
321- // recheck each row by matchFields and only create the ones missing.
322- toCreate = [ ] ;
323- for ( let i = 0 ; i < chunk . length ; i ++ ) {
324- const hit = await existingByMatch ( chunk [ i ] ) ;
325- if ( hit ) existingByIdx . set ( i , hit ) ; else toCreate . push ( chunk [ i ] ) ;
326- }
408+ // recheck by pre-assigned id and only create the missing rows.
409+ ( { existingByIdx, toCreate } = await splitByExisting ( chunk ) ) ;
327410 }
328411 try {
329412 let createdRecords : any [ ] ;
@@ -362,8 +445,9 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
362445 }
363446 } ,
364447 writeOne : async ( row , { attempt } ) => {
365- if ( ( attempt > 1 || lastBatchUncertain ) && matchFields . length > 0 ) {
366- const hit = await existingByMatch ( row ) ;
448+ if ( attempt > 1 || lastBatchUncertain ) {
449+ const found = await recheckByIds ( [ row ] ) ;
450+ const hit = row . id != null ? found . get ( String ( row . id ) ) : undefined ;
367451 if ( hit ) return hit ; // already committed by a prior attempt
368452 }
369453 try {
0 commit comments