Skip to content

Commit 88154be

Browse files
os-zhuangclaude
andauthored
feat(spec): declare {0000} as the contract default for format-less autonumber (#6555) (#7265)
`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`; the ObjectQL engine's in-memory fallback path parsed the empty string and fell through `renderAutonumber`'s no-slot branch to a bare `1`. One metadata document, two number shapes. The counter VALUE always agreed (#6468 pinned it) — the fork was rendering width alone. Per the maintainer's 2026-08-08 ruling (route 3, default fixed at `{0000}`), this lands the CONTRACT half only: - `DEFAULT_AUTONUMBER_FORMAT` — new export beside `renderAutonumber`; the one place the value is written down. - `resolveAutonumberFormat(field)` — new export; canonical `autonumberFormat`, then the `format` shorthand (#1603), then the declared default. A key holding anything but a non-empty string counts as undeclared — driver-sql's truthiness rule, chosen so already-stored SQL numbers keep their shape. - `FieldSchema.autonumberFormat` declares the default as a JSON-Schema annotation, registered in `DEFAULT_CHANGES_BY_MAJOR` (#4666 ratchet). Deliberately an annotation, not a Zod `.default()`: the key is flat on FieldSchema and shared by every field type, so a parse-time default was measured to materialize `autonumberFormat: '{0000}'` on `text`/`number`/ `lookup` fields and turned 28 cases in `build-schemas-check-mode.test.ts` red. Parse output is unchanged for every type. Both generators keep their hand-written fallbacks for now; removing them follows in two separate cards, so nothing about today's rendering moves. Part of #6555. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 74155c7 commit 88154be

10 files changed

Lines changed: 314 additions & 2 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): declare `{0000}` as the contract default for a format-less autonumber field (#6555)
6+
7+
`FieldSchema.autonumberFormat` is optional, and the two sides that mint record
8+
numbers each answered "no format declared" on their own — differently.
9+
`driver-sql` substituted `'{0000}'` and issued `0001`, `0002`, …; the ObjectQL
10+
engine's in-memory fallback path (taken whenever a driver does not advertise
11+
`supports.autonumber`) parsed the empty string and fell through
12+
`renderAutonumber`'s no-slot branch to a bare `1`, `2`, …. One metadata
13+
document, two number shapes: a suite asserting `'1'` against the memory driver
14+
did not hold in production on SQL, and an object's historical numbers changed
15+
shape at a driver switch. Both sides always agreed on the counter VALUE — #6468
16+
pinned that — the fork was purely in rendering width.
17+
18+
Per the maintainer's 2026-08-08 ruling on #6555 the default now lives in the
19+
contract instead of in either fallback:
20+
21+
- **`DEFAULT_AUTONUMBER_FORMAT`** (`'{0000}'`) — a new export from
22+
`@objectstack/spec/data`, beside `renderAutonumber`. The one place the value
23+
is written down.
24+
- **`resolveAutonumberFormat(field)`** — a new export: the canonical
25+
`autonumberFormat`, then the `format` shorthand (#1603), then the declared
26+
default. A key holding anything but a non-empty string counts as undeclared,
27+
which is the SQL driver's long-standing truthiness rule — the engine used
28+
`??` and the two also disagreed on `format: ''`.
29+
- **`FieldSchema.autonumberFormat`** now declares the default to schema
30+
consumers as a JSON-Schema `default` annotation. Deliberately an annotation
31+
and not a Zod `.default()`: the key is flat on `FieldSchema` and shared by all
32+
field types, so a parse-time default would materialize
33+
`autonumberFormat: '{0000}'` on every `text`, `number` and `lookup` field
34+
parsed anywhere. Parse output is unchanged for every field type.
35+
36+
Compatibility: choosing {0000} keeps stored driver-sql data undisturbed;
37+
engine-fallback deployments flip from bare 1 to 0001 for newly issued numbers.
38+
Counter continuity itself is unaffected (#6468 pinned it).
39+
40+
This is the contract half only. The two generators still carry their own
41+
fallbacks and are unchanged by this release; removing them — engine
42+
`applyAutonumbers` and `driver-sql`'s two `|| '{0000}'` sites, both reading the
43+
declared default through `resolveAutonumberFormat` instead — follows in separate
44+
changes, so nothing about today's rendering moves yet.

content/docs/references/data/field.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ const result = AddressSchema.parse(data);
128128
| **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. |
129129
| **sortable** | `boolean` | optional | Whether field is sortable in list views |
130130
| **inlineHelpText** | `string` | optional | Help text displayed below the field in forms |
131-
| **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). |
131+
| **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). |
132132
| **externalId** | `boolean` | optional | Is external ID for upsert operations |
133133
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
134134
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |

packages/spec/api-surface/data.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"AuthoringKeySurface (type)",
3737
"AutoPersistenceConfig (type)",
3838
"AutoPersistenceConfigSchema (const)",
39+
"AutonumberFormatSource (interface)",
3940
"AutonumberToken (type)",
4041
"BOOLEAN_VALUE_TYPES (const)",
4142
"BUILTIN_DRIVER_IDS (const)",
@@ -99,6 +100,7 @@
99100
"DATE_MACRO_TOKENS (const)",
100101
"DATE_MACRO_UNITS (const)",
101102
"DATE_MACRO_WRAPPED_RE (const)",
103+
"DEFAULT_AUTONUMBER_FORMAT (const)",
102104
"DEFAULT_VALUE_TOKENS (const)",
103105
"DEFAULT_VALUE_TOKEN_CURRENT_USER (const)",
104106
"DEFAULT_VALUE_TOKEN_DESCRIPTIONS (const)",
@@ -660,6 +662,7 @@
660662
"referenceTargetOf (function)",
661663
"referencedFields (function)",
662664
"renderAutonumber (function)",
665+
"resolveAutonumberFormat (function)",
663666
"resolveBulkPerRowHookBudget (function)",
664667
"resolveCrudAffordances (function)",
665668
"resolveDatabaseDriverId (function)",

packages/spec/authorable-defaults/data.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"data/ExternalDatasourceSettings:queryTimeoutMs = 30000",
4040
"data/ExternalDatasourceSettings:validation = {\"checkOnBoot\":true,\"onMismatch\":\"fail\"}",
4141
"data/ExternalFieldMapping:readonly = true",
42+
"data/Field:autonumberFormat = \"{0000}\"",
4243
"data/Field:deleteBehavior = \"set_null\"",
4344
"data/Field:externalId = false",
4445
"data/Field:hidden = false",

packages/spec/export-origins/data.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"AuthoringKeySurface": "src/data/authoring-key-lint.ts#AuthoringKeySurface (type)",
3737
"AutoPersistenceConfig": "src/data/driver/memory.zod.ts#AutoPersistenceConfig (type)",
3838
"AutoPersistenceConfigSchema": "src/data/driver/memory.zod.ts#AutoPersistenceConfigSchema (const)",
39+
"AutonumberFormatSource": "src/data/autonumber-format.ts#AutonumberFormatSource (interface)",
3940
"AutonumberToken": "src/data/autonumber-format.ts#AutonumberToken (type)",
4041
"BOOLEAN_VALUE_TYPES": "src/data/field-value.zod.ts#BOOLEAN_VALUE_TYPES (const)",
4142
"BUILTIN_DRIVER_IDS": "src/data/driver/config-registry.zod.ts#BUILTIN_DRIVER_IDS (const)",
@@ -99,6 +100,7 @@
99100
"DATE_MACRO_TOKENS": "src/data/date-macros.zod.ts#DATE_MACRO_TOKENS (const)",
100101
"DATE_MACRO_UNITS": "src/data/date-macros.zod.ts#DATE_MACRO_UNITS (const)",
101102
"DATE_MACRO_WRAPPED_RE": "src/data/date-macros.zod.ts#DATE_MACRO_WRAPPED_RE (const)",
103+
"DEFAULT_AUTONUMBER_FORMAT": "src/data/autonumber-format.ts#DEFAULT_AUTONUMBER_FORMAT (const)",
102104
"DEFAULT_VALUE_TOKENS": "src/data/default-value-tokens.ts#DEFAULT_VALUE_TOKENS (const)",
103105
"DEFAULT_VALUE_TOKEN_CURRENT_USER": "src/data/default-value-tokens.ts#DEFAULT_VALUE_TOKEN_CURRENT_USER (const)",
104106
"DEFAULT_VALUE_TOKEN_DESCRIPTIONS": "src/data/default-value-tokens.ts#DEFAULT_VALUE_TOKEN_DESCRIPTIONS (const)",
@@ -660,6 +662,7 @@
660662
"referenceTargetOf": "src/data/field-value.zod.ts#referenceTargetOf (function)",
661663
"referencedFields": "src/data/autonumber-format.ts#referencedFields (function)",
662664
"renderAutonumber": "src/data/autonumber-format.ts#renderAutonumber (function)",
665+
"resolveAutonumberFormat": "src/data/autonumber-format.ts#resolveAutonumberFormat (function)",
663666
"resolveBulkPerRowHookBudget": "src/data/bulk-write-hook-conformance.ts#resolveBulkPerRowHookBudget (function)",
664667
"resolveCrudAffordances": "src/data/object.zod.ts#resolveCrudAffordances (function)",
665668
"resolveDatabaseDriverId": "src/data/driver/config-registry.zod.ts#resolveDatabaseDriverId (function)",

packages/spec/scripts/lib/default-changes.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,37 @@ const IMPORT_RUN_AUTOMATIONS_REASON =
110110
+ 'server would have applied anyway. Maintainer ruling 2026-08-09 (#6704, disposition '
111111
+ 'A: the spec follows the runtime).';
112112

113+
const AUTONUMBER_FORMAT_DEFAULT_REASON =
114+
'A format-less `autonumber` field never had ONE rendering to rely on, so this row '
115+
+ 'declares an answer where the contract previously declared none — it does not '
116+
+ 'replace a default anyone could read off the schema. What the two generators did '
117+
+ 'instead, each with its own hand-written fallback, disagreed: `driver-sql` '
118+
+ "substituted `'{0000}'` and issued `0001`, `0002`, …, while the ObjectQL engine's "
119+
+ 'in-memory fallback path (taken whenever a driver does not advertise '
120+
+ '`supports.autonumber`) parsed the empty string and rendered a bare `1`, `2`, …. '
121+
+ 'One metadata document therefore minted differently-shaped numbers depending on '
122+
+ 'which driver served it. The counter VALUE always agreed on both sides — #6468 '
123+
+ 'pinned that — so what forked was width alone. '
124+
+ 'The maintainer ruling (2026-08-08, #6555, route 3) fixes the default at `{0000}` '
125+
+ 'and moves it into the contract: `DEFAULT_AUTONUMBER_FORMAT` and '
126+
+ '`resolveAutonumberFormat` in `@objectstack/spec/data` are the one place it is '
127+
+ 'written down, and both generators will read it instead of substituting their own. '
128+
+ '`{0000}` was chosen because it is the shape SQL deployments have already stored: '
129+
+ 'choosing it keeps stored driver-sql data undisturbed; engine-fallback deployments '
130+
+ 'flip from bare 1 to 0001 for newly issued numbers. Counter continuity itself is '
131+
+ 'unaffected (#6468 pinned it). '
132+
+ 'To keep the bare counter a memory-driver deployment was issuing, write a format '
133+
+ "with no sequence slot — `autonumberFormat: ''` is NOT that spelling, since an "
134+
+ "empty string resolves to the default too (`driver-sql`'s long-standing truthiness "
135+
+ 'rule); a slot-less literal format such as `PRE-` renders `PRE-1`. To keep the '
136+
+ '`0001` shape SQL already gives you, change nothing. '
137+
+ 'This is a JSON-Schema annotation, NOT a Zod `.default()`: `autonumberFormat` is '
138+
+ 'flat on `FieldSchema` and shared by every field type, so a parse-time default '
139+
+ "would materialise `'{0000}'` on every `text`, `number` and `lookup` field parsed "
140+
+ 'anywhere. Parse output is unchanged for every type, `autonumber` included — a '
141+
+ 'consumer reading `FieldParsed.autonumberFormat` still sees `undefined` when the '
142+
+ 'author omitted it, and asks `resolveAutonumberFormat` what that means.';
143+
113144
export const DEFAULT_CHANGES_BY_MAJOR: Readonly<Record<number, readonly DeclaredDefaultChange[]>> = {
114145
17: [
115146
{
@@ -153,5 +184,11 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly<Record<number, readonly Declared
153184
to: 'true',
154185
reason: IMPORT_RUN_AUTOMATIONS_REASON,
155186
},
187+
{
188+
key: 'data/Field:autonumberFormat',
189+
from: '(none)',
190+
to: '"{0000}"',
191+
reason: AUTONUMBER_FORMAT_DEFAULT_REASON,
192+
},
156193
],
157194
};

packages/spec/src/data/autonumber-format.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,66 @@ import {
99
referencedFields,
1010
missingFieldValues,
1111
readAutonumberCounter,
12+
resolveAutonumberFormat,
13+
DEFAULT_AUTONUMBER_FORMAT,
1214
} from './autonumber-format';
1315

1416
// A fixed instant: 2026-06-17 21:30 UTC. In Asia/Shanghai (UTC+8) this is
1517
// already 2026-06-18, which is exactly what makes the timezone assertions bite.
1618
const NOW = new Date('2026-06-17T21:30:00.000Z');
1719

20+
// #6555 — the contract default for a format-less autonumber field. Before it,
21+
// the SQL driver and the engine's in-memory fallback each substituted their own
22+
// answer and the two disagreed (`0001` vs `1`) for the very same metadata.
23+
describe('DEFAULT_AUTONUMBER_FORMAT / resolveAutonumberFormat (#6555)', () => {
24+
it('fixes the contract default at `{0000}` — four-digit zero padding', () => {
25+
expect(DEFAULT_AUTONUMBER_FORMAT).toBe('{0000}');
26+
});
27+
28+
it('resolves a format-less field to the declared default', () => {
29+
// The exact metadata from the bug report: `{ type: 'autonumber' }`, no format.
30+
expect(resolveAutonumberFormat({})).toBe(DEFAULT_AUTONUMBER_FORMAT);
31+
expect(resolveAutonumberFormat(undefined)).toBe(DEFAULT_AUTONUMBER_FORMAT);
32+
expect(resolveAutonumberFormat(null)).toBe(DEFAULT_AUTONUMBER_FORMAT);
33+
expect(resolveAutonumberFormat({ autonumberFormat: undefined })).toBe(DEFAULT_AUTONUMBER_FORMAT);
34+
});
35+
36+
it('prefers the canonical `autonumberFormat` over the `format` shorthand (#1603)', () => {
37+
expect(resolveAutonumberFormat({ autonumberFormat: 'INV-{0000}' })).toBe('INV-{0000}');
38+
expect(resolveAutonumberFormat({ format: 'TK-{00000}' })).toBe('TK-{00000}');
39+
expect(resolveAutonumberFormat({ autonumberFormat: 'A-{000}', format: 'B-{000}' })).toBe('A-{000}');
40+
});
41+
42+
it('treats a non-string or empty value as undeclared — the SQL driver\'s truthiness rule', () => {
43+
// The engine used `??`, the driver used `||`; they disagreed on `''` too.
44+
// Resolving `''` to the default is the direction that leaves already-stored
45+
// driver-sql numbers unchanged.
46+
expect(resolveAutonumberFormat({ autonumberFormat: '' })).toBe(DEFAULT_AUTONUMBER_FORMAT);
47+
expect(resolveAutonumberFormat({ format: '' })).toBe(DEFAULT_AUTONUMBER_FORMAT);
48+
expect(resolveAutonumberFormat({ autonumberFormat: '', format: 'B-{000}' })).toBe('B-{000}');
49+
expect(resolveAutonumberFormat({ autonumberFormat: 42 })).toBe(DEFAULT_AUTONUMBER_FORMAT);
50+
expect(resolveAutonumberFormat({ autonumberFormat: {} })).toBe(DEFAULT_AUTONUMBER_FORMAT);
51+
});
52+
53+
it('renders the resolved default as `0001`, not the bare counter', () => {
54+
// The end-to-end shape the ruling settles: one metadata document, one
55+
// number shape, whichever side generates it.
56+
const tokens = parseAutonumberFormat(resolveAutonumberFormat({}));
57+
expect(renderAutonumber({ tokens, seq: 1, now: NOW }).value).toBe('0001');
58+
expect(renderAutonumber({ tokens, seq: 11, now: NOW }).value).toBe('0011');
59+
// …and the counter is untouched by the width — #6468's territory, pinned
60+
// here only so a future widening of the default cannot be read as a reset.
61+
expect(renderAutonumber({ tokens, seq: 12345, now: NOW }).value).toBe('12345');
62+
});
63+
64+
it('leaves the bare-counter branch reachable for a declared slot-less format', () => {
65+
// `width === null` is no longer what a format-LESS field renders through;
66+
// it is what a format carrying no `{0..0}` slot renders through.
67+
const tokens = parseAutonumberFormat(resolveAutonumberFormat({ autonumberFormat: 'CASE-' }));
68+
expect(renderAutonumber({ tokens, seq: 1, now: NOW }).value).toBe('CASE-1');
69+
});
70+
});
71+
1872
describe('parseAutonumberFormat', () => {
1973
it('splits literal, sequence, date and field tokens in order', () => {
2074
expect(parseAutonumberFormat('AD{YYYYMMDD}{0000}')).toEqual([

packages/spec/src/data/autonumber-format.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,62 @@ function renderDate(pattern: string, p: CalendarParts): string {
145145
}
146146
}
147147

148+
/**
149+
* The format an `autonumber` field renders with when it declares none — the
150+
* **contract default**, not a per-caller fallback (#6555).
151+
*
152+
* Before this constant existed, "what does a format-less autonumber look
153+
* like?" was answered twice, by hand, and differently: the SQL driver
154+
* substituted `'{0000}'` locally (`0001`, `0002`, …) while the engine's
155+
* in-memory fallback path parsed the empty string and rendered the bare
156+
* counter through {@link renderAutonumber}'s no-slot branch (`1`, `2`, …). One
157+
* metadata document therefore produced a different number *shape* depending on
158+
* which driver served it — a test asserting `'1'` against the memory driver
159+
* did not hold in production on SQL.
160+
*
161+
* The maintainer ruling of 2026-08-08 on #6555 settles it at `{0000}` and puts
162+
* the default in the contract rather than in either fallback: `{@link
163+
* FieldSchema}`'s `autonumberFormat` declares it, and both sides resolve it
164+
* through {@link resolveAutonumberFormat}. `{0000}` is the shape already
165+
* stored by SQL deployments, so choosing it leaves landed data undisturbed.
166+
*/
167+
export const DEFAULT_AUTONUMBER_FORMAT = '{0000}';
168+
169+
/**
170+
* A field declaration, as far as autonumber formatting is concerned. Both
171+
* spellings appear in real metadata: `autonumberFormat` is the spec-canonical
172+
* key, `format` the shorthand that predates it (#1603). Typed loosely because
173+
* the engine and the drivers both reach this with an unvalidated field
174+
* document in hand, not a parsed {@link FieldSchema}.
175+
*/
176+
export interface AutonumberFormatSource {
177+
autonumberFormat?: unknown;
178+
format?: unknown;
179+
}
180+
181+
/**
182+
* Resolve the format an autonumber field renders with — the ONE place the
183+
* contract default is applied, so no caller has to keep its own copy (#6555).
184+
*
185+
* Precedence: the canonical `autonumberFormat`, then the `format` shorthand,
186+
* then {@link DEFAULT_AUTONUMBER_FORMAT}. A key holding anything other than a
187+
* NON-EMPTY string counts as undeclared — which is deliberately the SQL
188+
* driver's long-standing truthiness rule, not the engine's `??`. The two
189+
* disagreed on `format: ''` as well as on the missing key, and resolving the
190+
* empty string to the default is the direction that leaves already-stored
191+
* driver-sql numbers unchanged.
192+
*
193+
* A format that IS declared is honoured exactly as written, including one with
194+
* no `{0..0}` slot (`'CASE-'` → `CASE-1`) — see {@link renderAutonumber}.
195+
*/
196+
export function resolveAutonumberFormat(field: AutonumberFormatSource | null | undefined): string {
197+
const canonical = field?.autonumberFormat;
198+
if (typeof canonical === 'string' && canonical) return canonical;
199+
const shorthand = field?.format;
200+
if (typeof shorthand === 'string' && shorthand) return shorthand;
201+
return DEFAULT_AUTONUMBER_FORMAT;
202+
}
203+
148204
export interface RenderAutonumberInput {
149205
/** Parsed tokens (from {@link parseAutonumberFormat}). */
150206
tokens: AutonumberToken[];
@@ -209,6 +265,15 @@ export function renderAutonumber(input: RenderAutonumberInput): RenderedAutonumb
209265
const scope = dynamic ? prefix : '';
210266
const value = width === null
211267
// No `{0..0}` slot — append the bare counter (legacy behaviour).
268+
//
269+
// This branch is no longer how a FORMAT-LESS field renders (#6555): a
270+
// field that declares no format now carries the contract default
271+
// `{0000}` (see {@link DEFAULT_AUTONUMBER_FORMAT} /
272+
// {@link resolveAutonumberFormat}), so it takes the padded branch below
273+
// on both the engine and the SQL driver. What still reaches here is a
274+
// format the author DID declare that happens to carry no sequence slot —
275+
// `'CASE-'` → `CASE-1` — plus any caller that tokenizes a raw string
276+
// without going through the resolver.
212277
? `${prefix}${seq}`
213278
: `${prefix}${String(seq).padStart(width, '0')}${suffix}`;
214279
return { prefix, suffix, scope, value };

0 commit comments

Comments
 (0)