From 3d55c55cc05d6c8f02abb13ddb3e4b4611eb0c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Fri, 19 Jun 2026 10:30:10 +0800 Subject: [PATCH 1/4] feat(autonumber): date, {field} and per-scope counter reset for formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tokenize autonumberFormat via a shared pure renderer in @objectstack/spec (parseAutonumberFormat / renderAutonumber) that both the engine fallback and the SQL driver call, so they emit byte-identical numbers (#1603 parity): - date tokens {YYYY}{YY}{MM}{DD}{YYYYMMDD} resolve the calendar day in the request's business timezone (ExecutionContext.timezone, ADR-0053; UTC fallback), threaded through new DriverOptions.timezone - {field} interpolation substitutes record values into the prefix - counter scope = rendered prefix before the sequence slot, so AD{YYYYMMDD}{0000} resets daily, {section}{island_zone}{000} numbers per group, {plan_no}{000} numbers per parent — one mechanism, no separate reset config Fixed-prefix formats (CASE-{0000}) render an empty scope and keep their single global counter. _objectstack_sequences gains a scope column (PK widened to object,tenant_id,field,scope); legacy 3-column tables migrate in place on first use, carrying existing counters to scope=''. --- .changeset/autonumber-format-tokens.md | 30 +++ .../src/engine-autonumber-defer.test.ts | 39 ++++ packages/objectql/src/engine.ts | 60 ++++-- .../src/sql-driver-autonumber-tokens.test.ts | 165 +++++++++++++++ packages/plugins/driver-sql/src/sql-driver.ts | 115 +++++++++-- .../spec/src/data/autonumber-format.test.ts | 110 ++++++++++ packages/spec/src/data/autonumber-format.ts | 190 ++++++++++++++++++ packages/spec/src/data/driver.zod.ts | 8 + packages/spec/src/data/field.zod.ts | 20 +- packages/spec/src/data/index.ts | 1 + 10 files changed, 702 insertions(+), 36 deletions(-) create mode 100644 .changeset/autonumber-format-tokens.md create mode 100644 packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts create mode 100644 packages/spec/src/data/autonumber-format.test.ts create mode 100644 packages/spec/src/data/autonumber-format.ts diff --git a/.changeset/autonumber-format-tokens.md b/.changeset/autonumber-format-tokens.md new file mode 100644 index 0000000000..0c97393620 --- /dev/null +++ b/.changeset/autonumber-format-tokens.md @@ -0,0 +1,30 @@ +--- +"@objectstack/spec": minor +"@objectstack/objectql": minor +"@objectstack/driver-sql": minor +--- + +feat(autonumber): date, {field} and per-scope counter reset for autonumber formats + +`autonumberFormat` previously only understood a single `{0000}` sequence slot — +everything else was a fixed literal prefix on one global counter. Real MES/eHR +record numbers need three more token classes, so the format is now tokenized by a +shared pure renderer in `@objectstack/spec` (`parseAutonumberFormat` / +`renderAutonumber`) that the engine fallback and the SQL driver both call, so they +emit byte-identical numbers (#1603 parity): + +- **Date tokens** — `{YYYY}` `{YY}` `{MM}` `{DD}` `{YYYYMMDD}` resolve the calendar + day in the request's **business timezone** (`ExecutionContext.timezone`, ADR-0053; + UTC fallback), threaded through the new `DriverOptions.timezone`. +- **`{field}` interpolation** — `{section}{island_zone}{000}` substitutes record + field values into the prefix. +- **Per-scope counter reset** — the counter's scope is the rendered prefix *before* + the sequence slot, so `AD{YYYYMMDD}{0000}` resets daily, `{section}{island_zone}{000}` + numbers per group, and `{plan_no}{000}` numbers per parent — all from one + mechanism, no separate reset config. + +Fixed-prefix formats like `CASE-{0000}` render an empty scope and keep their single +global counter, so existing sequences are unchanged. The persistent +`_objectstack_sequences` table gains a `scope` column (PK widened to +`object, tenant_id, field, scope`); deployments with the legacy 3-column table are +migrated in place on first use, carrying existing counters to `scope=''`. diff --git a/packages/objectql/src/engine-autonumber-defer.test.ts b/packages/objectql/src/engine-autonumber-defer.test.ts index a8709b2e3b..2ff9759e25 100644 --- a/packages/objectql/src/engine-autonumber-defer.test.ts +++ b/packages/objectql/src/engine-autonumber-defer.test.ts @@ -112,4 +112,43 @@ describe('ObjectQL autonumber ownership (#1603)', () => { expect(driver.created[0].doc_no).toBe('D-0001'); expect(result.doc_no).toBe('D-0001'); }); + + // The fallback path renders the SAME format tokens as the SQL driver + // (shared @objectstack/spec renderer), so {field}/{date} grouping must match. + it('fallback renders {field} tokens and counts independently per scope', async () => { + const TASK_SCHEMA = { + name: 'task', + fields: { + zone: { type: 'text' }, + task_no: { type: 'autonumber', format: '{zone}{000}' }, + }, + }; + vi.mocked(SchemaRegistry.getObject).mockReturnValue(TASK_SCHEMA as any); + const driver = makeDriver(false); + engine.registerDriver(driver, true); + await engine.init(); + + const a1 = await engine.insert('task', { zone: 'A' }); + const b1 = await engine.insert('task', { zone: 'B' }); + const a2 = await engine.insert('task', { zone: 'A' }); + + expect(a1.task_no).toBe('A001'); + expect(b1.task_no).toBe('B001'); // a different scope restarts at 001 + expect(a2.task_no).toBe('A002'); + }); + + it('fallback renders {YYYYMMDD} date tokens in the business timezone', async () => { + const AUDIT_SCHEMA = { + name: 'audit', + fields: { audit_no: { type: 'autonumber', format: 'AD{YYYYMMDD}{0000}' } }, + }; + vi.mocked(SchemaRegistry.getObject).mockReturnValue(AUDIT_SCHEMA as any); + const driver = makeDriver(false); + engine.registerDriver(driver, true); + await engine.init(); + + const r = await engine.insert('audit', {}, { timezone: 'UTC' } as any); + // Today's UTC day + a fresh per-day counter. + expect(r.audit_no).toMatch(/^AD\d{8}0001$/); + }); }); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index f919f98e2c..0706ad83f6 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -10,6 +10,7 @@ import { EngineAggregateOptions, EngineCountOptions } from '@objectstack/spec/data'; +import { parseAutonumberFormat, renderAutonumber } from '@objectstack/spec/data'; import { ExecutionContext, ExecutionContextSchema } from '@objectstack/spec/kernel'; import { DriverInterface, IDataEngine, Logger, createLogger } from '@objectstack/core'; import { CoreServiceName, StorageNameMapping } from '@objectstack/spec/system'; @@ -699,8 +700,9 @@ export class ObjectQL implements IDataEngine { : this.txStore.getStore()?.transaction; const hasTx = tx !== undefined; const hasTenant = execCtx?.tenantId !== undefined; + const hasTz = execCtx?.timezone !== undefined; const isSystem = execCtx?.isSystem === true; - if (!hasTx && !hasTenant && !isSystem) return base; + if (!hasTx && !hasTenant && !isSystem && !hasTz) return base; const opts: any = base && typeof base === 'object' ? { ...base } : {}; if (hasTx && opts.transaction === undefined) { opts.transaction = tx; @@ -708,6 +710,11 @@ export class ObjectQL implements IDataEngine { if (hasTenant && opts.tenantId === undefined) { opts.tenantId = execCtx!.tenantId; } + if (hasTz && opts.timezone === undefined) { + // Thread the business timezone so date-dependent driver generation + // (autonumber `{YYYYMMDD}` tokens) resolves the calendar day correctly. + opts.timezone = execCtx!.timezone; + } if (isSystem && opts.bypassTenantAudit === undefined) { // System-elevated writes (boot-time seeds, internal mirrors, scheduled // hooks) are unscoped by design — silence the audit warn for them but @@ -793,9 +800,12 @@ export class ObjectQL implements IDataEngine { * owns the value, not the client. * * In the fallback path the next value is `max(existing) + 1`, seeded once per - * `object.field` from the store then incremented in memory (monotonic within - * the process, resilient to deletions). `autonumberFormat` is honored, e.g. - * `CASE-{0000}` → `CASE-0042`. NOTE: this in-memory seeding is single-instance. + * `object.field.` from the store then incremented in memory (monotonic + * within the process, resilient to deletions). The shared `autonumberFormat` + * renderer is honored end-to-end, so date tokens (`AD{YYYYMMDD}{0000}`), field + * interpolation (`{island_zone}{000}`) and per-scope reset behave identically + * to the SQL driver's persistent sequence (#1603). NOTE: this in-memory seeding + * is single-instance. */ private async applyAutonumbers( object: string, @@ -806,26 +816,40 @@ export class ObjectQL implements IDataEngine { if (driverOwnsAutonumber) return; // driver generates persistently in create() const fields = (this.getSchema(object) as any)?.fields; if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return; + const now = new Date(); + const timezone = execCtx?.timezone; for (const [name, def] of Object.entries(fields)) { if ((def as any)?.type !== 'autonumber') continue; const current = record[name]; if (current != null && current !== '') continue; // respect explicit value - const key = `${object}.${name}`; - let next = this.autonumberCounters.get(key); - if (next == null) next = await this.seedAutonumber(object, name, execCtx); - next += 1; - this.autonumberCounters.set(key, next); // Honor either the spec-canonical `autonumberFormat` or the shorthand // `format` (both appear in metadata; the driver reads both too) — #1603. const fmt = (def as any).autonumberFormat ?? (def as any).format; - record[name] = this.formatAutonumber(fmt, next); + const tokens = parseAutonumberFormat(typeof fmt === 'string' ? fmt : ''); + // The counter scope is the rendered prefix (date/field tokens before the + // sequence slot); it is independent of the counter value, so a throwaway + // render with seq 0 yields the scope and the literal prefix to seed from. + const probe = renderAutonumber({ tokens, seq: 0, record, now, timezone }); + const counterKey = `${object}.${name}.${probe.scope}`; + let next = this.autonumberCounters.get(counterKey); + if (next == null) next = await this.seedAutonumber(object, name, probe.prefix, execCtx); + next += 1; + this.autonumberCounters.set(counterKey, next); + record[name] = renderAutonumber({ tokens, seq: next, record, now, timezone }).value; } } - /** Seed the autonumber counter from the current max numeric value in store. */ + /** + * Seed the autonumber counter from the current max in store, scoped to + * `prefix`. With a non-empty prefix (date/field formats) only rows in the + * same scope count, and the counter is the digit-run immediately after the + * prefix; with an empty prefix (legacy fixed-prefix formats) the last digit + * run of the whole value is used, preserving the original behaviour. + */ private async seedAutonumber( object: string, field: string, + prefix: string, execCtx?: ExecutionContext, ): Promise { try { @@ -838,7 +862,10 @@ export class ObjectQL implements IDataEngine { for (const r of rows || []) { const v = r?.[field]; if (v == null) continue; - const m = String(v).match(/(\d+)(?!.*\d)/); // last run of digits + const s = String(v); + if (prefix && !s.startsWith(prefix)) continue; + const tail = prefix ? s.slice(prefix.length) : s; + const m = prefix ? tail.match(/^(\d+)/) : tail.match(/(\d+)(?!.*\d)/); if (m) max = Math.max(max, parseInt(m[1], 10) || 0); } return max; @@ -847,15 +874,6 @@ export class ObjectQL implements IDataEngine { } } - /** Apply an autonumber format like `CASE-{0000}`; default to the bare number. */ - private formatAutonumber(format: string | undefined, value: number): string { - if (!format) return String(value); - const m = format.match(/\{(0+)\}/); - if (!m) return format.includes('{0}') ? format.replace('{0}', String(value)) : `${format}${value}`; - const padded = String(value).padStart(m[1].length, '0'); - return format.replace(m[0], padded); - } - /** * Register contribution (Manifest) * diff --git a/packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts b/packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts new file mode 100644 index 0000000000..3fe1d61b88 --- /dev/null +++ b/packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts @@ -0,0 +1,165 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SqlDriver } from '../src/index.js'; + +/** + * Auto-number format tokens — date interpolation, {field} interpolation, and + * per-scope counter reset. These are the MES/eHR record-number shapes + * (`AD{YYYYMMDD}{0000}`, `{section}{island_zone}{000}`, `{plan_no}{000}`) that + * the persistent `_objectstack_sequences` table now backs natively. + */ + +/** Today's date in UTC as YYYYMMDD — matches the driver's renderer with tz=UTC. */ +function utcYmd(): string { + const p = new Intl.DateTimeFormat('en-CA', { + timeZone: 'UTC', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(new Date()); + const y = p.find((x) => x.type === 'year')!.value; + const m = p.find((x) => x.type === 'month')!.value; + const d = p.find((x) => x.type === 'day')!.value; + return `${y}${m}${d}`; +} + +describe('SqlDriver auto_number format tokens', () => { + let driver: SqlDriver; + + beforeEach(() => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + }); + + afterEach(async () => { + await driver.disconnect(); + }); + + it('renders {YYYYMMDD} date tokens in the business timezone', async () => { + await driver.initObjects([ + { name: 'andon', fields: { andon_no: { type: 'autonumber', format: 'AD{YYYYMMDD}{0000}' } } }, + ]); + const r1 = await driver.create('andon', {}, { timezone: 'UTC' } as any); + const r2 = await driver.create('andon', {}, { timezone: 'UTC' } as any); + expect(r1.andon_no).toBe(`AD${utcYmd()}0001`); + expect(r2.andon_no).toBe(`AD${utcYmd()}0002`); + }); + + it('resets the counter per day (prior-day rows do not bleed into today)', async () => { + await driver.initObjects([ + { name: 'andon', fields: { andon_no: { type: 'autonumber', format: 'AD{YYYYMMDD}{0000}' } } }, + ]); + // A row from a long-past day sits at 0099. Today's counter must ignore it. + const k = (driver as any).knex; + await k('andon').insert({ id: 'old', andon_no: 'AD202001010099' }); + + const r = await driver.create('andon', {}, { timezone: 'UTC' } as any); + expect(r.andon_no).toBe(`AD${utcYmd()}0001`); // fresh scope, not 0100 + }); + + it('interpolates {field} values and counts independently per group', async () => { + await driver.initObjects([ + { + name: 'task', + fields: { + section: { type: 'string' }, + island_zone: { type: 'string' }, + task_no: { type: 'autonumber', format: '{section}{island_zone}{000}' }, + }, + }, + ]); + const a1 = await driver.create('task', { section: 'JYG', island_zone: '1A' }); + const b1 = await driver.create('task', { section: 'JYG', island_zone: '2B' }); + const a2 = await driver.create('task', { section: 'JYG', island_zone: '1A' }); + const b2 = await driver.create('task', { section: 'JYG', island_zone: '2B' }); + + expect(a1.task_no).toBe('JYG1A001'); + expect(a2.task_no).toBe('JYG1A002'); + expect(b1.task_no).toBe('JYG2B001'); // a different island resets to 001 + expect(b2.task_no).toBe('JYG2B002'); + }); + + it('numbers child records per parent (dispatch order under a plan)', async () => { + await driver.initObjects([ + { + name: 'dispatch_order', + fields: { + plan_no: { type: 'string' }, + order_no: { type: 'autonumber', format: '{plan_no}{000}' }, + }, + }, + ]); + const p1a = await driver.create('dispatch_order', { plan_no: 'JYG1A1PROD20260617001' }); + const p2a = await driver.create('dispatch_order', { plan_no: 'JYG1A1PROD20260617002' }); + const p1b = await driver.create('dispatch_order', { plan_no: 'JYG1A1PROD20260617001' }); + + expect(p1a.order_no).toBe('JYG1A1PROD20260617001001'); + expect(p1b.order_no).toBe('JYG1A1PROD20260617001002'); + expect(p2a.order_no).toBe('JYG1A1PROD20260617002001'); + }); + + it('combines field + date tokens and stays tenant-isolated', async () => { + await driver.initObjects([ + { + name: 'plan', + fields: { + organization_id: { type: 'string' }, + line: { type: 'string' }, + plan_no: { type: 'autonumber', format: '{line}{YYYYMMDD}{000}' }, + }, + }, + ]); + const a = await driver.create('plan', { organization_id: 'orgA', line: 'PROD' }, { timezone: 'UTC' } as any); + const b = await driver.create('plan', { organization_id: 'orgB', line: 'PROD' }, { timezone: 'UTC' } as any); + const a2 = await driver.create('plan', { organization_id: 'orgA', line: 'PROD' }, { timezone: 'UTC' } as any); + + expect(a.plan_no).toBe(`PROD${utcYmd()}001`); + expect(a2.plan_no).toBe(`PROD${utcYmd()}002`); + // Same scope string, different tenant → independent counter. + expect(b.plan_no).toBe(`PROD${utcYmd()}001`); + }); + + it('leaves fixed-prefix formats on a single global counter (backward compatible)', async () => { + await driver.initObjects([ + { name: 'invoice', fields: { invoice_number: { type: 'autonumber', format: 'INV-{0000}' } } }, + ]); + const r1 = await driver.create('invoice', {}); + const r2 = await driver.create('invoice', {}); + expect(r1.invoice_number).toBe('INV-0001'); + expect(r2.invoice_number).toBe('INV-0002'); + }); + + it('migrates a legacy 3-column _objectstack_sequences table by adding scope', async () => { + const k = (driver as any).knex; + // Simulate a deployment whose sequence table predates the `scope` column. + await k.schema.createTable('_objectstack_sequences', (t: any) => { + t.string('object').notNullable(); + t.string('tenant_id').notNullable(); + t.string('field').notNullable(); + t.bigInteger('last_value').notNullable().defaultTo(0); + t.timestamp('updated_at').defaultTo(k.fn.now()); + t.primary(['object', 'tenant_id', 'field']); + }); + await k('_objectstack_sequences').insert({ + object: 'invoice', + tenant_id: '__global__', + field: 'invoice_number', + last_value: 41, + }); + + await driver.initObjects([ + { name: 'invoice', fields: { invoice_number: { type: 'autonumber', format: 'INV-{0000}' } } }, + ]); + // First create triggers the lazy table-ensure → in-place migration. + const r = await driver.create('invoice', {}); + + const cols = await k('_objectstack_sequences').columnInfo(); + expect(Object.keys(cols)).toContain('scope'); + // The legacy counter continued rather than restarting. + expect(r.invoice_number).toBe('INV-0042'); + }); +}); diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 84dc2f7d03..317c7c5127 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -8,6 +8,7 @@ */ import type { QueryAST, DriverOptions, SchemaMode } from '@objectstack/spec/data'; +import { parseAutonumberFormat, renderAutonumber, type AutonumberToken } from '@objectstack/spec/data'; import type { IDataDriver } from '@objectstack/spec/contracts'; import { StorageNameMapping } from '@objectstack/spec/system'; import { ExternalSchemaModeViolationError } from '@objectstack/spec/shared'; @@ -213,7 +214,7 @@ export class SqlDriver implements IDataDriver { */ protected autoNumberFields: Record< string, - Array<{ name: string; format: string; prefix: string; padWidth: number; tenantField: string | null }> + Array<{ name: string; format: string; tokens: AutonumberToken[]; tenantField: string | null }> > = {}; /** Whether the sequences table has been ensured this process. */ @@ -540,6 +541,11 @@ export class SqlDriver implements IDataDriver { /** * Ensure the sequence-counter table exists. Idempotent and cheap after * the first call (cached via `sequencesTableReady`). + * + * The key is `(object, tenant_id, field, scope)`. `scope` is the rendered + * autonumber prefix (date/field tokens before the `{0000}` slot), so a new + * day/group/parent starts a fresh counter. Fixed-prefix formats use the + * empty scope and keep their single global counter (backward compatible). */ protected async ensureSequencesTable(): Promise { if (this.sequencesTableReady) return; @@ -555,16 +561,22 @@ export class SqlDriver implements IDataDriver { t.string('object').notNullable(); t.string('tenant_id').notNullable(); t.string('field').notNullable(); + t.string('scope').notNullable().defaultTo(''); t.bigInteger('last_value').notNullable().defaultTo(0); t.timestamp('updated_at').defaultTo(this.knex.fn.now()); - t.primary(['object', 'tenant_id', 'field']); + t.primary(['object', 'tenant_id', 'field', 'scope']); }); } catch (err: any) { // Race or cross-process create — re-check existence; ignore // "already exists" errors from any dialect. const stillMissing = !(await this.knex.schema.hasTable(SEQUENCES_TABLE)); if (stillMissing) throw err; + // A racing creator may have used the legacy 3-column schema. + await this.ensureSequencesScopeColumn(); } + } else { + // Pre-existing table may predate the `scope` column. Migrate in place. + await this.ensureSequencesScopeColumn(); } this.sequencesTableReady = true; })(); @@ -575,6 +587,68 @@ export class SqlDriver implements IDataDriver { } } + /** + * Add the `scope` column (and fold it into the primary key) to a + * `_objectstack_sequences` table created before per-period/per-group + * counters existed. Every legacy row is a fixed-prefix sequence, so it + * migrates to `scope = ''` and keeps counting unchanged. Idempotent. + */ + protected async ensureSequencesScopeColumn(): Promise { + const hasScope = await this.knex.schema.hasColumn(SEQUENCES_TABLE, 'scope'); + if (hasScope) return; + + if (this.isSqlite) { + // SQLite can't alter a primary key in place — rebuild the table. Every + // legacy row carries scope '' so the copy is a straight projection. + const TMP = `${SEQUENCES_TABLE}__rebuild`; + await this.knex.schema.dropTableIfExists(TMP); + await this.knex.schema.createTable(TMP, (t) => { + t.string('object').notNullable(); + t.string('tenant_id').notNullable(); + t.string('field').notNullable(); + t.string('scope').notNullable().defaultTo(''); + t.bigInteger('last_value').notNullable().defaultTo(0); + t.timestamp('updated_at').defaultTo(this.knex.fn.now()); + t.primary(['object', 'tenant_id', 'field', 'scope']); + }); + await this.knex.raw( + `insert into ?? (object, tenant_id, field, scope, last_value, updated_at) + select object, tenant_id, field, '', last_value, updated_at from ??`, + [TMP, SEQUENCES_TABLE], + ); + await this.knex.schema.dropTable(SEQUENCES_TABLE); + await this.knex.schema.renameTable(TMP, SEQUENCES_TABLE); + return; + } + + // Postgres / MySQL: add the column, then widen the primary key. + await this.knex.schema.alterTable(SEQUENCES_TABLE, (t) => { + t.string('scope').notNullable().defaultTo(''); + }); + try { + if (this.isMysql) { + await this.knex.raw( + 'ALTER TABLE ?? DROP PRIMARY KEY, ADD PRIMARY KEY (object, tenant_id, field, scope)', + [SEQUENCES_TABLE], + ); + } else { + // Postgres: the implicit PK constraint is named `_pkey`. + await this.knex.raw('ALTER TABLE ?? DROP CONSTRAINT IF EXISTS ??', [ + SEQUENCES_TABLE, + `${SEQUENCES_TABLE}_pkey`, + ]); + await this.knex.raw('ALTER TABLE ?? ADD PRIMARY KEY (object, tenant_id, field, scope)', [ + SEQUENCES_TABLE, + ]); + } + } catch (err) { + // Widening the PK is best-effort; the column itself is what new writes + // need. A stale 3-column PK only bites if a field's format flips from + // fixed to dynamic on an already-seeded deployment. + this.logger.warn('Failed to widen _objectstack_sequences primary key', { error: String(err) }); + } + } + /** * Bootstrap helper: scan the data table for the highest numeric suffix * matching `prefix` (optionally scoped to a tenant). Used the first time @@ -628,10 +702,14 @@ export class SqlDriver implements IDataDriver { tenantField: string | null, tenantId: string | null, parentTrx?: Knex.Transaction, + scope = '', ): Promise { await this.ensureSequencesTable(); const resolvedTenantId = tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT; - const key = { object: tableName, tenant_id: resolvedTenantId, field }; + // `scope` (rendered date/field prefix) gives each period/group its own + // counter; '' keeps the single global counter for fixed-prefix formats. + // `prefix` is the full rendered prefix used to bootstrap from existing data. + const key = { object: tableName, tenant_id: resolvedTenantId, field, scope }; const runner: Knex | Knex.Transaction = parentTrx ?? this.knex; @@ -675,10 +753,12 @@ export class SqlDriver implements IDataDriver { } /** - * For each `auto_number` field on the object that the caller did not - * provide a value for, reserve the next sequence value scoped to the - * record's tenant (or globally if the object has no tenant field) and - * render `prefix + zero-padded(value)`. + * For each `auto_number` field the caller left empty, render the format and + * reserve the next counter value. The counter is scoped to the rendered + * prefix (date tokens like `{YYYYMMDD}` in the request's business timezone, + * plus `{field}` interpolation from the row), so it resets per period/group; + * the full rendered prefix bootstraps the counter from existing data, and the + * tenant scopes it for isolation. */ protected async fillAutoNumberFields( object: string, @@ -689,6 +769,8 @@ export class SqlDriver implements IDataDriver { const cfgs = this.autoNumberFields[tableName] || this.autoNumberFields[object]; if (!cfgs || cfgs.length === 0) return; const parentTrx = options?.transaction as Knex.Transaction | undefined; + const timezone = (options as any)?.timezone as string | undefined; + const now = new Date(); for (const cfg of cfgs) { if (row[cfg.name] !== undefined && row[cfg.name] !== null && row[cfg.name] !== '') continue; // Resolve tenant for this row: explicit field on the record wins, @@ -700,16 +782,20 @@ export class SqlDriver implements IDataDriver { : optTenant != null && optTenant !== '' ? String(optTenant) : null; + // Resolve the scope/prefix for this row (counter-value-independent), + // reserve the next value under that scope, then render the final string. + const probe = renderAutonumber({ tokens: cfg.tokens, seq: 0, record: row, now, timezone }); const next = await this.getNextSequenceValue( object, tableName, cfg.name, - cfg.prefix, + probe.prefix, cfg.tenantField, tenantId, parentTrx, + probe.scope, ); - row[cfg.name] = `${cfg.prefix}${String(next).padStart(cfg.padWidth, '0')}`; + row[cfg.name] = renderAutonumber({ tokens: cfg.tokens, seq: next, record: row, now, timezone }).value; } } @@ -1113,7 +1199,7 @@ export class SqlDriver implements IDataDriver { const jsonCols: string[] = []; const booleanCols: string[] = []; const numericCols: string[] = []; - const autoNumberCols: Array<{ name: string; format: string; prefix: string; padWidth: number; tenantField: string | null }> = []; + const autoNumberCols: Array<{ name: string; format: string; tokens: AutonumberToken[]; tenantField: string | null }> = []; // Auto-detect tenant field. Convention: the field named // `organization_id` (matching tenantPolicy default) scopes the // Resolve tenant scope declaratively first (obj.tenancy.{enabled, @@ -1164,10 +1250,11 @@ export class SqlDriver implements IDataDriver { ? field.autonumberFormat : (typeof field.format === 'string' && field.format ? field.format : ''); const fmt = rawFmt || '{0000}'; - const m = fmt.match(/\{(0+)\}/); - const padWidth = m ? m[1].length : 4; - const prefix = m ? fmt.slice(0, m.index ?? 0) : fmt; - autoNumberCols.push({ name, format: fmt, prefix, padWidth, tenantField }); + // Tokenize once: the renderer resolves date tokens (`{YYYYMMDD}`), + // field interpolation (`{island_zone}`) and the sequence slot at + // fill time. The counter scopes to whatever renders before the slot. + const tokens = parseAutonumberFormat(fmt); + autoNumberCols.push({ name, format: fmt, tokens, tenantField }); } } } diff --git a/packages/spec/src/data/autonumber-format.test.ts b/packages/spec/src/data/autonumber-format.test.ts new file mode 100644 index 0000000000..c3e675f18e --- /dev/null +++ b/packages/spec/src/data/autonumber-format.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + parseAutonumberFormat, + renderAutonumber, + hasDynamicTokens, + sequenceWidth, + referencedFields, +} from './autonumber-format'; + +// A fixed instant: 2026-06-17 21:30 UTC. In Asia/Shanghai (UTC+8) this is +// already 2026-06-18, which is exactly what makes the timezone assertions bite. +const NOW = new Date('2026-06-17T21:30:00.000Z'); + +describe('parseAutonumberFormat', () => { + it('splits literal, sequence, date and field tokens in order', () => { + expect(parseAutonumberFormat('AD{YYYYMMDD}{0000}')).toEqual([ + { kind: 'literal', text: 'AD' }, + { kind: 'date', pattern: 'YYYYMMDD' }, + { kind: 'seq', width: 4 }, + ]); + expect(parseAutonumberFormat('{section}{island_zone}{000}')).toEqual([ + { kind: 'field', field: 'section' }, + { kind: 'field', field: 'island_zone' }, + { kind: 'seq', width: 3 }, + ]); + }); + + it('treats a second {0..0} group as literal (only one counter)', () => { + const tokens = parseAutonumberFormat('{0000}-{000}'); + expect(tokens).toEqual([ + { kind: 'seq', width: 4 }, + { kind: 'literal', text: '-' }, + { kind: 'literal', text: '{000}' }, + ]); + expect(sequenceWidth(tokens)).toBe(4); + }); + + it('keeps unknown tokens as literal text', () => { + expect(parseAutonumberFormat('X{not a token}{0}')).toEqual([ + { kind: 'literal', text: 'X' }, + { kind: 'literal', text: '{not a token}' }, + { kind: 'seq', width: 1 }, + ]); + }); + + it('reports dynamic tokens and referenced fields', () => { + expect(hasDynamicTokens(parseAutonumberFormat('CASE-{0000}'))).toBe(false); + expect(hasDynamicTokens(parseAutonumberFormat('AD{YYYYMMDD}{0000}'))).toBe(true); + expect(referencedFields(parseAutonumberFormat('{section}{island_zone}{000}'))).toEqual([ + 'section', + 'island_zone', + ]); + }); +}); + +describe('renderAutonumber', () => { + it('renders a fixed prefix with an empty scope (backward compatible)', () => { + const tokens = parseAutonumberFormat('CASE-{0000}'); + const r = renderAutonumber({ tokens, seq: 42, now: NOW }); + expect(r.value).toBe('CASE-0042'); + expect(r.prefix).toBe('CASE-'); + expect(r.scope).toBe(''); // no date/field token → single global counter + }); + + it('appends the bare counter when there is no {0..0} slot', () => { + const tokens = parseAutonumberFormat('NO-SLOT'); + expect(renderAutonumber({ tokens, seq: 7, now: NOW }).value).toBe('NO-SLOT7'); + }); + + it('renders date tokens in the business timezone and scopes by the rendered prefix', () => { + const tokens = parseAutonumberFormat('AD{YYYYMMDD}{0000}'); + const shanghai = renderAutonumber({ tokens, seq: 32, now: NOW, timezone: 'Asia/Shanghai' }); + expect(shanghai.value).toBe('AD202606180032'); // local day rolled to the 18th + expect(shanghai.scope).toBe('AD20260618'); + + const utc = renderAutonumber({ tokens, seq: 32, now: NOW, timezone: 'UTC' }); + expect(utc.value).toBe('AD202606170032'); // still the 17th in UTC + expect(utc.scope).toBe('AD20260617'); + }); + + it('supports the individual {YYYY}/{YY}/{MM}/{DD} date tokens', () => { + const tokens = parseAutonumberFormat('WO-{YYYY}-{MM}-{DD}-{YY}-{000}'); + expect(renderAutonumber({ tokens, seq: 1, now: NOW, timezone: 'UTC' }).value).toBe( + 'WO-2026-06-17-26-001', + ); + }); + + it('interpolates {field} from the record and scopes per group', () => { + const tokens = parseAutonumberFormat('{section}{island_zone}{000}'); + const a = renderAutonumber({ tokens, seq: 1, now: NOW, record: { section: 'JYG', island_zone: '1A' } }); + const b = renderAutonumber({ tokens, seq: 5, now: NOW, record: { section: 'JYG', island_zone: '2B' } }); + expect(a.value).toBe('JYG1A001'); + expect(a.scope).toBe('JYG1A'); + expect(b.scope).toBe('JYG2B'); // a different island → a different counter + }); + + it('places literal/field text after the sequence into the suffix', () => { + const tokens = parseAutonumberFormat('{0000}-{section}'); + const r = renderAutonumber({ tokens, seq: 3, now: NOW, record: { section: 'X' } }); + expect(r.value).toBe('0003-X'); + expect(r.suffix).toBe('-X'); + }); + + it('renders a missing field token as empty rather than throwing', () => { + const tokens = parseAutonumberFormat('{missing}{000}'); + expect(renderAutonumber({ tokens, seq: 2, now: NOW, record: {} }).value).toBe('002'); + }); +}); diff --git a/packages/spec/src/data/autonumber-format.ts b/packages/spec/src/data/autonumber-format.ts new file mode 100644 index 0000000000..ca5fcf0ac3 --- /dev/null +++ b/packages/spec/src/data/autonumber-format.ts @@ -0,0 +1,190 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auto-number format renderer — shared by the ObjectQL engine (in-memory + * fallback) and the SQL driver (persistent atomic sequence) so both paths + * render identical record numbers from one `autonumberFormat` string (#1603). + * + * A format is literal text interleaved with `{...}` tokens: + * + * - Sequence token `{0000}` — one or more `0`s. The running counter, + * zero-padded to that width. At most one per format; a format with none + * appends the bare counter (legacy behaviour). + * - Date tokens `{YYYY} {YY} {MM} {DD} {YYYYMMDD}` — the generation date in + * the request's business timezone (ADR-0053), falling back to UTC. + * - Field tokens `{field_name}` — the value of another field on the SAME + * record (e.g. `{island_zone}`, `{plan_no}`), interpolated as a string. + * - Everything else is literal text. + * + * The counter is scoped to whatever the tokens BEFORE the sequence render to + * (the "scope"): `AD{YYYYMMDD}{0000}` counts per day, `{island_zone}{000}` + * counts per island, `{plan_no}{000}` counts per parent record. A fresh scope + * value starts a fresh count — so period reset (yearly/monthly/daily) and + * per-group numbering both fall out of one mechanism with no extra config. + * + * Backward compatibility: a format with NO date/field tokens (e.g. + * `CASE-{0000}`) has an empty scope, so existing fixed-prefix sequences keep + * their single global counter and their behaviour is unchanged. + */ + +export type AutonumberToken = + | { kind: 'literal'; text: string } + | { kind: 'date'; pattern: 'YYYY' | 'YY' | 'MM' | 'DD' | 'YYYYMMDD' } + | { kind: 'field'; field: string } + | { kind: 'seq'; width: number }; + +const DATE_PATTERNS = new Set(['YYYY', 'YY', 'MM', 'DD', 'YYYYMMDD']); + +/** + * Parse an `autonumberFormat` into an ordered token list. An unrecognized + * `{...}` group is kept verbatim as literal text, so a stray brace or a typo + * never throws — it just renders literally. + */ +export function parseAutonumberFormat(format: string): AutonumberToken[] { + const tokens: AutonumberToken[] = []; + if (!format) return tokens; + const re = /\{([^{}]*)\}/g; + let last = 0; + let m: RegExpExecArray | null; + let seenSeq = false; + while ((m = re.exec(format)) !== null) { + if (m.index > last) tokens.push({ kind: 'literal', text: format.slice(last, m.index) }); + const body = m[1]; + if (/^0+$/.test(body) && !seenSeq) { + // First `{0..0}` is the sequence slot. A second one is ambiguous, so + // treat it as literal rather than silently producing two counters. + tokens.push({ kind: 'seq', width: body.length }); + seenSeq = true; + } else if (DATE_PATTERNS.has(body)) { + tokens.push({ kind: 'date', pattern: body as 'YYYY' | 'YY' | 'MM' | 'DD' | 'YYYYMMDD' }); + } else if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(body)) { + tokens.push({ kind: 'field', field: body }); + } else { + tokens.push({ kind: 'literal', text: m[0] }); + } + last = re.lastIndex; + } + if (last < format.length) tokens.push({ kind: 'literal', text: format.slice(last) }); + return tokens; +} + +/** True when the format interpolates anything date- or record-dependent. */ +export function hasDynamicTokens(tokens: AutonumberToken[]): boolean { + return tokens.some((t) => t.kind === 'date' || t.kind === 'field'); +} + +/** The sequence token's pad width, or `fallback` (default 4) when none. */ +export function sequenceWidth(tokens: AutonumberToken[], fallback = 4): number { + const seq = tokens.find((t) => t.kind === 'seq'); + return seq && seq.kind === 'seq' ? seq.width : fallback; +} + +/** Field names referenced by `{field}` tokens (for read-back / validation). */ +export function referencedFields(tokens: AutonumberToken[]): string[] { + return tokens.filter((t): t is Extract => t.kind === 'field').map((t) => t.field); +} + +interface CalendarParts { + YYYY: string; + YY: string; + MM: string; + DD: string; +} + +/** Resolve the calendar Y/M/D of `now` in `timezone` (IANA), UTC on failure. */ +function calendarParts(now: Date, timezone?: string): CalendarParts { + let y: string; + let mo: string; + let d: string; + try { + const fmt = new Intl.DateTimeFormat('en-CA', { + timeZone: timezone || 'UTC', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + const parts = fmt.formatToParts(now); + y = parts.find((p) => p.type === 'year')?.value ?? '0000'; + mo = parts.find((p) => p.type === 'month')?.value ?? '01'; + d = parts.find((p) => p.type === 'day')?.value ?? '01'; + } catch { + y = String(now.getUTCFullYear()).padStart(4, '0'); + mo = String(now.getUTCMonth() + 1).padStart(2, '0'); + d = String(now.getUTCDate()).padStart(2, '0'); + } + return { YYYY: y, YY: y.slice(-2), MM: mo, DD: d }; +} + +function renderDate(pattern: string, p: CalendarParts): string { + switch (pattern) { + case 'YYYY': return p.YYYY; + case 'YY': return p.YY; + case 'MM': return p.MM; + case 'DD': return p.DD; + case 'YYYYMMDD': return `${p.YYYY}${p.MM}${p.DD}`; + default: return ''; + } +} + +export interface RenderAutonumberInput { + /** Parsed tokens (from {@link parseAutonumberFormat}). */ + tokens: AutonumberToken[]; + /** The reserved counter value. */ + seq: number; + /** The record being written — source for `{field}` interpolation. */ + record?: Record; + /** Generation instant. Callers pass an explicit `Date` (no implicit clock). */ + now: Date; + /** Business timezone for date tokens (ADR-0053); falls back to UTC. */ + timezone?: string; +} + +export interface RenderedAutonumber { + /** Rendered text before the sequence slot. */ + prefix: string; + /** Rendered text after the sequence slot. */ + suffix: string; + /** + * Counter scope: the rendered prefix when the format has date/field tokens, + * else '' (legacy fixed-prefix formats keep one global counter under a + * stable empty scope). Drives per-period / per-group numbering and reset. + */ + scope: string; + /** Final value: prefix + zero-padded(seq) + suffix. */ + value: string; +} + +/** + * Render a record number for a reserved counter value. Pure: identical inputs + * yield identical output, which is what lets the engine and the SQL driver + * agree on the same string. + */ +export function renderAutonumber(input: RenderAutonumberInput): RenderedAutonumber { + const { tokens, seq, record, now, timezone } = input; + const dp = calendarParts(now, timezone); + let prefix = ''; + let suffix = ''; + let width: number | null = null; + for (const t of tokens) { + if (t.kind === 'seq') { + width = t.width; + continue; + } + let piece = ''; + if (t.kind === 'literal') piece = t.text; + else if (t.kind === 'date') piece = renderDate(t.pattern, dp); + else if (t.kind === 'field') { + const v = record ? record[t.field] : undefined; + piece = v == null ? '' : String(v); + } + if (width === null) prefix += piece; + else suffix += piece; + } + const dynamic = hasDynamicTokens(tokens); + const scope = dynamic ? prefix : ''; + const value = width === null + // No `{0..0}` slot — append the bare counter (legacy behaviour). + ? `${prefix}${seq}` + : `${prefix}${String(seq).padStart(width, '0')}${suffix}`; + return { prefix, suffix, scope, value }; +} diff --git a/packages/spec/src/data/driver.zod.ts b/packages/spec/src/data/driver.zod.ts index 4065204b5a..7d27cccdd0 100644 --- a/packages/spec/src/data/driver.zod.ts +++ b/packages/spec/src/data/driver.zod.ts @@ -37,6 +37,14 @@ export const DriverOptionsSchema = lazySchema(() => z.object({ * For multi-tenant databases (row-level security or schema-per-tenant). */ tenantId: z.string().optional().describe('Tenant Isolation identifier'), + + /** + * Business reference timezone (IANA name, e.g. `Asia/Shanghai`) for the + * request, threaded from `ExecutionContext.timezone` (ADR-0053). Drivers that + * generate date-dependent values — notably `autonumber` date tokens + * (`{YYYYMMDD}`) — resolve the calendar day in this zone, falling back to UTC. + */ + timezone: z.string().optional().describe('Business reference timezone (IANA) for date-dependent generation, e.g. autonumber date tokens'), })); /** diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 9a5d695d15..235fb0eccf 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -528,7 +528,25 @@ export const FieldSchema = lazySchema(() => z.object({ sortable: z.boolean().optional().default(true).describe('Whether field is sortable in list views'), inlineHelpText: z.string().optional().describe('Help text displayed below the field in forms'), caseSensitive: z.boolean().optional().describe('Whether text comparisons are case-sensitive'), - autonumberFormat: z.string().optional().describe('Auto-number display format pattern (e.g., "CASE-{0000}")'), + /** + * Auto-number display format. Literal text interleaved with `{...}` tokens: + * + * - `{0000}` — the sequence counter, zero-padded to that many digits + * (at most one slot; omit it and the bare number is appended). + * - `{YYYY} {YY} {MM} {DD} {YYYYMMDD}` — generation date in the request's + * business timezone (`ExecutionContext.timezone`, ADR-0053; UTC fallback). + * - `{field_name}` — the value of another field on the SAME record + * (e.g. `{island_zone}`, `{plan_no}`), interpolated as text. + * + * The counter is scoped to whatever renders BEFORE the `{0000}` slot, so the + * period/group resets fall out automatically — no separate reset config: + * - `AD{YYYYMMDD}{0000}` → `AD202606170032` (resets each day) + * - `{section}{island_zone}{000}` → `JYG1A001` (per island) + * - `{plan_no}{000}` → `…PROD20260617001001` (per parent record) + * A fixed-prefix format with no date/field token (e.g. `CASE-{0000}`) keeps a + * single global counter — fully backward compatible. + */ + autonumberFormat: z.string().optional().describe('Auto-number format: literal text + {0000} counter, {YYYY}/{MM}/{DD}/{YYYYMMDD} date tokens (business tz), and {field_name} interpolation. Counter resets per rendered prefix (e.g. AD{YYYYMMDD}{0000} resets daily).'), /** Indexing */ index: z.boolean().default(false).describe('Create standard database index'), externalId: z.boolean().default(false).describe('Is external ID for upsert operations'), diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index 54874be87a..48a4d9d251 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -5,6 +5,7 @@ export * from './filter.zod'; export * from './date-macros.zod'; export * from './object.zod'; export * from './field.zod'; +export * from './autonumber-format'; export * from './validation.zod'; export * from './hook.zod'; export * from './hook-body.zod'; From 0f8786c03398a65d35028fc80d348746215aac8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Fri, 19 Jun 2026 10:55:46 +0800 Subject: [PATCH 2/4] fix(autonumber): drop backtracking lookahead in seed scan (ReDoS) The empty-prefix legacy branch used /(\d+)(?!.*\d)/ to grab the last digit run, whose negative lookahead is a polynomial-ReDoS sink on stored values with many repeated zeros (CodeQL js/polynomial-redos, high). Replace both branches with the linear /\d+/g, preserving the last-digit-run semantics. --- packages/objectql/src/engine.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 0706ad83f6..156470d966 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -865,8 +865,19 @@ export class ObjectQL implements IDataEngine { const s = String(v); if (prefix && !s.startsWith(prefix)) continue; const tail = prefix ? s.slice(prefix.length) : s; - const m = prefix ? tail.match(/^(\d+)/) : tail.match(/(\d+)(?!.*\d)/); - if (m) max = Math.max(max, parseInt(m[1], 10) || 0); + // With a prefix the counter is the digit run right after it; without one + // (legacy fixed-prefix formats) it is the LAST digit run. Both use the + // linear /\d+/g — a backtracking lookahead here is a polynomial-ReDoS + // sink on stored values full of zeros (CodeQL js/polynomial-redos). + let digits: string | undefined; + if (prefix) { + const head = tail.match(/^\d+/); + digits = head ? head[0] : undefined; + } else { + const runs = tail.match(/\d+/g); + digits = runs ? runs[runs.length - 1] : undefined; + } + if (digits) max = Math.max(max, parseInt(digits, 10) || 0); } return max; } catch { From 6b695fd931c18c03672a0428d583711a939d22be Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Fri, 19 Jun 2026 17:40:38 +0800 Subject: [PATCH 3/4] fix(autonumber): guard {field} interpolation footguns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three guardrails on top of the {field}/date/per-scope autonumber work: - Empty interpolated {field} now throws (shared missingFieldValues helper) in both the SQL driver and the engine fallback, instead of silently collapsing the record into the wrong counter scope. - Build-time lint (objectstack compile): unknown / self-referencing {field} fails the build; an optional {field} warns to mark it required. - Legacy _objectstack_sequences PK-widen failure fails safe — fixed-prefix sequences keep working and a per-scope write raises an actionable error rather than an opaque DB primary-key violation at insert time. Co-Authored-By: Claude Opus 4.8 --- .changeset/autonumber-format-tokens.md | 17 +++ packages/cli/src/commands/compile.ts | 33 ++++++ .../src/utils/lint-autonumber-formats.test.ts | 94 ++++++++++++++++ .../cli/src/utils/lint-autonumber-formats.ts | 105 ++++++++++++++++++ .../src/engine-autonumber-defer.test.ts | 18 +++ packages/objectql/src/engine.ts | 13 ++- .../src/sql-driver-autonumber-tokens.test.ts | 15 +++ packages/plugins/driver-sql/src/sql-driver.ts | 49 +++++++- .../spec/src/data/autonumber-format.test.ts | 25 +++++ packages/spec/src/data/autonumber-format.ts | 19 ++++ packages/spec/src/data/field.zod.ts | 6 + 11 files changed, 388 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/utils/lint-autonumber-formats.test.ts create mode 100644 packages/cli/src/utils/lint-autonumber-formats.ts diff --git a/.changeset/autonumber-format-tokens.md b/.changeset/autonumber-format-tokens.md index 0c97393620..769a1b34b5 100644 --- a/.changeset/autonumber-format-tokens.md +++ b/.changeset/autonumber-format-tokens.md @@ -2,6 +2,7 @@ "@objectstack/spec": minor "@objectstack/objectql": minor "@objectstack/driver-sql": minor +"@objectstack/cli": minor --- feat(autonumber): date, {field} and per-scope counter reset for autonumber formats @@ -28,3 +29,19 @@ global counter, so existing sequences are unchanged. The persistent `_objectstack_sequences` table gains a `scope` column (PK widened to `object, tenant_id, field, scope`); deployments with the legacy 3-column table are migrated in place on first use, carrying existing counters to `scope=''`. + +Guardrails against the `{field}` footguns: + +- **Empty interpolated field is a hard error, not a silent mis-number.** A + `{field}` token whose value is missing at create time would render to an empty + prefix and collapse the record into the wrong counter scope. Both the SQL driver + and the engine fallback now refuse to generate and throw a clear error naming the + empty field (shared `missingFieldValues` helper). +- **Build-time lint (`@objectstack/cli compile`).** `autonumber` formats are + checked against the object's fields: a `{field}` token naming a non-existent + field (or the autonumber field itself) **fails the build**; a token naming an + *optional* field emits an advisory warning to mark it `required: true`. +- **Legacy sequence-table migration fails safe.** If the legacy table's primary + key cannot be widened to include `scope`, fixed-prefix sequences keep working and + a per-scope write raises an actionable error instead of an opaque DB primary-key + violation at insert time. diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 3a44a236e5..6dd2eee856 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -11,6 +11,7 @@ import { lowerCallables } from '../utils/lower-callables.js'; import { validateStackExpressions } from '../utils/validate-expressions.js'; import { validateWidgetBindings } from '../utils/validate-widget-bindings.js'; import { lintFlowPatterns } from '../utils/lint-flow-patterns.js'; +import { lintAutonumberFormats } from '../utils/lint-autonumber-formats.js'; import { lintLivenessProperties } from '../utils/lint-liveness-properties.js'; import { collectAndLintDocs } from '../utils/collect-docs.js'; import { buildRuntimeBundle, cleanupOldRuntimeBundles } from '../utils/build-runtime.js'; @@ -225,6 +226,38 @@ export default class Compile extends Command { } } + // 3d-ter. Autonumber `{field}` interpolation lint. A format like + // `{plan_no}{000}` makes the referenced field part of the counter + // scope, so it must exist and be set at create time — otherwise the + // runtime throws (or, unlinted, silently mis-numbers). An unknown + // field is broken → fails the build; an optional field is fragile → + // advisory warning. Mirrors the broken/fragile two-level guardrail. + const autonumberLint = lintAutonumberFormats(result.data as Record); + const autonumberErrors = autonumberLint.filter((f) => f.severity === 'error'); + const autonumberWarnings = autonumberLint.filter((f) => f.severity === 'warning'); + if (autonumberErrors.length > 0) { + if (flags.json) { + console.log(JSON.stringify({ success: false, error: 'autonumber format validation failed', issues: autonumberErrors })); + this.exit(1); + } + console.log(''); + printError(`Autonumber format validation failed (${autonumberErrors.length} issue${autonumberErrors.length > 1 ? 's' : ''})`); + for (const f of autonumberErrors) { + console.log(` • ${f.where}: ${f.message}`); + console.log(chalk.dim(` ${f.hint}`)); + console.log(chalk.dim(` rule: ${f.rule}`)); + } + this.exit(1); + } + if (autonumberWarnings.length > 0 && !flags.json) { + console.log(''); + for (const f of autonumberWarnings) { + printWarning(`${f.where}: ${f.message}`); + console.log(chalk.dim(` ${f.hint}`)); + console.log(chalk.dim(` rule: ${f.rule}`)); + } + } + // 3d. Package docs (ADR-0046): compile flat `src/docs/*.md` into // `docs: DocSchema[]` and lint the combined set (flatness, // namespace-prefixed names, MDX/image ban, same-package link diff --git a/packages/cli/src/utils/lint-autonumber-formats.test.ts b/packages/cli/src/utils/lint-autonumber-formats.test.ts new file mode 100644 index 0000000000..a870e4041b --- /dev/null +++ b/packages/cli/src/utils/lint-autonumber-formats.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + lintAutonumberFormats, + AUTONUMBER_UNKNOWN_FIELD, + AUTONUMBER_OPTIONAL_FIELD, + AUTONUMBER_SELF_REFERENCE, +} from './lint-autonumber-formats.js'; + +describe('lintAutonumberFormats', () => { + it('passes a date-only / fixed-prefix format with no {field} tokens', () => { + const stack = { + objects: [ + { name: 'audit', fields: { audit_no: { type: 'autonumber', autonumberFormat: 'AD{YYYYMMDD}{0000}' } } }, + { name: 'case', fields: { case_no: { type: 'autonumber', autonumberFormat: 'CASE-{0000}' } } }, + ], + }; + expect(lintAutonumberFormats(stack)).toEqual([]); + }); + + it('passes when every {field} token is a required field on the object', () => { + const stack = { + objects: [ + { + name: 'task', + fields: { + section: { type: 'text', required: true }, + island_zone: { type: 'text', required: true }, + task_no: { type: 'autonumber', autonumberFormat: '{section}{island_zone}{000}' }, + }, + }, + ], + }; + expect(lintAutonumberFormats(stack)).toEqual([]); + }); + + it('errors when a {field} token names a non-existent field', () => { + const stack = { + objects: [ + { name: 'task', fields: { task_no: { type: 'autonumber', autonumberFormat: '{plan_no}{000}' } } }, + ], + }; + const out = lintAutonumberFormats(stack); + expect(out).toHaveLength(1); + expect(out[0].severity).toBe('error'); + expect(out[0].rule).toBe(AUTONUMBER_UNKNOWN_FIELD); + }); + + it('warns when a {field} token names an optional field', () => { + const stack = { + objects: [ + { + name: 'task', + fields: { + plan_no: { type: 'text' }, // not required + task_no: { type: 'autonumber', autonumberFormat: '{plan_no}{000}' }, + }, + }, + ], + }; + const out = lintAutonumberFormats(stack); + expect(out).toHaveLength(1); + expect(out[0].severity).toBe('warning'); + expect(out[0].rule).toBe(AUTONUMBER_OPTIONAL_FIELD); + }); + + it('errors when the format interpolates the autonumber field itself', () => { + const stack = { + objects: [ + { name: 'task', fields: { task_no: { type: 'autonumber', autonumberFormat: '{task_no}{000}' } } }, + ], + }; + const out = lintAutonumberFormats(stack); + expect(out).toHaveLength(1); + expect(out[0].severity).toBe('error'); + expect(out[0].rule).toBe(AUTONUMBER_SELF_REFERENCE); + }); + + it('handles array-shaped fields and the `format` shorthand', () => { + const stack = { + objects: [ + { + name: 'task', + fields: [ + { name: 'plan_no', type: 'text', required: true }, + { name: 'task_no', type: 'autonumber', format: '{plan_no}{000}' }, + ], + }, + ], + }; + expect(lintAutonumberFormats(stack)).toEqual([]); + }); +}); diff --git a/packages/cli/src/utils/lint-autonumber-formats.ts b/packages/cli/src/utils/lint-autonumber-formats.ts new file mode 100644 index 0000000000..e2e6938a3e --- /dev/null +++ b/packages/cli/src/utils/lint-autonumber-formats.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Build-time lint for `autonumber` field formats. An `autonumberFormat` may + * interpolate other fields of the same record (`{plan_no}{000}`, + * `{section}{island_zone}{000}`). That field value forms the counter SCOPE, so + * if it is missing at create time the record number silently collapses into the + * wrong scope — and the runtime now throws rather than emit a wrong number + * (see sql-driver / engine `missingFieldValues`). This lint catches the two + * ways an author (very often an AI generating templates) gets that wrong, + * BEFORE it ships: + * + * - ERROR: `{field}` names a field that does not exist on the object — the + * generation will always throw. This is broken, so it fails the build. + * - WARNING: `{field}` names an OPTIONAL field — generation throws on any + * record left blank. The robust shape marks the referenced field + * `required: true` (mirroring ERPNext/Odoo, where a field that drives the + * naming series must be mandatory). Advisory; does not fail the build. + * + * A self-reference (`{self}` on the autonumber field itself) is always an + * ERROR — the value does not exist yet when the format renders. + */ + +import { parseAutonumberFormat, referencedFields } from '@objectstack/spec/data'; + +export interface AutonumberLintFinding { + where: string; + message: string; + hint: string; + rule: string; + severity: 'error' | 'warning'; +} + +type AnyRec = Record; + +export const AUTONUMBER_UNKNOWN_FIELD = 'autonumber-references-unknown-field'; +export const AUTONUMBER_OPTIONAL_FIELD = 'autonumber-references-optional-field'; +export const AUTONUMBER_SELF_REFERENCE = 'autonumber-references-self'; + +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v as AnyRec[]; + if (v && typeof v === 'object') { + return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); + } + return []; +} + +/** + * Lint every `autonumber` field's format for unresolvable / fragile `{field}` + * interpolation. Returns a (possibly empty) list of findings; never throws. + */ +export function lintAutonumberFormats(stack: AnyRec): AutonumberLintFinding[] { + const findings: AutonumberLintFinding[] = []; + for (const obj of asArray(stack.objects)) { + const objectName = typeof obj.name === 'string' ? obj.name : '(unnamed object)'; + const fields = asArray(obj.fields); + // name → required?, for schema-aware reference checks. + const fieldMeta = new Map(); + for (const f of fields) { + if (typeof f.name === 'string') fieldMeta.set(f.name, { required: f.required === true }); + } + + for (const f of fields) { + if (f.type !== 'autonumber') continue; + const name = typeof f.name === 'string' ? f.name : '(unnamed field)'; + const fmt = typeof f.autonumberFormat === 'string' + ? f.autonumberFormat + : (typeof f.format === 'string' ? f.format : ''); + if (!fmt) continue; + const refs = referencedFields(parseAutonumberFormat(fmt)); + const where = `object '${objectName}' · field '${name}' (autonumber "${fmt}")`; + for (const ref of refs) { + if (ref === name) { + findings.push({ + where, + message: `format interpolates \`{${ref}}\` — its own value, which does not exist yet when the number is generated.`, + hint: `Reference a DIFFERENT field that is set before create (e.g. \`{plan_no}{000}\`), or drop the token.`, + rule: AUTONUMBER_SELF_REFERENCE, + severity: 'error', + }); + continue; + } + const meta = fieldMeta.get(ref); + if (!meta) { + findings.push({ + where, + message: `format interpolates \`{${ref}}\`, but object '${objectName}' has no field named '${ref}' — generation will always throw.`, + hint: `Reference an existing field, or remove the \`{${ref}}\` token from the format.`, + rule: AUTONUMBER_UNKNOWN_FIELD, + severity: 'error', + }); + } else if (!meta.required) { + findings.push({ + where, + message: `format interpolates \`{${ref}}\`, but '${ref}' is optional — any record left blank fails autonumber generation at create time.`, + hint: `Mark '${ref}' as \`required: true\` so it is always set before the record number is rendered.`, + rule: AUTONUMBER_OPTIONAL_FIELD, + severity: 'warning', + }); + } + } + } + } + return findings; +} diff --git a/packages/objectql/src/engine-autonumber-defer.test.ts b/packages/objectql/src/engine-autonumber-defer.test.ts index 2ff9759e25..04626693b8 100644 --- a/packages/objectql/src/engine-autonumber-defer.test.ts +++ b/packages/objectql/src/engine-autonumber-defer.test.ts @@ -151,4 +151,22 @@ describe('ObjectQL autonumber ownership (#1603)', () => { // Today's UTC day + a fresh per-day counter. expect(r.audit_no).toMatch(/^AD\d{8}0001$/); }); + + it('fallback refuses to generate when an interpolated {field} is empty', async () => { + const TASK_SCHEMA = { + name: 'task', + fields: { + zone: { type: 'text' }, + task_no: { type: 'autonumber', format: '{zone}{000}' }, + }, + }; + vi.mocked(SchemaRegistry.getObject).mockReturnValue(TASK_SCHEMA as any); + const driver = makeDriver(false); + engine.registerDriver(driver, true); + await engine.init(); + + // zone left blank → the prefix collapses to '' and would mis-scope the + // counter, so generation must throw rather than emit a wrong number. + await expect(engine.insert('task', {})).rejects.toThrow(/zone/); + }); }); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 156470d966..0b5593bd9d 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -10,7 +10,7 @@ import { EngineAggregateOptions, EngineCountOptions } from '@objectstack/spec/data'; -import { parseAutonumberFormat, renderAutonumber } from '@objectstack/spec/data'; +import { parseAutonumberFormat, renderAutonumber, missingFieldValues } from '@objectstack/spec/data'; import { ExecutionContext, ExecutionContextSchema } from '@objectstack/spec/kernel'; import { DriverInterface, IDataEngine, Logger, createLogger } from '@objectstack/core'; import { CoreServiceName, StorageNameMapping } from '@objectstack/spec/system'; @@ -826,6 +826,17 @@ export class ObjectQL implements IDataEngine { // `format` (both appear in metadata; the driver reads both too) — #1603. const fmt = (def as any).autonumberFormat ?? (def as any).format; const tokens = parseAutonumberFormat(typeof fmt === 'string' ? fmt : ''); + // Refuse to generate when an interpolated `{field}` is empty — it would + // render to an empty prefix and merge this record into the wrong counter + // scope. Mirror the SQL driver so both paths fail identically (#1603). + const missing = missingFieldValues(tokens, record); + if (missing.length > 0) { + throw new Error( + `Cannot generate autonumber "${object}.${name}" (format "${fmt}"): ` + + `referenced field(s) [${missing.join(', ')}] are empty on the record. ` + + `Fields interpolated into an autonumber format must be set before the record is created.`, + ); + } // The counter scope is the rendered prefix (date/field tokens before the // sequence slot); it is independent of the counter value, so a throwaway // render with seq 0 yields the scope and the literal prefix to seed from. diff --git a/packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts b/packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts index 3fe1d61b88..a61277fc7f 100644 --- a/packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts @@ -123,6 +123,21 @@ describe('SqlDriver auto_number format tokens', () => { expect(b.plan_no).toBe(`PROD${utcYmd()}001`); }); + it('refuses to generate when an interpolated {field} is empty (no silent mis-scope)', async () => { + await driver.initObjects([ + { + name: 'dispatch_order', + fields: { + plan_no: { type: 'string' }, + order_no: { type: 'autonumber', format: '{plan_no}{000}' }, + }, + }, + ]); + // plan_no left blank → the prefix would collapse to '' and merge this row + // into the wrong counter scope, so generation must throw instead. + await expect(driver.create('dispatch_order', {})).rejects.toThrow(/plan_no/); + }); + it('leaves fixed-prefix formats on a single global counter (backward compatible)', async () => { await driver.initObjects([ { name: 'invoice', fields: { invoice_number: { type: 'autonumber', format: 'INV-{0000}' } } }, diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 317c7c5127..4be4c6314d 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -8,7 +8,7 @@ */ import type { QueryAST, DriverOptions, SchemaMode } from '@objectstack/spec/data'; -import { parseAutonumberFormat, renderAutonumber, type AutonumberToken } from '@objectstack/spec/data'; +import { parseAutonumberFormat, renderAutonumber, missingFieldValues, type AutonumberToken } from '@objectstack/spec/data'; import type { IDataDriver } from '@objectstack/spec/contracts'; import { StorageNameMapping } from '@objectstack/spec/system'; import { ExternalSchemaModeViolationError } from '@objectstack/spec/shared'; @@ -219,6 +219,14 @@ export class SqlDriver implements IDataDriver { /** Whether the sequences table has been ensured this process. */ protected sequencesTableReady = false; + /** + * Set when a legacy 3-column `_objectstack_sequences` table could not have + * its primary key widened to include `scope`. Fixed-prefix sequences (empty + * scope) keep working under the old PK; only a per-scope write would collide, + * so `getNextSequenceValue` raises an actionable error in that case rather + * than letting an opaque PK violation surface at create time. + */ + protected sequencesScopeKeyUnsafe = false; /** In-flight ensure promise; deduplicates concurrent first calls. */ protected sequencesTableEnsurePromise: Promise | null = null; @@ -642,10 +650,18 @@ export class SqlDriver implements IDataDriver { ]); } } catch (err) { - // Widening the PK is best-effort; the column itself is what new writes - // need. A stale 3-column PK only bites if a field's format flips from - // fixed to dynamic on an already-seeded deployment. - this.logger.warn('Failed to widen _objectstack_sequences primary key', { error: String(err) }); + // The `scope` column landed (new writes can record it) but the PK is + // still the legacy 3-column shape. Fixed-prefix sequences are unaffected; + // a per-scope write, however, would hit an opaque PK violation at insert + // time. Flag it so getNextSequenceValue can fail loudly and actionably + // instead, and surface it at error level for operators to migrate by hand. + this.sequencesScopeKeyUnsafe = true; + this.logger.warn( + `[autonumber] Failed to widen ${SEQUENCES_TABLE} primary key to include "scope". ` + + `Fixed-prefix autonumbers keep working; date/{field}/per-parent formats will ` + + `error until the primary key is migrated to (object, tenant_id, field, scope) by hand.`, + { error: String(err) }, + ); } } @@ -705,6 +721,17 @@ export class SqlDriver implements IDataDriver { scope = '', ): Promise { await this.ensureSequencesTable(); + if (scope !== '' && this.sequencesScopeKeyUnsafe) { + // The legacy sequences table could not have its PK widened to include + // `scope`, so a second scope under the same (object, tenant, field) would + // collide. Fail with a clear, actionable message instead of an opaque + // database PK violation at insert time. + throw new Error( + `Cannot generate a per-scope autonumber for "${object}.${field}": the ` + + `${SEQUENCES_TABLE} primary key is still the legacy 3-column shape. ` + + `Migrate it to (object, tenant_id, field, scope) before using date/{field}/per-parent formats.`, + ); + } const resolvedTenantId = tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT; // `scope` (rendered date/field prefix) gives each period/group its own // counter; '' keeps the single global counter for fixed-prefix formats. @@ -773,6 +800,18 @@ export class SqlDriver implements IDataDriver { const now = new Date(); for (const cfg of cfgs) { if (row[cfg.name] !== undefined && row[cfg.name] !== null && row[cfg.name] !== '') continue; + // A `{field}` token with no value would render to an empty prefix and + // silently merge this record into the wrong counter scope, so refuse to + // generate rather than emit a wrong record number (the referenced field + // must be populated before the autonumber — see field.zod docs). + const missing = missingFieldValues(cfg.tokens, row); + if (missing.length > 0) { + throw new Error( + `Cannot generate autonumber "${object}.${cfg.name}" (format "${cfg.format}"): ` + + `referenced field(s) [${missing.join(', ')}] are empty on the record. ` + + `Fields interpolated into an autonumber format must be set before the record is created.`, + ); + } // Resolve tenant for this row: explicit field on the record wins, // then driver options, else null → global sequence. const rowTenant = cfg.tenantField ? row[cfg.tenantField] : undefined; diff --git a/packages/spec/src/data/autonumber-format.test.ts b/packages/spec/src/data/autonumber-format.test.ts index c3e675f18e..c652c60735 100644 --- a/packages/spec/src/data/autonumber-format.test.ts +++ b/packages/spec/src/data/autonumber-format.test.ts @@ -7,6 +7,7 @@ import { hasDynamicTokens, sequenceWidth, referencedFields, + missingFieldValues, } from './autonumber-format'; // A fixed instant: 2026-06-17 21:30 UTC. In Asia/Shanghai (UTC+8) this is @@ -55,6 +56,30 @@ describe('parseAutonumberFormat', () => { }); }); +describe('missingFieldValues', () => { + const tokens = parseAutonumberFormat('{section}{island_zone}{000}'); + + it('lists {field} tokens with no value on the record (null/undefined/empty)', () => { + expect(missingFieldValues(tokens, { section: 'JYG', island_zone: '1A' })).toEqual([]); + expect(missingFieldValues(tokens, { section: 'JYG' })).toEqual(['island_zone']); + expect(missingFieldValues(tokens, { section: '', island_zone: null })).toEqual([ + 'section', + 'island_zone', + ]); + expect(missingFieldValues(tokens, undefined)).toEqual(['section', 'island_zone']); + }); + + it('treats 0 and false as present (only null/undefined/"" are missing)', () => { + const t = parseAutonumberFormat('{n}{flag}{000}'); + expect(missingFieldValues(t, { n: 0, flag: false })).toEqual([]); + }); + + it('returns nothing for date-only / fixed-prefix formats (no {field} tokens)', () => { + expect(missingFieldValues(parseAutonumberFormat('AD{YYYYMMDD}{0000}'), {})).toEqual([]); + expect(missingFieldValues(parseAutonumberFormat('CASE-{0000}'), {})).toEqual([]); + }); +}); + describe('renderAutonumber', () => { it('renders a fixed prefix with an empty scope (backward compatible)', () => { const tokens = parseAutonumberFormat('CASE-{0000}'); diff --git a/packages/spec/src/data/autonumber-format.ts b/packages/spec/src/data/autonumber-format.ts index ca5fcf0ac3..e805a4e3a4 100644 --- a/packages/spec/src/data/autonumber-format.ts +++ b/packages/spec/src/data/autonumber-format.ts @@ -84,6 +84,25 @@ export function referencedFields(tokens: AutonumberToken[]): string[] { return tokens.filter((t): t is Extract => t.kind === 'field').map((t) => t.field); } +/** + * `{field}` tokens whose value is missing on the record (null / undefined / + * empty string). Such a field would silently render to an empty prefix and + * collapse the counter into the wrong scope (a different group's sequence), so + * callers should refuse to generate rather than emit a wrong record number. + * Returns the referenced field names in format order, deduplicated. + */ +export function missingFieldValues( + tokens: AutonumberToken[], + record?: Record, +): string[] { + const missing: string[] = []; + for (const field of referencedFields(tokens)) { + const v = record ? record[field] : undefined; + if ((v == null || v === '') && !missing.includes(field)) missing.push(field); + } + return missing; +} + interface CalendarParts { YYYY: string; YY: string; diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 235fb0eccf..0a613d2c7f 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -545,6 +545,12 @@ export const FieldSchema = lazySchema(() => z.object({ * - `{plan_no}{000}` → `…PROD20260617001001` (per parent record) * A fixed-prefix format with no date/field token (e.g. `CASE-{0000}`) keeps a * single global counter — fully backward compatible. + * + * A `{field}` token must name an EXISTING field that is SET before the record + * is created (mark it `required: true`). An empty interpolated field would + * collapse the number into the wrong counter scope, so generation throws + * instead; `objectstack compile` lints this (unknown field → build error, + * optional field → warning). */ autonumberFormat: z.string().optional().describe('Auto-number format: literal text + {0000} counter, {YYYY}/{MM}/{DD}/{YYYYMMDD} date tokens (business tz), and {field_name} interpolation. Counter resets per rendered prefix (e.g. AD{YYYYMMDD}{0000} resets daily).'), /** Indexing */ From 9813c420069f81c850aa1c530f368ea272074779 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Fri, 19 Jun 2026 18:00:34 +0800 Subject: [PATCH 4/4] fix(autonumber): hash-keyed sequence table; clarify scope & width semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/autonumber-format-tokens.md | 30 ++- .../src/sql-driver-autonumber-tokens.test.ts | 73 ++++++- packages/plugins/driver-sql/src/sql-driver.ts | 191 +++++++++--------- .../spec/src/data/autonumber-format.test.ts | 23 +++ packages/spec/src/data/autonumber-format.ts | 6 + packages/spec/src/data/field.zod.ts | 5 +- 6 files changed, 226 insertions(+), 102 deletions(-) diff --git a/.changeset/autonumber-format-tokens.md b/.changeset/autonumber-format-tokens.md index 769a1b34b5..7750854d2e 100644 --- a/.changeset/autonumber-format-tokens.md +++ b/.changeset/autonumber-format-tokens.md @@ -26,11 +26,14 @@ emit byte-identical numbers (#1603 parity): Fixed-prefix formats like `CASE-{0000}` render an empty scope and keep their single global counter, so existing sequences are unchanged. The persistent -`_objectstack_sequences` table gains a `scope` column (PK widened to -`object, tenant_id, field, scope`); deployments with the legacy 3-column table are -migrated in place on first use, carrying existing counters to `scope=''`. +`_objectstack_sequences` table is keyed by a `key_hash` (SHA-256 of +`object, tenant_id, field, scope`) — a single 64-char primary key that keys every +dialect uniformly, stays within MySQL's utf8mb4 index-length limit (four raw +columns would not), and lets `scope` be a generous non-indexed column. Deployments +with an older table (3-column, or an interim `scope` column) are migrated in place +on first use, carrying existing counters to `scope=''`. -Guardrails against the `{field}` footguns: +Guardrails: - **Empty interpolated field is a hard error, not a silent mis-number.** A `{field}` token whose value is missing at create time would render to an empty @@ -41,7 +44,18 @@ Guardrails against the `{field}` footguns: checked against the object's fields: a `{field}` token naming a non-existent field (or the autonumber field itself) **fails the build**; a token naming an *optional* field emits an advisory warning to mark it `required: true`. -- **Legacy sequence-table migration fails safe.** If the legacy table's primary - key cannot be widened to include `scope`, fixed-prefix sequences keep working and - a per-scope write raises an actionable error instead of an opaque DB primary-key - violation at insert time. +- **Migration fails safe.** If a legacy table cannot be migrated to the `key_hash` + shape, fixed-prefix sequences keep working via the legacy key and a per-scope + write raises an actionable error instead of corrupting counters. +- **Long `{field}` scopes are supported** (e.g. a long `{plan_no}`): the non-indexed + `scope` column and hashed key remove the old varchar/PK length ceiling. + +Notes on inherent semantics (documented, not bugs): + +- The counter scope IS the rendered prefix. When two records' tokens render to the + same prefix string (e.g. `{a}{b}` for `('AB','C')` and `('A','BC')`) they also + render the same visible number, so they share one counter to stay unique — the + remedy for genuinely-distinct groups is an unambiguous format (a delimiter + literal between variable tokens). +- The sequence pad width is a MINIMUM; past it the number grows (`{000}` → + `1000`), it never wraps — matching mainstream autonumber semantics. diff --git a/packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts b/packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts index a61277fc7f..817f6da3be 100644 --- a/packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts @@ -123,6 +123,46 @@ describe('SqlDriver auto_number format tokens', () => { expect(b.plan_no).toBe(`PROD${utcYmd()}001`); }); + it('shares one counter when adjacent {field} values concat to the same prefix', async () => { + await driver.initObjects([ + { + name: 'task', + fields: { + a: { type: 'string' }, + b: { type: 'string' }, + task_no: { type: 'autonumber', format: '{a}{b}{000}' }, + }, + }, + ]); + // ('AB','C') and ('A','BC') both render the prefix "ABC" and thus the same + // visible number — they MUST share one counter so the numbers stay unique + // (independent counters would mint two "ABC001"s). + const x1 = await driver.create('task', { a: 'AB', b: 'C' }); + const y1 = await driver.create('task', { a: 'A', b: 'BC' }); + const x2 = await driver.create('task', { a: 'AB', b: 'C' }); + + expect(x1.task_no).toBe('ABC001'); + expect(y1.task_no).toBe('ABC002'); // same namespace, keeps climbing (unique) + expect(x2.task_no).toBe('ABC003'); + }); + + it('handles a very long {field} scope (well past varchar(255))', async () => { + await driver.initObjects([ + { + name: 'doc', + fields: { + big: { type: 'string' }, + doc_no: { type: 'autonumber', format: '{big}{000}' }, + }, + }, + ]); + const big = 'P'.repeat(400); // 400-char prefix → scope far exceeds 255 + const r1 = await driver.create('doc', { big }); + const r2 = await driver.create('doc', { big }); + expect(r1.doc_no).toBe(`${big}001`); + expect(r2.doc_no).toBe(`${big}002`); + }); + it('refuses to generate when an interpolated {field} is empty (no silent mis-scope)', async () => { await driver.initObjects([ { @@ -148,7 +188,7 @@ describe('SqlDriver auto_number format tokens', () => { expect(r2.invoice_number).toBe('INV-0002'); }); - it('migrates a legacy 3-column _objectstack_sequences table by adding scope', async () => { + it('migrates a legacy 3-column _objectstack_sequences table to the key_hash shape', async () => { const k = (driver as any).knex; // Simulate a deployment whose sequence table predates the `scope` column. await k.schema.createTable('_objectstack_sequences', (t: any) => { @@ -174,7 +214,38 @@ describe('SqlDriver auto_number format tokens', () => { const cols = await k('_objectstack_sequences').columnInfo(); expect(Object.keys(cols)).toContain('scope'); + expect(Object.keys(cols)).toContain('key_hash'); // The legacy counter continued rather than restarting. expect(r.invoice_number).toBe('INV-0042'); }); + + it('migrates an interim {scope}-column table (no key_hash) and preserves counters', async () => { + const k = (driver as any).knex; + // A table from an earlier build of this feature: has `scope`, no `key_hash`. + await k.schema.createTable('_objectstack_sequences', (t: any) => { + t.string('object').notNullable(); + t.string('tenant_id').notNullable(); + t.string('field').notNullable(); + t.string('scope').notNullable().defaultTo(''); + t.bigInteger('last_value').notNullable().defaultTo(0); + t.timestamp('updated_at').defaultTo(k.fn.now()); + t.primary(['object', 'tenant_id', 'field', 'scope']); + }); + await k('_objectstack_sequences').insert({ + object: 'invoice', + tenant_id: '__global__', + field: 'invoice_number', + scope: '', + last_value: 7, + }); + + await driver.initObjects([ + { name: 'invoice', fields: { invoice_number: { type: 'autonumber', format: 'INV-{0000}' } } }, + ]); + const r = await driver.create('invoice', {}); + + const cols = await k('_objectstack_sequences').columnInfo(); + expect(Object.keys(cols)).toContain('key_hash'); + expect(r.invoice_number).toBe('INV-0008'); // continued from the interim counter + }); }); diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 4be4c6314d..9eea62820c 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -220,13 +220,13 @@ export class SqlDriver implements IDataDriver { /** Whether the sequences table has been ensured this process. */ protected sequencesTableReady = false; /** - * Set when a legacy 3-column `_objectstack_sequences` table could not have - * its primary key widened to include `scope`. Fixed-prefix sequences (empty - * scope) keep working under the old PK; only a per-scope write would collide, - * so `getNextSequenceValue` raises an actionable error in that case rather - * than letting an opaque PK violation surface at create time. + * Whether `_objectstack_sequences` is the current `key_hash`-keyed shape. + * Set on a fresh create or a successful in-place migration. If a legacy table + * could NOT be migrated, this stays false: fixed-prefix sequences (empty + * scope) keep working via the legacy `(object, tenant_id, field)` key, while a + * per-scope write raises an actionable error rather than corrupting counters. */ - protected sequencesScopeKeyUnsafe = false; + protected sequencesHasKeyHash = false; /** In-flight ensure promise; deduplicates concurrent first calls. */ protected sequencesTableEnsurePromise: Promise | null = null; @@ -550,10 +550,14 @@ export class SqlDriver implements IDataDriver { * Ensure the sequence-counter table exists. Idempotent and cheap after * the first call (cached via `sequencesTableReady`). * - * The key is `(object, tenant_id, field, scope)`. `scope` is the rendered - * autonumber prefix (date/field tokens before the `{0000}` slot), so a new - * day/group/parent starts a fresh counter. Fixed-prefix formats use the - * empty scope and keep their single global counter (backward compatible). + * The row key is `key_hash` — a SHA-256 of `(object, tenant_id, field, scope)` + * where `scope` is the rendered autonumber prefix (date/field tokens before + * the `{0000}` slot), so a new day/group/parent starts a fresh counter. A + * single 64-char hashed primary key (rather than the four raw columns, which + * blow past MySQL's 3072-byte index limit under utf8mb4 and bound how long a + * `{field}` scope may be) keys every dialect uniformly and lets `scope` be a + * generous non-indexed column. Fixed-prefix formats use the empty scope and + * keep their single global counter (backward compatible). */ protected async ensureSequencesTable(): Promise { if (this.sequencesTableReady) return; @@ -565,26 +569,19 @@ export class SqlDriver implements IDataDriver { const exists = await this.knex.schema.hasTable(SEQUENCES_TABLE); if (!exists) { try { - await this.knex.schema.createTable(SEQUENCES_TABLE, (t) => { - t.string('object').notNullable(); - t.string('tenant_id').notNullable(); - t.string('field').notNullable(); - t.string('scope').notNullable().defaultTo(''); - t.bigInteger('last_value').notNullable().defaultTo(0); - t.timestamp('updated_at').defaultTo(this.knex.fn.now()); - t.primary(['object', 'tenant_id', 'field', 'scope']); - }); + await this.createSequencesTable(SEQUENCES_TABLE); + this.sequencesHasKeyHash = true; } catch (err: any) { // Race or cross-process create — re-check existence; ignore // "already exists" errors from any dialect. const stillMissing = !(await this.knex.schema.hasTable(SEQUENCES_TABLE)); if (stillMissing) throw err; - // A racing creator may have used the legacy 3-column schema. - await this.ensureSequencesScopeColumn(); + // A racing creator may have used an older schema. Migrate in place. + await this.ensureSequencesKeyHashShape(); } } else { - // Pre-existing table may predate the `scope` column. Migrate in place. - await this.ensureSequencesScopeColumn(); + // Pre-existing table may predate the `key_hash`/`scope` shape. Migrate. + await this.ensureSequencesKeyHashShape(); } this.sequencesTableReady = true; })(); @@ -595,71 +592,76 @@ export class SqlDriver implements IDataDriver { } } + /** SHA-256 of the composite counter key — the table's single-column PK. */ + protected sequenceKeyHash(object: string, tenantId: string, field: string, scope: string): string { + return createHash('sha256') + .update(`${object}\u001f${tenantId}\u001f${field}\u001f${scope}`) + .digest('hex'); + } + + /** Create the current `key_hash`-keyed sequences table shape. */ + protected async createSequencesTable(table: string): Promise { + await this.knex.schema.createTable(table, (t) => { + t.string('key_hash', 64).notNullable().primary(); + t.string('object').notNullable(); + t.string('tenant_id').notNullable(); + t.string('field').notNullable(); + // Non-indexed, so it is free of the PK length limit — a long `{plan_no}` + // composite scope fits. 1024 is far above any realistic rendered prefix. + t.string('scope', 1024).notNullable().defaultTo(''); + t.bigInteger('last_value').notNullable().defaultTo(0); + t.timestamp('updated_at').defaultTo(this.knex.fn.now()); + }); + } + /** - * Add the `scope` column (and fold it into the primary key) to a - * `_objectstack_sequences` table created before per-period/per-group - * counters existed. Every legacy row is a fixed-prefix sequence, so it - * migrates to `scope = ''` and keeps counting unchanged. Idempotent. + * Migrate a pre-existing `_objectstack_sequences` table to the current + * `key_hash`-keyed shape. Handles both the original 3-column table (no + * `scope`) and an interim 4-column `(object, tenant_id, field, scope)` table: + * every legacy row is read, its `key_hash` computed in app code (no portable + * SQL hash exists), and re-inserted into a freshly built table that then + * replaces the original. Idempotent — a no-op once `key_hash` is present. + * + * If the rebuild fails, `sequencesHasKeyHash` stays false: fixed-prefix + * sequences keep working via the legacy key and per-scope writes error + * actionably (see getNextSequenceValue), rather than corrupting data. */ - protected async ensureSequencesScopeColumn(): Promise { + protected async ensureSequencesKeyHashShape(): Promise { + if (await this.knex.schema.hasColumn(SEQUENCES_TABLE, 'key_hash')) { + this.sequencesHasKeyHash = true; + return; + } const hasScope = await this.knex.schema.hasColumn(SEQUENCES_TABLE, 'scope'); - if (hasScope) return; - - if (this.isSqlite) { - // SQLite can't alter a primary key in place — rebuild the table. Every - // legacy row carries scope '' so the copy is a straight projection. - const TMP = `${SEQUENCES_TABLE}__rebuild`; + const TMP = `${SEQUENCES_TABLE}__rebuild`; + try { + const rows: any[] = await this.knex(SEQUENCES_TABLE).select('*'); await this.knex.schema.dropTableIfExists(TMP); - await this.knex.schema.createTable(TMP, (t) => { - t.string('object').notNullable(); - t.string('tenant_id').notNullable(); - t.string('field').notNullable(); - t.string('scope').notNullable().defaultTo(''); - t.bigInteger('last_value').notNullable().defaultTo(0); - t.timestamp('updated_at').defaultTo(this.knex.fn.now()); - t.primary(['object', 'tenant_id', 'field', 'scope']); + await this.createSequencesTable(TMP); + const migrated = rows.map((r) => { + const scope = hasScope && r.scope != null ? String(r.scope) : ''; + return { + key_hash: this.sequenceKeyHash(String(r.object), String(r.tenant_id), String(r.field), scope), + object: r.object, + tenant_id: r.tenant_id, + field: r.field, + scope, + last_value: r.last_value ?? 0, + updated_at: r.updated_at ?? this.knex.fn.now(), + }; }); - await this.knex.raw( - `insert into ?? (object, tenant_id, field, scope, last_value, updated_at) - select object, tenant_id, field, '', last_value, updated_at from ??`, - [TMP, SEQUENCES_TABLE], - ); + if (migrated.length > 0) await this.knex(TMP).insert(migrated); await this.knex.schema.dropTable(SEQUENCES_TABLE); await this.knex.schema.renameTable(TMP, SEQUENCES_TABLE); - return; - } - - // Postgres / MySQL: add the column, then widen the primary key. - await this.knex.schema.alterTable(SEQUENCES_TABLE, (t) => { - t.string('scope').notNullable().defaultTo(''); - }); - try { - if (this.isMysql) { - await this.knex.raw( - 'ALTER TABLE ?? DROP PRIMARY KEY, ADD PRIMARY KEY (object, tenant_id, field, scope)', - [SEQUENCES_TABLE], - ); - } else { - // Postgres: the implicit PK constraint is named `
_pkey`. - await this.knex.raw('ALTER TABLE ?? DROP CONSTRAINT IF EXISTS ??', [ - SEQUENCES_TABLE, - `${SEQUENCES_TABLE}_pkey`, - ]); - await this.knex.raw('ALTER TABLE ?? ADD PRIMARY KEY (object, tenant_id, field, scope)', [ - SEQUENCES_TABLE, - ]); - } + this.sequencesHasKeyHash = true; } catch (err) { - // The `scope` column landed (new writes can record it) but the PK is - // still the legacy 3-column shape. Fixed-prefix sequences are unaffected; - // a per-scope write, however, would hit an opaque PK violation at insert - // time. Flag it so getNextSequenceValue can fail loudly and actionably - // instead, and surface it at error level for operators to migrate by hand. - this.sequencesScopeKeyUnsafe = true; + // Leave the original table intact; fall back to legacy keying for + // fixed-prefix sequences and refuse per-scope writes until migrated. + this.sequencesHasKeyHash = false; + await this.knex.schema.dropTableIfExists(TMP).catch(() => {}); this.logger.warn( - `[autonumber] Failed to widen ${SEQUENCES_TABLE} primary key to include "scope". ` + + `[autonumber] Failed to migrate ${SEQUENCES_TABLE} to the key_hash shape. ` + `Fixed-prefix autonumbers keep working; date/{field}/per-parent formats will ` + - `error until the primary key is migrated to (object, tenant_id, field, scope) by hand.`, + `error until the table is migrated.`, { error: String(err) }, ); } @@ -721,22 +723,29 @@ export class SqlDriver implements IDataDriver { scope = '', ): Promise { await this.ensureSequencesTable(); - if (scope !== '' && this.sequencesScopeKeyUnsafe) { - // The legacy sequences table could not have its PK widened to include - // `scope`, so a second scope under the same (object, tenant, field) would - // collide. Fail with a clear, actionable message instead of an opaque - // database PK violation at insert time. + const resolvedTenantId = tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT; + if (scope !== '' && !this.sequencesHasKeyHash) { + // The legacy sequences table could not be migrated to the key_hash shape, + // so it cannot represent per-scope counters. Fail with a clear, actionable + // message instead of corrupting the single legacy counter. throw new Error( `Cannot generate a per-scope autonumber for "${object}.${field}": the ` + - `${SEQUENCES_TABLE} primary key is still the legacy 3-column shape. ` + - `Migrate it to (object, tenant_id, field, scope) before using date/{field}/per-parent formats.`, + `${SEQUENCES_TABLE} table is still the legacy shape. ` + + `Migrate it to the key_hash shape before using date/{field}/per-parent formats.`, ); } - const resolvedTenantId = tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT; - // `scope` (rendered date/field prefix) gives each period/group its own - // counter; '' keeps the single global counter for fixed-prefix formats. - // `prefix` is the full rendered prefix used to bootstrap from existing data. - const key = { object: tableName, tenant_id: resolvedTenantId, field, scope }; + // `scope` (rendered date/field prefix, boundary-delimited) gives each + // period/group its own counter; '' keeps the single global counter for + // fixed-prefix formats. `prefix` is the full rendered prefix used to + // bootstrap from existing data. The row is keyed by a hash of the composite; + // on an un-migrated legacy table only fixed-prefix (scope '') reaches here, + // so fall back to the original `(object, tenant_id, field)` key for it. + const key = this.sequencesHasKeyHash + ? { key_hash: this.sequenceKeyHash(tableName, resolvedTenantId, field, scope) } + : { object: tableName, tenant_id: resolvedTenantId, field }; + const insertRow = this.sequencesHasKeyHash + ? { ...key, object: tableName, tenant_id: resolvedTenantId, field, scope } + : { ...key }; const runner: Knex | Knex.Transaction = parentTrx ?? this.knex; @@ -763,7 +772,7 @@ export class SqlDriver implements IDataDriver { ); const initial = seedMax + 1; try { - await trx(SEQUENCES_TABLE).insert({ ...key, last_value: initial }); + await trx(SEQUENCES_TABLE).insert({ ...insertRow, last_value: initial }); return initial; } catch (err) { // Another writer raced us to the first INSERT. Fall through to diff --git a/packages/spec/src/data/autonumber-format.test.ts b/packages/spec/src/data/autonumber-format.test.ts index c652c60735..4a59f3ae84 100644 --- a/packages/spec/src/data/autonumber-format.test.ts +++ b/packages/spec/src/data/autonumber-format.test.ts @@ -121,6 +121,19 @@ describe('renderAutonumber', () => { expect(b.scope).toBe('JYG2B'); // a different island → a different counter }); + it('gives adjacent {field} tokens a shared scope when their concat collides', () => { + // ('AB','C') and ('A','BC') render the SAME prefix "ABC" and therefore the + // same visible number — so they must share one scope/counter to stay unique; + // splitting them would mint duplicate record numbers. (Distinct groups need + // an unambiguous format with a delimiter literal; the compile lint nudges.) + const tokens = parseAutonumberFormat('{a}{b}{000}'); + const ab_c = renderAutonumber({ tokens, seq: 1, now: NOW, record: { a: 'AB', b: 'C' } }); + const a_bc = renderAutonumber({ tokens, seq: 1, now: NOW, record: { a: 'A', b: 'BC' } }); + expect(ab_c.scope).toBe('ABC'); + expect(a_bc.scope).toBe('ABC'); + expect(ab_c.scope).toBe(a_bc.scope); + }); + it('places literal/field text after the sequence into the suffix', () => { const tokens = parseAutonumberFormat('{0000}-{section}'); const r = renderAutonumber({ tokens, seq: 3, now: NOW, record: { section: 'X' } }); @@ -128,6 +141,16 @@ describe('renderAutonumber', () => { expect(r.suffix).toBe('-X'); }); + it('treats the pad width as a MINIMUM — the counter grows past it, never wraps', () => { + const tokens = parseAutonumberFormat('CASE-{000}'); + expect(renderAutonumber({ tokens, seq: 7, now: NOW }).value).toBe('CASE-007'); + expect(renderAutonumber({ tokens, seq: 999, now: NOW }).value).toBe('CASE-999'); + // Past the pad width the number simply widens (it does not reset or error), + // matching mainstream autonumber semantics — uniqueness is preserved. + expect(renderAutonumber({ tokens, seq: 1000, now: NOW }).value).toBe('CASE-1000'); + expect(renderAutonumber({ tokens, seq: 12345, now: NOW }).value).toBe('CASE-12345'); + }); + it('renders a missing field token as empty rather than throwing', () => { const tokens = parseAutonumberFormat('{missing}{000}'); expect(renderAutonumber({ tokens, seq: 2, now: NOW, record: {} }).value).toBe('002'); diff --git a/packages/spec/src/data/autonumber-format.ts b/packages/spec/src/data/autonumber-format.ts index e805a4e3a4..64d2e1d13c 100644 --- a/packages/spec/src/data/autonumber-format.ts +++ b/packages/spec/src/data/autonumber-format.ts @@ -200,6 +200,12 @@ export function renderAutonumber(input: RenderAutonumberInput): RenderedAutonumb else suffix += piece; } const dynamic = hasDynamicTokens(tokens); + // The scope IS the rendered prefix. Two records whose tokens render to the + // same prefix string (e.g. `{a}{b}` for ('AB','C') and ('A','BC')) also render + // the same VISIBLE number, so they must share one counter to stay unique — + // splitting them by token boundary would mint duplicate record numbers. The + // remedy for genuinely-distinct groups is an unambiguous format (a delimiter + // literal between variable tokens); the compile lint nudges authors there. const scope = dynamic ? prefix : ''; const value = width === null // No `{0..0}` slot — append the bare counter (legacy behaviour). diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 0a613d2c7f..3dabc1ed91 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -531,8 +531,9 @@ export const FieldSchema = lazySchema(() => z.object({ /** * Auto-number display format. Literal text interleaved with `{...}` tokens: * - * - `{0000}` — the sequence counter, zero-padded to that many digits - * (at most one slot; omit it and the bare number is appended). + * - `{0000}` — the sequence counter, zero-padded to that many digits as a + * MINIMUM width (at most one slot; omit it and the bare number is + * appended). Past that width the number simply grows — it never wraps. * - `{YYYY} {YY} {MM} {DD} {YYYYMMDD}` — generation date in the request's * business timezone (`ExecutionContext.timezone`, ADR-0053; UTC fallback). * - `{field_name}` — the value of another field on the SAME record