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
44 changes: 44 additions & 0 deletions .changeset/autonumber-default-format-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
"@objectstack/spec": minor
---

feat(spec): declare `{0000}` as the contract default for a format-less autonumber field (#6555)

`FieldSchema.autonumberFormat` is optional, and the two sides that mint record
numbers each answered "no format declared" on their own — differently.
`driver-sql` substituted `'{0000}'` and issued `0001`, `0002`, …; the ObjectQL
engine's in-memory fallback path (taken whenever a driver does not advertise
`supports.autonumber`) parsed the empty string and fell through
`renderAutonumber`'s no-slot branch to a bare `1`, `2`, …. One metadata
document, two number shapes: a suite asserting `'1'` against the memory driver
did not hold in production on SQL, and an object's historical numbers changed
shape at a driver switch. Both sides always agreed on the counter VALUE — #6468
pinned that — the fork was purely in rendering width.

Per the maintainer's 2026-08-08 ruling on #6555 the default now lives in the
contract instead of in either fallback:

- **`DEFAULT_AUTONUMBER_FORMAT`** (`'{0000}'`) — a new export from
`@objectstack/spec/data`, beside `renderAutonumber`. The one place the value
is written down.
- **`resolveAutonumberFormat(field)`** — a new export: the canonical
`autonumberFormat`, then the `format` shorthand (#1603), then the declared
default. A key holding anything but a non-empty string counts as undeclared,
which is the SQL driver's long-standing truthiness rule — the engine used
`??` and the two also disagreed on `format: ''`.
- **`FieldSchema.autonumberFormat`** now declares the default to schema
consumers as a JSON-Schema `default` annotation. Deliberately an annotation
and not a Zod `.default()`: the key is flat on `FieldSchema` and shared by all
field types, so a parse-time default would materialize
`autonumberFormat: '{0000}'` on every `text`, `number` and `lookup` field
parsed anywhere. Parse output is unchanged for every field type.

Compatibility: choosing {0000} keeps stored driver-sql data undisturbed;
engine-fallback deployments flip from bare 1 to 0001 for newly issued numbers.
Counter continuity itself is unaffected (#6468 pinned it).

This is the contract half only. The two generators still carry their own
fallbacks and are unchanged by this release; removing them — engine
`applyAutonumbers` and `driver-sql`'s two `|| '{0000}'` sites, both reading the
declared default through `resolveAutonumberFormat` instead — follows in separate
changes, so nothing about today's rendering moves yet.
2 changes: 1 addition & 1 deletion content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ const result = AddressSchema.parse(data);
| **system** | `boolean` | optional | Auto-injected system/audit field (e.g. created_at, updated_by, organization_id). Tools that surface system fields separately from author-declared business fields should branch on this flag. |
| **sortable** | `boolean` | optional | Whether field is sortable in list views |
| **inlineHelpText** | `string` | optional | Help text displayed below the field in forms |
| **autonumberFormat** | `string` | optional | 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). |
| **autonumberFormat** | `string` | optional | 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). Omitted on an `autonumber` field ⇒ the contract default `{0000}` (#6555). |
| **externalId** | `boolean` | optional | Is external ID for upsert operations |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/api-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"AuthoringKeySurface (type)",
"AutoPersistenceConfig (type)",
"AutoPersistenceConfigSchema (const)",
"AutonumberFormatSource (interface)",
"AutonumberToken (type)",
"BOOLEAN_VALUE_TYPES (const)",
"BUILTIN_DRIVER_IDS (const)",
Expand Down Expand Up @@ -99,6 +100,7 @@
"DATE_MACRO_TOKENS (const)",
"DATE_MACRO_UNITS (const)",
"DATE_MACRO_WRAPPED_RE (const)",
"DEFAULT_AUTONUMBER_FORMAT (const)",
"DEFAULT_VALUE_TOKENS (const)",
"DEFAULT_VALUE_TOKEN_CURRENT_USER (const)",
"DEFAULT_VALUE_TOKEN_DESCRIPTIONS (const)",
Expand Down Expand Up @@ -660,6 +662,7 @@
"referenceTargetOf (function)",
"referencedFields (function)",
"renderAutonumber (function)",
"resolveAutonumberFormat (function)",
"resolveBulkPerRowHookBudget (function)",
"resolveCrudAffordances (function)",
"resolveDatabaseDriverId (function)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-defaults/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"data/ExternalDatasourceSettings:queryTimeoutMs = 30000",
"data/ExternalDatasourceSettings:validation = {\"checkOnBoot\":true,\"onMismatch\":\"fail\"}",
"data/ExternalFieldMapping:readonly = true",
"data/Field:autonumberFormat = \"{0000}\"",
"data/Field:deleteBehavior = \"set_null\"",
"data/Field:externalId = false",
"data/Field:hidden = false",
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/export-origins/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"AuthoringKeySurface": "src/data/authoring-key-lint.ts#AuthoringKeySurface (type)",
"AutoPersistenceConfig": "src/data/driver/memory.zod.ts#AutoPersistenceConfig (type)",
"AutoPersistenceConfigSchema": "src/data/driver/memory.zod.ts#AutoPersistenceConfigSchema (const)",
"AutonumberFormatSource": "src/data/autonumber-format.ts#AutonumberFormatSource (interface)",
"AutonumberToken": "src/data/autonumber-format.ts#AutonumberToken (type)",
"BOOLEAN_VALUE_TYPES": "src/data/field-value.zod.ts#BOOLEAN_VALUE_TYPES (const)",
"BUILTIN_DRIVER_IDS": "src/data/driver/config-registry.zod.ts#BUILTIN_DRIVER_IDS (const)",
Expand Down Expand Up @@ -99,6 +100,7 @@
"DATE_MACRO_TOKENS": "src/data/date-macros.zod.ts#DATE_MACRO_TOKENS (const)",
"DATE_MACRO_UNITS": "src/data/date-macros.zod.ts#DATE_MACRO_UNITS (const)",
"DATE_MACRO_WRAPPED_RE": "src/data/date-macros.zod.ts#DATE_MACRO_WRAPPED_RE (const)",
"DEFAULT_AUTONUMBER_FORMAT": "src/data/autonumber-format.ts#DEFAULT_AUTONUMBER_FORMAT (const)",
"DEFAULT_VALUE_TOKENS": "src/data/default-value-tokens.ts#DEFAULT_VALUE_TOKENS (const)",
"DEFAULT_VALUE_TOKEN_CURRENT_USER": "src/data/default-value-tokens.ts#DEFAULT_VALUE_TOKEN_CURRENT_USER (const)",
"DEFAULT_VALUE_TOKEN_DESCRIPTIONS": "src/data/default-value-tokens.ts#DEFAULT_VALUE_TOKEN_DESCRIPTIONS (const)",
Expand Down Expand Up @@ -660,6 +662,7 @@
"referenceTargetOf": "src/data/field-value.zod.ts#referenceTargetOf (function)",
"referencedFields": "src/data/autonumber-format.ts#referencedFields (function)",
"renderAutonumber": "src/data/autonumber-format.ts#renderAutonumber (function)",
"resolveAutonumberFormat": "src/data/autonumber-format.ts#resolveAutonumberFormat (function)",
"resolveBulkPerRowHookBudget": "src/data/bulk-write-hook-conformance.ts#resolveBulkPerRowHookBudget (function)",
"resolveCrudAffordances": "src/data/object.zod.ts#resolveCrudAffordances (function)",
"resolveDatabaseDriverId": "src/data/driver/config-registry.zod.ts#resolveDatabaseDriverId (function)",
Expand Down
37 changes: 37 additions & 0 deletions packages/spec/scripts/lib/default-changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,37 @@ const IMPORT_RUN_AUTOMATIONS_REASON =
+ 'server would have applied anyway. Maintainer ruling 2026-08-09 (#6704, disposition '
+ 'A: the spec follows the runtime).';

const AUTONUMBER_FORMAT_DEFAULT_REASON =
'A format-less `autonumber` field never had ONE rendering to rely on, so this row '
+ 'declares an answer where the contract previously declared none — it does not '
+ 'replace a default anyone could read off the schema. What the two generators did '
+ 'instead, each with its own hand-written fallback, disagreed: `driver-sql` '
+ "substituted `'{0000}'` and issued `0001`, `0002`, …, while the ObjectQL engine's "
+ 'in-memory fallback path (taken whenever a driver does not advertise '
+ '`supports.autonumber`) parsed the empty string and rendered a bare `1`, `2`, …. '
+ 'One metadata document therefore minted differently-shaped numbers depending on '
+ 'which driver served it. The counter VALUE always agreed on both sides — #6468 '
+ 'pinned that — so what forked was width alone. '
+ 'The maintainer ruling (2026-08-08, #6555, route 3) fixes the default at `{0000}` '
+ 'and moves it into the contract: `DEFAULT_AUTONUMBER_FORMAT` and '
+ '`resolveAutonumberFormat` in `@objectstack/spec/data` are the one place it is '
+ 'written down, and both generators will read it instead of substituting their own. '
+ '`{0000}` was chosen because it is the shape SQL deployments have already stored: '
+ 'choosing it keeps stored driver-sql data undisturbed; engine-fallback deployments '
+ 'flip from bare 1 to 0001 for newly issued numbers. Counter continuity itself is '
+ 'unaffected (#6468 pinned it). '
+ 'To keep the bare counter a memory-driver deployment was issuing, write a format '
+ "with no sequence slot — `autonumberFormat: ''` is NOT that spelling, since an "
+ "empty string resolves to the default too (`driver-sql`'s long-standing truthiness "
+ 'rule); a slot-less literal format such as `PRE-` renders `PRE-1`. To keep the '
+ '`0001` shape SQL already gives you, change nothing. '
+ 'This is a JSON-Schema annotation, NOT a Zod `.default()`: `autonumberFormat` is '
+ 'flat on `FieldSchema` and shared by every field type, so a parse-time default '
+ "would materialise `'{0000}'` on every `text`, `number` and `lookup` field parsed "
+ 'anywhere. Parse output is unchanged for every type, `autonumber` included — a '
+ 'consumer reading `FieldParsed.autonumberFormat` still sees `undefined` when the '
+ 'author omitted it, and asks `resolveAutonumberFormat` what that means.';

export const DEFAULT_CHANGES_BY_MAJOR: Readonly<Record<number, readonly DeclaredDefaultChange[]>> = {
17: [
{
Expand Down Expand Up @@ -153,5 +184,11 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly<Record<number, readonly Declared
to: 'true',
reason: IMPORT_RUN_AUTOMATIONS_REASON,
},
{
key: 'data/Field:autonumberFormat',
from: '(none)',
to: '"{0000}"',
reason: AUTONUMBER_FORMAT_DEFAULT_REASON,
},
],
};
54 changes: 54 additions & 0 deletions packages/spec/src/data/autonumber-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,66 @@ import {
referencedFields,
missingFieldValues,
readAutonumberCounter,
resolveAutonumberFormat,
DEFAULT_AUTONUMBER_FORMAT,
} 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');

// #6555 — the contract default for a format-less autonumber field. Before it,
// the SQL driver and the engine's in-memory fallback each substituted their own
// answer and the two disagreed (`0001` vs `1`) for the very same metadata.
describe('DEFAULT_AUTONUMBER_FORMAT / resolveAutonumberFormat (#6555)', () => {
it('fixes the contract default at `{0000}` — four-digit zero padding', () => {
expect(DEFAULT_AUTONUMBER_FORMAT).toBe('{0000}');
});

it('resolves a format-less field to the declared default', () => {
// The exact metadata from the bug report: `{ type: 'autonumber' }`, no format.
expect(resolveAutonumberFormat({})).toBe(DEFAULT_AUTONUMBER_FORMAT);
expect(resolveAutonumberFormat(undefined)).toBe(DEFAULT_AUTONUMBER_FORMAT);
expect(resolveAutonumberFormat(null)).toBe(DEFAULT_AUTONUMBER_FORMAT);
expect(resolveAutonumberFormat({ autonumberFormat: undefined })).toBe(DEFAULT_AUTONUMBER_FORMAT);
});

it('prefers the canonical `autonumberFormat` over the `format` shorthand (#1603)', () => {
expect(resolveAutonumberFormat({ autonumberFormat: 'INV-{0000}' })).toBe('INV-{0000}');
expect(resolveAutonumberFormat({ format: 'TK-{00000}' })).toBe('TK-{00000}');
expect(resolveAutonumberFormat({ autonumberFormat: 'A-{000}', format: 'B-{000}' })).toBe('A-{000}');
});

it('treats a non-string or empty value as undeclared — the SQL driver\'s truthiness rule', () => {
// The engine used `??`, the driver used `||`; they disagreed on `''` too.
// Resolving `''` to the default is the direction that leaves already-stored
// driver-sql numbers unchanged.
expect(resolveAutonumberFormat({ autonumberFormat: '' })).toBe(DEFAULT_AUTONUMBER_FORMAT);
expect(resolveAutonumberFormat({ format: '' })).toBe(DEFAULT_AUTONUMBER_FORMAT);
expect(resolveAutonumberFormat({ autonumberFormat: '', format: 'B-{000}' })).toBe('B-{000}');
expect(resolveAutonumberFormat({ autonumberFormat: 42 })).toBe(DEFAULT_AUTONUMBER_FORMAT);
expect(resolveAutonumberFormat({ autonumberFormat: {} })).toBe(DEFAULT_AUTONUMBER_FORMAT);
});

it('renders the resolved default as `0001`, not the bare counter', () => {
// The end-to-end shape the ruling settles: one metadata document, one
// number shape, whichever side generates it.
const tokens = parseAutonumberFormat(resolveAutonumberFormat({}));
expect(renderAutonumber({ tokens, seq: 1, now: NOW }).value).toBe('0001');
expect(renderAutonumber({ tokens, seq: 11, now: NOW }).value).toBe('0011');
// …and the counter is untouched by the width — #6468's territory, pinned
// here only so a future widening of the default cannot be read as a reset.
expect(renderAutonumber({ tokens, seq: 12345, now: NOW }).value).toBe('12345');
});

it('leaves the bare-counter branch reachable for a declared slot-less format', () => {
// `width === null` is no longer what a format-LESS field renders through;
// it is what a format carrying no `{0..0}` slot renders through.
const tokens = parseAutonumberFormat(resolveAutonumberFormat({ autonumberFormat: 'CASE-' }));
expect(renderAutonumber({ tokens, seq: 1, now: NOW }).value).toBe('CASE-1');
});
});

describe('parseAutonumberFormat', () => {
it('splits literal, sequence, date and field tokens in order', () => {
expect(parseAutonumberFormat('AD{YYYYMMDD}{0000}')).toEqual([
Expand Down
65 changes: 65 additions & 0 deletions packages/spec/src/data/autonumber-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,62 @@ function renderDate(pattern: string, p: CalendarParts): string {
}
}

/**
* The format an `autonumber` field renders with when it declares none — the
* **contract default**, not a per-caller fallback (#6555).
*
* Before this constant existed, "what does a format-less autonumber look
* like?" was answered twice, by hand, and differently: the SQL driver
* substituted `'{0000}'` locally (`0001`, `0002`, …) while the engine's
* in-memory fallback path parsed the empty string and rendered the bare
* counter through {@link renderAutonumber}'s no-slot branch (`1`, `2`, …). One
* metadata document therefore produced a different number *shape* depending on
* which driver served it — a test asserting `'1'` against the memory driver
* did not hold in production on SQL.
*
* The maintainer ruling of 2026-08-08 on #6555 settles it at `{0000}` and puts
* the default in the contract rather than in either fallback: `{@link
* FieldSchema}`'s `autonumberFormat` declares it, and both sides resolve it
* through {@link resolveAutonumberFormat}. `{0000}` is the shape already
* stored by SQL deployments, so choosing it leaves landed data undisturbed.
*/
export const DEFAULT_AUTONUMBER_FORMAT = '{0000}';

/**
* A field declaration, as far as autonumber formatting is concerned. Both
* spellings appear in real metadata: `autonumberFormat` is the spec-canonical
* key, `format` the shorthand that predates it (#1603). Typed loosely because
* the engine and the drivers both reach this with an unvalidated field
* document in hand, not a parsed {@link FieldSchema}.
*/
export interface AutonumberFormatSource {
autonumberFormat?: unknown;
format?: unknown;
}

/**
* Resolve the format an autonumber field renders with — the ONE place the
* contract default is applied, so no caller has to keep its own copy (#6555).
*
* Precedence: the canonical `autonumberFormat`, then the `format` shorthand,
* then {@link DEFAULT_AUTONUMBER_FORMAT}. A key holding anything other than a
* NON-EMPTY string counts as undeclared — which is deliberately the SQL
* driver's long-standing truthiness rule, not the engine's `??`. The two
* disagreed on `format: ''` as well as on the missing key, and resolving the
* empty string to the default is the direction that leaves already-stored
* driver-sql numbers unchanged.
*
* A format that IS declared is honoured exactly as written, including one with
* no `{0..0}` slot (`'CASE-'` → `CASE-1`) — see {@link renderAutonumber}.
*/
export function resolveAutonumberFormat(field: AutonumberFormatSource | null | undefined): string {
const canonical = field?.autonumberFormat;
if (typeof canonical === 'string' && canonical) return canonical;
const shorthand = field?.format;
if (typeof shorthand === 'string' && shorthand) return shorthand;
return DEFAULT_AUTONUMBER_FORMAT;
}

export interface RenderAutonumberInput {
/** Parsed tokens (from {@link parseAutonumberFormat}). */
tokens: AutonumberToken[];
Expand Down Expand Up @@ -209,6 +265,15 @@ export function renderAutonumber(input: RenderAutonumberInput): RenderedAutonumb
const scope = dynamic ? prefix : '';
const value = width === null
// No `{0..0}` slot — append the bare counter (legacy behaviour).
//
// This branch is no longer how a FORMAT-LESS field renders (#6555): a
// field that declares no format now carries the contract default
// `{0000}` (see {@link DEFAULT_AUTONUMBER_FORMAT} /
// {@link resolveAutonumberFormat}), so it takes the padded branch below
// on both the engine and the SQL driver. What still reaches here is a
// format the author DID declare that happens to carry no sequence slot —
// `'CASE-'` → `CASE-1` — plus any caller that tokenizes a raw string
// without going through the resolver.
? `${prefix}${seq}`
: `${prefix}${String(seq).padStart(width, '0')}${suffix}`;
return { prefix, suffix, scope, value };
Expand Down
Loading
Loading