diff --git a/.changeset/console-formpage-runtime-default-seed-5727.md b/.changeset/console-formpage-runtime-default-seed-5727.md new file mode 100644 index 0000000000..d69e013e00 --- /dev/null +++ b/.changeset/console-formpage-runtime-default-seed-5727.md @@ -0,0 +1,29 @@ +--- +'@object-ui/console': patch +--- + +The console's form routes no longer seed a RUNTIME `defaultValue` into the +control, so the server-side resolution the declaration asks for actually happens +(objectui#5727). + +`readPrefill` in `apps/console/src/components/FormPage.tsx` seeded every declared +default unconditionally. A `defaultValue` may be a literal, or an *instruction* +the server resolves per insert — a `DEFAULT_VALUE_TOKENS` token (`NOW()`, +`current_user`) or a CEL Expression envelope. Seeding one of those literally put +the text `NOW()` into a datetime input on both `/forms/:name` and the public +`/f/:slug` route, and submitting it sent that string as the field's value — +which is neither absent nor null, so `ObjectQL.applyFieldDefaults` never resolved +the declared default and the column stored the token text instead of a timestamp. + +The seed is now guarded by `isRuntimeDefault` from `@object-ui/core` — the same +published classifier `@object-ui/plugin-form`'s `schemaDefaults.ts` guards its +seeding with, and the one this renderer already reads once removed (through +`isServerOwnedValue`) for the create-mode `required` carve-out. A runtime default +leaves the key ABSENT rather than empty, because absent is precisely the case the +engine resolves. + +Nothing else about the prefill precedence moves: a literal default still seeds, a +stored record value still wins over a default, and an explicit `prefill_=` +param still wins over both — including for a field whose default is a runtime +token, since a value a producer supplies is not a declaration awaiting +resolution. diff --git a/apps/console/src/components/FormPage.test.ts b/apps/console/src/components/FormPage.test.ts index 0c3e4247fb..d76e6d132b 100644 --- a/apps/console/src/components/FormPage.test.ts +++ b/apps/console/src/components/FormPage.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest'; import { ActionRunner } from '@object-ui/core'; +import { DEFAULT_VALUE_TOKENS, discriminateDefaultValueShape } from '@objectstack/spec/data'; import { buildSections, FORM_RECORD_ID_PARAM, @@ -303,6 +304,123 @@ describe('readPrefill', () => { expect(readPrefill(fields, search, undefined)).toEqual(readPrefill(fields, search)); }); }); + + /** + * objectui#5727 — a `defaultValue` that is a runtime INSTRUCTION (a + * `DEFAULT_VALUE_TOKENS` token, or a CEL Expression envelope) is the + * server's to resolve per insert, so it must never reach the control. + * + * Seeding one literally puts the text `NOW()` in a datetime input and then + * SUBMITS it, and `ObjectQL.applyFieldDefaults` resolves a declared default + * only for a field that arrives absent or null — so the seed suppresses the + * very resolution the declaration asked for. Hence the assertion below is + * that the key is ABSENT, not that it is empty. + * + * Every specimen is the SPEC's own declaration, and is put through the + * spec's own three-way classifier (`discriminateDefaultValueShape`) before + * the behaviour is asserted. A hand-typed `'NOW()'` would keep passing after + * the token family moved on; a specimen the authority still classifies as a + * token cannot. + */ + describe('runtime defaults are left to the server (objectui#5727)', () => { + const withDefault = (defaultValue: unknown) => [ + { + name: 'remind_at', + label: 'Remind at', + type: 'datetime', + required: false, + readonly: false, + hidden: false, + colSpan: 1 as const, + defaultValue, + }, + ]; + + it.each([...DEFAULT_VALUE_TOKENS])('omits the %s token instead of seeding it', (token) => { + expect(discriminateDefaultValueShape(token)).toBe('token'); + const out = readPrefill(withDefault(token), new URLSearchParams()); + expect(out).not.toHaveProperty('remind_at'); + expect(out).toEqual({}); + }); + + it('omits a CEL Expression envelope instead of seeding it', () => { + const envelope = { dialect: 'cel', source: 'today()' }; + expect(discriminateDefaultValueShape(envelope)).toBe('expression'); + const out = readPrefill(withDefault(envelope), new URLSearchParams()); + expect(out).not.toHaveProperty('remind_at'); + expect(out).toEqual({}); + }); + + /** + * The guard delegates to the classifier's real semantics rather than + * comparing against a spelling: the `NOW()` token is case-insensitive and + * whitespace-tolerant (the rule the SQL driver has always applied), so + * these are the same instruction and are skipped too. + */ + it.each(['now()', ' NOW() '])('omits the tolerated spelling %j', (spelling) => { + expect(discriminateDefaultValueShape(spelling)).toBe('token'); + expect(readPrefill(withDefault(spelling), new URLSearchParams())).toEqual({}); + }); + + /** + * Controls — the other side of the discrimination. If any of these stopped + * seeding, the guard would have grown into "skip defaults", which is a + * different (and wrong) rule. + */ + describe('controls: what still seeds', () => { + it('seeds a literal default', () => { + expect(discriminateDefaultValueShape('Acme')).toBe('literal'); + expect(readPrefill(withDefault('Acme'), new URLSearchParams())).toEqual({ + remind_at: 'Acme', + }); + }); + + it('seeds a near-miss spelling that is NOT a token', () => { + // No parentheses — a genuinely intended literal, which the spec's + // token predicate deliberately does not widen to cover. + expect(discriminateDefaultValueShape('NOW')).toBe('literal'); + expect(readPrefill(withDefault('NOW'), new URLSearchParams())).toEqual({ + remind_at: 'NOW', + }); + }); + + it('seeds an object default that is NOT an Expression envelope', () => { + // Missing `dialect`: the engine falls through and stores it verbatim + // as a literal, so this renderer seeds it for the same reason. + const notAnEnvelope = { source: 'today()' }; + expect(discriminateDefaultValueShape(notAnEnvelope)).toBe('literal'); + expect(readPrefill(withDefault(notAnEnvelope), new URLSearchParams())).toEqual({ + remind_at: notAnEnvelope, + }); + }); + + it('seeds a declared null default (unchanged by this guard)', () => { + expect(readPrefill(withDefault(null), new URLSearchParams())).toEqual({ + remind_at: null, + }); + }); + + it('still fills the field from a stored record (edit mode)', () => { + // The token was resolved at insert; the persisted value is a real + // value and this form must show the row as the server holds it. + const out = readPrefill(withDefault('NOW()'), new URLSearchParams(), { + remind_at: '2026-08-23T01:13:51Z', + }); + expect(out.remind_at).toBe('2026-08-23T01:13:51Z'); + }); + + it('still honours an explicit prefill_ param', () => { + // A producer supplying a value is not a declaration awaiting + // resolution, so source 3 outranks the skip exactly as it outranks + // a literal default. + const out = readPrefill( + withDefault('NOW()'), + new URLSearchParams('prefill_remind_at=2026-01-01T00:00:00Z'), + ); + expect(out.remind_at).toBe('2026-01-01T00:00:00Z'); + }); + }); + }); }); /** diff --git a/apps/console/src/components/FormPage.tsx b/apps/console/src/components/FormPage.tsx index 5780daebfa..9919d1e172 100644 --- a/apps/console/src/components/FormPage.tsx +++ b/apps/console/src/components/FormPage.tsx @@ -98,6 +98,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; import { evalFieldPredicate, + isRuntimeDefault, isServerOwnedValue, resolveFieldRuleState, type FieldRulePredicate, @@ -888,7 +889,7 @@ export function readLoadedRecord( * Three sources, in strictly increasing precedence (#4278 ruling on prefill): * * 1. the field's `defaultValue` from the object schema — a CREATE-time - * proposal; + * proposal, and only when it is a LITERAL one (objectui#5727 — see below); * 2. the stored `record`, when this form is editing one. Present-but-null * counts and beats a default: on an edit form the stored value is the * truth, and letting a default paint over a cleared field would silently @@ -903,6 +904,48 @@ export function readLoadedRecord( * narrower, per-field instruction is the more specific one. Fields the params * do not name keep their stored values, so the precedence is per FIELD and * never wholesale. + * + * ## A RUNTIME default is not a value, so it is not seeded (objectui#5727) + * + * Source 1 above admits only LITERAL defaults. A `defaultValue` may instead be + * an *instruction* the server resolves per insert — a `DEFAULT_VALUE_TOKENS` + * token (`'NOW()'` / `'current_user'`) or a CEL Expression envelope + * (`{ dialect: 'cel', source: 'today()' }`). Seeding one of those literally is + * worse than leaving the control empty: the text `NOW()` lands in a datetime + * input and is then SUBMITTED as that field's value, and + * `ObjectQL.applyFieldDefaults` resolves a declared default only for a field + * that arrives absent or null — so seeding suppresses the very resolution the + * declaration asked for. Omitting the key is what makes the server the single + * authority for the value. + * + * The classifier is `@object-ui/core`'s {@link isRuntimeDefault}, imported — + * not re-derived. It is THE published authority for this distinction and this + * renderer already reads it once removed, via `isServerOwnedValue` in + * {@link resolveRowState}; a second consumer-side copy would be free to + * disagree about, say, a CEL envelope, and then this form would seed a field + * whose `required` rule it also suppresses. `@object-ui/plugin-form`'s + * `schemaDefaults.ts` guards its own seeding with the same call — this is that + * settled rule reaching the SECOND form renderer in this repo, which is why + * the gap survived (#4047 / #4068 fixed the other chain; #4069 / #4085 carried + * the required-ness half here already). + * + * `isRuntimeDefault` specifically, and not `schemaDefaults.ts`'s + * `isSeedableDefault` wrapper around it: that one additionally rejects `null`, + * which would quietly change what a declared `defaultValue: null` does on this + * route. The card is about runtime instructions; the null contract stays as it + * was (`!== undefined`). + * + * Skipping is deliberately NOT gated on create mode, unlike the sibling + * chain's caller-side gate. On an edit form a record that names the field + * already outranks any default (source 2), so gating would change nothing + * there — while a record that leaves the column UNSET is exactly where the + * literal `NOW()` would still reach a control today. One unconditional rule + * means "a runtime token never reaches an input on this route", with no mode + * for it to leak through. + * + * The two later sources are untouched: a stored value and an explicit + * `prefill_` param are real values a producer supplied, not declarations the + * server is waiting to resolve, so both still fill a runtime-default field. */ export function readPrefill( fields: RenderableField[], @@ -911,7 +954,11 @@ export function readPrefill( ): Record { const out: Record = {}; for (const f of fields) { - if (f.defaultValue !== undefined) out[f.name] = f.defaultValue; + // Runtime defaults are the server's to resolve, so the key is left ABSENT + // rather than seeded — see the docblock's #5727 section. + if (f.defaultValue !== undefined && !isRuntimeDefault(f.defaultValue)) { + out[f.name] = f.defaultValue; + } // `hasOwnProperty` rather than a truthiness/undefined test: a stored null // or empty string is a real value on an edit form, and must beat the // default.