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
11 changes: 11 additions & 0 deletions .changeset/24131-core-required-emptiness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/core": minor
---

feat(core): export the emptiness rule a `required` declaration is judged by — `isEmptyForRequired(def, value)` and `isMissing(value)`

Clause-②: yes (widening)

- **New exports from `@objectstack/core`'s `.` entry.** `isMissing(value)` is `true` for `undefined`, `null`, and a string that is empty or only whitespace. `isEmptyForRequired(def, value)` is `isMissing`, plus an empty list `[]` on a field whose DECLARED value is a set (`@objectstack/spec/data`'s `isMultiValueField`: `tags`, `multiselect`, `checkboxes`, or a `select` / `radio` / `lookup` / `user` / `file` / `image` field flagged `multiple: true`). `def` is a `ValueShapeFieldDef` (`{ type, multiple? }`). A structured-JSON field's `[]` is a document, not an empty value.
- **Why it moved.** It was private to `@objectstack/objectql`'s record validator, where the object-level `required` check runs. `@objectstack/rest`'s anonymous public-form submit route now judges a FORM's `required` with it, and rest does not depend on the engine at runtime. So the one rule lives in the package both depend on, and the two doors call the same field missing.
- **Unchanged:** the rule itself, byte for byte — the engine's object-level `required` refusal answers exactly what it did.
10 changes: 10 additions & 0 deletions .changeset/24131-objectql-required-emptiness-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@objectstack/objectql": patch
---

refactor(objectql): the record validator reads `isMissing` / `isEmptyForRequired` from `@objectstack/core` instead of private copies

Clause-②: no

- **No behaviour change.** The object-level `required` check, the `required_cleared` PATCH check, the blank-to-`null` normalisation of typed columns and the value-shape scan judge "missing" exactly as before; the private copies of the two predicates are deleted and `@objectstack/core`'s exports, moved there unchanged, are imported at every read site.
- **Why:** `@objectstack/rest`'s public-form submit route now judges a form's `required` by the same rule, and it cannot import the engine.
14 changes: 14 additions & 0 deletions .changeset/24131-rest-public-form-required.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@objectstack/rest": patch
---

fix(rest)!: an anonymous public-form submit that leaves a form-required field empty answers `400 VALIDATION_FAILED` naming the field, and stores nothing

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) the narrowing is a runtime REST door's accept set, POST /forms/:slug/submit: no stored metadata changes shape and nothing must be rewritten, so `objectstack migrate meta` has nothing to do; a caller that omitted a form-required field sends it -->

- **What changed.** `POST /forms/:slug/submit` now enforces the form's own `required`: a section entry `{ field: 'email', required: true }` is judged before the write, with the rule the engine's object-level check uses (`isEmptyForRequired` from `@objectstack/core`). A submission whose value for such a field is absent, `null`, `''`, only whitespace, or `[]` on a multi-value field is refused, and no row is created. Before, the engine judged only the fields the OBJECT requires, so a field only the form required answered `201` and was stored empty — while the rendered form refused the same submission.
- **The refusal** is the object-level one's envelope on this route, key for key: `400` with top-level `code: 'VALIDATION_FAILED'`, `error` (the messages), and `fields[]` of `{ field, code: 'required', message, label }`, worded by the same validation catalog (for example `Email is required`). Every empty form-required field is named in one refusal, in the form's order.
- **BREAKING — a narrowing, and who it affects.** A script or integration that posts to a public form and omits a field the form marks `required: true` now gets `400` instead of `201`. The fix is to send that field. Nothing that was refused before is accepted now.
- **Judged only where the form asks.** An entry the form hides (`hidden: true`), or shows only conditionally (`visibleWhen` on the entry or on its section), is not judged — nothing server-side evaluates a form-view `visibleWhen`. A server-managed anchor (`owner_id`, `organization_id`, …) is never written by this route, so it is never judged. An object `defaultValue` or a `beforeInsert` hook does not satisfy a field the visitor omitted: the route judges what the visitor sent.
2 changes: 1 addition & 1 deletion content/docs/ui/public-data-collection.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ POST /api/v1/forms/contact-us/submit → INSERT one showcase_inquiry

### 2. The field whitelist *is* the security boundary

Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side. Server-controlled fields (`status`, `source`, owner stamps) are set by a defaults hook, never by the submitter. The submit route derives a narrow `publicFormGrant: { object: 'showcase_inquiry' }` — create + read-back of the row it just made, and nothing else.
Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side. The submit route also enforces each entry's `required: true` before anything is written — a submission that leaves such a field absent, `null`, blank, or an empty list on a multi-value field is refused with `400 VALIDATION_FAILED` naming the field — except on an entry the form hides (`hidden: true`) or shows only conditionally (`visibleWhen` on the entry or its section), which nothing server-side evaluates. Server-controlled fields (`status`, `source`, owner stamps) are set by a defaults hook, never by the submitter. The submit route derives a narrow `publicFormGrant: { object: 'showcase_inquiry' }` — create + read-back of the row it just made, and nothing else.

