Skip to content

Commit 9813c42

Browse files
os-zhuangclaude
andcommitted
fix(autonumber): hash-keyed sequence table; clarify scope & width semantics
Full hardening of the remaining items from review: - #5 MySQL/length: key _objectstack_sequences by a single key_hash (SHA-256 of object,tenant_id,field,scope) instead of a 4-column natural PK. The natural PK exceeded MySQL's utf8mb4 index-length limit (a certain CREATE TABLE failure) and bounded how long a {field} scope could be. The hash PK keys every dialect uniformly and lets scope be a generous non-indexed column. Legacy 3-column and interim {scope}-column tables are migrated in place; migration fails safe (fixed-prefix keeps working, a per-scope write errors actionably). - #1 scope ambiguity: confirmed NOT fixable by separating adjacent token boundaries — when two records render the same prefix they render the same visible number, so they MUST share a counter to stay unique (a separator would mint duplicates). Documented the semantics + the remedy (delimiter literal in the format), backed by tests. The compile lint already nudges authors toward unambiguous formats. - #6 width overflow: confirmed by-design — the pad width is a MINIMUM, the counter grows past it and never wraps (mainstream autonumber semantics). Documented + regression test, no throw (throwing would break legitimate high-count sequences). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6b695fd commit 9813c42

6 files changed

Lines changed: 226 additions & 102 deletions

File tree

.changeset/autonumber-format-tokens.md

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,14 @@ emit byte-identical numbers (#1603 parity):
2626

2727
Fixed-prefix formats like `CASE-{0000}` render an empty scope and keep their single
2828
global counter, so existing sequences are unchanged. The persistent
29-
`_objectstack_sequences` table gains a `scope` column (PK widened to
30-
`object, tenant_id, field, scope`); deployments with the legacy 3-column table are
31-
migrated in place on first use, carrying existing counters to `scope=''`.
29+
`_objectstack_sequences` table is keyed by a `key_hash` (SHA-256 of
30+
`object, tenant_id, field, scope`) — a single 64-char primary key that keys every
31+
dialect uniformly, stays within MySQL's utf8mb4 index-length limit (four raw
32+
columns would not), and lets `scope` be a generous non-indexed column. Deployments
33+
with an older table (3-column, or an interim `scope` column) are migrated in place
34+
on first use, carrying existing counters to `scope=''`.
3235

33-
Guardrails against the `{field}` footguns:
36+
Guardrails:
3437

3538
- **Empty interpolated field is a hard error, not a silent mis-number.** A
3639
`{field}` token whose value is missing at create time would render to an empty
@@ -41,7 +44,18 @@ Guardrails against the `{field}` footguns:
4144
checked against the object's fields: a `{field}` token naming a non-existent
4245
field (or the autonumber field itself) **fails the build**; a token naming an
4346
*optional* field emits an advisory warning to mark it `required: true`.
44-
- **Legacy sequence-table migration fails safe.** If the legacy table's primary
45-
key cannot be widened to include `scope`, fixed-prefix sequences keep working and
46-
a per-scope write raises an actionable error instead of an opaque DB primary-key
47-
violation at insert time.
47+
- **Migration fails safe.** If a legacy table cannot be migrated to the `key_hash`
48+
shape, fixed-prefix sequences keep working via the legacy key and a per-scope
49+
write raises an actionable error instead of corrupting counters.
50+
- **Long `{field}` scopes are supported** (e.g. a long `{plan_no}`): the non-indexed
51+
`scope` column and hashed key remove the old varchar/PK length ceiling.
52+
53+
Notes on inherent semantics (documented, not bugs):
54+
55+
- The counter scope IS the rendered prefix. When two records' tokens render to the
56+
same prefix string (e.g. `{a}{b}` for `('AB','C')` and `('A','BC')`) they also
57+
render the same visible number, so they share one counter to stay unique — the
58+
remedy for genuinely-distinct groups is an unambiguous format (a delimiter
59+
literal between variable tokens).
60+
- The sequence pad width is a MINIMUM; past it the number grows (`{000}`
61+
`1000`), it never wraps — matching mainstream autonumber semantics.

packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,46 @@ describe('SqlDriver auto_number format tokens', () => {
123123
expect(b.plan_no).toBe(`PROD${utcYmd()}001`);
124124
});
125125

