2626 * can reassemble output in input order even though rows are processed in
2727 * batches (and a batch's flush may be interleaved with other, immediate,
2828 * per-row work such as updates).
29+ *
30+ * Delivery semantics: **at-least-once**. Transient retry and per-row
31+ * degradation both RE-RUN a write whose outcome was unknown — e.g. a turso
32+ * `fetch failed` that arrived *after* the row was already committed
33+ * (framework#3149), or a result-count mismatch that voids the batch
34+ * (framework#3151). A caller that needs exactly-once must make its
35+ * `writeBatch`/`writeOne` idempotent; both receive an `attempt` counter for
36+ * exactly this — see the natural-key recheck the seed loader and import
37+ * runner perform on `attempt > 1`. `writeBatch` MUST also resolve exactly one
38+ * record per input row, in input order: a short / long / non-array return is
39+ * rejected as a failed batch (framework#3151), never silently backfilled.
2940 */
3041
3142export interface BulkWriteRowResult < TRecord = any > {
@@ -56,10 +67,18 @@ export interface BulkWriteOptions<TRow, TRecord = any> extends RetryOptions {
5667 * `batch[i]` positionally (this is how every `bulkCreate` implementation in
5768 * this repo already behaves: sql's single `INSERT ... VALUES (...), (...)
5869 * RETURNING *`, memory's `Promise.all`, mongodb's ordered `insertMany`).
70+ *
71+ * `ctx.attempt` is the 1-based attempt number. `attempt > 1` means a prior
72+ * attempt's outcome is UNKNOWN (a transient blip that may have landed after
73+ * commit) — an exactly-once caller should recheck by natural key and skip
74+ * rows already present before re-writing (framework#3149).
75+ */
76+ writeBatch : ( batch : TRow [ ] , ctx : { attempt : number } ) => Promise < TRecord [ ] > ;
77+ /**
78+ * Write a single row — used only to degrade a failed batch. `ctx.attempt`
79+ * carries the same recheck signal as {@link writeBatch}.
5980 */
60- writeBatch : ( batch : TRow [ ] ) => Promise < TRecord [ ] > ;
61- /** Write a single row — used only to degrade a failed batch. */
62- writeOne : ( row : TRow ) => Promise < TRecord > ;
81+ writeOne : ( row : TRow , ctx : { attempt : number } ) => Promise < TRecord > ;
6382}
6483
6584const DEFAULT_BATCH_SIZE = 200 ;
@@ -86,11 +105,33 @@ const TRANSIENT_PATTERNS: RegExp[] = [
86105
87106const TRANSIENT_CODES = / ^ ( E C O N N R E S E T | E C O N N R E F U S E D | E C O N N A B O R T E D | E P I P E | E A I _ A G A I N | E T I M E D O U T | E H O S T U N R E A C H | E N E T U N R E A C H | E N O T F O U N D ) $ / i;
88107
108+ /**
109+ * Validation / constraint / schema signatures that are DEFINITIVELY logical,
110+ * never worth retrying. Checked before {@link TRANSIENT_PATTERNS} so a message
111+ * that happens to mention both (e.g. `CHECK constraint failed: network_zone`,
112+ * `column network_id is not allowed`) is classified as logical rather than
113+ * burning retries on a row that will fail identically every time (framework
114+ * #3150).
115+ */
116+ const NON_TRANSIENT_PATTERNS : RegExp [ ] = [
117+ / v a l i d a t i o n / i,
118+ / c o n s t r a i n t / i,
119+ / \b r e q u i r e d \b / i,
120+ / \b u n i q u e \b / i,
121+ / d u p l i c a t e / i,
122+ / n o t [ \s _ - ] * n u l l / i,
123+ / i n v a l i d / i,
124+ / n o t a l l o w e d / i,
125+ / o u t o f r a n g e / i,
126+ ] ;
127+
89128export function defaultIsTransientError ( err : unknown ) : boolean {
90- const code = ( err as { code ?: unknown } | null ) ?. code ;
91- if ( typeof code === 'string' && TRANSIENT_CODES . test ( code ) ) return true ;
92129 const message = ( err as { message ?: unknown } | null ) ?. message ;
93130 const text = typeof message === 'string' ? message : String ( err ?? '' ) ;
131+ // A definitive logical signature wins even if a transient word also appears.
132+ if ( NON_TRANSIENT_PATTERNS . some ( ( re ) => re . test ( text ) ) ) return false ;
133+ const code = ( err as { code ?: unknown } | null ) ?. code ;
134+ if ( typeof code === 'string' && TRANSIENT_CODES . test ( code ) ) return true ;
94135 return TRANSIENT_PATTERNS . some ( ( re ) => re . test ( text ) ) ;
95136}
96137
@@ -103,11 +144,11 @@ interface ResolvedRetryOptions {
103144 sleep : ( ms : number ) => Promise < void > ;
104145}
105146
106- async function withRetry < T > ( fn : ( ) => Promise < T > , opts : ResolvedRetryOptions ) : Promise < T > {
147+ async function withRetry < T > ( fn : ( attempt : number ) => Promise < T > , opts : ResolvedRetryOptions ) : Promise < T > {
107148 let lastError : unknown ;
108149 for ( let attempt = 1 ; attempt <= opts . maxRetries ; attempt ++ ) {
109150 try {
110- return await fn ( ) ;
151+ return await fn ( attempt ) ;
111152 } catch ( err ) {
112153 lastError = err ;
113154 if ( attempt >= opts . maxRetries || ! opts . isTransientError ( err ) ) throw err ;
@@ -126,7 +167,7 @@ async function withRetry<T>(fn: () => Promise<T>, opts: ResolvedRetryOptions): P
126167 * transient-error backoff {@link bulkWrite} applies to batches — so a
127168 * network blip doesn't drop an update the way it used to drop an insert.
128169 */
129- export async function withTransientRetry < T > ( fn : ( ) => Promise < T > , opts : RetryOptions = { } ) : Promise < T > {
170+ export async function withTransientRetry < T > ( fn : ( attempt : number ) => Promise < T > , opts : RetryOptions = { } ) : Promise < T > {
130171 return withRetry ( fn , {
131172 maxRetries : Math . max ( 1 , opts . maxRetries ?? DEFAULT_MAX_RETRIES ) ,
132173 backoffBaseMs : opts . backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS ,
@@ -161,7 +202,26 @@ export async function bulkWrite<TRow, TRecord = any>(
161202 for ( let start = 0 ; start < rows . length ; start += batchSize ) {
162203 const batch = rows . slice ( start , start + batchSize ) ;
163204 try {
164- const records = await withRetry ( ( ) => opts . writeBatch ( batch ) , retryOpts ) ;
205+ const records = await withRetry ( ( attempt ) => opts . writeBatch ( batch , { attempt } ) , retryOpts ) ;
206+ // Contract guard (framework#3151): `writeBatch` must resolve one record
207+ // per input row. A short / long / non-array return breaks the positional
208+ // correlation below, so backfilling it would report phantom successes
209+ // (`record: undefined`) or drop records. Treat the whole batch as failed
210+ // and fall through to per-row degradation (each row re-attempted via
211+ // `writeOne`, which under an idempotent caller rechecks before writing).
212+ // The message deliberately avoids any transient signature so this never
213+ // reads as a retryable blip — and it is thrown *outside* `withRetry`, so
214+ // the batch is not retried on it.
215+ if ( ! Array . isArray ( records ) || records . length !== batch . length ) {
216+ throw Object . assign (
217+ new Error (
218+ `bulkWrite: writeBatch returned ${
219+ Array . isArray ( records ) ? `${ records . length } record(s)` : String ( typeof records )
220+ } for a ${ batch . length } -row batch — treating batch as failed`,
221+ ) ,
222+ { code : 'ERR_BULK_RESULT_MISMATCH' } ,
223+ ) ;
224+ }
165225 for ( let i = 0 ; i < batch . length ; i ++ ) {
166226 results [ start + i ] = { index : start + i , ok : true , record : records [ i ] } ;
167227 }
@@ -181,7 +241,7 @@ export async function bulkWrite<TRow, TRecord = any>(
181241 for ( let i = 0 ; i < batch . length ; i ++ ) {
182242 const idx = start + i ;
183243 try {
184- const record = await withRetry ( ( ) => opts . writeOne ( batch [ i ] ) , retryOpts ) ;
244+ const record = await withRetry ( ( attempt ) => opts . writeOne ( batch [ i ] , { attempt } ) , retryOpts ) ;
185245 results [ idx ] = { index : idx , ok : true , record } ;
186246 } catch ( err ) {
187247 results [ idx ] = { index : idx , ok : false , error : err } ;
0 commit comments