System-managed anchors (`owner_id`, `organization_id`, audit columns, `id`) are **never** accepted from a public submission — not even when a section declares them, and not through the zero-sections all-fields fallback. The route filter and the data layer enforce the same set (`PUBLIC_FORM_SERVER_MANAGED_FIELDS` from `@objectstack/spec/security`), so an anonymous visitor cannot forge record ownership or tenant placement regardless of how the form is declared (#3022).

Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@ export * from './utils/temporal-storage-form.js';
// because those two do not depend on each other at runtime.
export * from './utils/multi-value-storage-form.js';

// [#24131] …and the emptiness rule a `required` declaration is judged by.
// `@objectstack/objectql`'s record validator judges an object field's
// `required` with it, and `@objectstack/rest`'s public-form submit door the
// form's own `required` — one rule, here because rest does not depend on the
// engine at runtime.
export * from './utils/required-emptiness.js';

// [#21007] …and the refusal a scalar comparison gets on a field stored as a
// JSON column: the operator set and the words. `driver-sql` refuses it on
// `where`, and `@objectstack/objectql` on the per-aggregation `filter` it
Expand Down
79 changes: 79 additions & 0 deletions packages/core/src/utils/required-emptiness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#24131] The emptiness rule `required` is judged by, pinned per arm, and
* read through core's `.` entry — the specifier both of its doors import
* (`@objectstack/objectql`'s record validator, `@objectstack/rest`'s public-form
* submit route).
*
* The arms are the ones the rule moved out of `record-validator.ts` with:
* absent, `null`, `''`, a whitespace-only string, `[]` on a field whose
* declared value is a set (empty), and `[]` on a structured-JSON field (a
* document, NOT empty). Each "empty" row sits beside a present twin, so a
* predicate that answered `true` for everything fails as loudly as one that
* answered `false`.
*/

import { describe, it, expect } from 'vitest';
import { isEmptyForRequired, isMissing } from '../index.js';

const TEXT = { type: 'text' };
const TAGS = { type: 'tags' };
const MULTISELECT = { type: 'multiselect' };
const MULTI_LOOKUP = { type: 'lookup', multiple: true };
const SINGLE_LOOKUP = { type: 'lookup' };
const JSON_FIELD = { type: 'json' };

describe('[#24131] isMissing — the def-free half', () => {
it.each([
['absent (undefined)', undefined],
['null', null],
["the empty string ''", ''],
['a whitespace-only string', ' '],
['a tab-and-newline string', '\t\n'],
])('%s is missing', (_label, value) => {
expect(isMissing(value)).toBe(true);
});

it.each([
['a non-blank string', 'Ada'],
['a padded non-blank string', ' Ada '],
['zero', 0],
['false', false],
['an empty list (def-free: never missing here)', []],
['an empty object', {}],
])('%s is present', (_label, value) => {
expect(isMissing(value)).toBe(false);
});
});

describe('[#24131] isEmptyForRequired — isMissing, plus [] where the declared value is a set', () => {
it.each([
['absent', TEXT, undefined],
['null', TEXT, null],
["''", TEXT, ''],
['whitespace-only', TEXT, ' '],
['[] on tags (an inherently-multi type)', TAGS, []],
['[] on multiselect', MULTISELECT, []],
['[] on a lookup flagged multiple: true', MULTI_LOOKUP, []],
['null on tags', TAGS, null],
])('%s is empty', (_label, def, value) => {
expect(isEmptyForRequired(def, value)).toBe(true);
});

it.each([
['a non-blank string', TEXT, 'ada@example.test'],
['zero', { type: 'number' }, 0],
['false', { type: 'boolean' }, false],
['a one-member list on tags', TAGS, ['x']],
['a one-member list on a multi lookup', MULTI_LOOKUP, ['rec_1']],
// A structured-JSON field's `[]` is a legitimate document, not an emptied
// set — the half of the rule a value-shape guess would get wrong.
['[] on a json field (a document, not an emptied set)', JSON_FIELD, []],
// A single-valued lookup's declared value is NOT a set, so its `[]` is
// not judged empty here; the value-shape arm downstream refuses it.
['[] on a single-valued lookup', SINGLE_LOOKUP, []],
])('%s is not empty', (_label, def, value) => {
expect(isEmptyForRequired(def, value)).toBe(false);
});
});
63 changes: 63 additions & 0 deletions packages/core/src/utils/required-emptiness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#24131] THE emptiness rule a `required` declaration is judged by — one rule,
* read by every door that refuses a missing required value.
*
* ## Two faces, one rule
*
* - `@objectstack/objectql`'s record validator judges an object field's
* `required` with it on every insert, and a PATCH that clears a required
* field (`required_cleared`).
* - `@objectstack/rest`'s anonymous public-form submit door judges the FORM's
* own `required` (a form-field entry's `required: true`) with it before the
* write, so a field the form requires and the object does not is refused in
* the same words the object-level refusal uses.
*
* `@objectstack/rest` does not depend on the engine at runtime (the
* `check:undeclared-dep-imports` ledger records why), and a copy each is how
* one value comes to be "missing" at one door and "present" at the other — the
* whitespace-only string and the empty list are the two cells a hand-written
* check drops. So the rule lives here, where both already depend, beside the
* other value-level rules those two packages share
* (`multi-value-storage-form.ts`). It moved out of `record-validator.ts`
* unchanged.
*
* ## The rule
*
* - {@link isMissing}: `undefined`, `null`, and a string that is empty or only
* whitespace. Def-free.
* - {@link isEmptyForRequired}: everything {@link isMissing} calls missing, and
* — only where the DECLARED value is a set (`@objectstack/spec/data`'s
* `isMultiValueField`, ADR-0104 D1) — an empty list. The #9447 maintainer
* ruling sets that half: `required` on a multi-value field means a NON-EMPTY
* list, because the empty set is representable (it reads back as `[]`, never
* `null`), so an explicit `[]` is an empty value exactly as `null` / `''`
* are. A structured-JSON field's `[]` is a legitimate document, not an
* emptied set, and is NOT empty here.
*
* ⛔ What a door does with "missing" is the door's: the engine skips a
* runtime-owned `autonumber`, the form door judges only the entries the visitor
* is asked to fill. Neither belongs in this rule, and neither is a reason for a
* second copy of it.
*/

import { isMultiValueField, type ValueShapeFieldDef } from '@objectstack/spec/data';

/**
* Whether a value is missing: `undefined`, `null`, or a string that is empty
* or only whitespace. The def-free half of {@link isEmptyForRequired}.
*/
export function isMissing(v: unknown): boolean {
return v === undefined || v === null || (typeof v === 'string' && v.trim() === '');
}

/**
* Whether a value leaves a `required` field empty — {@link isMissing}, plus an
* empty list on a field whose declared value is a set. Judged against the
* field's DECLARATION (`type`, `multiple`), never against the value's shape.
*/
export function isEmptyForRequired(def: ValueShapeFieldDef, value: unknown): boolean {
if (isMissing(value)) return true;
return isMultiValueField(def) && Array.isArray(value) && value.length === 0;
}
33 changes: 11 additions & 22 deletions packages/objectql/src/validation/record-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ import {
classifyFilterToken,
} from '@objectstack/spec/data';
import type { FieldErrorCode } from '@objectstack/spec/api';
import { SUPPORTED_TEMPORAL_YEARS, isOutsideTemporalYearRange, isUninterpretableTemporalComparand, multiValueStorageForm } from '@objectstack/core';
// [#24131] `isMissing` / `isEmptyForRequired` — the emptiness rule `required`
// is judged by — live in core, so the public-form submit door in
// `@objectstack/rest` judges a form's `required` by the same rule.
import { SUPPORTED_TEMPORAL_YEARS, isOutsideTemporalYearRange, isUninterpretableTemporalComparand, multiValueStorageForm, isMissing, isEmptyForRequired } from '@objectstack/core';
import { isValueDomainMember, type ValueDomain } from '@objectstack/spec/shared';
import {
renderValidationMessage,
Expand Down Expand Up @@ -301,27 +304,13 @@ interface FieldDef {
picklist?: string;
}

function isMissing(v: unknown): boolean {
return v === undefined || v === null || (typeof v === 'string' && v.trim() === '');
}

/**
* #9476 — the emptiness judgment the `required` check runs, def-aware where
* `isMissing` is not. The #9447 maintainer ruling (2026-08-18) sets the
* contract: `required` on a multi-value field means NON-EMPTY array — the
* empty set is representable (it reads back as `[]`, never `null`), so an
* explicit `[]` is an empty value exactly as `null` / `''` are.
*
* Judged only where the DECLARED value is a set (`isMultiValueField`,
* ADR-0104 D1): a structured-JSON field's `[]` is a legitimate document, not
* an emptied set, and every other type keeps the def-free `isMissing`
* semantics. Only the two `required` read sites call this — a bare `[]` on a
* non-required multi-value field still flows to the array-shape branch.
*/
function isEmptyForRequired(def: FieldDef, value: unknown): boolean {
if (isMissing(value)) return true;
return isMultiValueField(def) && Array.isArray(value) && value.length === 0;
}
// [#24131] `isMissing` (the def-free "missing": `undefined`, `null`, a blank
// string) and `isEmptyForRequired` (#9476 — that, plus `[]` where the DECLARED
// value is a set, per the #9447 ruling) moved to `@objectstack/core`
// (`utils/required-emptiness.ts`, which carries their contract) unchanged, and
// are imported above. Only the two `required` read sites call
// `isEmptyForRequired` — a bare `[]` on a non-required multi-value field still
// flows to the array-shape branch.

/**
* How many decimal places a finite number carries (#7501).
Expand Down
Loading
Loading