126+
it('shares one counter when adjacent {field} values concat to the same prefix', async () => {
127+
await driver.initObjects([
128+
{
129+
name: 'task',
130+
fields: {
131+
a: { type: 'string' },
132+
b: { type: 'string' },
133+
task_no: { type: 'autonumber', format: '{a}{b}{000}' },
134+
},
135+
},
136+
]);
137+
// ('AB','C') and ('A','BC') both render the prefix "ABC" and thus the same
138+
// visible number — they MUST share one counter so the numbers stay unique
139+
// (independent counters would mint two "ABC001"s).
140+
const x1 = await driver.create('task', { a: 'AB', b: 'C' });
141+
const y1 = await driver.create('task', { a: 'A', b: 'BC' });
142+
const x2 = await driver.create('task', { a: 'AB', b: 'C' });
143+
144+
expect(x1.task_no).toBe('ABC001');
145+
expect(y1.task_no).toBe('ABC002'); // same namespace, keeps climbing (unique)
146+
expect(x2.task_no).toBe('ABC003');
147+
});
148+
149+
it('handles a very long {field} scope (well past varchar(255))', async () => {
150+
await driver.initObjects([
151+
{
152+
name: 'doc',
153+
fields: {
154+
big: { type: 'string' },
155+
doc_no: { type: 'autonumber', format: '{big}{000}' },
156+
},
157+
},
158+
]);
159+
const big = 'P'.repeat(400); // 400-char prefix → scope far exceeds 255
160+
const r1 = await driver.create('doc', { big });
161+
const r2 = await driver.create('doc', { big });
162+
expect(r1.doc_no).toBe(`${big}001`);
163+
expect(r2.doc_no).toBe(`${big}002`);
164+
});
165+
126166
it('refuses to generate when an interpolated {field} is empty (no silent mis-scope)', async () => {
127167
await driver.initObjects([
128168
{
@@ -148,7 +188,7 @@ describe('SqlDriver auto_number format tokens', () => {
148188
expect(r2.invoice_number).toBe('INV-0002');
149189
});
150190

151-
it('migrates a legacy 3-column _objectstack_sequences table by adding scope', async () => {
191+
it('migrates a legacy 3-column _objectstack_sequences table to the key_hash shape', async () => {
152192
const k = (driver as any).knex;
153193
// Simulate a deployment whose sequence table predates the `scope` column.
154194
await k.schema.createTable('_objectstack_sequences', (t: any) => {
@@ -174,7 +214,38 @@ describe('SqlDriver auto_number format tokens', () => {
174214

175215
const cols = await k('_objectstack_sequences').columnInfo();
176216
expect(Object.keys(cols)).toContain('scope');
217+
expect(Object.keys(cols)).toContain('key_hash');
177218
// The legacy counter continued rather than restarting.
178219
expect(r.invoice_number).toBe('INV-0042');
179220
});
221+
222+
it('migrates an interim {scope}-column table (no key_hash) and preserves counters', async () => {
223+
const k = (driver as any).knex;
224+
// A table from an earlier build of this feature: has `scope`, no `key_hash`.
225+
await k.schema.createTable('_objectstack_sequences', (t: any) => {
226+
t.string('object').notNullable();
227+
t.string('tenant_id').notNullable();
228+
t.string('field').notNullable();
229+
t.string('scope').notNullable().defaultTo('');
230+
t.bigInteger('last_value').notNullable().defaultTo(0);
231+
t.timestamp('updated_at').defaultTo(k.fn.now());
232+
t.primary(['object', 'tenant_id', 'field', 'scope']);
233+
});
234+
await k('_objectstack_sequences').insert({
235+
object: 'invoice',
236+
tenant_id: '__global__',
237+
field: 'invoice_number',
238+
scope: '',
239+
last_value: 7,
240+
});
241+
242+
await driver.initObjects([
243+
{ name: 'invoice', fields: { invoice_number: { type: 'autonumber', format: 'INV-{0000}' } } },
244+
]);
245+
const r = await driver.create('invoice', {});
246+
247+
const cols = await k('_objectstack_sequences').columnInfo();
248+
expect(Object.keys(cols)).toContain('key_hash');
249+
expect(r.invoice_number).toBe('INV-0008'); // continued from the interim counter
250+
});
180251
});

packages/plugins/driver-sql/src/sql-driver.ts

Lines changed: 100 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)