Skip to content

Commit 5b5e83f

Browse files
fix(objectql)!: a write's returned row and its prior read serve the declared fields, never an orphaned column (#21631)
Fixes #21613 Clause-②: no (narrowing) A write now answers with the object's declared fields plus the platform's system columns. A column no metadata declares, such as a field retired in an upgrade whose column additive sync leaves behind, no longer leaves the engine through a write. The same holds for the prior read a write binds as a hook's `previous`. This completes #21571's read rule for writes. It is decided in the same place, the engine (`packages/objectql`), with the same helper (`declared-read-columns.ts`), on the rows as the driver returned them: before formulas, hooks, events and the door's own ingress strip. No driver source and no `metadata-protocol` source is edited. There is no per-door strip, no allow-list and no flag. ## Measured first (before any fix) The probes were two temporary tests on this branch: `9c73f3702b` (rest) and `b923112e06` (plugin-audit), reverted by `dfbb80ec2e`. The net diff carries none of them. Harness: the composed REST harness of #21571's reach pin (`RestServer`, then `ObjectStackProtocolImplementation`, then `ObjectQL`, then a real `SqlDriver` on better-sqlite3), two boots, at `5c9138b4b6`. | door | before | after | | --- | --- | --- | | `PATCH /data/:object/:id` | 200, `record.mailing_street = "1 Retired Way"` | no retired key | | `POST /data/:object` | 201, both retired keys, `null` | no retired key | | `POST /data/:object/:id/clone` | 201, both retired keys, `null` | no retired key | | `POST /data/:object/createMany` | 201, every record carries both, `null` | none | | `POST /data/:object/batch` update / upsert (update arm) | values | none | | `POST /data/:object/batch` create / upsert (create arm) | `null` | none | | `POST /data/:object/updateMany` | values | none | | `DELETE /data/:object/:id` | no record in the body | unchanged | | `engine.update` by id, `engine.insert`, `engine.insertMany` outcomes | values or `null` | none | | `data.record.created` / `data.record.updated` events, `after` | 13 of 13 carried them (16 events in all) | 0 of 16 events carry one | | hook contexts, `previous` or `result` | 28 of 28 carried them | 0 of 28 carry one | Bulk `data.records.*` events carry a count and no row, and `changes` carries the input patch only. **The audit ledger serves the prior read.** Measured with a plugin-audit probe (real `ObjectQL`, a SQL-shaped store, `installAuditWriters`): - a delete's `old_value` recorded `mailing_street: "c2 Retired Way"`, and a create's `new_value` recorded both columns as `null`; - an update recorded nothing of them, only because both sides of the diff carried them; - with ONLY the write result shaped (the prior read left whole), every update of such a row recorded `mailing_street: "c1 Retired Way"` in `old_value` against `null` in `new_value`. That is a phantom change, and it puts the stored value into a served row on every write. `sys_audit_log` is read back through the data door, so the prior read is a served row. That puts it in this card's class (H3), and shaping the result alone would have made the ledger worse. So the prior reads are shaped too. ### Hypothesis verdicts - **H1 (confirmed):** `SqlDriver.update` returns its `select *` readback, and create and `bulkCreate` return `returning('*')`. `ObjectQL` returned both as they came. - **H2 (confirmed):** the engine publishes `data.record.*` from the same `result` variable the hooks get, after the driver. The webhook enqueuer copies the event's `payload` verbatim (`payload: { ...payload, ... }`). With the shaping placed after the driver, the event and the webhook payload are clean, with no edit in `plugin-webhooks` or `service-realtime`. The pins below drive the real `AutoEnqueuer`. - **H3 (served, in class, shaped):** see the audit ledger above. The four prior reads are shaped at the read: - update by id (`driver.findOne`); - update by predicate (the matched rows the per-row hooks compose `result` from); - delete by id; - delete by predicate. The delete event reads only the tenant column off its pre-image, which is declared. - **H4 (enumerated):** the write verbs that return a row are: - `insert` (one row and a batch); - `insertMany` (its `ok` outcomes carry `rowHookContexts[i].result`); - `update` by id. Predicate `update` and `delete` return a count, and `delete` by id returns the driver's boolean. The engine has no upsert verb: the batch door's upsert is `update` or `insert`. `ObjectRepository` and the scoped context delegate to these verbs. Every REST and protocol face above reaches one of the three, so shaping them covers every door. - **H5 (pinned):** an `internal: true` field stays whole on the engine-level write result (the A-prime ruling) and is still stripped by the data door. The rest pin and the conformance matrix both assert it. ### In-process readers of undeclared columns The suites ran with the shaping in place: objectql at `10d7f333d9`, the others at `f082789802` (the probes were still on the branch): | suite | result | | --- | --- | | objectql | 4 failures, all fixtures, see below | | rest | 260 files, all pass | | metadata-protocol | 208 files pass, 3 skipped | | plugin-audit | 39 files pass | | plugin-webhooks | 13 files pass | | plugin-sharing | 38 files pass | | plugin-auth | 118 files pass | | service-automation | 166 files pass | | runtime | 318 files pass | The 4 objectql failures were fixtures whose `readonlyWhen` or validation rule reads a field the fixture never declared (`record.locked`, `record.limit`), off the prior read. #21571 saw the same shape in trim mode. The fixtures now declare those fields (`engine.test.ts` × 3, `plugin.integration.test.ts` × 1). No assertion changed. At authoring, the formula validator's `unknown field` check (`packages/formula/src/validate.ts`) flags a production rule that names no field. The operator reads that legitimately need a retired column's values go through the driver, so they are unaffected: - `os migrate plan`'s `unmapped_column` detection; - `os migrate account-issuer`. ## Changes - `packages/objectql/src/engine.ts`, one shaping per site, each with `declaredColumnSet(registry object)`: - `insert`: the driver's result rows, after the one-row-per-input guard; - `update` by id: the driver readback; - the update by-id prior read, and the update predicate's matched rows; - the delete by-id pre-image, and the delete predicate's doomed rows. - Tests: - `packages/rest/src/data-write-result-declared-fields.test.ts`: the reach pin, on the composed harness. - `packages/objectql/src/write-result-declared-fields-conformance.test.ts`: the engine matrix. - `packages/plugins/plugin-webhooks/src/webhook-payload-declared-fields.test.ts`: the webhook payload through the real `AutoEnqueuer`. - `packages/plugins/plugin-audit/src/audit-ledger-declared-fields.test.ts`: the served ledger. - The objectql fixtures above. - `.changeset/21613-write-result-declared-fields.md`: `@objectstack/objectql` minor, BREAKING (narrowing), ADR-0087 `not-required (no-migration-prescription)`, and the interim route (convert before upgrading, or #21573's operator read once it lands). **File surface beyond the claim, test-only:** the webhook and audit pins live in their own packages. The composed REST package has no dependency on either plugin. Adding one would need a devDependency plus a source alias, because `KNOWN_UNALIASED_TEST_IMPORTS` is shrink-only. So the event both of them consume is pinned on the composed harness, and each consumer is pinned where it lives. ## Pins - **Reach pin (rest, SqlDriver, two boots, 8 cases):** - the fixture is real: a raw driver read still finds the stored values; - PATCH, POST and clone carry no retired key; - the bulk faces: createMany, batch upsert / create / update, updateMany; - every `data.record.created` / `updated` event's `after`; - the update and delete `previous`; - declared fields and the eight system columns are served, and every key is declared or provisioned; - `internal`: stripped at the door, whole on `engine.update`. - **Conformance matrix (objectql, SQL-shaped store, 12 cases):** - insert one row, a batch, `insertMany`, and update by id; - the hooks' `previous` and `result`, for by id, per-row predicate update, and delete by id and by predicate; - the events; - `createData`, `updateData` and `cloneData`; - the declared treatment: `internal` whole on the engine result and stripped at the door, formula hydrated; - the driver's stored row is not mutated. - **Webhook pin:** a real `ObjectQL` write, then the realtime publish, then the real `AutoEnqueuer`. The enqueued `payload.after` carries no retired key, for created and for updated. - **Audit pin:** the update diff is exactly `{ name }`, and the delete `old_value` and create `new_value` carry no retired key. - **Pin sweep:** a repo-wide `git grep` over `*.test.ts` for orphaned, retired, unmapped or undeclared column readings, plus the suite runs above. No test asserted that a write's returned row or prior read carries an undeclared column, so no pin flipped. Each new pin asserts the declared values as well as the absence: ids, names and system columns. ## Reverse verification (fix committed first) Each shaping was removed with `node scripts/ablation-replace.mjs`: the anchor went from 1 hit to 0 and the blob changed. objectql was then rebuilt (exit 0), and `ablation-dist-preflight --absent` confirmed the call is absent from all 14 built files (exit 0 each). The pristine build carries every marker. Final run at `bfbeffed43`: | removed | conformance (12) | reach (8) | webhook (1) | audit (3) | | --- | --- | --- | --- | --- | | insert result | 5 red: insert, batch, `insertMany`, events, doors | 4 red: POST, clone, bulk faces, events | red | create red | | update by-id result | 4 red: update, hooks, events, doors | 4 red: PATCH, bulk faces, events, system columns | red | update red (inverse phantom diff) | | update prior reads | 2 red: by-id and per-row hooks | 1 red: `previous` | green | update red (the phantom diff) | | delete prior reads | 1 red: delete hooks | 1 red: `previous` | green | delete red | Restore: `ablation-replace` restored with `git checkout HEAD -- PATH` each time. The blob `6008f2bc` equals the HEAD blob, `git diff HEAD` is empty and `git status --porcelain` is clean. After a rebuild, the preflight finds all five markers present, and the pins are 12/12, 8/8, 1/1 and 3/3. Each mutation was replaced with a type-valid spelling so the DTS step builds. A first C1 run with an arity-breaking spelling failed the DTS step and was redone; its red set was the same. ## Local verification - objectql: `test` 368 files, 7427 passed, and `test:repo` 5 passed, at `bfb7b28a49` (after merging `origin/main` `e367002e11`). `typecheck` (tsc, scripts and test-typecheck) exits 0 at `bfbeffed43`. - rest: - `test` 260 files, 4897 passed, 326 skipped; - `test:repo` 5 files, 177 passed; - `typecheck` exits 0; - all at `bfb7b28a49`. The rest test file is unchanged since. - plugin-audit and plugin-webhooks: - full suites at `bfb7b28a49`: 39 files / 621 tests and 14 / 161; - `typecheck` exits 0 at `bfbeffed43`; - the two later commits touched only the new pin files, which were rerun green. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands` (no paths) at `bfbeffed43` derived 71 commands. All 71 were run on that head and each exited 0. `--ran` reconciles with "71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN", with every exit code recorded. - Added beyond the dispatch's list, by this diff's paths: - `check-adr-0087-registration` ×2, `check-empty-changeset` ×2 and `check-tenant-audit-census` ×2; - the `release-rehearsal-clone` and `release-pending-publish` self-tests; - `check:engine-double-contract`, `check:i18n`, `check:i18n-stale-fill` and `check:objectql-double-limit`; - `check:objectui-changeset`, `check:pm-changeset-deadline-census` and `check:query-options-erasure`; - `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. - `check:objectql-double-limit` first flagged the three new store doubles as limit-blind. They now apply the caller's bound (`bfbeffed43`). - `check:dual-build-cjs-loads` and `check:i18n` first answered PREREQUISITE NOT MET (exit 3), which is not a measurement. Both were rerun after the build they name, and both exit 0. - **Lint, narrowed and proven:** - Population, from eslint's own config: the 8 changed files went to `eslint --no-inline-config --format json`. eslint reports the changeset as "File ignored because no matching configuration was supplied". - Count, from the JSON: 8 files in the report, 7 TypeScript files linted, 0 errors. The 1 warning is that ignore notice. - Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move an untouched file's verdict. - **Declared to CI:** the remaining downstream consumers of `@objectstack/objectql`. The suites above are the ones that read write results (rest, metadata-protocol, runtime, plugin-auth, plugin-sharing, plugin-audit, plugin-webhooks, service-automation). ## Acceptance notes - `declared-read-columns.ts`'s header still describes the read side only. The write-side seats carry their own comments in `engine.ts`. It was not edited, because the claim names it only for a new entry point and none was needed. Carrier: none. - `content/docs/data-modeling/queries.mdx` states the default projection for reads. A write now follows the same rule, but no sentence says so. `check-affected-docs` is green. Carrier: none. - `engine.aggregate`'s undeclared `groupBy` name in process is unchanged (already noted on #21571). Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5d095a0 commit 5b5e83f

