Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/autonumber-format-tokens.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown>);
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
Expand Down
94 changes: 94 additions & 0 deletions packages/cli/src/utils/lint-autonumber-formats.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
105 changes: 105 additions & 0 deletions packages/cli/src/utils/lint-autonumber-formats.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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<string, { required: boolean }>();
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;
}
57 changes: 57 additions & 0 deletions packages/objectql/src/engine-autonumber-defer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
Loading
Loading