@@ -220,13 +220,13 @@ export class SqlDriver implements IDataDriver {
220220 /** Whether the sequences table has been ensured this process. */
221221 protected sequencesTableReady = false ;
222222 /**
223- * Set when a legacy 3-column `_objectstack_sequences` table could not have
224- * its primary key widened to include `scope`. Fixed-prefix sequences (empty
225- * scope) keep working under the old PK; only a per-scope write would collide,
226- * so `getNextSequenceValue` raises an actionable error in that case rather
227- * than letting an opaque PK violation surface at create time .
223+ * Whether `_objectstack_sequences` is the current `key_hash`-keyed shape.
224+ * Set on a fresh create or a successful in-place migration. If a legacy table
225+ * could NOT be migrated, this stays false: fixed-prefix sequences (empty
226+ * scope) keep working via the legacy `(object, tenant_id, field)` key, while a
227+ * per-scope write raises an actionable error rather than corrupting counters .
228228 */
229- protected sequencesScopeKeyUnsafe = false ;
229+ protected sequencesHasKeyHash = false ;
230230 /** In-flight ensure promise; deduplicates concurrent first calls. */
231231 protected sequencesTableEnsurePromise : Promise < void > | null = null ;
232232
@@ -550,10 +550,14 @@ export class SqlDriver implements IDataDriver {
550550 * Ensure the sequence-counter table exists. Idempotent and cheap after
551551 * the first call (cached via `sequencesTableReady`).
552552 *
553- * The key is `(object, tenant_id, field, scope)`. `scope` is the rendered
554- * autonumber prefix (date/field tokens before the `{0000}` slot), so a new
555- * day/group/parent starts a fresh counter. Fixed-prefix formats use the
556- * empty scope and keep their single global counter (backward compatible).
553+ * The row key is `key_hash` — a SHA-256 of `(object, tenant_id, field, scope)`
554+ * where `scope` is the rendered autonumber prefix (date/field tokens before
555+ * the `{0000}` slot), so a new day/group/parent starts a fresh counter. A
556+ * single 64-char hashed primary key (rather than the four raw columns, which
557+ * blow past MySQL's 3072-byte index limit under utf8mb4 and bound how long a
558+ * `{field}` scope may be) keys every dialect uniformly and lets `scope` be a
559+ * generous non-indexed column. Fixed-prefix formats use the empty scope and
560+ * keep their single global counter (backward compatible).
557561 */
558562 protected async ensureSequencesTable ( ) : Promise < void > {
559563 if ( this . sequencesTableReady ) return ;
@@ -565,26 +569,19 @@ export class SqlDriver implements IDataDriver {
565569 const exists = await this . knex . schema . hasTable ( SEQUENCES_TABLE ) ;
566570 if ( ! exists ) {
567571 try {
568- await this . knex . schema . createTable ( SEQUENCES_TABLE , ( t ) => {
569- t . string ( 'object' ) . notNullable ( ) ;
570- t . string ( 'tenant_id' ) . notNullable ( ) ;
571- t . string ( 'field' ) . notNullable ( ) ;
572- t . string ( 'scope' ) . notNullable ( ) . defaultTo ( '' ) ;
573- t . bigInteger ( 'last_value' ) . notNullable ( ) . defaultTo ( 0 ) ;
574- t . timestamp ( 'updated_at' ) . defaultTo ( this . knex . fn . now ( ) ) ;
575- t . primary ( [ 'object' , 'tenant_id' , 'field' , 'scope' ] ) ;
576- } ) ;
572+ await this . createSequencesTable ( SEQUENCES_TABLE ) ;
573+ this . sequencesHasKeyHash = true ;
577574 } catch ( err : any ) {
578575 // Race or cross-process create — re-check existence; ignore
579576 // "already exists" errors from any dialect.
580577 const stillMissing = ! ( await this . knex . schema . hasTable ( SEQUENCES_TABLE ) ) ;
581578 if ( stillMissing ) throw err ;
582- // A racing creator may have used the legacy 3-column schema.
583- await this . ensureSequencesScopeColumn ( ) ;
579+ // A racing creator may have used an older schema. Migrate in place .
580+ await this . ensureSequencesKeyHashShape ( ) ;
584581 }
585582 } else {
586- // Pre-existing table may predate the `scope` column . Migrate in place .
587- await this . ensureSequencesScopeColumn ( ) ;
583+ // Pre-existing table may predate the `key_hash`/` scope` shape . Migrate.
584+ await this . ensureSequencesKeyHashShape ( ) ;
588585 }
589586 this . sequencesTableReady = true ;
590587 } ) ( ) ;
@@ -595,71 +592,76 @@ export class SqlDriver implements IDataDriver {
595592 }
596593 }
597594
595+ /** SHA-256 of the composite counter key — the table's single-column PK. */
596+ protected sequenceKeyHash ( object : string , tenantId : string , field : string , scope : string ) : string {
597+ return createHash ( 'sha256' )
598+ . update ( `${ object } \u001f${ tenantId } \u001f${ field } \u001f${ scope } ` )
599+ . digest ( 'hex' ) ;
600+ }
601+
602+ /** Create the current `key_hash`-keyed sequences table shape. */
603+ protected async createSequencesTable ( table : string ) : Promise < void > {
604+ await this . knex . schema . createTable ( table , ( t ) => {
605+ t . string ( 'key_hash' , 64 ) . notNullable ( ) . primary ( ) ;
606+ t . string ( 'object' ) . notNullable ( ) ;
607+ t . string ( 'tenant_id' ) . notNullable ( ) ;
608+ t . string ( 'field' ) . notNullable ( ) ;
609+ // Non-indexed, so it is free of the PK length limit — a long `{plan_no}`
610+ // composite scope fits. 1024 is far above any realistic rendered prefix.
611+ t . string ( 'scope' , 1024 ) . notNullable ( ) . defaultTo ( '' ) ;
612+ t . bigInteger ( 'last_value' ) . notNullable ( ) . defaultTo ( 0 ) ;
613+ t . timestamp ( 'updated_at' ) . defaultTo ( this . knex . fn . now ( ) ) ;
614+ } ) ;
615+ }
616+
598617 /**
599- * Add the `scope` column (and fold it into the primary key) to a
600- * `_objectstack_sequences` table created before per-period/per-group
601- * counters existed. Every legacy row is a fixed-prefix sequence, so it
602- * migrates to `scope = ''` and keeps counting unchanged. Idempotent.
618+ * Migrate a pre-existing `_objectstack_sequences` table to the current
619+ * `key_hash`-keyed shape. Handles both the original 3-column table (no
620+ * `scope`) and an interim 4-column `(object, tenant_id, field, scope)` table:
621+ * every legacy row is read, its `key_hash` computed in app code (no portable
622+ * SQL hash exists), and re-inserted into a freshly built table that then
623+ * replaces the original. Idempotent — a no-op once `key_hash` is present.
624+ *
625+ * If the rebuild fails, `sequencesHasKeyHash` stays false: fixed-prefix
626+ * sequences keep working via the legacy key and per-scope writes error
627+ * actionably (see getNextSequenceValue), rather than corrupting data.
603628 */
604- protected async ensureSequencesScopeColumn ( ) : Promise < void > {
629+ protected async ensureSequencesKeyHashShape ( ) : Promise < void > {
630+ if ( await this . knex . schema . hasColumn ( SEQUENCES_TABLE , 'key_hash' ) ) {
631+ this . sequencesHasKeyHash = true ;
632+ return ;
633+ }
605634 const hasScope = await this . knex . schema . hasColumn ( SEQUENCES_TABLE , 'scope' ) ;
606- if ( hasScope ) return ;
607-
608- if ( this . isSqlite ) {
609- // SQLite can't alter a primary key in place — rebuild the table. Every
610- // legacy row carries scope '' so the copy is a straight projection.
611- const TMP = `${ SEQUENCES_TABLE } __rebuild` ;
635+ const TMP = `${ SEQUENCES_TABLE } __rebuild` ;
636+ try {
637+ const rows : any [ ] = await this . knex ( SEQUENCES_TABLE ) . select ( '*' ) ;
612638 await this . knex . schema . dropTableIfExists ( TMP ) ;
613- await this . knex . schema . createTable ( TMP , ( t ) => {
614- t . string ( 'object' ) . notNullable ( ) ;
615- t . string ( 'tenant_id' ) . notNullable ( ) ;
616- t . string ( 'field' ) . notNullable ( ) ;
617- t . string ( 'scope' ) . notNullable ( ) . defaultTo ( '' ) ;
618- t . bigInteger ( 'last_value' ) . notNullable ( ) . defaultTo ( 0 ) ;
619- t . timestamp ( 'updated_at' ) . defaultTo ( this . knex . fn . now ( ) ) ;
620- t . primary ( [ 'object' , 'tenant_id' , 'field' , 'scope' ] ) ;
639+ await this . createSequencesTable ( TMP ) ;
640+ const migrated = rows . map ( ( r ) => {
641+ const scope = hasScope && r . scope != null ? String ( r . scope ) : '' ;
642+ return {
643+ key_hash : this . sequenceKeyHash ( String ( r . object ) , String ( r . tenant_id ) , String ( r . field ) , scope ) ,
644+ object : r . object ,
645+ tenant_id : r . tenant_id ,
646+ field : r . field ,
647+ scope,
648+ last_value : r . last_value ?? 0 ,
649+ updated_at : r . updated_at ?? this . knex . fn . now ( ) ,
650+ } ;
621651 } ) ;
622- await this . knex . raw (
623- `insert into ?? (object, tenant_id, field, scope, last_value, updated_at)
624- select object, tenant_id, field, '', last_value, updated_at from ??` ,
625- [ TMP , SEQUENCES_TABLE ] ,
626- ) ;
652+ if ( migrated . length > 0 ) await this . knex ( TMP ) . insert ( migrated ) ;
627653 await this . knex . schema . dropTable ( SEQUENCES_TABLE ) ;
628654 await this . knex . schema . renameTable ( TMP , SEQUENCES_TABLE ) ;
629- return ;
630- }
631-
632- // Postgres / MySQL: add the column, then widen the primary key.
633- await this . knex . schema . alterTable ( SEQUENCES_TABLE , ( t ) => {
634- t . string ( 'scope' ) . notNullable ( ) . defaultTo ( '' ) ;
635- } ) ;
636- try {
637- if ( this . isMysql ) {
638- await this . knex . raw (
639- 'ALTER TABLE ?? DROP PRIMARY KEY, ADD PRIMARY KEY (object, tenant_id, field, scope)' ,
640- [ SEQUENCES_TABLE ] ,
641- ) ;
642- } else {
643- // Postgres: the implicit PK constraint is named `<table>_pkey`.
644- await this . knex . raw ( 'ALTER TABLE ?? DROP CONSTRAINT IF EXISTS ??' , [
645- SEQUENCES_TABLE ,
646- `${ SEQUENCES_TABLE } _pkey` ,
647- ] ) ;
648- await this . knex . raw ( 'ALTER TABLE ?? ADD PRIMARY KEY (object, tenant_id, field, scope)' , [
649- SEQUENCES_TABLE ,
650- ] ) ;
651- }
655+ this . sequencesHasKeyHash = true ;
652656 } catch ( err ) {
653- // The `scope` column landed (new writes can record it) but the PK is
654- // still the legacy 3-column shape. Fixed-prefix sequences are unaffected;
655- // a per-scope write, however, would hit an opaque PK violation at insert
656- // time. Flag it so getNextSequenceValue can fail loudly and actionably
657- // instead, and surface it at error level for operators to migrate by hand.
658- this . sequencesScopeKeyUnsafe = true ;
657+ // Leave the original table intact; fall back to legacy keying for
658+ // fixed-prefix sequences and refuse per-scope writes until migrated.
659+ this . sequencesHasKeyHash = false ;
660+ await this . knex . schema . dropTableIfExists ( TMP ) . catch ( ( ) => { } ) ;
659661 this . logger . warn (
660- `[autonumber] Failed to widen ${ SEQUENCES_TABLE } primary key to include "scope" . ` +
662+ `[autonumber] Failed to migrate ${ SEQUENCES_TABLE } to the key_hash shape . ` +
661663 `Fixed-prefix autonumbers keep working; date/{field}/per-parent formats will ` +
662- `error until the primary key is migrated to (object, tenant_id, field, scope) by hand .` ,
664+ `error until the table is migrated.` ,
663665 { error : String ( err ) } ,
664666 ) ;
665667 }
@@ -721,22 +723,29 @@ export class SqlDriver implements IDataDriver {
721723 scope = '' ,
722724 ) : Promise < number > {
723725 await this . ensureSequencesTable ( ) ;
724- if ( scope !== '' && this . sequencesScopeKeyUnsafe ) {
725- // The legacy sequences table could not have its PK widened to include
726- // `scope`, so a second scope under the same (object, tenant, field) would
727- // collide . Fail with a clear, actionable message instead of an opaque
728- // database PK violation at insert time .
726+ const resolvedTenantId = tenantField && tenantId ? String ( tenantId ) : GLOBAL_TENANT ;
727+ if ( scope !== '' && ! this . sequencesHasKeyHash ) {
728+ // The legacy sequences table could not be migrated to the key_hash shape,
729+ // so it cannot represent per-scope counters . Fail with a clear, actionable
730+ // message instead of corrupting the single legacy counter .
729731 throw new Error (
730732 `Cannot generate a per-scope autonumber for "${ object } .${ field } ": the ` +
731- `${ SEQUENCES_TABLE } primary key is still the legacy 3-column shape. ` +
732- `Migrate it to (object, tenant_id, field, scope) before using date/{field}/per-parent formats.` ,
733+ `${ SEQUENCES_TABLE } table is still the legacy shape. ` +
734+ `Migrate it to the key_hash shape before using date/{field}/per-parent formats.` ,
733735 ) ;
734736 }
735- const resolvedTenantId = tenantField && tenantId ? String ( tenantId ) : GLOBAL_TENANT ;
736- // `scope` (rendered date/field prefix) gives each period/group its own
737- // counter; '' keeps the single global counter for fixed-prefix formats.
738- // `prefix` is the full rendered prefix used to bootstrap from existing data.
739- const key = { object : tableName , tenant_id : resolvedTenantId , field, scope } ;
737+ // `scope` (rendered date/field prefix, boundary-delimited) gives each
738+ // period/group its own counter; '' keeps the single global counter for
739+ // fixed-prefix formats. `prefix` is the full rendered prefix used to
740+ // bootstrap from existing data. The row is keyed by a hash of the composite;
741+ // on an un-migrated legacy table only fixed-prefix (scope '') reaches here,
742+ // so fall back to the original `(object, tenant_id, field)` key for it.
743+ const key = this . sequencesHasKeyHash
744+ ? { key_hash : this . sequenceKeyHash ( tableName , resolvedTenantId , field , scope ) }
745+ : { object : tableName , tenant_id : resolvedTenantId , field } ;
746+ const insertRow = this . sequencesHasKeyHash
747+ ? { ...key , object : tableName , tenant_id : resolvedTenantId , field, scope }
748+ : { ...key } ;
740749
741750 const runner : Knex | Knex . Transaction = parentTrx ?? this . knex ;
742751
@@ -763,7 +772,7 @@ export class SqlDriver implements IDataDriver {
763772 ) ;
764773 const initial = seedMax + 1 ;
765774 try {
766- await trx ( SEQUENCES_TABLE ) . insert ( { ...key , last_value : initial } ) ;
775+ await trx ( SEQUENCES_TABLE ) . insert ( { ...insertRow , last_value : initial } ) ;
767776 return initial ;
768777 } catch ( err ) {
769778 // Another writer raced us to the first INSERT. Fall through to
0 commit comments