@@ -701,6 +701,71 @@ export class ObjectQL implements IDataEngine {
701701 return out ;
702702 }
703703
704+ /**
705+ * Generate values for empty `autonumber` fields on insert. Runs BEFORE
706+ * required-validation so a `required` autonumber (e.g. a record number) is
707+ * never rejected for "missing" — the runtime owns the value, not the client.
708+ *
709+ * The next value is `max(existing) + 1`, seeded once per `object.field` from
710+ * the store then incremented in memory (monotonic within the process,
711+ * resilient to deletions). `autonumberFormat` is honored, e.g.
712+ * `CASE-{0000}` → `CASE-0042`. NOTE: in-memory seeding is single-instance;
713+ * a persistent sequence store is a follow-up for multi-instance setups.
714+ */
715+ private async applyAutonumbers (
716+ object : string ,
717+ record : Record < string , unknown > ,
718+ execCtx ?: ExecutionContext ,
719+ ) : Promise < void > {
720+ const fields = ( this . getSchema ( object ) as any ) ?. fields ;
721+ if ( ! fields || typeof fields !== 'object' || Array . isArray ( fields ) ) return ;
722+ for ( const [ name , def ] of Object . entries ( fields ) ) {
723+ if ( ( def as any ) ?. type !== 'autonumber' ) continue ;
724+ const current = record [ name ] ;
725+ if ( current != null && current !== '' ) continue ; // respect explicit value
726+ const key = `${ object } .${ name } ` ;
727+ let next = this . autonumberCounters . get ( key ) ;
728+ if ( next == null ) next = await this . seedAutonumber ( object , name , execCtx ) ;
729+ next += 1 ;
730+ this . autonumberCounters . set ( key , next ) ;
731+ record [ name ] = this . formatAutonumber ( ( def as any ) . autonumberFormat , next ) ;
732+ }
733+ }
734+
735+ /** Seed the autonumber counter from the current max numeric value in store. */
736+ private async seedAutonumber (
737+ object : string ,
738+ field : string ,
739+ execCtx ?: ExecutionContext ,
740+ ) : Promise < number > {
741+ try {
742+ const rows = await this . find ( object , {
743+ select : [ 'id' , field ] ,
744+ limit : 5000 ,
745+ context : execCtx ,
746+ } as any ) ;
747+ let max = 0 ;
748+ for ( const r of rows || [ ] ) {
749+ const v = r ?. [ field ] ;
750+ if ( v == null ) continue ;
751+ const m = String ( v ) . match ( / ( \d + ) (? ! .* \d ) / ) ; // last run of digits
752+ if ( m ) max = Math . max ( max , parseInt ( m [ 1 ] , 10 ) || 0 ) ;
753+ }
754+ return max ;
755+ } catch {
756+ return 0 ;
757+ }
758+ }
759+
760+ /** Apply an autonumber format like `CASE-{0000}`; default to the bare number. */
761+ private formatAutonumber ( format : string | undefined , value : number ) : string {
762+ if ( ! format ) return String ( value ) ;
763+ const m = format . match ( / \{ ( 0 + ) \} / ) ;
764+ if ( ! m ) return format . includes ( '{0}' ) ? format . replace ( '{0}' , String ( value ) ) : `${ format } ${ value } ` ;
765+ const padded = String ( value ) . padStart ( m [ 1 ] . length , '0' ) ;
766+ return format . replace ( m [ 0 ] , padded ) ;
767+ }
768+
704769 /**
705770 * Register contribution (Manifest)
706771 *
@@ -1431,6 +1496,10 @@ export class ObjectQL implements IDataEngine {
14311496
14321497 /** Maximum depth for recursive expand to prevent infinite loops */
14331498 private static readonly MAX_EXPAND_DEPTH = 3 ;
1499+ private static readonly MAX_CASCADE_DEPTH = 10 ;
1500+ /** In-memory next-value cache per `object.field` for autonumber generation,
1501+ * lazily seeded from the current max in the store. */
1502+ private readonly autonumberCounters = new Map < string , number > ( ) ;
14341503
14351504 /**
14361505 * Post-process expand: resolve lookup/master_detail fields by batch-loading related records.
@@ -1753,6 +1822,9 @@ export class ObjectQL implements IDataEngine {
17531822 const rows = ( hookContext . input . data as any [ ] ) . map ( ( row ) =>
17541823 this . applyFieldDefaults ( object , row as Record < string , unknown > , opCtx . context , nowSnap ) ,
17551824 ) ;
1825+ for ( const r of rows ) {
1826+ await this . applyAutonumbers ( object , r as Record < string , unknown > , opCtx . context ) ;
1827+ }
17561828 for ( const r of rows ) {
17571829 await this . encryptSecretFields ( object , r , opCtx . context , hookContext . input . options ) ;
17581830 }
@@ -1773,6 +1845,7 @@ export class ObjectQL implements IDataEngine {
17731845 opCtx . context ,
17741846 nowSnap ,
17751847 ) ;
1848+ await this . applyAutonumbers ( object , row , opCtx . context ) ;
17761849 await this . encryptSecretFields ( object , row , opCtx . context , hookContext . input . options ) ;
17771850 validateRecord ( schemaForValidation , row , 'insert' ) ;
17781851 evaluateValidationRules ( schemaForValidation as any , row , 'insert' , { logger : this . logger } ) ;
@@ -1946,6 +2019,82 @@ export class ObjectQL implements IDataEngine {
19462019 return opCtx . result ;
19472020 }
19482021
2022+ /**
2023+ * Apply referential delete behavior for relations pointing AT this record,
2024+ * before it is removed. For every registered object with a `master_detail`
2025+ * or `lookup` field referencing `object`, honor the field's `deleteBehavior`:
2026+ * - `cascade` → delete the dependent rows (recursively, so grandchildren
2027+ * are handled by each child's own delete),
2028+ * - `set_null` → clear the foreign key,
2029+ * - `restrict` → refuse the delete when dependents exist.
2030+ * `master_detail` defaults to `cascade` (the parent owns the child
2031+ * lifecycle); `lookup` defaults to `set_null`. Only runs for single-id
2032+ * deletes — multi/predicate deletes skip cascade (logged).
2033+ */
2034+ private async cascadeDeleteRelations (
2035+ object : string ,
2036+ id : string | number ,
2037+ context ?: ExecutionContext ,
2038+ depth = 0 ,
2039+ ) : Promise < void > {
2040+ if ( id == null || depth >= ObjectQL . MAX_CASCADE_DEPTH ) return ;
2041+ let objects : ServiceObject [ ] ;
2042+ try {
2043+ objects = this . _registry . getAllObjects ( ) ;
2044+ } catch {
2045+ return ;
2046+ }
2047+ for ( const child of objects ) {
2048+ const childName = ( child as any ) ?. name as string | undefined ;
2049+ const fields = ( child as any ) ?. fields as Record < string , any > | undefined ;
2050+ if ( ! childName || ! fields ) continue ;
2051+ for ( const [ fieldName , fdef ] of Object . entries ( fields ) ) {
2052+ if ( ! fdef || ( fdef . type !== 'master_detail' && fdef . type !== 'lookup' ) ) continue ;
2053+ const ref = fdef . reference ;
2054+ if ( ! ref ) continue ;
2055+ // Match the target object by raw or resolved name.
2056+ let resolvedRef : string | undefined ;
2057+ try { resolvedRef = this . resolveObjectName ( ref ) ; } catch { resolvedRef = undefined ; }
2058+ if ( ref !== object && resolvedRef !== object ) continue ;
2059+
2060+ // A master-detail parent owns its children: cascade by default (the
2061+ // child FK is typically required, so set_null would be invalid). Only
2062+ // an explicit `restrict` deviates. A plain lookup honors its
2063+ // configured deleteBehavior (default set_null).
2064+ const behavior : string =
2065+ fdef . type === 'master_detail'
2066+ ? ( fdef . deleteBehavior === 'restrict' ? 'restrict' : 'cascade' )
2067+ : ( fdef . deleteBehavior || 'set_null' ) ;
2068+
2069+ let dependents : any [ ] ;
2070+ try {
2071+ dependents = await this . find ( childName , { where : { [ fieldName ] : id } , context } as any ) ;
2072+ } catch {
2073+ continue ;
2074+ }
2075+ if ( ! dependents || dependents . length === 0 ) continue ;
2076+
2077+ if ( behavior === 'restrict' ) {
2078+ throw new Error (
2079+ `Cannot delete ${ object } (${ id } ): ${ dependents . length } dependent ${ childName } record(s) via ${ fieldName } ` ,
2080+ ) ;
2081+ }
2082+
2083+ for ( const dep of dependents ) {
2084+ const depId = dep ?. id ;
2085+ if ( depId == null ) continue ;
2086+ if ( behavior === 'cascade' ) {
2087+ // Recurse via the public delete so the child's own cascade,
2088+ // hooks and events fire.
2089+ await this . delete ( childName , { where : { id : depId } , context } as any ) ;
2090+ } else {
2091+ await this . update ( childName , { id : depId , [ fieldName ] : null } , { context } as any ) ;
2092+ }
2093+ }
2094+ }
2095+ }
2096+ }
2097+
19492098 async delete ( object : string , options ?: EngineDeleteOptions ) : Promise < any > {
19502099 object = this . resolveObjectName ( object ) ;
19512100 this . logger . debug ( 'Delete operation starting' , { object } ) ;
@@ -1981,6 +2130,9 @@ export class ObjectQL implements IDataEngine {
19812130 try {
19822131 let result ;
19832132 if ( hookContext . input . id ) {
2133+ // Honor referential delete behavior (cascade/set_null/restrict)
2134+ // for relations pointing at this record before removing it.
2135+ await this . cascadeDeleteRelations ( object , hookContext . input . id as string | number , opCtx . context ) ;
19842136 result = await driver . delete ( object , hookContext . input . id as string , hookContext . input . options as any ) ;
19852137 } else if ( options ?. multi && driver . deleteMany ) {
19862138 const ast : QueryAST = { object, where : options . where } ;
0 commit comments