6565 * call identify a single row by primary key?*
6666 *
6767 * - `options.where.id` is a **scalar** (`string` / `number` / `bigint`, not
68- * `null`) → `by-id`: routes to `driver.delete`, runs cascade-delete and the
69- * by-id RLS pre-image check.
68+ * `null`) **and truthy** → `by-id`: routes to `driver.delete`, runs
69+ * cascade-delete and the by-id RLS pre-image check.
7070 * - otherwise, `options.multi` is truthy → `multi`: routes to
7171 * `driver.deleteMany` with the middleware-composed AST.
7272 * - otherwise → **`reject`**. The call names neither one row nor a bulk
7373 * intent, and the engine throws rather than guessing.
7474 *
75- * The scalar test is load-bearing and is the half a hand-written double most
76- * often drops: `where: { id: { $in: [...] } }` is a *multi-row predicate*, not
77- * an id. Treating it as an id would bind the operator object literally into
78- * `driver.delete(object, {$in: […]})` **and** skip both the row-scoping AST
79- * seeding (#2982) and the by-id pre-image check. So it is `reject` unless the
80- * caller also said `multi`.
75+ * Two halves of that first line are load-bearing, and a hand-written double
76+ * drops one or the other — which is the whole argument for importing this
77+ * instead of copying it:
78+ *
79+ * 1. **The scalar test.** `where: { id: { $in: [...] } }` is a *multi-row
80+ * predicate*, not an id. Treating it as an id would bind the operator
81+ * object literally into `driver.delete(object, {$in: […]})` **and** skip
82+ * both the row-scoping AST seeding (#2982) and the by-id pre-image check.
83+ * So it is `reject` unless the caller also said `multi`.
84+ * 2. **Truthiness, not `!== undefined`.** The engine branches on
85+ * `if (hookContext.input.id)`, so a falsy scalar id — `where: { id: 0 }`,
86+ * `where: { id: '' }` — does **not** take the by-id route; it falls through
87+ * to `multi`/`reject` like any other non-identifying call. Byte-for-byte
88+ * the rule the twin states as its own point 3, and until objectstack#5747
89+ * this module read `!== undefined` and answered `by-id` for both — the one
90+ * input on which a double pinned to `assertEngineDeleteDispatch` was still
91+ * *looser than the producer* (it accepted `delete(o, { where: { id: '' } })`
92+ * while a running server answers `ENGINE_DELETE_REJECT_MESSAGE`), which is
93+ * the #4434 shape this module exists to remove. `where: { id: '' }` is a
94+ * reachable spelling, not a curiosity: an empty path segment or an unfilled
95+ * form field passed straight through to `where.id` produces it.
96+ *
97+ * Note this **changed the predicate, never the engine**. `resolveEngine…`
98+ * is a description of `ObjectQL.delete`, and it was the description that
99+ * was wrong; `delete(o, { where: { id: 0 } })` threw before this change and
100+ * throws after it. Realigning the engine to the old description instead —
101+ * making `{ id: 0 }` a real by-id delete — would have been a change to the
102+ * producer's behaviour, and was rejected as such (objectstack#5747 option
103+ * B, deliberately not taken).
81104 *
82105 * @see ObjectQL.delete in `packages/objectql/src/engine.ts` — the only production caller.
106+ * @see engine-update-dispatch.ts — the twin; its point 3 is this module's point 2.
83107 * @see packages/objectql/src/engine-delete-dispatch.ts — the re-export shim that keeps
84108 * objectql's original import path (and its public API) working.
85109 * @see packages/objectql/src/engine-delete-dispatch.test.ts — the test that drives the
@@ -93,7 +117,7 @@ export const ENGINE_DELETE_REJECT_MESSAGE = 'Delete requires an ID or options.mu
93117
94118/** What `ObjectQLEngine.delete` will do with a given options bag. */
95119export type EngineDeleteDispatch =
96- /** A scalar `where.id` — `driver.delete`, cascade + by-id RLS pre-image. */
120+ /** A TRUTHY scalar `where.id` — `driver.delete`, cascade + by-id RLS pre-image. */
97121 | { readonly kind : 'by-id' ; readonly id : string | number | bigint }
98122 /** No single id but `options.multi` — `driver.deleteMany` with the composed AST. */
99123 | { readonly kind : 'multi' }
@@ -108,12 +132,21 @@ export interface EngineDeleteDispatchInput {
108132}
109133
110134/**
111- * Extract the SCALAR `where.id`, or `undefined` when the call does not name one
112- * row by primary key .
135+ * Extract the SCALAR `where.id`, or `undefined` when `where` carries no scalar
136+ * there at all .
113137 *
114138 * `null`, `undefined`, arrays, and operator objects (`{ $in: [...] }`,
115139 * `{ $ne: … }`) all yield `undefined` — they are predicates over many rows, not
116140 * a primary key.
141+ *
142+ * This answers only "is that VALUE a scalar?", which is **not** the whole
143+ * by-id question: the engine additionally requires the id to be TRUTHY, so
144+ * `scalarDeleteId({ where: { id: 0 } })` is `0` while the call itself
145+ * dispatches `multi`/`reject` (objectstack#5747). Kept value-faithful on
146+ * purpose — narrowing it to "truthy scalar" would make the extractor and its
147+ * name disagree, and a caller asking what the `where` holds would have to
148+ * reach for a second spelling. Use {@link resolveEngineDeleteDispatch} for the
149+ * verdict about a CALL; its twin `scalarUpdateId` splits the same way.
117150 */
118151export function scalarDeleteId (
119152 options ?: EngineDeleteDispatchInput | null ,
@@ -140,7 +173,11 @@ export function resolveEngineDeleteDispatch(
140173 options ?: EngineDeleteDispatchInput | null ,
141174) : EngineDeleteDispatch {
142175 const id = scalarDeleteId ( options ) ;
143- if ( id !== undefined ) return { kind : 'by-id' , id } ;
176+ // The engine branches on `if (hookContext.input.id)` — truthiness, not
177+ // `!== undefined`, so a falsy scalar id (`0`, `''`) is not an identifying
178+ // call and falls down the same ladder as a non-scalar one. See header
179+ // point 2, and the twin's point 3 (objectstack#5747 / objectstack#5748).
180+ if ( id ) return { kind : 'by-id' , id } ;
144181 if ( options ?. multi ) return { kind : 'multi' } ;
145182 return { kind : 'reject' , message : ENGINE_DELETE_REJECT_MESSAGE } ;
146183}
@@ -192,12 +229,27 @@ export const ENGINE_DELETE_DISPATCH_CASES: readonly EngineDeleteDispatchCase[] =
192229 { what : 'multi with a predicate' , options : { where : { rule_id : 'r1' } , multi : true } , expect : 'multi' } ,
193230 { what : 'multi with no predicate at all' , options : { multi : true } , expect : 'multi' } ,
194231 { what : 'multi alongside an $in id set' , options : { where : { id : { $in : [ 'a' , 'b' ] } } , multi : true } , expect : 'multi' } ,
232+ // ── The FALSY scalars (objectstack#5747). `0` and `''` are scalars, so
233+ // `scalarDeleteId` returns them — but the engine's `if (input.id)` is a
234+ // truthiness test, so neither identifies a row. With a declared bulk
235+ // intent they are honoured as `multi` (the caller's `where` still rides
236+ // onto the AST, so the predicate is `id = 0` / `id = ''`, not "every
237+ // row"); without one they are `reject`, below. Same pair the twin pins
238+ // on the update side.
239+ { what : 'falsy scalar where.id (0) with multi:true' , options : { where : { id : 0 } , multi : true } , expect : 'multi' } ,
240+ { what : "falsy scalar where.id ('') with multi:true" , options : { where : { id : '' } , multi : true } , expect : 'multi' } ,
195241 // ── The rejects. Everything below is what #4434 shipped against a fake that
196242 // accepted it, and what a running server answers 500 to.
197243 { what : 'predicate on a non-id column, no multi' , options : { where : { rule_id : 'r1' } } , expect : 'reject' } ,
198244 { what : '$in over ids, no multi (an operator object is NOT an id)' , options : { where : { id : { $in : [ 'a' , 'b' ] } } } , expect : 'reject' } ,
199245 { what : 'array id, no multi' , options : { where : { id : [ 'a' , 'b' ] } } , expect : 'reject' } ,
200246 { what : 'null id, no multi' , options : { where : { id : null } } , expect : 'reject' } ,
247+ // The two shapes objectstack#5747 was filed for: a fake pinned to
248+ // `assertEngineDeleteDispatch` ACCEPTED both until this case-set could
249+ // reach them (#4868 family — a per-case parity run cannot contradict an
250+ // input nobody listed).
251+ { what : 'falsy scalar where.id (0), no multi' , options : { where : { id : 0 } } , expect : 'reject' } ,
252+ { what : "falsy scalar where.id (''), no multi — the empty path segment / unfilled form field" , options : { where : { id : '' } } , expect : 'reject' } ,
201253 { what : 'empty where, no multi' , options : { where : { } } , expect : 'reject' } ,
202254 { what : 'no options at all' , options : undefined , expect : 'reject' } ,
203255 { what : 'multi explicitly false with a predicate' , options : { where : { rule_id : 'r1' } , multi : false } , expect : 'reject' } ,
0 commit comments