diff --git a/.changeset/autonumber-format-tokens.md b/.changeset/autonumber-format-tokens.md new file mode 100644 index 0000000000..7750854d2e --- /dev/null +++ b/.changeset/autonumber-format-tokens.md @@ -0,0 +1,61 @@ +--- +"@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 + +`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 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: + +- **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`. +- **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/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 a8709b2e3b..04626693b8 100644 --- a/packages/objectql/src/engine-autonumber-defer.test.ts +++ b/packages/objectql/src/engine-autonumber-defer.test.ts @@ -112,4 +112,61 @@ 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$/); + }); + + 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 f919f98e2c..0b5593bd9d 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, 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'; @@ -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,51 @@ 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 : ''); + // 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. + 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,8 +873,22 @@ 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 - if (m) max = Math.max(max, parseInt(m[1], 10) || 0); + const s = String(v); + if (prefix && !s.startsWith(prefix)) continue; + const tail = prefix ? s.slice(prefix.length) : s; + // 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 { @@ -847,15 +896,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..817f6da3be --- /dev/null +++ b/packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts @@ -0,0 +1,251 @@ +// 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('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([ + { + 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}' } } }, + ]); + 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 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) => { + 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'); + 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 84dc2f7d03..9eea62820c 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, 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'; @@ -213,11 +214,19 @@ 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. */ protected sequencesTableReady = false; + /** + * 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 sequencesHasKeyHash = false; /** In-flight ensure promise; deduplicates concurrent first calls. */ protected sequencesTableEnsurePromise: Promise | null = null; @@ -540,6 +549,15 @@ export class SqlDriver implements IDataDriver { /** * Ensure the sequence-counter table exists. Idempotent and cheap after * the first call (cached via `sequencesTableReady`). + * + * 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; @@ -551,20 +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.bigInteger('last_value').notNullable().defaultTo(0); - t.timestamp('updated_at').defaultTo(this.knex.fn.now()); - t.primary(['object', 'tenant_id', 'field']); - }); + 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 an older schema. Migrate in place. + await this.ensureSequencesKeyHashShape(); } + } else { + // Pre-existing table may predate the `key_hash`/`scope` shape. Migrate. + await this.ensureSequencesKeyHashShape(); } this.sequencesTableReady = true; })(); @@ -575,6 +592,81 @@ 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()); + }); + } + + /** + * 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 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'); + const TMP = `${SEQUENCES_TABLE}__rebuild`; + try { + const rows: any[] = await this.knex(SEQUENCES_TABLE).select('*'); + await this.knex.schema.dropTableIfExists(TMP); + 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(), + }; + }); + 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); + this.sequencesHasKeyHash = true; + } catch (err) { + // 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 migrate ${SEQUENCES_TABLE} to the key_hash shape. ` + + `Fixed-prefix autonumbers keep working; date/{field}/per-parent formats will ` + + `error until the table is migrated.`, + { 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 +720,32 @@ 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 }; + 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} table is still the legacy shape. ` + + `Migrate it to the key_hash shape before using date/{field}/per-parent formats.`, + ); + } + // `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; @@ -658,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 @@ -675,10 +789,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,8 +805,22 @@ 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; + // 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; @@ -700,16 +830,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 +1247,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 +1298,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..4a59f3ae84 --- /dev/null +++ b/packages/spec/src/data/autonumber-format.test.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + parseAutonumberFormat, + renderAutonumber, + hasDynamicTokens, + sequenceWidth, + referencedFields, + missingFieldValues, +} 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('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}'); + 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('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' } }); + expect(r.value).toBe('0003-X'); + 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 new file mode 100644 index 0000000000..64d2e1d13c --- /dev/null +++ b/packages/spec/src/data/autonumber-format.ts @@ -0,0 +1,215 @@ +// 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); +} + +/** + * `{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; + 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); + // 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). + ? `${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..3dabc1ed91 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -528,7 +528,32 @@ 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 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 + * (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. + * + * 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 */ 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';