@@ -183,15 +183,7 @@ export function stripReadonlyWhenFields(
183183 let result = data ;
184184 for ( const [ name , def ] of Object . entries ( fields ) ) {
185185 if ( ! def ?. readonlyWhen || ! ( name in data ) ) continue ;
186- const res = ExpressionEngine . evaluate < boolean > ( toExpression ( def . readonlyWhen ) , {
187- record : merged ,
188- previous : previous ?? undefined ,
189- } ) ;
190- if ( ! res . ok ) {
191- logger ?. warn ?.( `readonlyWhen for '${ name } ' failed to evaluate — change allowed through` ) ;
192- continue ;
193- }
194- if ( res . value === true ) {
186+ if ( isReadonlyWhenLocked ( def , merged , previous ?? undefined , name , logger ) ) {
195187 if ( result === data ) result = { ...data } ;
196188 delete ( result as Record < string , unknown > ) [ name ] ;
197189 logger ?. warn ?.( `Field '${ name } ' is read-only (readonlyWhen) — ignoring incoming change` ) ;
@@ -200,6 +192,94 @@ export function stripReadonlyWhenFields(
200192 return result ;
201193}
202194
195+ /**
196+ * Evaluate one field's `readonlyWhen` predicate against a (merged) record.
197+ * TRUE ⇒ the field is locked for that record and the incoming change must be
198+ * dropped. A broken predicate is fail-open (returns `false` — the change is
199+ * allowed through), matching the strip's historical behaviour. Shared by the
200+ * single-id ({@link stripReadonlyWhenFields}) and bulk
201+ * ({@link stripReadonlyWhenFieldsMulti}) strips.
202+ */
203+ function isReadonlyWhenLocked (
204+ def : ConditionalFieldDef ,
205+ merged : Record < string , unknown > ,
206+ previous : Record < string , unknown > | undefined ,
207+ name : string ,
208+ logger ?: EvaluateRulesOptions [ 'logger' ] ,
209+ ) : boolean {
210+ const res = ExpressionEngine . evaluate < boolean > ( toExpression ( def . readonlyWhen ! ) , {
211+ record : merged ,
212+ previous,
213+ } ) ;
214+ if ( ! res . ok ) {
215+ logger ?. warn ?.( `readonlyWhen for '${ name } ' failed to evaluate — change allowed through` ) ;
216+ return false ;
217+ }
218+ return res . value === true ;
219+ }
220+
221+ /**
222+ * True when the UPDATE payload writes at least one field that declares a
223+ * `readonlyWhen` predicate. A cheap gate the engine uses to decide whether the
224+ * bulk update path must fetch its matched rows for
225+ * {@link stripReadonlyWhenFieldsMulti} — no `readonlyWhen` field in the payload
226+ * ⇒ no fetch, no cost.
227+ */
228+ export function hasReadonlyWhenInPayload (
229+ objectSchema : { fields ?: Record < string , ConditionalFieldDef > } | undefined | null ,
230+ data : Record < string , unknown > | undefined | null ,
231+ ) : boolean {
232+ const fields = objectSchema ?. fields ;
233+ if ( ! fields || ! data ) return false ;
234+ for ( const [ name , def ] of Object . entries ( fields ) ) {
235+ if ( def ?. readonlyWhen && name in data ) return true ;
236+ }
237+ return false ;
238+ }
239+
240+ /**
241+ * Multi-row counterpart of {@link stripReadonlyWhenFields} (#3042). A bulk
242+ * `updateMany` applies ONE payload to every matched row, but `readonlyWhen` is a
243+ * PER-ROW predicate — a single merged prior is not enough. Given the matched
244+ * rows' prior state (the engine reads them with the SAME row-scoped AST the
245+ * write binds — one query), a `readonlyWhen` field is dropped from the batch
246+ * when its predicate is TRUE for AT LEAST ONE matched row: a bulk write cannot
247+ * lock the field for some rows and write it for others, so a field locked in any
248+ * target row is fail-safe-dropped for all (narrow the `where` to reach the rows
249+ * where it is unlocked). A field NO matched row locks is written normally — a
250+ * legitimate bulk edit of an unlocked conditional field is unaffected. A broken
251+ * predicate is fail-open for that row. INSERT is exempt (update path only),
252+ * symmetric with the single-id strip.
253+ *
254+ * Returns the same object when nothing is stripped, else a shallow copy with the
255+ * locked keys removed.
256+ */
257+ export function stripReadonlyWhenFieldsMulti (
258+ objectSchema : { fields ?: Record < string , ConditionalFieldDef > } | undefined | null ,
259+ data : Record < string , unknown > | undefined | null ,
260+ priorRows : ReadonlyArray < Record < string , unknown > > | undefined | null ,
261+ logger ?: EvaluateRulesOptions [ 'logger' ] ,
262+ ) : Record < string , unknown > | undefined | null {
263+ const fields = objectSchema ?. fields ;
264+ if ( ! fields || ! data ) return data ;
265+ const rows = priorRows ?? [ ] ;
266+ let result = data ;
267+ for ( const [ name , def ] of Object . entries ( fields ) ) {
268+ if ( ! def ?. readonlyWhen || ! ( name in data ) ) continue ;
269+ const lockedInSomeRow = rows . some ( ( row ) =>
270+ isReadonlyWhenLocked ( def , { ...( row ?? { } ) , ...data } , row ?? undefined , name , logger ) ,
271+ ) ;
272+ if ( lockedInSomeRow ) {
273+ if ( result === data ) result = { ...data } ;
274+ delete ( result as Record < string , unknown > ) [ name ] ;
275+ logger ?. warn ?.(
276+ `Field '${ name } ' is read-only (readonlyWhen) in ≥1 matched row — ignoring incoming change on bulk update` ,
277+ ) ;
278+ }
279+ }
280+ return result ;
281+ }
282+
203283/**
204284 * Strip CALLER-SUPPLIED writes to statically `readonly: true` fields from an
205285 * UPDATE payload (#2948). Unlike `readonlyWhen` (conditional, handled above), a
0 commit comments