@@ -590,7 +590,16 @@ export class ObjectQL implements IDataEngine {
590590 }
591591
592592 this . logger . debug ( 'Triggering hooks' , { event, count : entries . length } ) ;
593-
593+
594+ // `session.skipAutomations` (set from ExecutionContext.skipAutomations —
595+ // import with "run automations & triggers" unchecked, import undo)
596+ // suppresses hooks bound FROM METADATA (`bindHooksToEngine` stamps
597+ // `entry.meta`). Hooks registered in code by plugins — audit, capability
598+ // gates, sharing projection — have no `meta` and always run: the opt-out
599+ // must never bypass security or audit (#2922).
600+ const skipAutomations =
601+ ( context . session as { skipAutomations ?: boolean } | undefined ) ?. skipAutomations === true ;
602+
594603 for ( const entry of entries ) {
595604 // Per-object matching
596605 if ( entry . object ) {
@@ -599,6 +608,10 @@ export class ObjectQL implements IDataEngine {
599608 continue ; // Skip non-matching hooks
600609 }
601610 }
611+ if ( skipAutomations && entry . meta ) {
612+ this . logger . debug ( 'Skipping metadata-bound hook (skipAutomations)' , { event, hook : entry . hookName } ) ;
613+ continue ;
614+ }
602615 await entry . handler ( context ) ;
603616 }
604617 }
@@ -692,8 +705,13 @@ export class ObjectQL implements IDataEngine {
692705 ...( ( execCtx as any ) . isSystem ? { isSystem : true } : { } ) ,
693706 // Propagate the automation-suppression flag so the record-change trigger
694707 // can skip flow dispatch for seed/bulk writes (ADR: seed loads end-state
695- // data, not user events).
696- ...( ( execCtx as any ) . skipTriggers ? { skipTriggers : true } : { } ) ,
708+ // data, not user events). `skipAutomations` implies `skipTriggers` —
709+ // suppressing metadata hooks while still dispatching flows would leave
710+ // the "run automations & triggers" opt-out half-working (#2922).
711+ ...( ( execCtx as any ) . skipTriggers || ( execCtx as any ) . skipAutomations ? { skipTriggers : true } : { } ) ,
712+ // Propagate the full automation opt-out so `triggerHooks` can skip
713+ // metadata-bound hooks (import with "run automations" unchecked, undo).
714+ ...( ( execCtx as any ) . skipAutomations ? { skipAutomations : true } : { } ) ,
697715 } as HookContext [ 'session' ] ;
698716 }
699717
@@ -2175,76 +2193,88 @@ export class ObjectQL implements IDataEngine {
21752193 // any defaulted field. `applyFieldDefaults` returns a fresh copy and only
21762194 // fills fields left `undefined`, so client-supplied values are untouched.
21772195 const nowSnap = new Date ( ) ;
2178- const defaultedData = Array . isArray ( opCtx . data )
2196+ const isBatch = Array . isArray ( opCtx . data ) ;
2197+ const defaultedData = isBatch
21792198 ? ( opCtx . data as any [ ] ) . map ( ( row ) =>
21802199 this . applyFieldDefaults ( object , row as Record < string , unknown > , opCtx . context , nowSnap ) ,
21812200 )
21822201 : this . applyFieldDefaults ( object , opCtx . data as Record < string , unknown > , opCtx . context , nowSnap ) ;
21832202
2184- const hookContext : HookContext = {
2203+ // Batch inserts trigger beforeInsert/afterInsert PER ROW, each with the
2204+ // exact single-record context shape (`input.data` = one row, `result` =
2205+ // its returned record). A single array-shaped context broke every
2206+ // consumer built for the single shape — the flat-input proxy read
2207+ // `undefined`s, declarative `condition`s evaluated against an array,
2208+ // audit rows and flow-trigger contexts came out mangled (#2922).
2209+ const rowHookContexts : HookContext [ ] = ( isBatch ? ( defaultedData as any [ ] ) : [ defaultedData ] ) . map (
2210+ ( row ) => ( {
21852211 object,
21862212 event : 'beforeInsert' ,
2187- input : { data : defaultedData , options : opCtx . options } ,
2213+ input : { data : row , options : opCtx . options } ,
21882214 session : this . buildSession ( opCtx . context ) ,
21892215 api : this . buildHookApi ( opCtx . context ) ,
21902216 transaction : opCtx . context ?. transaction ,
2191- ql : this
2192- } ;
2193- await this . triggerHooks ( 'beforeInsert' , hookContext ) ;
2217+ ql : this ,
2218+ } ) ,
2219+ ) ;
2220+ for ( const rowCtx of rowHookContexts ) {
2221+ await this . triggerHooks ( 'beforeInsert' , rowCtx ) ;
2222+ }
21942223 // Thread the open transaction (if any) into the driver-facing
21952224 // options so that knex's `.transacting(trx)` is honoured. Without
21962225 // this, calls inside a `engine.transaction(...)` block would deadlock
21972226 // on SQLite's single-connection pool. Also propagates tenantId so
21982227 // the driver can enforce per-tenant isolation.
2199- hookContext . input . options = this . buildDriverOptions ( opCtx . context , hookContext . input . options as any ) ;
2228+ // Base the merge on the first row context's options: hooks share the
2229+ // same underlying options object (in-place mutations are visible), and
2230+ // for single inserts this is exactly the pre-#2922 behaviour.
2231+ const driverOptions = this . buildDriverOptions ( opCtx . context , rowHookContexts [ 0 ] ?. input . options as any ) ;
2232+ for ( const rowCtx of rowHookContexts ) {
2233+ rowCtx . input . options = driverOptions ;
2234+ }
22002235
22012236 try {
22022237 let result ;
22032238 const schemaForValidation = this . _registry . getObject ( object ) ;
22042239 // When the driver generates autonumbers natively (persistent SQL
22052240 // sequence), the engine defers to it — see #1603.
22062241 const driverOwnsAutonumber = ( driver as any ) ?. supports ?. autonumber === true ;
2207- if ( Array . isArray ( hookContext . input . data ) ) {
2208- // Defaults are already resolved above (pre-hook, #2703); the hook may
2209- // have overridden or added fields — take its data as-is.
2210- const rows = hookContext . input . data as Array < Record < string , unknown > > ;
2211- for ( const r of rows ) {
2212- await this . applyAutonumbers ( object , r as Record < string , unknown > , opCtx . context , driverOwnsAutonumber ) ;
2213- }
2214- for ( const r of rows ) {
2215- await this . encryptSecretFields ( object , r , opCtx . context , hookContext . input . options ) ;
2216- }
2217- for ( const r of rows ) {
2218- normalizeMultiValueFields ( schemaForValidation , r ) ;
2219- validateRecord ( schemaForValidation , r , 'insert' ) ;
2220- evaluateValidationRules ( schemaForValidation as any , r , 'insert' , { logger : this . logger , currentUser : this . buildEvalUser ( opCtx . context ) } ) ;
2221- }
2242+ // Defaults are already resolved above (pre-hook, #2703); a hook may
2243+ // have overridden fields or replaced `input.data` — take its data as-is.
2244+ const rows = rowHookContexts . map ( ( rowCtx ) => rowCtx . input . data as Record < string , unknown > ) ;
2245+ for ( const r of rows ) {
2246+ await this . applyAutonumbers ( object , r , opCtx . context , driverOwnsAutonumber ) ;
2247+ }
2248+ for ( const r of rows ) {
2249+ await this . encryptSecretFields ( object , r , opCtx . context , driverOptions ) ;
2250+ }
2251+ for ( const r of rows ) {
2252+ normalizeMultiValueFields ( schemaForValidation , r ) ;
2253+ validateRecord ( schemaForValidation , r , 'insert' ) ;
2254+ evaluateValidationRules ( schemaForValidation as any , r , 'insert' , { logger : this . logger , currentUser : this . buildEvalUser ( opCtx . context ) } ) ;
2255+ }
2256+ if ( isBatch ) {
22222257 if ( driver . bulkCreate ) {
2223- result = await driver . bulkCreate ( object , rows , hookContext . input . options as any ) ;
2258+ result = await driver . bulkCreate ( object , rows , driverOptions ) ;
22242259 } else {
22252260 // Fallback loop
2226- result = await Promise . all ( rows . map ( ( item ) => driver . create ( object , item , hookContext . input . options as any ) ) ) ;
2261+ result = await Promise . all ( rows . map ( ( item ) => driver . create ( object , item , driverOptions ) ) ) ;
22272262 }
22282263 } else {
2229- // Defaults already resolved pre-hook (#2703); use the hook's data.
2230- const row = hookContext . input . data as Record < string , unknown > ;
2231- await this . applyAutonumbers ( object , row , opCtx . context , driverOwnsAutonumber ) ;
2232- await this . encryptSecretFields ( object , row , opCtx . context , hookContext . input . options ) ;
2233- normalizeMultiValueFields ( schemaForValidation , row ) ;
2234- validateRecord ( schemaForValidation , row , 'insert' ) ;
2235- evaluateValidationRules ( schemaForValidation as any , row , 'insert' , { logger : this . logger , currentUser : this . buildEvalUser ( opCtx . context ) } ) ;
2236- result = await driver . create ( object , row , hookContext . input . options as any ) ;
2264+ result = await driver . create ( object , rows [ 0 ] , driverOptions ) ;
22372265 }
22382266
2239- hookContext . event = 'afterInsert' ;
22402267 // Coerce `boolean` fields (SQLite/libsql return 0/1) to real booleans on
22412268 // the after-hook view so flow trigger conditions (`record.is_escalated
22422269 // != true`) and `{record.<bool>}` interpolation see JS booleans, not
22432270 // ints. A shallow copy — the value returned to the caller is untouched.
2244- hookContext . result = Array . isArray ( result )
2245- ? result . map ( ( r ) => coerceBooleanFields ( schemaForValidation as any , r as any ) )
2246- : coerceBooleanFields ( schemaForValidation as any , result as any ) ;
2247- await this . triggerHooks ( 'afterInsert' , hookContext ) ;
2271+ const resultRows : any [ ] = isBatch ? ( Array . isArray ( result ) ? result : [ result ] ) : [ result ] ;
2272+ for ( let i = 0 ; i < rowHookContexts . length ; i ++ ) {
2273+ const rowCtx = rowHookContexts [ i ] ;
2274+ rowCtx . event = 'afterInsert' ;
2275+ rowCtx . result = coerceBooleanFields ( schemaForValidation as any , resultRows [ i ] as any ) ;
2276+ await this . triggerHooks ( 'afterInsert' , rowCtx ) ;
2277+ }
22482278
22492279 // Roll-up: recompute parent summary fields that aggregate this object.
22502280 await this . recomputeSummaries ( object , result , null , opCtx . context ) ;
@@ -2285,7 +2315,9 @@ export class ObjectQL implements IDataEngine {
22852315 }
22862316 }
22872317
2288- return hookContext . result ;
2318+ // Return the (possibly hook-mutated) after-view: the array of per-row
2319+ // results for batch, the single record otherwise.
2320+ return isBatch ? rowHookContexts . map ( ( rowCtx ) => rowCtx . result ) : rowHookContexts [ 0 ] . result ;
22892321 } catch ( e ) {
22902322 this . logger . error ( 'Insert operation failed' , e as Error , { object } ) ;
22912323 throw e ;
0 commit comments