Skip to content

Commit 854639b

Browse files
os-muskclaude
andauthored
feat(engine)!: findOne, update and delete declare what they answer, and their hook seams are guarded (#16231) (#16783)
* wip(engine): narrow findOne/update/delete result declarations — census measurement leg Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * feat(engine): declare findOne/update/delete result shapes and guard their hook seams Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * test(engine): pin the three declarations and repair the census consumers Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * chore(engine): changeset for the verb result declarations Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * chore(spec): regenerate the error-code ledger docs for the three new codes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * fix(engine): keep the tracker id out of the update refusal's runtime prose Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * fix(engine): the seam refusal names the seam, not a culprit; repair the off-contract driver doubles Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * fix(changeset): grade the three repaired consumers `minor`, as the clause-② declaration requires `Check Changeset`'s level axis is red on this PR: it declares `Clause-②: yes` and grades three packages whose `src/**` the diff moves at `patch`. A purely additive widening of a published package's public surface takes at least `minor` (maintainer ruling 2026-09-04, decision batch #35, on #15294). `@objectstack/metadata` and `@objectstack/metadata-protocol` are the two the gate can name. `@objectstack/plugin-auth` rises for the same reason and is NOT graded by the gate: `PUBLISHED_SOURCE_PATH` is anchored `^packages/([^/]+)/src/` and this package's changed source is `packages/plugins/plugin-auth/src/` — one directory level deeper, so it never enters the gate's "grown" set. That is the blind spot carded as #16713. The level floor comes from the act the PR declares, not from what the instrument happens to measure. No source, test or config byte moves; the level axis is the only thing this commit answers for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * test(spec): pin the three narrowed verb declarations, not only their seam guards Ruling A has two halves — the declarations and the seam guards — and only the guard half was pinned. Reverting `findOne` / `update` / `delete` to `Promise<any>` while keeping the guards reddened nothing: every consumer repair the census produced compiles identically against `any`, so those repairs record that a narrowing once happened, not that it still holds. That is ADR-0049's enforce-or-remove target. Three `@ts-expect-error` cases under `check:test-typecheck` close it, on the mechanism the neighbouring #12248 block already relies on: each directive is resolved by tsc today, so a widening back to `Promise<any>` leaves it UNUSED, which is itself an error in a file whose debt ledger is exact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * docs(changeset): state the runtime FROM/TO per door, and name both sources The type FROM/TO was already per verb; the runtime half was one sentence for three doors and named only the handler. The refusals' own `developerMessage` names TWO sources — an `after*` handler that assigned an off-declaration value, and a driver whose exit answered off `IDataDriver` — and the second one is the source the seven test-double repairs in this PR actually came from, which is why the refusal sentence names the seam instead of accusing the handler. Three per-door lines now carry FROM (what the dispatch left, returned silently, and who read it first) to TO (the registered 500 code raised at that seam). Driver limbs cited are read off `packages/spec/src/contracts/data-driver.ts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * chore(docs): regenerate the api reference from the merged tree The merge of `origin/main` brought fourteen newly registered error codes into `ERROR_CODE_LEDGER`; this branch adds three. Neither side's bytes can be text merged into the other — both files are generated — so `os-regen-merge.sh` took main's side in the merge commit and this commit re-derives them from the merged source with `gen:schema && gen:docs`. `contract.mdx`'s `Enum<... +N more>` counter reads `+325` = main's `+322` plus this branch's three. `error-code-ledger.mdx` carries both sides' rows; all four hook-result codes (the three added here and the `find()` sibling) are present. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018rzQyhLGC5iVs11V3TzRs5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent edf59e3 commit 854639b

31 files changed

Lines changed: 1273 additions & 96 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/metadata": minor
5+
"@objectstack/metadata-protocol": minor
6+
"@objectstack/plugin-auth": minor
7+
---
8+
9+
feat(engine)!: `findOne`, `update` and `delete` declare what they answer, and their hook seams are guarded (#16231)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves. No spec key, no authored metadata property, no config field, no accepted request shape and no stored artifact changes spelling or shape; `objectstack migrate meta` has nothing to rewrite, `spec-changes.json` has nothing to project and the upgrade guide has no row to gain. What moves is the declared RETURN TYPE of three TypeScript methods (`packages/spec/src/contracts/data-engine.ts`, its `scoped-context.ts` mirrors, and `ObjectQL` itself) plus three new registered ADR-0112 error codes. The rewrite this ships — add the null check the type now demands — is addressed to a TYPESCRIPT CONSUMER and is delivered by the compiler at their own call site, which is the audience the ADR-0087 ledger explicitly does not serve. `type-surface-only` is the category built for exactly this class and it is NOT claimed here, because its predicate 2 (`no-spec-diff`) is mechanically false for this PR: the surface the maintainer ruling names IS `packages/spec/src/contracts/**`. That gap is reported on the card rather than worked around, and the `**BREAKING**` banner below is carried rather than dropped. -->
12+
13+
**BREAKING** on three published `.d.ts` surfaces. `ObjectQL.findOne`, `ObjectQL.update` and `ObjectQL.delete` — and the `IDataEngine` / `IScopedObjectRepository` contracts they implement — declared `Promise<any>` and now declare the answers they have always given:
14+
15+
- `findOne``Promise<Record<string, any> | null>`
16+
- `update``Promise<Record<string, any> | number | null>`
17+
- `delete``Promise<boolean | number>`
18+
19+
`any` is assignable to everything and admits every property read, so TypeScript consumers of these three methods can stop compiling — most often on the null check the declaration now demands. Shipped as `minor` under the repo's launch-window convention, in which `major` is refused by `check-changeset-no-major` and breaking-ness is carried by this banner plus the ADR-0087 disposition rather than by the level. The governing text is the **WHICH LEVEL** maintainer ruling of 2026-09-04 (decision batch #35, on #15294) recorded at `.github/workflows/pr-automation.yml`; `AGENTS.md`'s "a bug fix in a released package takes a patch changeset — never none" is the floor against `none` and was rejected as the ceiling here, because this PR also widens `@objectstack/objectql`'s index with new exported symbols, which that ruling puts at `minor` on its own.
20+
21+
**Why.** `engine.ts` has four `return hookContext.result` sites, one per hook-bearing verb. #15823 closed the `find()` one — an `afterFind` handler that replaced the array made a method declared `Promise<any[]>` resolve to an envelope, silently — and recorded that it could close only that one: the other three declared `Promise<any>` and so carried no declaration a handler could break. A guard cannot exist before a declaration worth guarding does. The maintainer ruled the gap shut (option A, 2026-09-07, director seat summon #17, decision batch #2; option B "declare only, no enforcement" and option C "record `any` as intended" were refused).
22+
23+
The shapes are read off the driver contract each engine exit delegates to, not invented: `driver.findOne` and the by-id `driver.update` declare `Record<string, unknown> | null`, `driver.delete` declares `boolean`, and the predicate exits `driver.updateMany` / `driver.deleteMany` declare the affected-row `number` a bulk write resolves (#4639). Row FIELD values stay erased (`Record<string, any>`), which is #15823's precedent extended exactly rather than softened: `find()` declares `Promise<any[]>`, so the CONTAINER is the contract and the rows inside it are `any`. It is also the only spelling that can state "record or null" at all, since `any | null` collapses to `any`.
24+
25+
**What is enforced now.** Each seam re-checks `hookContext.result` against its declaration immediately after the `after*` dispatch and ahead of the consumers that already assume the shape, and refuses a value outside it with a registered ADR-0112 envelope — `FIND_ONE_HOOK_RESULT_NOT_RECORD`, `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, all `500`, all branchable on `error.code`. Shaping stays legal exactly as it does on `find()`: a handler may mutate what it is handed, drop keys, or assign a different value of a declared shape. The falsy answers are legal and deliberately so — `null` from `findOne`, `null` or a count from `update`, and `false` or `0` from `delete`, the two most ordinary answers that verb gives.
26+
27+
**Who has to change something, on the TYPE axis.** A TypeScript consumer that reads a field off `findOne`'s result without a null check, or off `update`'s result without separating the by-id record from the predicate count. In this repository that was measured before anything moved, at the maintainer's instruction: 18 files and 92 compile errors, all repaired here.
28+
29+
**What changes at RUNTIME, per door.** TWO things can put an off-declaration value at a seam, and every refusal's `developerMessage` names both: an `after*` handler that assigned one, and a DRIVER whose own exit answered off `IDataDriver`. Each door goes from returning that value silently to refusing it — one door, one registered code, all `500`:
30+
31+
- `findOne` — FROM: whatever the `afterFind` dispatch left in `ctx.result`, or whatever `driver.findOne` answered off its declared `Promise<Record<string, unknown> | null>`, returned to the caller as-is and walked first by `maskSecretFields` / `stripSearchCompanionFromRead`. TO: `500 FIND_ONE_HOOK_RESULT_NOT_RECORD`, raised at the seam when that value is neither a record nor `null`.
32+
- `update` — FROM: whatever the `afterUpdate` dispatch left in the batch `ctx.result`, or whatever `driver.update` / `driver.updateMany` answered off their declared `Promise<Record<string, unknown> | null>` / `Promise<number>`, returned as-is and read first by `stripSearchCompanion` and the realtime publish. TO: `500 UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is outside record-or-count-or-`null`.
33+
- `delete` — FROM: whatever the `afterDelete` dispatch left in `ctx.result`, or whatever `driver.delete` / `driver.deleteMany` answered off their declared `Promise<boolean>` / `Promise<number>`, returned as-is to a caller such as `metadata-protocol`'s `deleteData`, which turns `false` into a 404. TO: `500 DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`, raised when that value is neither a boolean nor a number — never on `false` or `0`, which are declared answers.
34+
35+
The driver half of each line is not hypothetical: the seven off-contract test doubles this PR repairs are exactly that source, and they are why the refusal sentence names the SEAM instead of accusing the handler.

content/docs/references/api/contract.mdx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);
2727

2828
| Property | Type | Required | Description |
2929
| :--- | :--- | :--- | :--- |
30-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +322 more>` || Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
30+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +325 more>` || Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3131
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3232
| **message** | `string` || Readable error message |
3333
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -134,6 +134,7 @@ const result = ApiErrorSchema.parse(data);
134134
* `DATASET_INVALID`
135135
* `DATASOURCE_ADMIN_ERROR`
136136
* `DELEGABLE_SCOPE_FAILED`
137+
* `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`
137138
* `DELIVERY_NEVER_SENT`
138139
* `DELIVERY_NOT_ELIGIBLE`
139140
* `DESTRUCTIVE_CHANGE`
@@ -185,6 +186,7 @@ const result = ApiErrorSchema.parse(data);
185186
* `FILTER_TOKEN_UNKNOWN`
186187
* `FILTER_TOKEN_UNRESOLVED`
187188
* `FIND_HOOK_RESULT_NOT_ARRAY`
189+
* `FIND_ONE_HOOK_RESULT_NOT_RECORD`
188190
* `FLOW_CONVERSION_CONFLICT`
189191
* `FLOW_DISABLED`
190192
* `FLOW_FAILED`
@@ -356,6 +358,7 @@ const result = ApiErrorSchema.parse(data);
356358
* `UNSUPPORTED`
357359
* `UNSUPPORTED_QUERY_PARAM`
358360
* `UNSUPPORTED_TRANSFORM`
361+
* `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`
359362
* `UPDATE_ID_MISMATCH`
360363
* `UPLOAD_SESSION_EXPIRED`
361364
* `UPLOAD_SESSION_NOT_FOUND`

content/docs/references/api/error-code-ledger.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ const result = ErrorCode.parse(data);
293293
* `DATASET_INVALID`
294294
* `DATASOURCE_ADMIN_ERROR`
295295
* `DELEGABLE_SCOPE_FAILED`
296+
* `DELETE_HOOK_RESULT_NOT_WRITE_SHAPE`
296297
* `DELIVERY_NEVER_SENT`
297298
* `DELIVERY_NOT_ELIGIBLE`
298299
* `DESTRUCTIVE_CHANGE`
@@ -344,6 +345,7 @@ const result = ErrorCode.parse(data);
344345
* `FILTER_TOKEN_UNKNOWN`
345346
* `FILTER_TOKEN_UNRESOLVED`
346347
* `FIND_HOOK_RESULT_NOT_ARRAY`
348+
* `FIND_ONE_HOOK_RESULT_NOT_RECORD`
347349
* `FLOW_CONVERSION_CONFLICT`
348350
* `FLOW_DISABLED`
349351
* `FLOW_FAILED`
@@ -515,6 +517,7 @@ const result = ErrorCode.parse(data);
515517
* `UNSUPPORTED`
516518
* `UNSUPPORTED_QUERY_PARAM`
517519
* `UNSUPPORTED_TRANSFORM`
520+
* `UPDATE_HOOK_RESULT_NOT_WRITE_SHAPE`
518521
* `UPDATE_ID_MISMATCH`
519522
* `UPLOAD_SESSION_EXPIRED`
520523
* `UPLOAD_SESSION_NOT_FOUND`

packages/metadata-protocol/src/protocol.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,42 @@ export function zodIssuesToMetadataIssues(issues: unknown): MetadataIssueEntry[]
869869
*/
870870
export { recordNotFoundError };
871871

872+
/**
873+
* The RECORD limb of `IDataEngine.update`'s declared result, for the four
874+
* ingresses in this file whose call is BY-ID by construction (#16231).
875+
*
876+
* `update()` used to declare `Promise<any>` and now declares the union its two
877+
* dispatch paths actually answer: a record (or `null`) from the by-id exit
878+
* (`driver.update`), or the affected-row COUNT from the predicate exit
879+
* (`driver.updateMany`, #4639). Every call site below hands the engine a
880+
* `where` naming exactly one primary key — or folds the row's id into the
881+
* payload — with no `multi`, which `resolveEngineUpdateDispatch` resolves
882+
* `by-id`. So the count limb is unreachable from here, and it is REFUSED
883+
* loudly rather than cast away: a dispatch change that started routing these
884+
* calls through `updateMany` would otherwise drop an affected count into a
885+
* per-row receipt's `record` / `data` slot, where every consumer reads it as a
886+
* row.
887+
*
888+
* The `null` limb is NOT refused and NOT narrowed away — it is passed through
889+
* exactly as it was while the declaration said `any`. `update()`'s by-id exit
890+
* answers `null` when the post-write readback leaves the caller's row scope
891+
* (see `updateData`'s own note on that), while `DataProtocol`'s row receipts
892+
* declare `record` / `data` non-null. That disagreement pre-dates this change,
893+
* and this file preserves it rather than widening a shipped response shape as
894+
* a rider; the assertion below is the one place it is written down.
895+
*/
896+
function byIdUpdateRecord(result: Record<string, any> | number | null): Record<string, any> {
897+
if (typeof result === 'number') {
898+
throw new Error(
899+
`A by-id update resolved an affected-row count (${result}) instead of a record. ` +
900+
`This ingress addresses exactly one row, so the engine's predicate dispatch ` +
901+
`(driver.updateMany) is not reachable from it — the dispatch ladder or this ` +
902+
`call site changed.`,
903+
);
904+
}
905+
return result as Record<string, any>;
906+
}
907+
872908
/**
873909
* A 400 for a `$filter` ARRAY that looks like a filter AST but is not one.
874910
*
@@ -11096,7 +11132,7 @@ export class ObjectStackProtocolImplementation implements
1109611132
)
1109711133
? { ...(request.data as Record<string, unknown>), id: request.id }
1109811134
: request.data;
11099-
const result = await this.engine.update(request.object, writeData, opts);
11135+
const result = byIdUpdateRecord(await this.engine.update(request.object, writeData, opts));
1110011136
// [#7823] The PATCH 200 body is the surface #7728's fourth measurement
1110111137
// caught: a client revoking a `sys_api_key` (apiMethods keeps `update`
1110211138
// open, #7727) got the stored hash back in this response. That closure
@@ -12132,7 +12168,7 @@ export class ObjectStackProtocolImplementation implements
1213212168
await this.assertRecordExists(object, record.id);
1213312169
// [#3455] Collect the engine's LEGAL write strips per row.
1213412170
const dropped: DroppedFieldsEvent[] = [];
12135-
const updated = await this.engine.update(object, record.data || {}, { where: { id: record.id }, onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); }, ...ctxOpt } as any);
12171+
const updated = byIdUpdateRecord(await this.engine.update(object, record.data || {}, { where: { id: record.id }, onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); }, ...ctxOpt } as any));
1213612172
omitInternalFieldsFromWriteResponse(batchSchema, updated); // [#7823]
1213712173
results.push({ id: record.id, success: true, data: updated, index, ...(dropped.length > 0 ? { droppedFields: dropped } : {}) });
1213812174
succeeded++;
@@ -12164,7 +12200,7 @@ export class ObjectStackProtocolImplementation implements
1216412200
const existing = await this.probeRecord(object, record.id);
1216512201
if (existing) {
1216612202
const dropped: DroppedFieldsEvent[] = [];
12167-
const updated = await this.engine.update(object, record.data || {}, { where: { id: record.id }, onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); }, ...ctxOpt } as any);
12203+
const updated = byIdUpdateRecord(await this.engine.update(object, record.data || {}, { where: { id: record.id }, onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); }, ...ctxOpt } as any));
1216812204
omitInternalFieldsFromWriteResponse(batchSchema, updated); // [#7823]
1216912205
results.push({ id: record.id, success: true, data: updated, index, ...(dropped.length > 0 ? { droppedFields: dropped } : {}) });
1217012206
} else {
@@ -12547,7 +12583,7 @@ export class ObjectStackProtocolImplementation implements
1254712583
const dropped: DroppedFieldsEvent[] = [];
1254812584
const opts: any = { where: { id: record.id }, onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); } };
1254912585
if (context !== undefined) opts.context = context;
12550-
const updated = await this.engine.update(object, record.data || {}, opts);
12586+
const updated = byIdUpdateRecord(await this.engine.update(object, record.data || {}, opts));
1255112587
omitInternalFieldsFromWriteResponse(updateManySchema, updated); // [#7823]
1255212588
results.push({ id: record.id, success: true, data: updated, index, ...(dropped.length > 0 ? { droppedFields: dropped } : {}) });
1255312589
succeeded++;

packages/metadata/src/loaders/database-loader.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,16 @@ export class DatabaseLoader implements MetadataLoader {
430430
// nothing to compare against: a `data.id` spread over the id parameter
431431
// would retarget the write to a row no caller resolved. The separate
432432
// `id` parameter is the row address — do not let a payload outrank it.
433-
return this.engine.update(table, { ...data, id });
433+
// [#16231] `IDataEngine.update` now declares its dispatch union
434+
// (`record | affected-count | null`). This call passes NO `where`, so
435+
// the payload id is the only address the dispatch ladder sees and it
436+
// resolves `by-id` — the limb that answers a record or `null`. The
437+
// `number` limb is the predicate path (`driver.updateMany`'s affected
438+
// count, #4639), which this call cannot reach; it is narrowed away here
439+
// rather than cast, so a future dispatch change surfaces at THIS line
440+
// instead of as a wrong-shaped row at the caller.
441+
const updated = await this.engine.update(table, { ...data, id });
442+
return typeof updated === 'number' ? null : updated;
434443
}
435444
return this.driver!.update(table, id, data);
436445
}

packages/objectql/src/batch-row-authoring-feedback.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,13 @@ describe('[#8502] a REAL validation refusal keeps its sentence on a batch row',
140140
expect(res.results[0].errors[0].message).toContain('Reason');
141141
expect(res.results[0].errors[0].message).not.toContain('The reason is in the server log');
142142
// The stored row is untouched: the refusal happened before the write.
143-
expect((await engine.findOne('bf_leave_request', { where: { id: 'lr1' } })).reason).toBe('ok');
143+
// [#16231] `findOne` declares record-or-null now, so the read is
144+
// narrowed before the field is asserted — the row's presence IS half of
145+
// what this case measures ("the stored row is untouched"), and reading
146+
// it through `?.` would have let a vanished row pass as `undefined`.
147+
const untouched = await engine.findOne('bf_leave_request', { where: { id: 'lr1' } });
148+
expect(untouched).not.toBeNull();
149+
expect(untouched!.reason).toBe('ok');
144150
});
145151

146152
it('the refusal carries no `status`, so a status-only rule WOULD have blanked it', async () => {

0 commit comments

Comments
 (0)