Skip to content

Commit 35ef501

Browse files
fix(objectql): an update-mode validate() preview judges the stored row merged with the patch (#22445) (#22471)
Fixes #22445 Clause-②: yes ## Patch round 1 — contract review 6080151175, finding ①-8 **The defect the review found.** plugin-security's read mask (`field-masker.ts` `maskRecord`) deletes a hidden field, but a field under a partial-masking rule keeps its key and gets a masked value. The round-0 column filter kept every key the read door returned (`hasOwnProperty`). So for a partially masked column the preview judged the raw stored value, which gave any caller who can read the row a predicate over a value they only see masked. **The mechanism now (`engine.ts`, `storedRows` block).** A stored column is kept only where the read door served this caller that very value. That is decided by `servedAsStored(served, stored)`, a module-private structural comparison: - `Object.is` decides primitives. - `Date` compares by time; arrays and plain objects compare recursively. - Any other object kind, and nesting past 16 levels, answers "not the same" (fail closed). Every other column is judged as empty. That covers a hidden column (key deleted), a partially masked column (key kept, value replaced), a masked secret, an `internal` column, and a read hook's rewrite. So the verdict never depends on a value the caller could not have read in full. - **File fields.** The visibility read passes `RAW_FILE_VALUES_CONTEXT_KEY`, the spec-declared opt-out for a caller whose subject is the stored form. A readable file reference therefore compares as the id it stores, not as the read door's expanded object. - **Why value equality and not a declared signal.** The engine can see a field's `maskingRule` but not whether this caller holds the unmask permission; plugin-security decides that. A declared-signal rule would judge every masking-rule field empty for every caller, unmasked ones included, and would miss any transformer that is not a `maskingRule` (a masked secret, a read hook). The comparison asks the read door's own answer for this caller. - **Representation, measured on head `1f4ca1504b`.** A throwaway probe (not committed) wrote one row of every common type through a real engine, then compared the write's prior-row read with the read door's row under a user context. - `driver-sql` (SQLite, `better-sqlite3`): 21 columns, 0 differences. That covers number, currency, percent, a 11-digit number, boolean true and false, date, datetime, JSON, multiselect, select, text, empty text, plus `id` and the seven injected system columns. - `driver-memory`: 16 columns, 0 differences. - Every pair was deep-equal, so no plain readable column turns empty because of serialization on either driver. The formula case stays pinned (read door evaluates it, store holds none, preview agrees with the write). - **Write-gate probe (optional defence in depth): not added.** The related-row read sits behind `registerWriteGateProbe` because it is SYSTEM-elevated. The stored-row read is not elevated: after this filter its image holds only values the read door served this caller unchanged, which the same caller can already read with `findOne`. Gating it behind the write probe would protect nothing further. It would also make a preview's verdict depend on a probe that this read does not need. **Pins added (`validate-update-stored-row.test.ts` now 18 cases; one new file `packages/rest/src/validate-update-stored-row-representation.test.ts`, 2 cases).** - **(d2)** A middleware shaped like the security plugin's mask keeps the `country` key and replaces its value with its mask. The precondition asserts the read door serves `country: '**'`. The verdict for `{ province: 'zj', id }` is deep-equal whether `cn` or `us` is stored: `province` `invalid_option`, judged as empty. - **(d3) Control.** A caller served the column unmasked gets the stored row's verdict: admitted on `cn`; `province` `invalid_option` on `us`. - **(d4) Representation control, real driver.** On `driver-sql` / SQLite, eight `n_COLUMN` fields carry a `requiredWhen` over number, boolean, date, datetime, JSON, multiselect, select and text. Clearing every `n_COLUMN` gets `required` on all eight, from the preview and from the by-id update alike, so each rule judged the stored value. Control: clearing the columns too changes the answer (only the number rule faults: `null > 10`), so an empty column would have shown. - **File reference.** The read door expands the file id (precondition). Clearing the memo the stored attachment requires gets `memo` `required` from both the preview and the write. **Ablations through `scripts/ablation-replace.mjs`.** Each anchor hit once, and each restore was proven by blob == HEAD with `git diff HEAD` empty. - `servedAsStored` condition removed (back to key presence only): **1 failed / 17 passed** (d2). - `RAW_FILE_VALUES_CONTEXT_KEY` removed from the visibility read: **1 failed / 17 passed** (the file pin). **Sentences corrected.** - The `storedRows` block comment and the `validate()` docblock now state the served-as-stored rule. - The changeset's "Read under the caller's access" paragraph now states the same rule and the measured representation. `Clause-②: yes` and the `minor` levels are unchanged. - `protocol.zod.ts` says nothing about masking, and its sentence ("a row the caller cannot read is judged on the supplied keys alone") stays true, so it is untouched. **Base.** `origin/main` was merged (`a6be68f2e6`, 9 commits). The merge driver deferred `content/docs/references/api/protocol.mdx`. It was regenerated from the merged tree by `check:generated` in its regenerate mode and committed in `1f18eaddc0`, which discharged the deferral. The joint diff carries the `label` row from #22323 plus this PR's `mode` text. The branch is now 3 commits behind `origin/main` (`f66c440de9`). Those commits touch none of this PR's files, so they were not merged. **Evidence on head `f874bb5600`** (builds and tests ran under `os-verify-lock`): - `pnpm --filter @objectstack/objectql test`: **391 files, 7716 passed**; `test:repo` 5 passed. - `pnpm --filter @objectstack/core test`: **85 files, 2261 passed**; `test:repo` 48 passed. - Typecheck exit 0 for objectql, core and rest, each with `check:test-typecheck` OK. - Rest: the nine `import-*.test.ts` files plus the new representation file: **10 files, 115 passed**. This ran after building `@objectstack/rest^...` and `driver-memory`; the objectql dist carries `servedAsStored`. - Narrowed lint: `eslint --no-inline-config --format json` over the 8 changed `.ts` files gave 8 results, 0 errors, 0 warnings. The config enables no type-aware linting, so the diff cannot move a verdict on an untouched file. - Gates: `dispatch-gates --commands` derived **117** commands at `f874bb5600`. All 117 ran, with exit codes captured before any pipe, and all ended **exit 0**. Reconciliation with `--ran`: `117 derived, 117 run, 0 NOT-MEASURED, 0 UNRUN`. - Two of the 117 first answered **exit 3** (prerequisite not met, no measurement) inside the battery: `check:skill-examples` and `check:dual-build-cjs-loads`. Both lacked package dists in the fresh worktree. The battery's own `check:type-check-debt` build then supplied the dists, and both were re-run at the same head: exit 0. - Also run: `check:adr-anchors` exit 0 and `check-nul-bytes` exit 0. ## What this changes An `update`-mode `validate()` preview now judges what the by-id update judges: the stored row merged with the patch. Before this change, the preview judged the patch alone. - **Engine (`packages/objectql/src/engine.ts`, `validate()`).** A row that carries its `id` is judged against the row that id names. The `id` is the address every update door folds into the payload, and it is classified by the write's own `resolveEngineUpdateDispatch`. The stored row becomes the rules' `previous`, and their record is that row merged with the patch, as on the by-id branch of `update()`. A reference that a traversing rule reads is resolved from the same merge. That retires the named limit the old `:13177` comment stated. - **One source for the stored row.** The by-id update's prior-row read is extracted into `readUpdatePriorRow`, and both `update()` and the preview call it. It is the same driver read, with the same `buildDriverOptions` and the same declared-columns shaping. The preview therefore judges the row as stored, not the read door's served image. - **Read under the caller's access (assumption 4; corrected in patch round 1).** Before it reads anything, the preview asks the read door (`findOne` under the caller's own context) whether the caller can see the row. If the read door returns nothing, the preview judges the row on the patch alone. A hidden row therefore gets exactly the verdict a missing row gets. A stored column is kept only where the read door served this caller that very value. A column the read door hid, or served transformed (a partial mask keeps the key and replaces the value), is judged as empty. A refused read propagates as raised. - **Import runner (`packages/core/src/utils/import-runner.ts`).** The dry run of a matched row calls `validateData` with `{ ...data, id: matched.id }`. That is the same fold `updateData` applies to the write. Nothing new crosses the protocol, and `ValidateDataRequestSchema` gains no key. - **The refusal names the real cause (`cel-fault.ts`, `rule-validator.ts`).** Sometimes a rule reads a column the object declares and the judged record does not hold it (a preview with no stored row). The refusal used to say "which this object does not declare". It now says the value was not supplied and no stored row was read. `describeCelFault` takes an optional `isDeclaredColumn`, which the four rule-validator refusal builders answer from the object's fields: field rule, option gate, script and conditional. The answer is true only for a key the source reads directly off `record` / `previous`. An undeclared key keeps the old sentence. Hook conditions pass nothing and are unchanged. - **Spec text (cross-lane, `domain:spec`).** The `ValidateDataRequest.mode` description said "`update` judges only the supplied keys, matching a PATCH". The `ValidateDataResponse` note said the preview has no prior record. This change makes both partly false, so both are reworded. No key or type changes. `content/docs/references/api/protocol.mdx` was regenerated with `check:generated --fix`. ## Premise, measured on `440bed63e7` before the fix - **Showcase object as shipped.** `engine.validate('showcase_cascade', { province: 'zj' }, { mode: 'update' })` refuses `province` with `rule_violation` / `unevaluable`. That is the option gate since #22402. The real by-id update `{ province: 'zj' }` over a stored `country: 'cn'` row is admitted. - **The card's `note` call.** The shipped `showcase_cascade` declares no `note` field, at `440bed63e7` and at the card's `3054516ef1` alike. So `{ note: 'x' }` throws `INVALID_FIELD` on the shipped object. The card's `note` measurement needs a fixture. - **With a fixture `note` whose `requiredWhen` reads `country`.** Both card calls refuse `note` unevaluable, and `{ province: 'zj' }` also refuses `province`. The message was "The predicate reads 'country', which this object does not declare". Passing `id` changed nothing, because the preview read no row. The by-id update admits. ## Pins, round 0 (`packages/objectql/src/validate-update-stored-row.test.ts`, 15 cases then, 18 now; `packages/core/src/utils/import-runner-update-preview-address.test.ts`, 3 cases) - **(a)** The card's two calls, `{ province: 'zj' }` and `{ note: 'x' }`, are admitted over a stored `country: 'cn'` row, and the by-id update admits the same patches. Also, a formula column that the read door evaluates is judged the way the write judges it: preview and write agree. - **(b) Control.** `{ country: 'us' }` gets `note` `required`, and `{ province: 'ca' }` gets `province` `invalid_option`. Preview and write give the same findings, and the store is unchanged. - **(c)** With no address, or with an id naming no row, the `requiredWhen` and option refusals say "its value was not supplied" and never "which this object does not declare". A `validations[]` script rule and a conditional rule do the same. Control: an undeclared key (`contry`) keeps "does not declare". - **(d)** The read scope comes from a middleware shaped like the RLS/sharing ones. The owner's preview judges the stored row. A caller who cannot read the row gets a verdict deep-equal to the one a nonexistent id gets, whether the hidden row holds `cn` or `us`. A column masked from the caller is judged as empty, with the same verdict whichever value is stored, and the control caller who can read it gets the stored row's verdict. - **(e)** The real door is `runImport` through `ObjectStackProtocolImplementation` over a kernel with `ObjectQLPlugin`. The update-mode dry run of a matched row admits `province` and `note` and agrees with the committed import. Control: `country: 'us'` fails `note` `required` in both. The core test pins that the dry run carries the matched id, that the matched id wins over an id cell (as the write's fold makes it win), and that a create row gets no id. ### Round 0: red before the fix, and ablations (each from a committed state) - **Reverse verification.** The four fix files were restored to `440bed63e7` and core was rebuilt; the dist marker count fell from 1 to 0. The pin file then went **10 failed / 4 passed**. Red: (a) ×2, (b) `province: 'ca'`, (c) ×3, (d) owner and masked-column, (e) ×2. Green, as expected: (b) `country: 'us'`, the (c) undeclared control, (d) hidden-row equality, and the (e) control. Restore leg: blobs equal HEAD, `git diff HEAD` empty, core rebuilt, dist marker back to 1, **14/14 green**. The core address test against the base runner: **2 failed / 1 passed** (the control passed). - **Ablations through `scripts/ablation-replace.mjs`.** Each anchor hit once, and each restore was proven by blob == HEAD with `git diff HEAD` empty. - Image = read door's row: 1 failed (the formula pin). - Column filter removed: 1 failed (the masked column). - Visibility gate bypassed: 2 failed (the hidden row and the masked column). - The first attempt at the image ablation was a refused no-op. Its replacement contained the anchor, so the tool refused, restored, and ran nothing. It was rerun with a non-overlapping replacement. ## Tests, round 0 (superseded by the patch round 1 section above) All runs are on head `31ca6a891a` unless a line says otherwise. Builds and tests went through `scripts/pm/os-verify-lock.sh`. - **Package suites.** These ran on `45c2877b8f`. The commits after it change only the changeset and the two pin files, and the pin files were re-run on `31ca6a891a`. - `pnpm --filter @objectstack/objectql test`: **391 files, 7713 passed**. - `pnpm --filter @objectstack/objectql run test:repo`: 1 file, 5 passed. - `pnpm --filter @objectstack/core test` (run on `d6fb574835`): 84 files, 2235 passed. `test:repo`: 3 files, 48 passed. - `pnpm --filter @objectstack/spec test`: 629 files, 18779 passed, 1 todo. - **Pin files.** `validate-update-stored-row.test.ts` **15 passed**. `import-runner-update-preview-address.test.ts` **3 passed**. - **Typecheck.** `pnpm --filter @objectstack/objectql run typecheck` and `pnpm --filter @objectstack/core run typecheck` both exit 0. Each includes `check:test-typecheck` OK, with the new test files compiled under each package's `tsconfig.test.json`. - **Downstream consumer (the public import door through dist).** I built `@objectstack/rest^...`, then ran the nine `packages/rest/src/import-*.test.ts` files (dry-run parity, integration, job, dropped-fields, row report, schema drift, run-automations agreement, historical readonly insert, unique violation): **9 files, 113 passed**. The objectql and core dists were confirmed to carry the change (`readUpdatePriorRow`: 4 hits; the `...data, id: existing.id` fold: 1 hit). - **Spec.** `pnpm --filter @objectstack/spec build`, then `check:generated --fix`. That regenerated only `content/docs/references/api/protocol.mdx`. A re-run reports all 15 artifacts up to date. - **Gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived **117** commands at `31ca6a891a`. All 117 ran with exit codes captured before any pipe, and all **exit 0**. Reconciliation with `--ran`: `117 derived, 117 run, 0 NOT-MEASURED, 0 UNRUN`. An earlier run of the same battery at `f4fc729b08` found two real reds, both in my test file, and fixed them: - `check:error-code-casing`: a one-line `code: 'rule_violation'` with no `field`. The assertion now names `field: '_record'`. - `check:query-options-erasure`: an `as any` on a `findOne` options bag. The cast was removed. - The same run had two prerequisite exit-3 results: `check:skill-examples` needed `client-react` built, and `check:dual-build-cjs-loads` needed more dists. Both measured green on the final run. - Also run: `pnpm check:adr-anchors` exit 0, and `node scripts/check-nul-bytes.mjs` exit 0. - **Lint, narrowed (a measurement, not a skip).** `pnpm exec eslint --no-inline-config --format json` over the 7 changed `.ts` files returned 7 results, 0 errors and 0 warnings. Those files are inside `eslint.config.mjs`'s `packages/**/*.{ts,tsx,mts,cts}` and `**/*.{ts,...}` populations. The config enables no type-aware linting (no `parserOptions.project` / `projectService` anywhere), so this diff cannot move a verdict on any file it does not touch. The repo-wide `pnpm lint` is CI's. ## Acceptance notes - **Preview/write drift, same family, measured on this branch (not fixed here).** `validate()` binds no master-detail `parent` header, in either mode. On a detail object whose field carries `requiredWhen: "parent.status == 'sent'"`, the preview refuses with `rule_violation` / `unevaluable` (unbound `parent`), both for an insert under a draft header and for an update of a stored line. The real insert and the real by-id update admit both. The import dry run reaches this through `previewVerdict` -> `validateData`. Reported for the seat to file; not filed here. - **Named limit kept, as dispatched.** The update preview still runs no `readonlyWhen` or primary-key strip. It now holds the stored row those strips would judge, so closing this limit is possible, but it is out of this card's scope. The `ValidateDataResponse` note keeps stating it. - **Observed, not filed.** A `requiredWhen` that reads a `formula` column (`record.doubled > 100`) faults with a null overload on every by-id update. The write's prior row is the stored row, which holds no formula value. The preview now agrees with the write here, and a pin holds that agreement. Whether `os validate` refuses such a predicate was not measured. - **Observed, not filed.** `ValidateDataIssueSchema` declares `{ field, code, message }`. The engine's findings, which `validateData` relays, also carry `constraint`, and an option refusal carries `value`. The pin file reads `constraint` through a typed accessor for that reason. - **New read surface.** The update preview's visibility read goes through `findOne`, so it fires the object's `beforeFind` / `afterFind` hooks once per addressed row. The related-row read already did the same through `find`. No write hook runs. - **The card's `note` call.** It needed a fixture field: the shipped `showcase_cascade` declares no `note`. The shipped object's own `province` pick reproduces the defect. - **Base (round 0).** `origin/main` was merged once (`b7c02e713f`). Patch round 1 merged it again; see that section. - **Cross-lane.** `packages/spec/src/api/protocol.zod.ts` is edited: description text and a JSDoc note only, no key or type. The changeset bumps `@objectstack/spec` at minor along with `@objectstack/objectql` and `@objectstack/core`. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6abd265 commit 35ef501

10 files changed

Lines changed: 962 additions & 43 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/core': minor
4+
'@objectstack/spec': minor
5+
---
6+
7+
An `update`-mode `validate()` preview judges the stored row merged with the patch, as the by-id update does, so an import dry run of a matched row admits the rows the import's update admits (#22445)
8+
9+
Clause-②: yes
10+
11+
The preview's accept set widens: rows it refused, and the write admits, are now admitted. No key, export or error code is added, removed or renamed.
12+
13+
- **Before.** An `update`-mode preview (`engine.validate(object, rows, { mode: 'update' })`, `validateData`, and the import dry run of a row that matched a stored record) read no stored row, so the record its rules judged held only the patch. A `requiredWhen`, an option `visibleWhen` or a `validations[]` rule that reads a column the patch omits faulted there, and the preview refused the row with `rule_violation` / `unevaluable`, although the real update reads the stored row first and admits it. The refusal also said the omitted column was one "which this object does not declare", sending the author looking for a field that exists.
14+
- **Now.** A row that carries its `id` (the address every update door already folds into the payload) is judged against the row that id names: the stored row is the rules' `previous`, and their record is the stored row merged with the patch, exactly as the by-id update judges it. A reference field a traversing rule reads is resolved from that merge too. The import dry run of a matched row sends the matched record's id this way, so nothing new crosses the protocol.
15+
- **Read under the caller's access.** The engine's read door, under the caller's own context, decides whether there is a row to judge: a row the caller cannot read gets the verdict a missing row gets. The values judged are the row as stored, read the way the by-id update reads its prior row (so a formula column, which has no stored value, is judged as the update judges it). A stored column is judged only where the read door served this caller that very value. A column the read door hid, or served transformed (a partially masked field, a masked secret), is judged as empty, so the verdict never depends on a value the caller could not read in full. File fields are compared in their stored form, so a readable file reference is judged as the id it holds. Measured on `driver-sql` (SQLite) and `driver-memory`, a plain readable column (number, currency, percent, boolean, date, datetime, JSON, multiselect, select, text) reaches the rules as its stored value.
16+
- **Unchanged.** A row with no `id`, or whose id names no row the caller can read, is judged on the patch alone, as before. `required` and value checks still look only at the supplied keys, matching a PATCH. A merge that really violates a rule is refused, as the write refuses it. The preview still runs no `readonlyWhen` or primary-key strip, so it can report fewer dropped fields than the update.
17+
- **The refusal names the real cause.** Where a rule reads a column the object declares and the judged record does not hold it (a preview with no stored row), the refusal now says the value was not supplied and no stored row was read, instead of saying the object does not declare the column. A column the object really does not declare keeps the old sentence.
18+
19+
`@objectstack/core`: the import runner's dry run passes the matched record's id with an update row. `@objectstack/spec`: the `ValidateDataRequest.mode` description and the `ValidateDataResponse` note say what an `update` preview now reads.
20+
21+
The pending entry for the option-gate change in this release says an `update`-mode preview reads no stored row and refuses a cascade pick whose parent column the patch omits. That describes the preview before this change: with the row's `id`, the preview now reads the stored row and judges the pick as the write does.

‎content/docs/references/api/protocol.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2985,7 +2985,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
29852985
| :--- | :--- | :--- | :--- |
29862986
| **object** | `string` | ✅ | The object name. |
29872987
| **data** | `Record<string, any> \| Record<string, any>[]` | ✅ | A candidate record, or an array of them. Nothing is persisted. |
2988-
| **mode** | `Enum<'insert' \| 'update'>` | optional | Which write the verdict should predict. `insert` (default) walks every declared field, so a missing required field is a finding; `update` judges only the supplied keys, matching a PATCH. |
2988+
| **mode** | `Enum<'insert' \| 'update'>` | optional | Which write the verdict should predict. `insert` (default) walks every declared field, so a missing required field is a finding; `update` checks only the supplied keys, matching a PATCH. An `update` row that carries its `id` is judged as the by-id update judges it: its rules read the stored row that id names, merged with the supplied keys. The stored row is read with the caller's own read access, so a row the caller cannot read is judged on the supplied keys alone, as a row with no `id` is. |
29892989

29902990

29912991
---
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22445] The dry run of an update asks `validateData` about the row the
5+
* update would write: the matched record's id rides in the payload exactly as
6+
* the write's `updateData` folds it (`{ ...data, id }`), so the engine's
7+
* `update`-mode preview can read that stored row and judge the merge.
8+
*
9+
* Driven against `ImportProtocolLike` doubles, recording what each call was
10+
* asked. End to end over a real engine and the real protocol, where the
11+
* preview reads the stored row: `packages/objectql/src/validate-update-stored-row.test.ts`.
12+
*/
13+
14+
import { describe, it, expect, vi } from 'vitest';
15+
import { runImport, type ImportProtocolLike } from './import-runner';
16+
import type { ExportFieldMeta } from './import-field-meta.js';
17+
18+
type ValidateArgs = Parameters<NonNullable<ImportProtocolLike['validateData']>>[0];
19+
type UpdateArgs = Parameters<ImportProtocolLike['updateData']>[0];
20+
21+
const OBJECT = 'task';
22+
const metaMap = new Map<string, ExportFieldMeta>([
23+
['code', { name: 'code', type: 'text' }],
24+
['title', { name: 'title', type: 'text' }],
25+
]);
26+
27+
const baseOpts = {
28+
objectName: OBJECT,
29+
metaMap,
30+
matchFields: ['code'],
31+
runAutomations: false,
32+
trimWhitespace: true,
33+
createMissingOptions: false,
34+
skipBlankMatchKey: false,
35+
};
36+
37+
function protocol(matched: Record<string, unknown>[]) {
38+
const validateData = vi.fn(async (args: ValidateArgs) => ({
39+
object: OBJECT, mode: args.mode ?? 'insert', valid: true,
40+
results: [{ valid: true, errors: [], warnings: [] }],
41+
posture: { valueShapeStrict: false, mediaValueShapeStrict: false },
42+
}));
43+
const updateData = vi.fn(async (args: UpdateArgs) => ({ object: OBJECT, id: args.id, record: { ...args.data, id: args.id } }));
44+
const p: ImportProtocolLike = {
45+
findData: vi.fn(async () => matched.map((r) => ({ ...r }))),
46+
createData: vi.fn(),
47+
updateData,
48+
validateData,
49+
};
50+
return { p, validateData, updateData };
51+
}
52+
53+
describe('[#22445] the dry run of a matched row names the stored row it would update', () => {
54+
it('an update row is previewed with the matched record\'s id in its payload', async () => {
55+
const { p, validateData } = protocol([{ id: 'rec_7', code: 'c7', title: 'stored' }]);
56+
const s = await runImport({ ...baseOpts, p, writeMode: 'update', dryRun: true, rows: [{ code: 'c7', title: 'new' }] });
57+
58+
expect(s.results[0]).toMatchObject({ ok: true, action: 'updated', id: 'rec_7' });
59+
expect(validateData).toHaveBeenCalledTimes(1);
60+
expect(validateData.mock.calls[0]![0]).toMatchObject({ mode: 'update', data: { code: 'c7', title: 'new', id: 'rec_7' } });
61+
});
62+
63+
it('the matched id wins over an id cell, as the write\'s fold makes it win', async () => {
64+
const { p, validateData, updateData } = protocol([{ id: 'rec_7', code: 'c7' }]);
65+
const metaWithId = new Map(metaMap).set('id', { name: 'id', type: 'text' });
66+
const row = { code: 'c7', id: 'other', title: 'new' };
67+
68+
await runImport({ ...baseOpts, metaMap: metaWithId, p, writeMode: 'update', dryRun: true, rows: [row] });
69+
expect((validateData.mock.calls[0]![0].data as Record<string, unknown>).id).toBe('rec_7');
70+
71+
// The write binds the matched id too (the protocol folds `{ ...data, id }`).
72+
await runImport({ ...baseOpts, metaMap: metaWithId, p, writeMode: 'update', dryRun: false, rows: [row] });
73+
expect(updateData.mock.calls[0]![0]).toMatchObject({ id: 'rec_7' });
74+
});
75+
76+
it('control: a create row is previewed in insert mode, with no id added', async () => {
77+
const { p, validateData } = protocol([]);
78+
await runImport({ ...baseOpts, p, writeMode: 'upsert', dryRun: true, rows: [{ code: 'c8', title: 'fresh' }] });
79+
80+
expect(validateData.mock.calls[0]![0]).toMatchObject({ mode: 'insert' });
81+
expect(validateData.mock.calls[0]![0].data).toEqual({ code: 'c8', title: 'fresh' });
82+
});
83+
});