8 files changed

Lines changed: 963 additions & 6 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
fix(objectql)!: the record a write returns, and the prior read it binds as `previous`, serve the object's declared fields and the platform's system columns, never a column no metadata declares (#21613)
6+
7+
**BREAKING (narrowing)** — what a released write door returns shrinks. A column that
8+
no metadata declares, typically a field retired in an upgrade whose column additive
9+
schema sync leaves in the table until `os migrate apply --allow-destructive`, is no
10+
longer returned by any write through the engine. This completes the read-side rule of
11+
the previous release (#21571) for writes.
12+
13+
| write | before | now |
14+
| --- | --- | --- |
15+
| `PATCH /api/v1/data/:object/:id`, batch `update`, `updateMany` | the post-write row read back with `select *`: retired columns with their stored values | the declared fields, the registry's system columns, `id`, `created_at`, `updated_at` |
16+
| `POST /api/v1/data/:object`, `POST /api/v1/data/:object/:id/clone`, `createMany`, batch `create` / `upsert`, `insertMany` outcomes | the inserted row from `returning('*')`: retired columns as `null` | the same declared set |
17+
| `data.record.created` / `data.record.updated` events, and the webhook deliveries that carry them as `after` | the same whole row | the declared set |
18+
| `engine.insert` / `engine.update` in process (actions, flows, plugins), and a hook's `ctx.result` | the whole row | the declared set |
19+
| a hook's `ctx.previous` on update and delete (by id and per row), and the audit ledger's delete `old_value` and create `new_value` | the whole stored row, retired columns and their values included | the declared set |
20+
21+
**Unchanged:** declared fields keep their treatment. An `internal: true` field is still
22+
returned whole on the engine-level write result to the privileged writer that just
23+
wrote it, and still stripped from every data-door response; formulas are still
24+
hydrated onto the result; the registry-injected tenant, owner and audit columns are
25+
still returned. No driver changed: the engine shapes the rows any driver returns, so
26+
the answer is the same on every driver and every door. A retired column's values stay
27+
in the table.
28+
29+
**If you still read a retired column's values off a write** (for example a hook, a
30+
flow or a webhook receiver that copied an old column into its replacement): run that
31+
conversion BEFORE upgrading to this release, while the old field is still declared, or,
32+
once it lands, read the unmapped columns through the operator-only `os migrate` read
33+
(objectstack#21573). There is no flag that re-opens undeclared columns on a write. A
34+
reader that needs a column must declare it as a field; a validation rule, a
35+
`readonlyWhen` or a hook condition that reads a column must name a declared field too.
36+
37+
Clause-②: no (narrowing)
38+
39+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key, spelling, export, config field or stored shape is removed or renamed, and no stored row is read or rewritten: the change narrows which physical columns the engine's write verbs and their prior reads return, to the field map the metadata already declares. A retired column's values stay in the table; what an app does with them is an operator action stated above (convert before upgrading, or the operator-only os migrate read), not a FROM to TO mapping that objectstack migrate meta could apply. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers a write result's projection (not registered / already-registered); and the change is runtime behaviour, not a declaration (not runtime-interface-only / type-surface-only). -->

‎packages/objectql/src/engine.test.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,6 +1406,10 @@ describe('ObjectQL Engine', () => {
14061406
name: 'invoice',
14071407
fields: {
14081408
amount: { type: 'number', readonlyWhen: 'record.locked == true' },
1409+
// [#21613] Declared: the rules below read them, and a
1410+
// write's prior read now carries the declared fields only.
1411+
locked: { type: 'boolean' },
1412+
limit: { type: 'number' },
14091413
},
14101414
validations: [{ type: 'cross_field', name: 'amount_cap', message: 'amount exceeds limit', condition: 'record.amount > record.limit', fields: ['amount'] }],
14111415
} as any);
@@ -1466,7 +1470,9 @@ describe('ObjectQL Engine', () => {
14661470
it('reports a readonlyWhen-locked field on a single-id update (reason readonly_when)', async () => {
14671471
vi.mocked(SchemaRegistry.getObject).mockReturnValue({
14681472
name: 'invoice',
1469-
fields: { amount: { type: 'number', readonlyWhen: 'record.locked == true' } },
1473+
// [#21613] `locked` is declared: the prior read the rule
1474+
// reads carries the declared fields only.
1475+
fields: { amount: { type: 'number', readonlyWhen: 'record.locked == true' }, locked: { type: 'boolean' } },
14701476
} as any);
14711477
vi.mocked(mockDriver.findOne).mockResolvedValue({ id: '1', locked: true, amount: 100 } as any);
14721478

@@ -1483,7 +1489,9 @@ describe('ObjectQL Engine', () => {
14831489
it('reports bulk-path strips too (multi update — locked in ≥1 matched row)', async () => {
14841490
vi.mocked(SchemaRegistry.getObject).mockReturnValue({
14851491
name: 'invoice',
1486-
fields: { amount: { type: 'number', readonlyWhen: 'record.locked == true' } },
1492+
// [#21613] `locked` is declared: the prior read the rule
1493+
// reads carries the declared fields only.
1494+
fields: { amount: { type: 'number', readonlyWhen: 'record.locked == true' }, locked: { type: 'boolean' } },
14871495
} as any);
14881496
vi.mocked(mockDriver.find).mockResolvedValue([
14891497
{ id: 'a', locked: false, amount: 10 },

‎packages/objectql/src/engine.ts‎

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13628,6 +13628,25 @@ export class ObjectQL implements IObjectQLEngine {
1362813628
);
1362913629
}
1363013630

13631+
// [#21613] A write's returned row is a served row, so it carries the
13632+
// read verbs' default projection: the DECLARED field set
13633+
// (`declared-read-columns.ts`). A table column no metadata declares —
13634+
// a field retired in an upgrade, whose column additive sync leaves
13635+
// behind — rides back in `returning('*')` on driver-sql, and on a new
13636+
// row it reads null. Shaped HERE, on the rows as the driver returned
13637+
// them and after the one-row-per-input guard, so the formulas, the
13638+
// `afterInsert` hooks, the roll-up, the `data.record.created` events
13639+
// (and the webhooks that deliver them), the partial-mode outcomes and
13640+
// the door's own ingress strip all see the declared record — one
13641+
// decision for every door, never a per-door strip. Declared fields
13642+
// keep their treatment: `internal: true` fields stay whole on this
13643+
// engine-level result (the A-prime ruling, below), because an
13644+
// undeclared column is no field at all.
13645+
const declaredInsertColumns = declaredColumnSet(schemaForValidation);
13646+
result = isBatch
13647+
? rowsWithDeclaredColumnsOnly(result as unknown[], declaredInsertColumns)
13648+
: withDeclaredColumnsOnly(result, declaredInsertColumns);
13649+
1363113650
// Coerce `boolean` fields (SQLite/libsql return 0/1) to real booleans on
1363213651
// the after-hook view so flow trigger conditions (`record.is_escalated
1363313652
// != true`) and `{record.<bool>}` interpolation see JS booleans, not
@@ -14433,7 +14452,16 @@ export class ObjectQL implements IObjectQLEngine {
1443314452
// the same for the same reason.
1443414453
const priorAst: QueryAST = { object, where: { id }, limit: 1 };
1443514454
const preOpts = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any);
14436-
priorRecord = await driver.findOne(object, priorAst, preOpts);
14455+
// [#21613] The pre-image is shaped like every other row this engine
14456+
// reads off a driver: the declared field set, so `previous` (bound
14457+
// for the hooks below, and the audit ledger's side of every update
14458+
// diff) and the write's returned row are ONE view. Shaping only the
14459+
// returned row would make every update of a row carrying a retired
14460+
// column record that column as changed, its stored value included.
14461+
priorRecord = withDeclaredColumnsOnly(
14462+
await driver.findOne(object, priorAst, preOpts) as Record<string, unknown> | null,
14463+
declaredColumnSet(updateSchema),
14464+
);
1443714465
// ── [#7867] The not-found gate ──────────────────────────────────
1443814466
//
1443914467
// A by-id update whose id names no row was a SILENT NO-OP that
@@ -14556,7 +14584,13 @@ export class ObjectQL implements IObjectQLEngine {
1455614584
readPriorRows = async () => {
1455714585
if (!priorRowsRead) {
1455814586
priorRowsRead = true;
14559-
priorRows = (await driver.find(object, ast, preOpts) as Record<string, unknown>[]) ?? [];
14587+
// [#21613] The matched rows' pre-images, shaped like the
14588+
// by-id prior read: each per-row `afterUpdate` composes its
14589+
// `result` from one of them, so both sides stay declared.
14590+
priorRows = rowsWithDeclaredColumnsOnly(
14591+
(await driver.find(object, ast, preOpts) as Record<string, unknown>[]) ?? [],
14592+
declaredColumnSet(updateSchema),
14593+
);
1456014594
}
1456114595
return priorRows;
1456214596
};
@@ -15082,6 +15116,15 @@ export class ObjectQL implements IObjectQLEngine {
1508215116
} catch (driverError) {
1508315117
throw envelopeUniqueViolation(driverError, object);
1508415118
}
15119+
// [#21613] The by-id update returns the driver's post-write
15120+
// readback (`select *` on driver-sql), so a column no metadata
15121+
// declares rode back in the PATCH 200 `record` with its stored
15122+
// value. Same default projection as the read verbs and the
15123+
// insert result, applied on the row as the driver returned it:
15124+
// before the formulas, the `afterUpdate` hooks, the roll-up, the
15125+
// `data.record.updated` event and the door's ingress strip. A
15126+
// `null` readback (the row left the caller's scope) stays `null`.
15127+
result = withDeclaredColumnsOnly(result, declaredColumnSet(updateSchema));
1508515128
} else {
1508615129
// [#6262] A bulk SET clause must not carry `id`. Reaching this
1508715130
// branch AT ALL means `resolveEngineUpdateDispatch` returned
@@ -16735,10 +16778,18 @@ export class ObjectQL implements IObjectQLEngine {
1673516778
// tenant scope onto a raw driver read. Skipping it here would read
1673616779
// outside this write's transaction and across the tenant boundary —
1673716780
// `update()`'s prior read passes the same bag for the same reason.
16781+
// [#21613] Both pre-image reads below are shaped to the declared field
16782+
// set, as `update()`'s are: the pre-image is the delete's `previous`, and
16783+
// the audit ledger records it whole as the delete's `old_value`, so a
16784+
// column no metadata declares reached that served row with its value.
16785+
const declaredDeleteColumns = declaredColumnSet(deleteSchema);
1673816786
const readPreImage = async (targetId: unknown): Promise<Record<string, unknown> | null> => {
1673916787
const preAst: QueryAST = { object, where: { id: targetId }, limit: 1 };
1674016788
const preOpts = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any);
16741-
return (await driver.findOne(object, preAst, preOpts)) as Record<string, unknown> | null;
16789+
return withDeclaredColumnsOnly(
16790+
(await driver.findOne(object, preAst, preOpts)) as Record<string, unknown> | null,
16791+
declaredDeleteColumns,
16792+
);
1674216793
};
1674316794
const bindPreImage = (row: Record<string, unknown> | null): void => {
1674416795
// Never fabricate: a row that is not there leaves `previous` UNBOUND
@@ -16878,7 +16929,11 @@ export class ObjectQL implements IObjectQLEngine {
1687816929
const perRowAfterHooks = this.hasHooksFor('afterDelete', object);
1687916930
if (perRowBeforeHooks || perRowAfterHooks) {
1688016931
const preOpts = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any);
16881-
const doomed = (await driver.find(object, ast, preOpts) as Record<string, unknown>[]) ?? [];
16932+
// [#21613] Each doomed row is its per-row `previous`: declared set.
16933+
const doomed = rowsWithDeclaredColumnsOnly(
16934+
(await driver.find(object, ast, preOpts) as Record<string, unknown>[]) ?? [],
16935+
declaredDeleteColumns,
16936+
);
1688216937
// [D6] One ceiling, both phases, BEFORE the first per-row dispatch
1688316938
// and before the driver call.
1688416939
this.assertBulkPerRowHookBudget(

‎packages/objectql/src/plugin.integration.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1725,6 +1725,9 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
17251725
fields: {
17261726
status: { name: 'status', label: 'Status', type: 'text' },
17271727
amount: { name: 'amount', label: 'Amount', type: 'number', readonlyWhen: "record.status == 'paid'" } as any,
1728+
// [#21613] Declared: the rule reads it off the prior read, which
1729+
// carries the declared fields only.
1730+
limit: { name: 'limit', label: 'Limit', type: 'number' },
17281731
},
17291732
validations: [{ type: 'cross_field', name: 'amount_cap', message: 'amount exceeds limit', condition: 'record.amount > record.limit', fields: ['amount'] }] as any,
17301733
},

0 commit comments

Comments
 (0)