@@ -1002,6 +1002,112 @@ describe('ObjectQL Engine', () => {
10021002 } ) ;
10031003 } ) ;
10041004
1005+ /**
1006+ * #3407 — the readonly/readonlyWhen strips are legal semantics, but they
1007+ * must not be SILENT to the caller: `options.onFieldsDropped` reports each
1008+ * strip pass's dropped keys + reason so callers (flow `update_record`
1009+ * steps) can surface a warning on an otherwise-successful write.
1010+ */
1011+ describe ( 'Dropped-field write observability (#3407)' , ( ) => {
1012+ beforeEach ( async ( ) => {
1013+ engine . registerDriver ( mockDriver , true ) ;
1014+ await engine . init ( ) ;
1015+ ( mockDriver as any ) . updateMany = vi . fn ( ) . mockResolvedValue ( 1 ) ;
1016+ } ) ;
1017+
1018+ const docSchema = {
1019+ name : 'doc' ,
1020+ fields : {
1021+ title : { type : 'text' } ,
1022+ created_by : { type : 'text' , readonly : true } ,
1023+ } ,
1024+ } as any ;
1025+
1026+ it ( 'reports caller-supplied static-readonly fields stripped on a single-id update' , async ( ) => {
1027+ vi . mocked ( SchemaRegistry . getObject ) . mockReturnValue ( docSchema ) ;
1028+ const events : any [ ] = [ ] ;
1029+ await engine . update ( 'doc' , { id : '1' , title : 'x' , created_by : 'attacker' } , {
1030+ onFieldsDropped : ( e : any ) => events . push ( e ) ,
1031+ } as any ) ;
1032+
1033+ expect ( events ) . toEqual ( [ { object : 'doc' , fields : [ 'created_by' ] , reason : 'readonly' } ] ) ;
1034+ // The strip behavior itself is unchanged: the write proceeded without the field.
1035+ const [ , , data ] = vi . mocked ( mockDriver . update ) . mock . calls [ 0 ] ;
1036+ expect ( data ) . not . toHaveProperty ( 'created_by' ) ;
1037+ expect ( data ) . toHaveProperty ( 'title' , 'x' ) ;
1038+ } ) ;
1039+
1040+ it ( 'reports a readonlyWhen-locked field on a single-id update (reason readonly_when)' , async ( ) => {
1041+ vi . mocked ( SchemaRegistry . getObject ) . mockReturnValue ( {
1042+ name : 'invoice' ,
1043+ fields : { amount : { type : 'number' , readonlyWhen : 'record.locked == true' } } ,
1044+ } as any ) ;
1045+ vi . mocked ( mockDriver . findOne ) . mockResolvedValue ( { id : '1' , locked : true , amount : 100 } as any ) ;
1046+
1047+ const events : any [ ] = [ ] ;
1048+ await engine . update ( 'invoice' , { id : '1' , amount : 999 } , {
1049+ onFieldsDropped : ( e : any ) => events . push ( e ) ,
1050+ } as any ) ;
1051+
1052+ expect ( events ) . toEqual ( [ { object : 'invoice' , fields : [ 'amount' ] , reason : 'readonly_when' } ] ) ;
1053+ const [ , , data ] = vi . mocked ( mockDriver . update ) . mock . calls [ 0 ] ;
1054+ expect ( data ) . not . toHaveProperty ( 'amount' ) ;
1055+ } ) ;
1056+
1057+ it ( 'reports bulk-path strips too (multi update — locked in ≥1 matched row)' , async ( ) => {
1058+ vi . mocked ( SchemaRegistry . getObject ) . mockReturnValue ( {
1059+ name : 'invoice' ,
1060+ fields : { amount : { type : 'number' , readonlyWhen : 'record.locked == true' } } ,
1061+ } as any ) ;
1062+ vi . mocked ( mockDriver . find ) . mockResolvedValue ( [
1063+ { id : 'a' , locked : false , amount : 10 } ,
1064+ { id : 'b' , locked : true , amount : 20 } ,
1065+ ] as any ) ;
1066+
1067+ const events : any [ ] = [ ] ;
1068+ await engine . update ( 'invoice' , { amount : 999 } , {
1069+ where : { status : 'draft' } , multi : true ,
1070+ onFieldsDropped : ( e : any ) => events . push ( e ) ,
1071+ } as any ) ;
1072+
1073+ expect ( events ) . toEqual ( [ { object : 'invoice' , fields : [ 'amount' ] , reason : 'readonly_when' } ] ) ;
1074+ const [ , , data ] = ( mockDriver as any ) . updateMany . mock . calls [ 0 ] ;
1075+ expect ( data ) . not . toHaveProperty ( 'amount' ) ;
1076+ } ) ;
1077+
1078+ it ( 'does NOT report for a system-context update (the readonly strip is skipped)' , async ( ) => {
1079+ vi . mocked ( SchemaRegistry . getObject ) . mockReturnValue ( docSchema ) ;
1080+ const events : any [ ] = [ ] ;
1081+ await engine . update ( 'doc' , { id : '1' , created_by : 'importer' } , {
1082+ context : { isSystem : true } ,
1083+ onFieldsDropped : ( e : any ) => events . push ( e ) ,
1084+ } as any ) ;
1085+ expect ( events ) . toEqual ( [ ] ) ;
1086+ // System writes legitimately set read-only columns — kept, not stripped.
1087+ const [ , , data ] = vi . mocked ( mockDriver . update ) . mock . calls [ 0 ] ;
1088+ expect ( data ) . toHaveProperty ( 'created_by' , 'importer' ) ;
1089+ } ) ;
1090+
1091+ it ( 'does NOT report when nothing was stripped' , async ( ) => {
1092+ vi . mocked ( SchemaRegistry . getObject ) . mockReturnValue ( docSchema ) ;
1093+ const events : any [ ] = [ ] ;
1094+ await engine . update ( 'doc' , { id : '1' , title : 'clean' } , {
1095+ onFieldsDropped : ( e : any ) => events . push ( e ) ,
1096+ } as any ) ;
1097+ expect ( events ) . toEqual ( [ ] ) ;
1098+ } ) ;
1099+
1100+ it ( 'a throwing listener never breaks the write' , async ( ) => {
1101+ vi . mocked ( SchemaRegistry . getObject ) . mockReturnValue ( docSchema ) ;
1102+ await expect (
1103+ engine . update ( 'doc' , { id : '1' , created_by : 'x' } , {
1104+ onFieldsDropped : ( ) => { throw new Error ( 'listener bug' ) ; } ,
1105+ } as any ) ,
1106+ ) . resolves . not . toThrow ( ) ;
1107+ expect ( vi . mocked ( mockDriver . update ) ) . toHaveBeenCalledTimes ( 1 ) ;
1108+ } ) ;
1109+ } ) ;
1110+
10051111 describe ( 'Expand Related Records' , ( ) => {
10061112 beforeEach ( async ( ) => {
10071113 engine . registerDriver ( mockDriver , true ) ;
0 commit comments