Skip to content

Commit 3230d66

Browse files
fix(types,rest): a /data write's NOT NULL refusal names the field by its label (#24759)
Fixes #24715 Clause-②: yes (widening: three additive exports, `notNullRefusalSite`, `relabelNotNullRefusal` and the type `NotNullRefusalSite`, consumed by `@objectstack/rest`'s `/data` doors; no accept set, status or code moves) Basis, per package: `@objectstack/types` widens its published surface by exactly those three names, which reach its built entry through `export *` (`packages/types/src/index.ts:98`). Measured on the built `dist/index.d.ts` export list, `origin/main` `580fd4ed3` against this branch: 122 → 125 names, three added, none removed. Its changeset is therefore `minor`, `Clause-②: yes (widening)`. `@objectstack/rest` changes message text only: the refusal's `status`, `code` and `fields[]` keys and codes do not move, and its new module is not on its entry. Its changeset stays `patch`, `Clause-②: no`. ## What changes A field declared `storage: { notNull: true }` without `required` is ADR-0113's engine-populated column. The record validator judges `required` only, so a write with no value for it reaches the driver and the database refuses it. `mapDataError` classifies that refusal from the driver's text, which names the column and nothing else. - **`@objectstack/types`.** The NOT NULL branch stays field-map-free (per triage, no field map is threaded into it). It now brands the body it builds. The caller that holds the field map uses `notNullRefusalSite(answer)` to recognise that answer, and no lookalike, and gets back the column and the table where the dialect names one (SQLite, Postgres). It calls `relabelNotNullRefusal(answer, { message, declaresNotNull })` to swap the sentence and, for a declared `storage.notNull` field, the hint. The branch's own hint no longer adds "or declare the field `required`". That hint is what every door that does not relabel still serves, so it must hold for both ways in: a declared NOT NULL column and drift. - **`@objectstack/rest`, `POST /data/:object` and `PATCH /data/:object/:id`.** The catch relabels a NOT NULL refusal (`not-null-refusal-label.ts`). The field is named the way the record validator names its own `required` refusal. The validator's builder is not on `@objectstack/objectql`'s public entry, and `rest` does not depend on the engine at run time, so no new export in `packages/objectql` was needed or made. Every piece that builder composes is a `@objectstack/spec/system` export, and this door reuses them: - the catalog's `required` sentence through `renderValidationMessage`, with the `validation.field.required` override honoured; - the label through `t(objectFieldLabelKey(...))`, then the declared `label`, then the API name; - the locale: the request's `ExecutionContext.locale` (the one the engine's validator was given), negotiated by `resolveBundleLocale` against the i18n service's locales, as the engine does. - **One extra read, only on this path.** The door reads the object by name, through the read the exposure gate already makes on every `/data` request. That read is now one private method, `objectItemReader`, shared by both callers. The exposure gate's behaviour is unchanged. - **Unchanged.** `status`, `code`, `fields[0].field`, `fields[0].code`, `object` and the key set, on the body and on the field. - **Left alone, on purpose.** A column the object does not declare. A refusal the driver attributes to another table (a hook's write to a second object). Any failed read. Each keeps the classification's answer. ## Before and after, at both `/data` doors Measured on a real store: `SqlDriver` on better-sqlite3 `:memory:`, the real `ObjectQL` and `ObjectStackProtocolImplementation`. The field is `unit_price` (label "Sales Price"), declared `storage: { notNull: true }` with no `required`. **`RestServer` door, before** (`origin/main` `7e7e605de`). `PATCH /api/v1/data/OBJECT/ID` with `{ "unit_price": null }`, and `POST` with no value, both answered: ```json {"status":400,"body":{"error":"unit_price is required","code":"VALIDATION_FAILED","fields":[{"field":"unit_price","code":"required","message":"unit_price is required"}],"hint":"The database column for 'unit_price' requires a value: provide it, or declare the field `required` in the object metadata. If the object declares neither `required` nor `storage: { notNull: true }` for 'unit_price', the physical schema has drifted from metadata instead: run 'os migrate' to reconcile (or reset the dev database).","object":"line_item_probe"}} ``` **`RestServer` door, after** (this branch). `PATCH` and `POST` both answer: ```json {"status":400,"body":{"error":"Sales Price is required","code":"VALIDATION_FAILED","fields":[{"field":"unit_price","code":"required","message":"Sales Price is required"}],"hint":"The field 'unit_price' is declared `storage: { notNull: true }`, so its database column requires a value: send one, or omit the field when a default value or a hook populates it (an update that omits it keeps the stored value).","object":"line_item_24715"}} ``` - A `zh-CN` caller with a `zh-CN` bundle reads `"error":"销售价格不能为空"`. That is the validator's own zh-CN `required` template; a bare `zh` negotiates to the same text. - Drift control (`drift_col`, declared with neither `required` nor `storage.notNull` over a NOT NULL column): `"error":"Drift Column is required"`, and the classification's hint, which leads with "requires a value: provide one" and keeps the conditional `os migrate` sentence. - Undeclared-column control (`gone_col`): `"error":"gone_col is required"`, with the same hint. **Dispatcher `/data` door (`packages/runtime`), before = after.** Its premise does not hold: this door never answered the `VALIDATION_FAILED` / `required` envelope. - `domains/data.ts` does not catch, so `HttpDispatcher.dispatch('PATCH', '/data/OBJECT/ID', { unit_price: null })` lets the raw `SqliteError` out of `dispatch()`. That error carries `code: SQLITE_CONSTRAINT_NOTNULL`, no status, and the message `update [statement and bound values redacted] - NOT NULL constraint failed: OBJECT.unit_price`. - `declaredEnvelopeForThrow` declines it. `resolveThrownHttpError` answers `500 INTERNAL_ERROR`, and `looksLikeInternalErrorLeak` is true for the message. - So `@objectstack/hono`'s catch-all answers `500 {"success":false,"error":{"code":"INTERNAL_ERROR","message":"Internal server error"}}`, and `dispatcher-plugin`'s exit answers the same status and code. - Naming a field there means changing that door's status and code, which this card holds off. `packages/runtime` is untouched. The report carries this as an open question and a finding. ## Pins - `packages/rest/src/data-not-null-field-label-24715.test.ts`: both doors on the real driver. - `PATCH` and `POST` assert `error`, `fields[0].message` and the hint's kind: it names the field and `storage: { notNull: true }`, and names neither `required` nor drift. They also assert the unchanged envelope and key sets, and that the row did not move. - The zh-CN case is checked against the validator's own message for a `required` sibling field, on the same request shape, and the bare `zh` case is checked too. - The drift control and the undeclared-column control. - The sentence is asserted as `renderValidationMessage`'s `required` sentence for the label, never as a literal. - `packages/rest/src/not-null-refusal-label.test.ts`: the helper's declines, each with the source it must not ask: another table, another answer, a failed read, an undeclared column. - `packages/types/src/data-error-classification.not-null-label.test.ts`: - the site per dialect (SQLite, Postgres with and without the relation, MySQL); - the validator's own `required` refusal not recognised, and a copied body not recognised; - the hint holding for both cases; - the relabel moving text only. ## Ablations One per change, each through `node scripts/ablation-replace.mjs`: the anchor hit once, landed on disk, and was restored. Each restore was proven by blob (`git hash-object` equal to the HEAD blob, and `git diff HEAD` empty). Each leg went red, the expected direction. The subjects resolve from source here (relative imports), so no `dist` build sits between mutation and run. 1. **Hint.** The classification's first sentence was put back to "provide it, or declare the field `required`". Result: `data-error-classification.not-null-label.test.ts` went 1 failed / 9 passed, and the hint case is the one that failed. Blob `61ebd27afc24` → `2f324ae3d717` → `61ebd27afc24`. 2. **Brand.** The line that records the branch's body was deleted. Result: 6 failed / 4 passed. The four site cases and both relabel cases went red; the validator lookalike, copy and pass-through cases stayed green. Blob `61ebd27afc24` → `b3d98ac5a64e` → `61ebd27afc24`. 3. **Door relabel.** `labelNotNullRefusal` was made to decline every answer. Result: 6 failed / 6 passed. `PATCH`, `POST`, zh-CN, `zh`, the drift label and the helper's CONTROL went red. The fixture case, the undeclared-column control and the four declines stayed green. Blob `f2a76857df43` → `e2383610aa51` → `f2a76857df43`. ## Census of the old-text pins - **The hint.** `git grep` over every package's tests for the hint's wording found one pin this change made false: `packages/rest/src/data-not-null-hint-20963.test.ts`, which asserted that the hint's first sentence contains `` `required` ``. It now asserts the opposite. Its harness registers no stored-metadata family, so the door's object read fails there and the classification's answer goes out unchanged. That makes it the fail-safe control for the read, and its docblock now says so. - **The `X is required` text.** The other tests that carry it either call `mapDataError` directly, where the sentence is unchanged (`rest.test.ts`, `rest-unclassified-fault-status.test.ts`, `rest-unique-violation-dialects.test.ts`), or assert the record validator's own messages, or an import row's report. None drives a labelled declared field through the relabelled doors. None changed. ## Tests and gates The test suites, typecheck and the first lint ran at `ee57acee6`. The one later commit, `055d54575`, edits `.changeset/24715-not-null-field-label-types.md` alone (`minor`, `Clause-②: yes (widening)`). The derived gates and lint were re-run at `055d54575`. - **Full suites, every project.** Each part's exit was recorded before any pipe. - `@objectstack/types`: `local` 29 files, 774 passed, exit 0; `repo` 1 file, 11 passed, exit 0. - `@objectstack/rest`: `local` 304 files, 6124 passed / 326 skipped, exit 0; `repo` 5 files, 280 passed / 18 skipped, exit 0. - **Typecheck.** `pnpm --filter @objectstack/types typecheck` and `pnpm --filter @objectstack/rest typecheck` both exit 0, including rest's `check:test-typecheck` (0 debt). `tsc --listFiles` confirms that each package's program compiles the new test files. - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands` derived 67 commands, and all 67 were run with their exit codes recorded. - At `ee57acee6`: `pnpm check:dual-build-cjs-loads` first exited 3 (`PREREQUISITE NOT MET`, no `dist/`). It exited 0 after a full `turbo run build`. `dispatch-gates --ran` then printed: `67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN`. - Re-run at `055d54575` (the same 67 derived): all 67 exit 0, the changeset readers included (`check-changeset-no-major.mjs`, `check-adr-0087-registration.mjs`, `check-empty-changeset.mjs`, `check:changeset-gate-self-tests`, `check:pm-changeset-deadline-census`, `check:published-files`). `dispatch-gates --ran` printed: `67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN`. - Check Changeset's level axis, run locally as `check-changeset-no-major.mjs --event` against this PR's payload: it passes with the body declaring `no`, and it passes with this body declaring `yes (widening)` ("the declared widening is accounted for"). - **Lint.** `pnpm lint` (`eslint . --no-inline-config`, the whole repository, the lane's full union) exits 0 with no findings, at `ee57acee6` and again at `055d54575`. ## Acceptance notes - **Other doors, same family, not relabelled here.** The `/data/:object/batch` and `/batch` rows, import rows, the public form submit, and data actions still name the column when the database refuses NOT NULL. Those that carry the classification's hint now carry it without the `required` prescription (an import row carries no hint). None was measured with a labelled field, so none is filed here. - **The dispatcher `/data` door.** See above. A driver refusal leaves `dispatch()` raw and is answered `500`. - **The update sentence.** The relabel uses the catalog's `required` sentence for both create and update, per the card. The validator words its own update refusal of a `required` field with `required_cleared` ("is required and cannot be cleared"); that sentence was not adopted. --- _Generated by [Claude Code](https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7d45c8e commit 3230d66

