|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The **one** answer to "what does `ObjectQLEngine.delete` do with this call?" |
| 5 | + * — extracted so that the engine and every test double that stands in for it |
| 6 | + * read the same predicate rather than two hand-written approximations of it |
| 7 | + * (objectstack#4550, from objectstack#4434). |
| 8 | + * |
| 9 | + * ## Why this is a shared module and not four lines inside `engine.ts` |
| 10 | + * |
| 11 | + * `#4434` shipped green. `DELETE /api/v1/sharing/rules/:idOrName` answered 500 |
| 12 | + * for **both** address forms the route advertises, for every rule, from the day |
| 13 | + * it was written — and `plugin-sharing`'s `deleteRule drops rule + all its |
| 14 | + * grants` test asserted success against it the whole time. The route was not |
| 15 | + * untested; it was tested against a **fake engine whose `delete` accepted a |
| 16 | + * call the real engine refuses**. A predicate-shaped purge of |
| 17 | + * `sys_record_share` (no scalar `where.id`, no `options.multi`) is precisely |
| 18 | + * the one shape `delete()` throws on, and the fake happily deleted by |
| 19 | + * predicate. |
| 20 | + * |
| 21 | + * The fix for #4434 mirrored the guard into that fake by hand. That closes one |
| 22 | + * fake and starts a second copy of the contract — the failure mode this module |
| 23 | + * exists to remove. A double that *imports the producer's own decision* cannot |
| 24 | + * be looser than the producer, ever, which is the property the gate wants and |
| 25 | + * the property a copy can only have until someone edits one side. |
| 26 | + * |
| 27 | + * Same reasoning as `packages/spec/src/data/*-conformance.ts` for drivers, and |
| 28 | + * the same shape as objectstack#4455: **the scan and the validator must answer |
| 29 | + * with one predicate.** |
| 30 | + * |
| 31 | + * ## The contract, normatively |
| 32 | + * |
| 33 | + * `delete(object, options)` dispatches on exactly one question — *does this |
| 34 | + * call identify a single row by primary key?* |
| 35 | + * |
| 36 | + * - `options.where.id` is a **scalar** (`string` / `number` / `bigint`, not |
| 37 | + * `null`) → `by-id`: routes to `driver.delete`, runs cascade-delete and the |
| 38 | + * by-id RLS pre-image check. |
| 39 | + * - otherwise, `options.multi` is truthy → `multi`: routes to |
| 40 | + * `driver.deleteMany` with the middleware-composed AST. |
| 41 | + * - otherwise → **`reject`**. The call names neither one row nor a bulk |
| 42 | + * intent, and the engine throws rather than guessing. |
| 43 | + * |
| 44 | + * The scalar test is load-bearing and is the half a hand-written double most |
| 45 | + * often drops: `where: { id: { $in: [...] } }` is a *multi-row predicate*, not |
| 46 | + * an id. Treating it as an id would bind the operator object literally into |
| 47 | + * `driver.delete(object, {$in: […]})` **and** skip both the row-scoping AST |
| 48 | + * seeding (#2982) and the by-id pre-image check. So it is `reject` unless the |
| 49 | + * caller also said `multi`. |
| 50 | + * |
| 51 | + * @see ObjectQL.delete in `engine.ts` — the only production caller. |
| 52 | + * @see scripts/check-engine-double-contract.mjs — the gate that keeps doubles on it. |
| 53 | + */ |
| 54 | + |
| 55 | +/** The message `delete()` throws when a call identifies neither one row nor a bulk intent. */ |
| 56 | +export const ENGINE_DELETE_REJECT_MESSAGE = 'Delete requires an ID or options.multi=true'; |
| 57 | + |
| 58 | +/** What `ObjectQLEngine.delete` will do with a given options bag. */ |
| 59 | +export type EngineDeleteDispatch = |
| 60 | + /** A scalar `where.id` — `driver.delete`, cascade + by-id RLS pre-image. */ |
| 61 | + | { readonly kind: 'by-id'; readonly id: string | number | bigint } |
| 62 | + /** No single id but `options.multi` — `driver.deleteMany` with the composed AST. */ |
| 63 | + | { readonly kind: 'multi' } |
| 64 | + /** Neither — the engine throws `ENGINE_DELETE_REJECT_MESSAGE`. */ |
| 65 | + | { readonly kind: 'reject'; readonly message: string }; |
| 66 | + |
| 67 | +/** The subset of `EngineDeleteOptions` the dispatch decision actually reads. */ |
| 68 | +export interface EngineDeleteDispatchInput { |
| 69 | + readonly where?: unknown; |
| 70 | + readonly multi?: unknown; |
| 71 | + readonly [k: string]: unknown; |
| 72 | +} |
| 73 | + |
| 74 | +/** |
| 75 | + * Extract the SCALAR `where.id`, or `undefined` when the call does not name one |
| 76 | + * row by primary key. |
| 77 | + * |
| 78 | + * `null`, `undefined`, arrays, and operator objects (`{ $in: [...] }`, |
| 79 | + * `{ $ne: … }`) all yield `undefined` — they are predicates over many rows, not |
| 80 | + * a primary key. |
| 81 | + */ |
| 82 | +export function scalarDeleteId( |
| 83 | + options?: EngineDeleteDispatchInput | null, |
| 84 | +): string | number | bigint | undefined { |
| 85 | + const where = options?.where; |
| 86 | + if (!where || typeof where !== 'object') return undefined; |
| 87 | + if (!('id' in (where as Record<string, unknown>))) return undefined; |
| 88 | + const whereId = (where as Record<string, unknown>).id; |
| 89 | + const t = typeof whereId; |
| 90 | + if (whereId !== null && (t === 'string' || t === 'number' || t === 'bigint')) { |
| 91 | + return whereId as string | number | bigint; |
| 92 | + } |
| 93 | + return undefined; |
| 94 | +} |
| 95 | + |
| 96 | +/** |
| 97 | + * Decide what `ObjectQLEngine.delete` does with `options`, without doing it. |
| 98 | + * |
| 99 | + * Pure and side-effect free, so a test double can call it to *classify* a call |
| 100 | + * and then implement `by-id` / `multi` however its fixture stores rows — while |
| 101 | + * being bound to the real engine's `reject` surface for free. |
| 102 | + */ |
| 103 | +export function resolveEngineDeleteDispatch( |
| 104 | + options?: EngineDeleteDispatchInput | null, |
| 105 | +): EngineDeleteDispatch { |
| 106 | + const id = scalarDeleteId(options); |
| 107 | + if (id !== undefined) return { kind: 'by-id', id }; |
| 108 | + if (options?.multi) return { kind: 'multi' }; |
| 109 | + return { kind: 'reject', message: ENGINE_DELETE_REJECT_MESSAGE }; |
| 110 | +} |
| 111 | + |
| 112 | +/** |
| 113 | + * Throw exactly what `ObjectQLEngine.delete` throws when a call is neither |
| 114 | + * `by-id` nor `multi`; return the resolved dispatch otherwise. |
| 115 | + * |
| 116 | + * This is the line a fake engine's `delete` opens with. One call pins the fake |
| 117 | + * to the producer's rejection surface, and — unlike a mirrored `if` — it cannot |
| 118 | + * drift when the producer's rule changes. |
| 119 | + * |
| 120 | + * ```ts |
| 121 | + * async delete(object: string, options?: any) { |
| 122 | + * assertEngineDeleteDispatch(options); // refuses what a real server refuses |
| 123 | + * … |
| 124 | + * } |
| 125 | + * ``` |
| 126 | + */ |
| 127 | +export function assertEngineDeleteDispatch( |
| 128 | + options?: EngineDeleteDispatchInput | null, |
| 129 | +): Exclude<EngineDeleteDispatch, { kind: 'reject' }> { |
| 130 | + const dispatch = resolveEngineDeleteDispatch(options); |
| 131 | + if (dispatch.kind === 'reject') throw new Error(dispatch.message); |
| 132 | + return dispatch; |
| 133 | +} |
| 134 | + |
| 135 | +/** |
| 136 | + * The shared conformance case-set for the delete dispatch — the same role |
| 137 | + * `packages/spec/src/data/*-conformance.ts` plays for drivers. |
| 138 | + * |
| 139 | + * Every case names a call shape and the verdict the **real engine** gives it. |
| 140 | + * A double proved against these is proved against the producer, including the |
| 141 | + * three shapes that look like an id and are not. |
| 142 | + */ |
| 143 | +export interface EngineDeleteDispatchCase { |
| 144 | + /** What the shape is, in the words a failure message should use. */ |
| 145 | + readonly what: string; |
| 146 | + /** The options bag handed to `delete(object, options)`. */ |
| 147 | + readonly options: EngineDeleteDispatchInput | undefined; |
| 148 | + /** The verdict the engine gives it. */ |
| 149 | + readonly expect: EngineDeleteDispatch['kind']; |
| 150 | +} |
| 151 | + |
| 152 | +export const ENGINE_DELETE_DISPATCH_CASES: readonly EngineDeleteDispatchCase[] = [ |
| 153 | + { what: 'scalar string id', options: { where: { id: 'rec_1' } }, expect: 'by-id' }, |
| 154 | + { what: 'scalar number id', options: { where: { id: 42 } }, expect: 'by-id' }, |
| 155 | + { what: 'scalar id alongside other predicates', options: { where: { id: 'rec_1', tenant: 't1' } }, expect: 'by-id' }, |
| 156 | + { what: 'multi with a predicate', options: { where: { rule_id: 'r1' }, multi: true }, expect: 'multi' }, |
| 157 | + { what: 'multi with no predicate at all', options: { multi: true }, expect: 'multi' }, |
| 158 | + { what: 'multi alongside an $in id set', options: { where: { id: { $in: ['a', 'b'] } }, multi: true }, expect: 'multi' }, |
| 159 | + // ── The rejects. Everything below is what #4434 shipped against a fake that |
| 160 | + // accepted it, and what a running server answers 500 to. |
| 161 | + { what: 'predicate on a non-id column, no multi', options: { where: { rule_id: 'r1' } }, expect: 'reject' }, |
| 162 | + { what: '$in over ids, no multi (an operator object is NOT an id)', options: { where: { id: { $in: ['a', 'b'] } } }, expect: 'reject' }, |
| 163 | + { what: 'array id, no multi', options: { where: { id: ['a', 'b'] } }, expect: 'reject' }, |
| 164 | + { what: 'null id, no multi', options: { where: { id: null } }, expect: 'reject' }, |
| 165 | + { what: 'empty where, no multi', options: { where: {} }, expect: 'reject' }, |
| 166 | + { what: 'no options at all', options: undefined, expect: 'reject' }, |
| 167 | + { what: 'multi explicitly false with a predicate', options: { where: { rule_id: 'r1' }, multi: false }, expect: 'reject' }, |
| 168 | +]; |
0 commit comments