|
| 1 | +--- |
| 2 | +'@objectstack/spec': minor |
| 3 | +'@objectstack/runtime': minor |
| 4 | +--- |
| 5 | + |
| 6 | +**BREAKING for action handlers** — `ActionEngineFacade.find` takes the engine's query ENVELOPE; the bare-filter parameter shape is withdrawn (#15124) |
| 7 | + |
| 8 | +Clause-②: yes (narrowing) |
| 9 | + |
| 10 | +`ctx.engine.find(object, query)` now takes `EngineQueryOptions` — the same |
| 11 | +options bag `IDataEngine.find` and ObjectQL's own `engine.find` take, named by |
| 12 | +identity rather than restated. **One platform, one query shape.** |
| 13 | + |
| 14 | +### Migration — FROM → TO |
| 15 | + |
| 16 | +| You wrote | Write instead | |
| 17 | +| --- | --- | |
| 18 | +| `ctx.engine.find('task', { status: 'open' })` | `ctx.engine.find('task', { where: { status: 'open' } })` | |
| 19 | +| `ctx.engine.find('task', { amount: { $gt: 100 } })` | `ctx.engine.find('task', { where: { amount: { $gt: 100 } } })` | |
| 20 | +| `ctx.engine.find('task', {})` | unchanged — an empty envelope is still the unfiltered read | |
| 21 | + |
| 22 | +The rewrite is lossless and mechanical: the filter moves under `where`, verbatim. |
| 23 | +`tsc --noEmit` over your handlers finds every unmigrated call — see below. |
| 24 | + |
| 25 | +### Why the shape was withdrawn rather than the bar closed |
| 26 | + |
| 27 | +Until now this parameter was the `where` HALF of a query while every other |
| 28 | +`find` on the platform took the whole envelope, and the runtime wrapped what it |
| 29 | +was given. That made the most natural spelling the wrong one, silently: an |
| 30 | +author who passed the engine's own envelope reached the engine as |
| 31 | +`{ where: { where: … } }` — a filter on a field named `where` — which matches no |
| 32 | +row and resolves to `[]` with **no error at all**. A handler that made the |
| 33 | +mistake ran to completion over zero rows for as long as it shipped, and its own |
| 34 | +hand-written test double, written to the same belief, passed every assertion. |
| 35 | +Because an empty `{}` skipped the wrap, one unfiltered read kept working under |
| 36 | +either belief, so a dead handler looked partially alive. |
| 37 | + |
| 38 | +Refusing `where` at the top level instead — intersecting the old parameter with |
| 39 | +`{ where?: never }` — was rejected: it asserts a vocabulary fact the spec |
| 40 | +declares nowhere, reserving the field name `where` across every customer's data |
| 41 | +model to buy one parameter's compile-time check. Aligning the parameter removes |
| 42 | +the ambiguity at its root and reserves nothing. |
| 43 | + |
| 44 | +### What the new declaration refuses, measured |
| 45 | + |
| 46 | +If your handler is typed with the published `ActionHandlerContext`, a bare filter |
| 47 | +no longer type-checks on **either** path you can reach it by: |
| 48 | + |
| 49 | +- an object literal (`{ status: 'completed' }`) fails the excess-property check — |
| 50 | + a field name is not an envelope key; |
| 51 | +- a filter held in a `FilterCondition` variable fails **TS2559** — every envelope |
| 52 | + key is optional, so a bag of field names has no property in common with it. |
| 53 | + |
| 54 | +The envelope's own keys are typed too: `where: 'a = b'`, `fields: 'id,subject'` |
| 55 | +and `limit: '50'` are each refused. |
| 56 | + |
| 57 | +**If your handler is NOT typed with it** — a handler in an `objectstack.config.js` |
| 58 | +/ `.mjs`, one annotated with your own copy of the context type, or a `(ctx: any)` |
| 59 | +handler — nothing above reaches you, so the facade refuses the withdrawn shape at |
| 60 | +**runtime** instead, before the engine, with the same prescription: |
| 61 | + |
| 62 | +``` |
| 63 | +find('task') was given a key 'status' the query envelope does not carry. |
| 64 | +ctx.engine.find(object, query) takes the engine QUERY ENVELOPE, not a bare |
| 65 | +filter — move the filter under `where`: find(object, { where: { … } }). |
| 66 | +Envelope keys: context, cursor, distinct, expand, fields, limit, offset, |
| 67 | +orderBy, search, searchFields, top, where. |
| 68 | +``` |
| 69 | + |
| 70 | +⚠️ **That refusal matters most for a filter whose value is `null`.** The engine's |
| 71 | +own unknown-option check exempts a `null` value, because on an option bag a |
| 72 | +`null` is a withdrawal. On a filter it is the "rows with no X" idiom, so |
| 73 | +`{ deleted_at: null }` would have been dropped unexecuted and the read would have |
| 74 | +widened to **every row** — including the ones you were excluding — with no error |
| 75 | +at all. It is refused instead. |
| 76 | + |
| 77 | +### What this opens |
| 78 | + |
| 79 | +`fields`, `orderBy`, `limit`, `offset` and `expand` are reachable from an action |
| 80 | +handler for the first time — under the old parameter there was nowhere to carry |
| 81 | +them. A caller-supplied `context` is **ignored**: this facade is trusted and |
| 82 | +context-less by design, and the runtime stamps its own elevated |
| 83 | +`ExecutionContext` last. Do not write one — it reads as authorization and is |
| 84 | +none. |
| 85 | + |
| 86 | +### Checking a migrated handler |
| 87 | + |
| 88 | +Do not settle for "it still resolves". A handler that had been passing the |
| 89 | +envelope was returning `[]` on **every** call, so a suite written against the |
| 90 | +mistake passes and the row count is the only witness. Re-run each migrated |
| 91 | +handler against seeded data and assert it returns the rows its filter selects. |
| 92 | + |
| 93 | +<!-- adr-0087: registered action-engine-facade-find-query-envelope --> |
0 commit comments