10 files changed

Lines changed: 874 additions & 50 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): a `/data` create or update refused by a NOT NULL column names the field by its label, in the caller's language
6+
7+
Clause-②: no (message text only: the refusal's `status`, `code` and `fields[]` keys and codes do not move, and no accept set changes)
8+
9+
- **Before.** A field declared `storage: { notNull: true }` without `required` is refused by the database, not by the record validator. `PATCH /api/v1/data/OBJECT/ID` with `{ "unit_price": null }`, on a field labelled "Sales Price", answered `400 VALIDATION_FAILED` with `error` and `fields[0].message` both `"unit_price is required"`. The user saw the column key. The same field declared `required` was refused by the validator, by its label.
10+
- **Now.** `POST /data/:object` and `PATCH /data/:object/:id` name the field the way the record validator names its own `required` refusal: the catalog's `required` sentence, in the caller's locale, with the field's translated label, then its declared label. A `zh-CN` caller reads `销售价格不能为空`. For a field that declares `storage.notNull`, the `hint` says to send a value, or to omit the field when a default or a hook populates it. It no longer prescribes `required` or `os migrate`.
11+
- **Unchanged.** `status`, `code`, `fields[0].field` (the column key), `fields[0].code` (`required`), `object` and the key set. Drift keeps the drift hint: a field declared with neither `required` nor `storage.notNull` over a column that is still NOT NULL. A column the object does not declare keeps today's sentence. So does a refusal the driver attributes to another table, and any answer when the object or the i18n service cannot be read. The door makes the extra object read only for a NOT NULL refusal, and it is the by-name read the exposure gate already makes on every `/data` request.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/types': minor
3+
---
4+
5+
fix(types): a NOT NULL refusal's hint no longer tells the author to declare the field `required`, and the caller that holds the field map can name the field by its label
6+
7+
Clause-②: yes (widening: three additive exports, `notNullRefusalSite`, `relabelNotNullRefusal` and the type `NotNullRefusalSite`, consumed by `@objectstack/rest`'s `/data` doors; no accept set, status or code moves, and the refusal's `fields[]` keys and codes do not move)
8+
9+
- **The hint holds for both ways in.** `mapDataError` classifies a driver's NOT NULL refusal from the driver's text, which names the column and nothing else. Its hint used to lead with "provide it, or declare the field `required` in the object metadata". For a field declared `storage: { notNull: true }` without `required` (ADR-0113's engine-populated column, filled by a hook or a default), declaring `required` is the move ADR-0113 D1 / D2 rule out: it puts the demand back on the form. The hint now leads with "requires a value: provide one", and names drift (`os migrate`) only as the case where the object declares neither `required` nor `storage.notNull`.
10+
- **`notNullRefusalSite(answer)` and `relabelNotNullRefusal(answer, { message, declaresNotNull })`.** The first answers the column, and the table where the dialect names one (SQLite, Postgres), when `answer` is the NOT NULL refusal `mapDataError` built. It answers `undefined` for every other answer, including a record validator's own `required` refusal with the same `code`. The second replaces `error` and `fields[0].message` with the caller's sentence. For a field that declares `storage.notNull`, it also replaces the hint with one that names neither `required` nor `os migrate`. Every other key and value is unchanged. The classification itself still reads no field map.

‎packages/rest/src/crud-routes.ts‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export interface CrudRouteHost {
9696
resolveExecCtx: RestServer['resolveExecCtx'];
9797
enforceAuth: RestServer['enforceAuth'];
9898
enforceApiAccess: RestServer['enforceApiAccess'];
99+
labelNotNullRefusal: RestServer['labelNotNullRefusal'];
99100
}
100101

101102
/**
@@ -242,10 +243,13 @@ export function registerCrudEndpoints(this: CrudRouteHost, basePath: string): vo
242243
method: 'POST',
243244
path: `${dataPath}/:object`,
244245
handler: async (req: any, res: any) => {
246+
// [#24715] Declared out here so the catch can read the
247+
// caller's locale off it.
248+
let context: any;
245249
try {
246250
const environmentId = isScoped ? req.params?.environmentId : undefined;
247251
const p = await this.resolveProtocol(environmentId, req);
248-
const context = await this.resolveExecCtx(environmentId, req);
252+
context = await this.resolveExecCtx(environmentId, req);
249253
if (this.enforceAuth(req, res, context)) return;
250254
if (await this.enforceApiAccess(req, res, p, environmentId, 'create')) return;
251255
// [#3899] The wire body IS the record. Validate the
@@ -291,7 +295,14 @@ export function registerCrudEndpoints(this: CrudRouteHost, basePath: string): vo
291295
applyDroppedFieldsHeader(res, result);
292296
res.status(201).json(result);
293297
} catch (error: any) {
294-
const mapped = mapDataError(error, req.params?.object);
298+
// [#24715] A NOT NULL refusal names the field by its label,
299+
// in the caller's locale; every other answer is unchanged.
300+
const mapped = await this.labelNotNullRefusal(
301+
mapDataError(error, req.params?.object),
302+
req,
303+
isScoped ? req.params?.environmentId : undefined,
304+
context,
305+
);
295306
logUnexpectedRouteError(error, mapped);
296307
res.status(mapped.status).json(mapped.body);
297308
}
@@ -382,10 +393,12 @@ export function registerCrudEndpoints(this: CrudRouteHost, basePath: string): vo
382393
method: 'PATCH',
383394
path: `${dataPath}/:object/:id`,
384395
handler: async (req: any, res: any) => {
396+
// [#24715] As on the create: read by the catch.
397+
let context: any;
385398
try {
386399
const environmentId = isScoped ? req.params?.environmentId : undefined;
387400
const p = await this.resolveProtocol(environmentId, req);
388-
const context = await this.resolveExecCtx(environmentId, req);
401+
context = await this.resolveExecCtx(environmentId, req);
389402
if (this.enforceAuth(req, res, context)) return;
390403
// OCC: clients opt in by sending either the standard
391404
// `If-Match` header or an `expectedVersion` field in
@@ -472,7 +485,13 @@ export function registerCrudEndpoints(this: CrudRouteHost, basePath: string): vo
472485
applyDroppedFieldsHeader(res, result);
473486
res.json(result);
474487
} catch (error: any) {
475-
const mapped = mapDataError(error, req.params?.object);
488+
// [#24715] The create's relabel, on the update.
489+
const mapped = await this.labelNotNullRefusal(
490+
mapDataError(error, req.params?.object),
491+
req,
492+
isScoped ? req.params?.environmentId : undefined,
493+
context,
494+
);
476495
logUnexpectedRouteError(error, mapped);
477496
res.status(mapped.status).json(mapped.body);
478497
}
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24715] A `/data` write's NOT NULL refusal names the field by its LABEL, in
5+
* the caller's locale, and a field declared `storage: { notNull: true }` is not
6+
* told to declare `required`.
7+
*
8+
* `unit_price` (label "Sales Price") is ADR-0113's engine-populated column:
9+
* `storage: { notNull: true }`, no `required`. The record validator judges
10+
* `required` only, so a write with no value for it reaches the driver and the
11+
* DATABASE refuses it. Measured on `origin/main` `7e7e605de` through these same
12+
* two doors, before the change:
13+
*
14+
* PATCH /api/v1/data/OBJECT/ID { "unit_price": null }
15+
* POST /api/v1/data/OBJECT { …, no unit_price }
16+
* 400 { "error": "unit_price is required", "code": "VALIDATION_FAILED",
17+
* "fields": [{ "field": "unit_price", "code": "required",
18+
* "message": "unit_price is required" }],
19+
* "hint": "The database column for 'unit_price' requires a value:
20+
* provide it, or declare the field `required` in the
21+
* object metadata. If the object declares neither … run
22+
* 'os migrate' …", "object": "OBJECT" }
23+
*
24+
* Read through the public doors, over a REAL `ObjectQL` +
25+
* `ObjectStackProtocolImplementation` + SQLite `:memory:`, with the stored
26+
* metadata family registered so the protocol's by-name object read answers
27+
* (the door reads the field's label and `storage` there).
28+
*
29+
* The controls: a field the metadata declares with neither `required` nor
30+
* `storage.notNull` on a NOT NULL column (drift) keeps the drift hint; a column
31+
* the metadata does not declare at all keeps the column's sentence. Pinned by
32+
* kind: the sentence is the catalog's `required` sentence for the label (asked
33+
* of `renderValidationMessage`, the record validator's own renderer), and the
34+
* hint is judged by what it prescribes.
35+
*/
36+
37+
import { describe, it, expect, afterEach } from 'vitest';
38+
import { ObjectQL } from '@objectstack/objectql';
39+
import { SqlDriver } from '@objectstack/driver-sql';
40+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
41+
import {
42+
SysMetadataAuditObject,
43+
SysMetadataCommitObject,
44+
SysMetadataHistoryObject,
45+
SysMetadataObject,
46+
} from '@objectstack/metadata-core';
47+
import { renderValidationMessage } from '@objectstack/spec/system';
48+
import { RestServer } from './rest-server';
49+
50+
const OBJECT = 'line_item_24715';
51+
52+
/** The metadata as the DDL saw it: every column the table is built with. */
53+
const AS_SYNCED = {
54+
name: OBJECT, label: 'Line item', systemFields: false,
55+
fields: {
56+
id: { name: 'id', type: 'text' as const, primaryKey: true },
57+
name: { name: 'name', type: 'text' as const, label: 'Name' },
58+
// ADR-0113's engine-populated column: NOT NULL on purpose, not `required`.
59+
unit_price: { name: 'unit_price', type: 'number' as const, label: 'Sales Price', storage: { notNull: true } },
60+
// The validator's own `required`, for the parity case.
61+
list_price: { name: 'list_price', type: 'number' as const, label: 'List Price', required: true },
62+
drift_col: { name: 'drift_col', type: 'text' as const, label: 'Drift Column', storage: { notNull: true } },
63+
gone_col: { name: 'gone_col', type: 'text' as const, label: 'Gone Column', storage: { notNull: true } },
64+
},
65+
};
66+
67+
/**
68+
* The metadata the doors read afterwards: `drift_col` declares neither
69+
* `required` nor `storage.notNull` over a column that is still NOT NULL (drift),
70+
* and `gone_col` is not declared at all.
71+
*/
72+
const SERVED = (() => {
73+
const { gone_col: _gone, drift_col, ...fields } = AS_SYNCED.fields;
74+
const { storage: _storage, ...undeclared } = drift_col;
75+
return { ...AS_SYNCED, fields: { ...fields, drift_col: undeclared } };
76+
})();
77+
78+
const ZH_LABELS: Record<string, string> = {
79+
[`objects.${OBJECT}.fields.unit_price.label`]: '销售价格',
80+
[`objects.${OBJECT}.fields.list_price.label`]: '标价',
81+
};
82+
/** An `II18nService` holding a `zh-CN` bundle: `t` echoes the key on a miss. */
83+
const I18N = {
84+
t: (key: string, locale: string) => (locale === 'zh-CN' && ZH_LABELS[key]) || key,
85+
getLocales: () => ['en', 'zh-CN'],
86+
};
87+
88+
function createMockServer() {
89+
const noop = () => {};
90+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
91+
}
92+
93+
function makeRes() {
94+
const res: any = {
95+
write: () => true, end: () => {},
96+
header: () => res,
97+
status: (code: number) => { res._status = code; return res; },
98+
json: (body: any) => { res._json = body; return res; },
99+
};
100+
return res;
101+
}
102+
103+
const liveEngines: ObjectQL[] = [];
104+
afterEach(async () => {
105+
while (liveEngines.length) {
106+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
107+
}
108+
});
109+
110+
async function boot(opts: { locale?: string; i18n?: boolean } = {}) {
111+
const engine = new ObjectQL();
112+
liveEngines.push(engine);
113+
engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true);
114+
await engine.init();
115+
if (opts.i18n) engine.setI18nService(I18N);
116+
engine.registry.registerObject(AS_SYNCED as any);
117+
await engine.syncSchemas();
118+
await engine.insert(OBJECT, { id: 'e1', name: 'existing', unit_price: 5, list_price: 7, drift_col: 'd', gone_col: 'g' } as any);
119+
engine.registry.registerObject(SERVED as any);
120+
// The protocol's object read consults the stored-metadata family; registered
121+
// after the DDL, as a boot does, so its unprovisioned table reads as empty.
122+
for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) {
123+
if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any);
124+
}
125+
126+
const protocol = new ObjectStackProtocolImplementation(engine as any);
127+
const rest = new RestServer(
128+
createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any,
129+
undefined, undefined, undefined, undefined, undefined,
130+
undefined, undefined, undefined, undefined, undefined,
131+
// i18nServiceProvider — the 14th constructor argument.
132+
async () => (opts.i18n ? I18N : undefined) as any,
133+
);
134+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user', ...(opts.locale ? { locale: opts.locale } : {}) });
135+
rest.registerRoutes();
136+
const call = async (method: string, path: string, body: unknown, params: Record<string, string> = { object: OBJECT }) => {
137+
const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path);
138+
expect(route, `${method} ${path}`).toBeDefined();
139+
const res = makeRes();
140+
await route!.handler({ params, body, query: {}, headers: {} } as any, res);
141+
return { status: res._status ?? 200, body: res._json };
142+
};
143+
const patch = (data: Record<string, unknown>) =>
144+
call('PATCH', '/api/v1/data/:object/:id', data, { object: OBJECT, id: 'e1' });
145+
const create = (data: Record<string, unknown>) => call('POST', '/api/v1/data/:object', data);
146+
const stored = async () => (await engine.find(OBJECT, { where: { id: 'e1' } }) as any[])[0];
147+
return { engine, patch, create, stored };
148+
}
149+
150+
/** The catalog's `required` sentence for `label`, in `locale` — what the record validator renders. */
151+
const requiredSentence = (label: string, field: string, locale?: string) =>
152+
renderValidationMessage({ messageKey: 'required', label, field, params: {} }, { locale });
153+
154+
/** The envelope the classification answers, with the field's sentence `message`. */
155+
function expectRefusal(door: { status: number; body: any }, field: string, message: string) {
156+
expect(door.status, JSON.stringify(door.body)).toBe(400);
157+
expect(door.body).toMatchObject({
158+
code: 'VALIDATION_FAILED',
159+
error: message,
160+
fields: [{ field, code: 'required', message }],
161+
object: OBJECT,
162+
});
163+
// No key is added or dropped, on the body or on the field.
164+
expect(Object.keys(door.body).sort()).toEqual(['code', 'error', 'fields', 'hint', 'object']);
165+
expect(Object.keys(door.body.fields[0]).sort()).toEqual(['code', 'field', 'message']);
166+
}
167+
168+
/** The declared column's hint: the value, or the default / hook that supplies it — never `required`, never drift. */
169+
function expectDeclaredNotNullHint(hint: unknown, field: string) {
170+
const text = String(hint);
171+
expect(text).toContain(`'${field}'`);
172+
expect(text).toContain('`storage: { notNull: true }`');
173+
expect(text).toMatch(/requires a value/);
174+
expect(text).not.toContain('`required`');
175+
expect(text).not.toMatch(/drift|os migrate/);
176+
}
177+
178+
describe('[#24715] a declared `storage.notNull` field\'s refusal names its label', () => {
179+
it('the fixture is the ADR-0113 posture: the validator lets the write through and the database refuses it', async () => {
180+
const { engine } = await boot();
181+
expect(AS_SYNCED.fields.unit_price).toMatchObject({ label: 'Sales Price', storage: { notNull: true } });
182+
expect('required' in AS_SYNCED.fields.unit_price).toBe(false);
183+
await expect(engine.update(OBJECT, { unit_price: null } as any, { where: { id: 'e1' } } as any)).rejects.toThrow(/not null/i);
184+
});
185+
186+
it('PATCH writing null: the label in `error` and `fields[0].message`, the declared column\'s hint', async () => {
187+
const { patch, stored } = await boot();
188+
const door = await patch({ unit_price: null });
189+
expectRefusal(door, 'unit_price', requiredSentence('Sales Price', 'unit_price'));
190+
expect(door.body.error).not.toContain('unit_price');
191+
expectDeclaredNotNullHint(door.body.hint, 'unit_price');
192+
expect((await stored()).unit_price).toBe(5);
193+
});
194+
195+
it('POST with no value: the same answer', async () => {
196+
const { create, engine } = await boot();
197+
const door = await create({ id: 'n1', name: 'new', list_price: 1, drift_col: 'd' });
198+
expectRefusal(door, 'unit_price', requiredSentence('Sales Price', 'unit_price'));
199+
expectDeclaredNotNullHint(door.body.hint, 'unit_price');
200+
expect(await engine.find(OBJECT, { where: { id: 'n1' } })).toHaveLength(0);
201+
});
202+
});
203+
204+
describe('[#24715] localized the way the record validator localizes its own `required`', () => {
205+
it('zh-CN: the translated label in the catalog\'s zh-CN sentence — the validator\'s template, word for word', async () => {
206+
const { patch, create } = await boot({ locale: 'zh-CN', i18n: true });
207+
208+
const door = await patch({ unit_price: null });
209+
expectRefusal(door, 'unit_price', requiredSentence('销售价格', 'unit_price', 'zh-CN'));
210+
expectDeclaredNotNullHint(door.body.hint, 'unit_price');
211+
212+
// The validator, on the same request shape, for the field it refuses itself.
213+
const own = await create({ id: 'n1', name: 'new', unit_price: 1, drift_col: 'd' });
214+
expect(own.status, JSON.stringify(own.body)).toBe(400);
215+
const validatorMessage = own.body.fields.find((f: any) => f.field === 'list_price')?.message;
216+
expect(validatorMessage).toBe(requiredSentence('标价', 'list_price', 'zh-CN'));
217+
expect(door.body.error.replace('销售价格', 'LABEL')).toBe(validatorMessage.replace('标价', 'LABEL'));
218+
});
219+
220+
it('a bare `zh` reaches the zh-CN bundle, as the validator\'s locale negotiation does', async () => {
221+
const { patch } = await boot({ locale: 'zh', i18n: true });
222+
const door = await patch({ unit_price: null });
223+
expectRefusal(door, 'unit_price', requiredSentence('销售价格', 'unit_price', 'zh-CN'));
224+
});
225+
});
226+
227+
describe('[#24715] the controls: what the field map cannot name keeps today\'s answer', () => {
228+
it('drift — declared with neither `required` nor `storage.notNull` over a NOT NULL column: the label, and the drift hint', async () => {
229+
const { patch } = await boot();
230+
const door = await patch({ drift_col: null });
231+
expectRefusal(door, 'drift_col', requiredSentence('Drift Column', 'drift_col'));
232+
expect(String(door.body.hint)).toMatch(/drifted from metadata/);
233+
expect(String(door.body.hint)).toMatch(/os migrate/);
234+
});
235+
236+
it('a column the metadata does not declare: the column\'s sentence and the drift hint', async () => {
237+
const { create } = await boot();
238+
const door = await create({ id: 'n1', name: 'new', unit_price: 1, list_price: 1, drift_col: 'd' });
239+
expectRefusal(door, 'gone_col', 'gone_col is required');
240+
expect(String(door.body.hint)).toMatch(/os migrate/);
241+
});
242+
});

0 commit comments

Comments
 (0)