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
29 changes: 29 additions & 0 deletions .changeset/console-formpage-runtime-default-seed-5727.md
Original file line numberDiff line numberDiff line change
@@ -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_<field>=`
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.
118 changes: 118 additions & 0 deletions apps/console/src/components/FormPage.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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');
});
});
});
});

/**
Expand Down
51 changes: 49 additions & 2 deletions apps/console/src/components/FormPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,6 +98,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { toast } from 'sonner';
import {
evalFieldPredicate,
isRuntimeDefault,
isServerOwnedValue,
resolveFieldRuleState,
type FieldRulePredicate,
Expand DownExpand Up@@ -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
Expand All@@ -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[],
Expand All@@ -911,7 +954,11 @@ export function readPrefill(
): Record<string, unknown> {
const out: Record<string, unknown> = {};
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.
Expand Down
Loading