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
21 changes: 21 additions & 0 deletions .changeset/22445-update-preview-stored-row.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/objectql': minor
'@objectstack/core': minor
'@objectstack/spec': minor
---

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)

Clause-②: yes

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.

- **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.
- **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.
- **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.
- **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.
- **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.

`@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.

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.
2 changes: 1 addition & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2985,7 +2985,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | The object name. |
| **data** | `Record<string, any> \| Record<string, any>[]` | ✅ | A candidate record, or an array of them. Nothing is persisted. |
| **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. |
| **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. |


---
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#22445] The dry run of an update asks `validateData` about the row the
* update would write: the matched record's id rides in the payload exactly as
* the write's `updateData` folds it (`{ ...data, id }`), so the engine's
* `update`-mode preview can read that stored row and judge the merge.
*
* Driven against `ImportProtocolLike` doubles, recording what each call was
* asked. End to end over a real engine and the real protocol, where the
* preview reads the stored row: `packages/objectql/src/validate-update-stored-row.test.ts`.
*/

import { describe, it, expect, vi } from 'vitest';
import { runImport, type ImportProtocolLike } from './import-runner';
import type { ExportFieldMeta } from './import-field-meta.js';

type ValidateArgs = Parameters<NonNullable<ImportProtocolLike['validateData']>>[0];
type UpdateArgs = Parameters<ImportProtocolLike['updateData']>[0];

const OBJECT = 'task';
const metaMap = new Map<string, ExportFieldMeta>([
['code', { name: 'code', type: 'text' }],
['title', { name: 'title', type: 'text' }],
]);

const baseOpts = {
objectName: OBJECT,
metaMap,
matchFields: ['code'],
runAutomations: false,
trimWhitespace: true,
createMissingOptions: false,
skipBlankMatchKey: false,
};

function protocol(matched: Record<string, unknown>[]) {
const validateData = vi.fn(async (args: ValidateArgs) => ({
object: OBJECT, mode: args.mode ?? 'insert', valid: true,
results: [{ valid: true, errors: [], warnings: [] }],
posture: { valueShapeStrict: false, mediaValueShapeStrict: false },
}));
const updateData = vi.fn(async (args: UpdateArgs) => ({ object: OBJECT, id: args.id, record: { ...args.data, id: args.id } }));
const p: ImportProtocolLike = {
findData: vi.fn(async () => matched.map((r) => ({ ...r }))),
createData: vi.fn(),
updateData,
validateData,
};
return { p, validateData, updateData };
}

describe('[#22445] the dry run of a matched row names the stored row it would update', () => {
it('an update row is previewed with the matched record\'s id in its payload', async () => {
const { p, validateData } = protocol([{ id: 'rec_7', code: 'c7', title: 'stored' }]);
const s = await runImport({ ...baseOpts, p, writeMode: 'update', dryRun: true, rows: [{ code: 'c7', title: 'new' }] });

expect(s.results[0]).toMatchObject({ ok: true, action: 'updated', id: 'rec_7' });
expect(validateData).toHaveBeenCalledTimes(1);
expect(validateData.mock.calls[0]![0]).toMatchObject({ mode: 'update', data: { code: 'c7', title: 'new', id: 'rec_7' } });
});

it('the matched id wins over an id cell, as the write\'s fold makes it win', async () => {
const { p, validateData, updateData } = protocol([{ id: 'rec_7', code: 'c7' }]);
const metaWithId = new Map(metaMap).set('id', { name: 'id', type: 'text' });
const row = { code: 'c7', id: 'other', title: 'new' };

await runImport({ ...baseOpts, metaMap: metaWithId, p, writeMode: 'update', dryRun: true, rows: [row] });
expect((validateData.mock.calls[0]![0].data as Record<string, unknown>).id).toBe('rec_7');

// The write binds the matched id too (the protocol folds `{ ...data, id }`).
await runImport({ ...baseOpts, metaMap: metaWithId, p, writeMode: 'update', dryRun: false, rows: [row] });
expect(updateData.mock.calls[0]![0]).toMatchObject({ id: 'rec_7' });
});

it('control: a create row is previewed in insert mode, with no id added', async () => {
const { p, validateData } = protocol([]);
await runImport({ ...baseOpts, p, writeMode: 'upsert', dryRun: true, rows: [{ code: 'c8', title: 'fresh' }] });

expect(validateData.mock.calls[0]![0]).toMatchObject({ mode: 'insert' });
expect(validateData.mock.calls[0]![0].data).toEqual({ code: 'c8', title: 'fresh' });
});
});
13 changes: 12 additions & 1 deletion packages/core/src/utils/import-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1022,7 +1022,18 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
// The write path needs no counterpart: `validateRecord` runs
// there for real, after the hooks, and the row report is built
// from the very same findings by `toFailedResult`.
const verdict = await previewVerdict(data, willUpdate ? 'update' : 'insert', rowCtx);
//
// [#22445] An update is asked about the row it would write: the
// matched record's id rides in the payload exactly as the
// write's `updateData` folds it (`{ ...data, id }`), and an
// `update`-mode preview reads the row that id names, under this
// row's context, and judges the stored row merged with the
// patch, as the by-id update does. Nothing new crosses the
// protocol: the id is the address every update door already
// carries in the payload.
const verdict = willUpdate
? await previewVerdict({ ...data, id: (existing as Record<string, any>).id }, 'update', rowCtx)
: await previewVerdict(data, 'insert', rowCtx);
if (verdict && !verdict.valid) {
errCount++;
const first = verdict.errors[0];
Expand Down
23 changes: 21 additions & 2 deletions packages/objectql/src/cel-fault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@
* 1. What broke, in one line? (`summary`)
* 2. Did the expression read a key the record does not carry, and which?
* (`missingKey` — after materialisation this can only mean an UNDECLARED
* key, i.e. an author typo or a retired field)
* key, i.e. an author typo or a retired field; on a record that was NOT
* materialised — an update judged without its stored row — it can also
* be a declared key the patch did not supply, which a caller holding the
* declared field set tells apart through `isDeclaredColumn`)
* 3. Did it name a ROOT that is not in scope at all? (`unknownVariable` —
* the fault an unbound `previous` produces, which is a different
* diagnosis from a missing key on a bound root)
Expand Down Expand Up @@ -106,6 +109,18 @@ export interface CelFaultSubject {
what: string;
/** How to fix an undeclared key, e.g. `"fix the rule's condition, or declare the field"`. */
undeclaredKeyFix: string;
/**
* [#22445] Whether a missing key is a column THIS object declares, read
* directly off the judged record. A record is total only when it was
* materialised — on insert, or on an update whose stored row was read. An
* update judged without its stored row (an `update`-mode preview that names
* no row) holds only the keys the patch supplied, so a declared column the
* patch omits faults as `No such key` too, and "this object does not
* declare" would send the author looking for a field that exists. A caller
* that holds the object's declared field set answers this; one that does
* not omits it and keeps the undeclared-key sentence.
*/
isDeclaredColumn?: (key: string) => boolean;
}

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