‎packages/core/src/utils/import-runner.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1022,7 +1022,18 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
10221022
// The write path needs no counterpart: `validateRecord` runs
10231023
// there for real, after the hooks, and the row report is built
10241024
// from the very same findings by `toFailedResult`.
1025-
const verdict = await previewVerdict(data, willUpdate ? 'update' : 'insert', rowCtx);
1025+
//
1026+
// [#22445] An update is asked about the row it would write: the
1027+
// matched record's id rides in the payload exactly as the
1028+
// write's `updateData` folds it (`{ ...data, id }`), and an
1029+
// `update`-mode preview reads the row that id names, under this
1030+
// row's context, and judges the stored row merged with the
1031+
// patch, as the by-id update does. Nothing new crosses the
1032+
// protocol: the id is the address every update door already
1033+
// carries in the payload.
1034+
const verdict = willUpdate
1035+
? await previewVerdict({ ...data, id: (existing as Record<string, any>).id }, 'update', rowCtx)
1036+
: await previewVerdict(data, 'insert', rowCtx);
10261037
if (verdict && !verdict.valid) {
10271038
errCount++;
10281039
const first = verdict.errors[0];

‎packages/objectql/src/cel-fault.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@
2424
* 1. What broke, in one line? (`summary`)
2525
* 2. Did the expression read a key the record does not carry, and which?
2626
* (`missingKey` — after materialisation this can only mean an UNDECLARED
27-
* key, i.e. an author typo or a retired field)
27+
* key, i.e. an author typo or a retired field; on a record that was NOT
28+
* materialised — an update judged without its stored row — it can also
29+
* be a declared key the patch did not supply, which a caller holding the
30+
* declared field set tells apart through `isDeclaredColumn`)
2831
* 3. Did it name a ROOT that is not in scope at all? (`unknownVariable` —
2932
* the fault an unbound `previous` produces, which is a different
3033
* diagnosis from a missing key on a bound root)
@@ -106,6 +109,18 @@ export interface CelFaultSubject {
106109
what: string;
107110
/** How to fix an undeclared key, e.g. `"fix the rule's condition, or declare the field"`. */
108111
undeclaredKeyFix: string;
112+
/**
113+
* [#22445] Whether a missing key is a column THIS object declares, read
114+
* directly off the judged record. A record is total only when it was
115+
* materialised — on insert, or on an update whose stored row was read. An
116+
* update judged without its stored row (an `update`-mode preview that names
117+
* no row) holds only the keys the patch supplied, so a declared column the
118+
* patch omits faults as `No such key` too, and "this object does not
119+
* declare" would send the author looking for a field that exists. A caller
120+
* that holds the object's declared field set answers this; one that does
121+
* not omits it and keeps the undeclared-key sentence.
122+
*/
123+
isDeclaredColumn?: (key: string) => boolean;
109124
}
110125

111126
export interface CelFaultDescription {
@@ -134,7 +149,11 @@ export function describeCelFault(error: CelFault, subject: CelFaultSubject): Cel
134149
const unknownVariable = missingKey ? undefined : unknownVariableOf(error);
135150
const nullOverload = isNullOverloadFault(error);
136151
let detail = '';
137-
if (missingKey) {
152+
if (missingKey && subject.isDeclaredColumn?.(missingKey) === true) {
153+
detail =
154+
` The ${subject.what} reads '${missingKey}', a field this object declares, but its value was not supplied` +
155+
' and no stored row was read to supply it.';
156+
} else if (missingKey) {
138157
detail =
139158
` The ${subject.what} reads '${missingKey}', which this object does not declare` +
140159
` — ${subject.undeclaredKeyFix}.`;

0 commit comments

Comments
 (0)