From 6f5348e63dc39398f7aaf59e216f8cbc86c1b36e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 04:03:00 +0000 Subject: [PATCH] feat(spec): declare `{0000}` as the contract default for format-less autonumber (#6555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../autonumber-default-format-contract.md | 44 ++++++++++ content/docs/references/data/field.mdx | 2 +- packages/spec/api-surface/data.json | 3 + packages/spec/authorable-defaults/data.json | 1 + packages/spec/export-origins/data.json | 3 + packages/spec/scripts/lib/default-changes.ts | 37 +++++++++ .../spec/src/data/autonumber-format.test.ts | 54 ++++++++++++ packages/spec/src/data/autonumber-format.ts | 65 +++++++++++++++ .../field-autonumber-default-format.test.ts | 82 +++++++++++++++++++ packages/spec/src/data/field.zod.ts | 25 +++++- 10 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 .changeset/autonumber-default-format-contract.md create mode 100644 packages/spec/src/data/field-autonumber-default-format.test.ts diff --git a/.changeset/autonumber-default-format-contract.md b/.changeset/autonumber-default-format-contract.md new file mode 100644 index 0000000000..77ff83bf46 --- /dev/null +++ b/.changeset/autonumber-default-format-contract.md @@ -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. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 0e0374357d..2af0fa0277 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -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. | diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 490530976e..4ff84dc1bf 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -36,6 +36,7 @@ "AuthoringKeySurface (type)", "AutoPersistenceConfig (type)", "AutoPersistenceConfigSchema (const)", + "AutonumberFormatSource (interface)", "AutonumberToken (type)", "BOOLEAN_VALUE_TYPES (const)", "BUILTIN_DRIVER_IDS (const)", @@ -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)", @@ -659,6 +661,7 @@ "referenceTargetOf (function)", "referencedFields (function)", "renderAutonumber (function)", + "resolveAutonumberFormat (function)", "resolveBulkPerRowHookBudget (function)", "resolveCrudAffordances (function)", "resolveDatabaseDriverId (function)", diff --git a/packages/spec/authorable-defaults/data.json b/packages/spec/authorable-defaults/data.json index fc0fdf263c..57ee560540 100644 --- a/packages/spec/authorable-defaults/data.json +++ b/packages/spec/authorable-defaults/data.json @@ -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", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 19082dbd30..915781cc63 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -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)", @@ -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)", @@ -659,6 +661,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)", diff --git a/packages/spec/scripts/lib/default-changes.ts b/packages/spec/scripts/lib/default-changes.ts index 149c2da4fb..07f2bec79e 100644 --- a/packages/spec/scripts/lib/default-changes.ts +++ b/packages/spec/scripts/lib/default-changes.ts @@ -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> = { 17: [ { @@ -153,5 +184,11 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly { + 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([ diff --git a/packages/spec/src/data/autonumber-format.ts b/packages/spec/src/data/autonumber-format.ts index 64d2e1d13c..23a3bf6438 100644 --- a/packages/spec/src/data/autonumber-format.ts +++ b/packages/spec/src/data/autonumber-format.ts @@ -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[]; @@ -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 }; diff --git a/packages/spec/src/data/field-autonumber-default-format.test.ts b/packages/spec/src/data/field-autonumber-default-format.test.ts new file mode 100644 index 0000000000..ee6e4c4a50 --- /dev/null +++ b/packages/spec/src/data/field-autonumber-default-format.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #6555 — a format-less `autonumber` field has ONE declared rendering, and it + * lives in the contract rather than in each generator's fallback. + * + * The defect: `autonumberFormat` is optional, and the two sides that mint + * record numbers answered "no format declared" differently. `driver-sql` + * substituted `'{0000}'` (`0001`, `0002`, …); the engine's in-memory fallback + * path — taken whenever a driver does not advertise `supports.autonumber` — + * parsed the empty string and fell through {@link renderAutonumber}'s no-slot + * branch to a bare counter (`1`, `2`, …). Both sides agreed on the counter + * VALUE (#6468 pinned that); they disagreed on its shape, so a suite asserting + * `'1'` against the memory driver did not hold in production on SQL, and the + * same object's historical numbers changed shape at a driver switch. + * + * The maintainer ruling of 2026-08-08 picks `{0000}` and route 3 — put the + * default in the contract, then delete both fallbacks. These cases pin the + * contract half: what `FieldSchema` DECLARES about the omitted key, and — just + * as load-bearing — what it does NOT do to parse output while declaring it. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { FieldSchema } from './field.zod'; +import { DEFAULT_AUTONUMBER_FORMAT, resolveAutonumberFormat } from './autonumber-format'; + +describe('FieldSchema.autonumberFormat — the declared contract default (#6555)', () => { + it('declares `{0000}` as the JSON-Schema default for the key', () => { + const js = z.toJSONSchema(FieldSchema as unknown as z.ZodType, { + unrepresentable: 'any', + io: 'input', + }) as any; + const prop = js.properties?.autonumberFormat; + expect(prop).toBeDefined(); + // The machine-readable half of "declared": a schema consumer or an AI + // metadata author reads the default off the contract instead of guessing + // per-driver behaviour. + expect(prop.default).toBe(DEFAULT_AUTONUMBER_FORMAT); + expect(prop.default).toBe('{0000}'); + expect(prop.description).toContain('{0000}'); + }); + + it('keeps the key OPTIONAL — declaring the default must not make it required', () => { + const js = z.toJSONSchema(FieldSchema as unknown as z.ZodType, { + unrepresentable: 'any', + io: 'input', + }) as any; + expect(js.required ?? []).not.toContain('autonumberFormat'); + }); + + it('does not materialize the default into parse output — on any field type', () => { + // The reason this is an annotation and not a Zod `.default()`: + // `autonumberFormat` is FLAT on FieldSchema, shared by every field type, so + // a parse-time default would stamp a counter format onto every `text`, + // `number` and `lookup` field in the system. Parse output is observed by + // metadata loaders, the metadata API and the drivers' `initObjects`, so + // that shift would be visible far outside autonumber. + for (const type of ['text', 'number', 'lookup', 'boolean'] as const) { + const parsed = FieldSchema.parse({ type, label: 'X' }) as Record; + expect(parsed).not.toHaveProperty('autonumberFormat'); + } + const auto = FieldSchema.parse({ type: 'autonumber', label: 'No.' }) as Record; + expect(auto).not.toHaveProperty('autonumberFormat'); + }); + + it('resolves a parsed format-less autonumber field to `{0000}` semantics', () => { + // The declared contract, exercised the way a generator will: parse the + // author's document, then ask the contract what it renders with. The key + // is absent from the parse output above, and the answer is still `{0000}`. + const parsed = FieldSchema.parse({ type: 'autonumber', label: 'Record No.' }); + expect(resolveAutonumberFormat(parsed as never)).toBe('{0000}'); + + // An explicitly declared format survives the round trip untouched. + const declared = FieldSchema.parse({ + type: 'autonumber', + label: 'Invoice No.', + autonumberFormat: 'INV-{YYYY}-{0000}', + }); + expect(resolveAutonumberFormat(declared as never)).toBe('INV-{YYYY}-{0000}'); + }); +}); diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index b944b36d80..3dc62a38ad 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -8,6 +8,7 @@ import { SystemIdentifierSchema } from '../shared/identifiers.zod'; import { ExpressionInputSchema } from '../shared/expression.zod'; import { FilterConditionSchema } from './filter.zod'; import { FIELD_KEY_GUIDANCE } from './authoring-key-lint'; +import { DEFAULT_AUTONUMBER_FORMAT } from './autonumber-format'; /** * Field Type Enum @@ -879,8 +880,30 @@ export const FieldSchema = lazySchema(() => strictObject({ * collapse the number into the wrong counter scope, so generation throws * instead; `objectstack compile` lints this (unknown field → build error, * optional field → warning). + * + * ## Omitting it — the contract default (#6555) + * + * The key stays optional, and a field that omits it renders with + * {@link DEFAULT_AUTONUMBER_FORMAT} — `{0000}`, i.e. `0001`, `0002`, … The + * default belongs to the CONTRACT, not to whoever happens to be generating + * the number: every consumer resolves it through + * {@link resolveAutonumberFormat} rather than substituting its own. That is + * the whole point of the maintainer's 2026-08-08 ruling — the two hand-written + * fallbacks it replaces disagreed (the SQL driver substituted `{0000}` while + * the engine's in-memory fallback emitted a bare `1`), so one metadata + * document minted differently-shaped numbers depending on the driver behind it. + * + * Declared here as a JSON-Schema `default` annotation rather than a Zod + * `.default()`: this key is flat on `FieldSchema`, shared by all ~49 field + * types, so a parse-time default would materialize `autonumberFormat: + * '{0000}'` on every `text`, `number` and `lookup` field ever parsed — a + * format on a field that has no counter. The annotation states the default + * to schema consumers and AI metadata authors without touching parse output. */ - 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).'), + autonumberFormat: z.string().optional().meta({ + description: '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).', + default: DEFAULT_AUTONUMBER_FORMAT, + }), // `index` (field-level bool) removed in the 16.x line (#2377, ADR-0049): the // driver builds indexes from the object's `indexes[]` array; a field-level // `index: true` created no index. Declare the index in object `indexes[]`.