Repository navigation
Commit 48e84a9
fix(objectql,types,rest)!: a database NOT NULL refusal on a declared field is the record validator's ValidationError at every door (#26026)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XCDufXsPatdpw4EF3iFqDwB)_
Fixes #24768
Fixes #26065
Clause-②: yes (narrowing: `@objectstack/types` adds the export
`notNullViolationSite` and retires `notNullRefusalSite` and
`relabelNotNullRefusal`; the HTTP doors' answer for this refusal moves
to the record validator's envelope)
Metadata-protocol: none
**BREAKING** (an export retirement and a wire-answer change), shipped as
`patch`: no metadata protocol kind moves. The changeset carries the
ADR-0087 disposition (`runtime-export-retired`) and the FROM → TO lines
for both retired exports.
## What was wrong
A field declared `storage: { notNull: true }` without `required` is
ADR-0113's engine-populated column. The record validator judges
`required` only (D1 / D2), so a write with no value for that field
reaches the driver and the database refuses it. The engine let the
driver's error out, and each door classified it from the driver's text
its own way.
Measured on `origin/main` `0c37228826` with a real better-sqlite3 store,
the real `ObjectQL` engine and protocol, and `HttpDispatcher.dispatch()`
over a Fetch `Request` (the rig is
`packages/runtime/src/dispatcher-not-null-refusal-24768.test.ts`):
| Door | Before | After |
|:--|:--|:--|
| dispatcher `POST /data/:object` without the field | raw `SqliteError`
(`SQLITE_CONSTRAINT_NOTNULL`) out of `dispatch()`;
`declaredEnvelopeForThrow` and `validationFailureDetails` both decline
it, so `500 INTERNAL_ERROR` | `400 VALIDATION_FAILED`, `Sales Price is
required`, `details.fields` = the validator's envelope with `label` |
| dispatcher `PATCH /data/:object/:id` writing null | same `500
INTERNAL_ERROR` | `400 VALIDATION_FAILED`, the clear-out sentence
(`required_cleared`) |
| actions door, declarative `operation: 'update'` writing null | `400
VALIDATION_ERROR`, the driver's text as message, `declaredCode:
'SQLITE_CONSTRAINT_NOTNULL'` | `400 VALIDATION_FAILED` with the field
envelope, no `declaredCode`, no driver text |
| `RestServer` `POST` / `PATCH` | `400 VALIDATION_FAILED`, relabelled by
a second object read (#24715) plus a `hint` | the validator's `required`
body exactly: `fields[0]` carries `label`, no `hint`, PATCH reads
`required_cleared` |
| `RestServer` `POST /data/:object/batch` row | the column key (the
card's measurement; not re-measured here) | row error `{ code:
'VALIDATION_FAILED', message: 'Sales Price is required' }` |
Import rows, the public form submit and MCP write through the same
engine doors and answer `ValidationError` through the same arms; they
were not driven separately.
## The change
- **`@objectstack/objectql`** (`not-null-refusal-error.ts`, new).
`envelopeNotNullViolation` turns a driver NOT NULL refusal on a field
the object's field map holds into `NotNullRefusalError extends
ValidationError`, built by the validator's own `buildFieldError` with
the validator's message context: one field error, `code: 'required'`,
named by its label (translation bundle, then declared `label`, then API
name) in the caller's locale, with the sentence the validator gives the
same verb (`required` on insert, `required_cleared` on update). The
driver's error rides `cause`. `envelopeDriverWriteRefusal` composes it
after the existing unique translation (#14095) and replaces
`envelopeUniqueViolation` at all five driver exits in `engine.ts` (both
autonumber-retry sites, the `bulkCreate` exit, by-id update,
`updateMany`). The two write-failure log lines log `cause` for this
class too, so the log still carries the database's diagnosis. Where the
error leaves the engine, `redactPropagatedDriverFault` cuts its `cause`
like any driver error.
- Left as the driver threw it: a column no field declares (drift), a
refusal the driver attributes to another table, and a NOT NULL code
whose column the driver did not name. ⛔ No pre-write check reads
`storage.notNull`: `required` and `storage.notNull` stay apart (ADR-0113
D1 / D2).
- **`@objectstack/types`.** New `notNullViolationSite(error)`: the
column (and table, where the dialect names it) of a driver's NOT NULL
refusal, read from the error's codes first (better-sqlite3
`SQLITE_CONSTRAINT_NOTNULL`; libsql `extendedCode` / `rawCode: 1299`;
node-postgres `23502` with `column` / `table`; mysql2
`ER_BAD_NULL_ERROR` / `errno: 1048`), then its text (all sql.js gives),
then down its `cause` chain. An error whose code names another refusal
is not read for NOT NULL text. `notNullRefusalSite` and
`relabelNotNullRefusal` retire. Before removing them, `ui/**`,
`examples/**` and hotcrm were grepped: zero consumers (the control legs,
the export names in this repo's own sources, matched). `mapDataError`'s
NOT NULL branch is unchanged and answers the undeclared column.
- **`@objectstack/rest`.** PR #24759's door-side relabel
(`not-null-refusal-label.ts` and its test) retires with its
`CrudRouteHost.labelNotNullRefusal` member and call sites: every path it
served is an engine write. `crud-routes.ts` goes back to `const context`
inside the try. `objectItemReader` stays (the exposure gate reads it);
its doc no longer mentions the relabel.
## Interpretations for review
- **`Clause-②` departs from the claim's `no`.** Measured: two exports of
`@objectstack/types`' published entry retire, and the dispatcher's `500`
becomes a `400` with a different body, so this declares `yes
(narrowing)`. The seat's contract review applies.
- **`required_cleared` on update.** Same sentence the validator gives a
PATCH that clears a `required` field, so the two refusals read alike.
- **No `hint` for a declared field.** The validator's `required`
envelope has none. The undeclared-column answer keeps the
classification's column sentence and drift hint.
- **A drifted column that a field still declares is now labelled** (the
24715 drift control now reads `Drift Column` with `required_cleared`).
- **The table check.** A nested write's refusal on another object is
left for that object's door to name.
- **The tenant column is a declared field.** `sys_user_permission_set`
declares `organization_id`, so plugin-auth's anchor-parity case "fresh
tenant column, no organization object, single" now gets
`VALIDATION_FAILED` naming `organization_id`, with
`SQLITE_CONSTRAINT_NOTNULL` on `cause`. The pin moved (claim amendment
6411384588) and still proves the column refused the row. Whether
platform-filled columns (tenant, audit, owner, readonly) should be
excluded is left as an open question in the report.
## Tests
- `packages/runtime/src/dispatcher-not-null-refusal-24768.test.ts`
(new): red on `main` (3 failed), green here (3 passed).
- `packages/objectql/src/engine-not-null-refusal.test.ts` (new, 18):
every driver exit, the log line, five dialects, zh-CN / zh parity with
the validator's own `required`, and the controls (undeclared column,
other table, `23502` with no column, unique still
`DuplicateRecordError`, `required` still pre-driver).
- `packages/types/src/data-error-classification.not-null-label.test.ts`
(rewritten, 14): the reader per dialect, the cause chain, the other-code
refusals, the undefined cases.
- `packages/rest/src/data-not-null-field-label-24715.test.ts` (8) and
`data-not-null-hint-20963.test.ts`: the RestServer doors and batch row
by label; the undeclared column keeps its sentence and hint.
- `driver-fault-boundary-redaction.test.ts` (168): the NOT NULL shapes ×
write doors pinned as `NotNullRefusalError` with a cut `cause` of the
same prototype and code.
- The duplicate-record controls' NOT NULL fixture now names an
undeclared column; plugin-auth's anchor-parity pin as above.
- Full `--no-bail` run of types (781), objectql (8756), rest (6469),
runtime (7214), plugin-audit (993), plugin-auth (3720 + the moved pin)
and service-package (125): green.
## Ablation
`envelopeDriverWriteRefusal` mutated to return the unique translation
only (`scripts/ablation-replace.mjs --hold`), objectql rebuilt and
`ablation-dist-preflight` confirmed the mutation in `dist/`. Red:
runtime 3/3, rest 24715 7/8, objectql 12/18, plugin-auth anchor-parity
1/13. Restored: `git diff HEAD` empty, file hash equals `HEAD`'s blob,
preflight `--absent` 0, rebuilt, all four green.
## Gates
On head `50c3929ba3`: `dispatch-gates --commands` derived 77; all 77 ran
and exit 0 (`check:dual-build-cjs-loads` re-run after a full `turbo run
build`, its first run exit 3 for missing `dist/`). `dispatch-gates
--ran`: 77 derived, 77 run, 0 NOT-MEASURED. `eslint --no-inline-config`
over the touched packages' `src`: 0. `typecheck` for types, objectql,
rest, runtime, plugin-auth: 0. `check-platform-checklist --changed-from
origin/main`: OK. No open PR touches `packages/objectql/**` or
`packages/rest/**`.
## Dogfood
`pnpm --filter @objectstack/dogfood test` under the verify lock, after a
full `turbo run build` on head `50c3929ba3`: `VERDICT command-exit 1`,
Test Files 1 failed | 295 passed | 3 skipped (299), Tests 1 failed |
2522 passed | 18 skipped (2541).
The one failure is not this change's:
`test/authz-probe-blind-spot.test.ts` >
`packages/runtime/src/domains/i18n.ts — every positive control is still
present in THAT file` (`control "handleI18nRequest" moved: expected 4 to
be 3`). The census reads source text; this PR touches neither the census
nor `i18n.ts`, and `origin/main`'s `i18n.ts` holds `handleI18nRequest` 4
times since `69fa23d60b` (#25956), against 3 at its parent. It is red on
`main` by construction and is reported, not fixed here.
## Acceptance notes
- Every HTTP door answers a database NOT NULL refusal on a declared
field as `400 VALIDATION_FAILED`, the field named by its label, in the
shape the record validator gives `required`.
- A column no field declares keeps today's answer (column sentence,
drift hint).
- No pre-write check reads `storage.notNull`.
- The REST relabel from #24759 is gone; the two `@objectstack/types`
exports it used retire with a declared migration.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01E1oXN1ZJ8NnZKKhzWeCZ6q)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 95294be commit 48e84a9
18 files changed
Lines changed: 1007 additions & 573 deletions
File tree
- .changeset
- packages
- objectql/src
- plugins
- plugin-auth/src
- plugin-security/src/objects
- rest/src
- runtime/src
- types/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
45 | 45 | | |
46 | 46 | | |
47 | 47 | | |
| 48 | + | |
48 | 49 | | |
49 | 50 | | |
50 | 51 | | |
| |||
489 | 490 | | |
490 | 491 | | |
491 | 492 | | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
492 | 503 | | |
493 | 504 | | |
494 | 505 | | |
| 506 | + | |
495 | 507 | | |
496 | 508 | | |
497 | 509 | | |
| |||
527 | 539 | | |
528 | 540 | | |
529 | 541 | | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
530 | 559 | | |
531 | 560 | | |
532 | 561 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
30 | 30 | | |
31 | 31 | | |
32 | 32 | | |
33 | | - | |
| 33 | + | |
| 34 | + | |
34 | 35 | | |
35 | 36 | | |
36 | 37 | | |
| |||
125 | 126 | | |
126 | 127 | | |
127 | 128 | | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
128 | 135 | | |
129 | | - | |
| 136 | + | |
130 | 137 | | |
131 | 138 | | |
132 | 139 | | |
| |||
408 | 415 | | |
409 | 416 | | |
410 | 417 | | |
411 | | - | |
| 418 | + | |
412 | 419 | | |
413 | 420 | | |
414 | 421 | | |
| |||
0 commit comments