|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [ADR-0139 D3] The one refusal every driver answers for a `ReadableIds` leaf |
| 5 | + * it will not evaluate, and the walk that finds such a leaf. |
| 6 | + * |
| 7 | + * ## What a driver is handed |
| 8 | + * |
| 9 | + * The engine composes every `ReadableIds` leaf at its executor boundary, |
| 10 | + * immediately before the driver call (D2), so a driver that compiles the leaf |
| 11 | + * receives the COMPOSED form (`isComposedReadableIds`) and only as the |
| 12 | + * comparand of `$in`. Anything else is a composition fault, not an author |
| 13 | + * error, and every driver answers it with the same envelope (ADR-0112): |
| 14 | + * `INTERNAL_ERROR` / 500, a message naming the driver, the leaf and the remedy. |
| 15 | + * ⛔ Not `INVALID_FILTER`: no author can spell the leaf, so no author can fix |
| 16 | + * the filter. |
| 17 | + * |
| 18 | + * - an UNCOMPOSED leaf (`isReadableIds`) at any position — a direct driver |
| 19 | + * call that bypassed the seam. Fail closed: ⛔ never evaluated as "no |
| 20 | + * restriction", which would serve every row of the child; |
| 21 | + * - a composed leaf anywhere but a `$in` comparand; |
| 22 | + * - a composed leaf on a driver that does not compile it (`driver-mongodb` |
| 23 | + * until a pipeline form is measured, ADR-0139 open item O3). |
| 24 | + * |
| 25 | + * ## Why here |
| 26 | + * |
| 27 | + * Four compilers answer it — `driver-sql` (and the two drivers that inherit |
| 28 | + * its compiler), Turso's remote transport, `driver-memory` and |
| 29 | + * `driver-mongodb` — and none of them imports another. A copy each is how one |
| 30 | + * refusal comes to answer one fault four ways, so the walk and the words live |
| 31 | + * on the floor all four already stand on, beside |
| 32 | + * `json-column-operator-refusal.ts`, which the drivers share for the same |
| 33 | + * reason. Each driver keeps its own logger: the refusal is logged at `error` |
| 34 | + * once per driver instance (see {@link ReadableIdsRefusalLog}). |
| 35 | + * |
| 36 | + * ⛔ No `DriverCapabilities` bit: a driver answers by compiling or refusing, |
| 37 | + * so there is nothing for the engine to read. |
| 38 | + */ |
| 39 | + |
| 40 | +import { |
| 41 | + filterSubtreeProvenanceOf, |
| 42 | + isComposedReadableIds, |
| 43 | + isReadableIds, |
| 44 | + markFilterSubtreeProvenance, |
| 45 | + type ComposedReadableIds, |
| 46 | +} from '@objectstack/spec/data'; |
| 47 | + |
| 48 | +/** Where a leaf a driver will not evaluate sits, and which one it is. */ |
| 49 | +export interface MisplacedReadableIds { |
| 50 | + /** The path of the leaf in the filter, `where.record_id.$in` style. */ |
| 51 | + readonly at: string; |
| 52 | + /** The parent object the leaf names. */ |
| 53 | + readonly object: string; |
| 54 | + /** |
| 55 | + * - `uncomposed` — a `ReadableIds.of(...)` leaf that never passed the |
| 56 | + * engine's composition seam; |
| 57 | + * - `misplaced` — a composed leaf outside a `$in` comparand; |
| 58 | + * - `unsupported` — a composed leaf on a driver that does not compile it. |
| 59 | + */ |
| 60 | + readonly kind: 'uncomposed' | 'misplaced' | 'unsupported'; |
| 61 | +} |
| 62 | + |
| 63 | +/** |
| 64 | + * The first leaf in `where` this driver will not evaluate, or `null`. |
| 65 | + * |
| 66 | + * `compilesComposed` is the driver's own answer: `true` for a driver that |
| 67 | + * compiles the composed leaf (a composed `$in` comparand is then legal and |
| 68 | + * not descended into — its `scope` is the PARENT's filter, compiled against |
| 69 | + * the parent), `false` for one that refuses it (every composed leaf is then |
| 70 | + * `unsupported`). |
| 71 | + * |
| 72 | + * The walk is the engine's own (`collectComposableReadableIds`): plain |
| 73 | + * objects and arrays only, cycle-safe, no other prototype descended into. |
| 74 | + */ |
| 75 | +export function findMisplacedReadableIds( |
| 76 | + where: unknown, |
| 77 | + compilesComposed: boolean, |
| 78 | + path = 'where', |
| 79 | +): MisplacedReadableIds | null { |
| 80 | + const onPath = new Set<object>(); |
| 81 | + const visit = (value: unknown, at: string, asInComparand: boolean): MisplacedReadableIds | null => { |
| 82 | + if (value === null || typeof value !== 'object') return null; |
| 83 | + if (isReadableIds(value)) return { at, object: value.object, kind: 'uncomposed' }; |
| 84 | + if (isComposedReadableIds(value)) { |
| 85 | + if (!compilesComposed) return { at, object: value.object, kind: 'unsupported' }; |
| 86 | + return asInComparand ? null : { at, object: value.object, kind: 'misplaced' }; |
| 87 | + } |
| 88 | + if (onPath.has(value)) return null; |
| 89 | + const isArray = Array.isArray(value); |
| 90 | + if (!isArray) { |
| 91 | + const proto = Object.getPrototypeOf(value); |
| 92 | + if (proto !== Object.prototype && proto !== null) return null; |
| 93 | + } |
| 94 | + onPath.add(value); |
| 95 | + try { |
| 96 | + if (isArray) { |
| 97 | + const list = value as unknown[]; |
| 98 | + for (let i = 0; i < list.length; i++) { |
| 99 | + const found = visit(list[i], `${at}[${i}]`, false); |
| 100 | + if (found) return found; |
| 101 | + } |
| 102 | + } else { |
| 103 | + for (const key of Object.keys(value)) { |
| 104 | + const found = visit((value as Record<string, unknown>)[key], `${at}.${key}`, key === '$in'); |
| 105 | + if (found) return found; |
| 106 | + } |
| 107 | + } |
| 108 | + return null; |
| 109 | + } finally { |
| 110 | + onPath.delete(value); |
| 111 | + } |
| 112 | + }; |
| 113 | + return visit(where, path, false); |
| 114 | +} |
| 115 | + |
| 116 | +/** |
| 117 | + * `where` with the comparand of every composed `$in` leaf replaced by |
| 118 | + * `answer(leaf)`, copy-on-write: a subtree holding no leaf is returned by |
| 119 | + * reference, the caller's tree is never edited (the leaf is frozen, and the |
| 120 | + * hooks' AST is the caller's), and a rewritten node carries the |
| 121 | + * filter-subtree provenance mark of the node it replaces, so a refusal raised |
| 122 | + * inside it resolves to the same author / policy attribution it did before. |
| 123 | + * |
| 124 | + * Asked only after {@link findMisplacedReadableIds} answered `null` for the |
| 125 | + * same `where`, so every composed leaf the walk meets sits in a `$in`. |
| 126 | + * For a driver that evaluates the leaf by rewriting it — `driver-memory` |
| 127 | + * into the readable id list, Turso's remote face into its resolved semi-join. |
| 128 | + */ |
| 129 | +export function replaceComposedReadableIds<T>(where: T, answer: (leaf: ComposedReadableIds) => unknown): T { |
| 130 | + const onPath = new Set<object>(); |
| 131 | + const rewrite = (value: unknown): unknown => { |
| 132 | + if (value === null || typeof value !== 'object' || isComposedReadableIds(value)) return value; |
| 133 | + if (onPath.has(value)) return value; |
| 134 | + const isArray = Array.isArray(value); |
| 135 | + if (!isArray) { |
| 136 | + const proto = Object.getPrototypeOf(value); |
| 137 | + if (proto !== Object.prototype && proto !== null) return value; |
| 138 | + } |
| 139 | + onPath.add(value); |
| 140 | + try { |
| 141 | + if (isArray) { |
| 142 | + let out: unknown[] | undefined; |
| 143 | + (value as unknown[]).forEach((item, i) => { |
| 144 | + const next = rewrite(item); |
| 145 | + if (next !== item) (out ??= [...(value as unknown[])])[i] = next; |
| 146 | + }); |
| 147 | + return out ?? value; |
| 148 | + } |
| 149 | + let out: Record<string, unknown> | undefined; |
| 150 | + for (const key of Object.keys(value)) { |
| 151 | + const item = (value as Record<string, unknown>)[key]; |
| 152 | + const next = key === '$in' && isComposedReadableIds(item) ? answer(item) : rewrite(item); |
| 153 | + if (next !== item) (out ??= { ...(value as Record<string, unknown>) })[key] = next; |
| 154 | + } |
| 155 | + if (out === undefined) return value; |
| 156 | + const mark = filterSubtreeProvenanceOf(value); |
| 157 | + return mark === null ? out : markFilterSubtreeProvenance(out, mark); |
| 158 | + } finally { |
| 159 | + onPath.delete(value); |
| 160 | + } |
| 161 | + }; |
| 162 | + return rewrite(where) as T; |
| 163 | +} |
| 164 | + |
| 165 | +/** The ADR-0112 envelope of the refusal: a composition fault, never `INVALID_FILTER`. */ |
| 166 | +export type ReadableIdsDriverRefusalError = Error & { |
| 167 | + code: 'INTERNAL_ERROR'; |
| 168 | + status: 500; |
| 169 | + httpStatus: 500; |
| 170 | +}; |
| 171 | + |
| 172 | +/** |
| 173 | + * The refusal for `site`, raised by `driver` (its package name, e.g. |
| 174 | + * `driver-sql`). The message names the driver, the leaf and the remedy; the |
| 175 | + * read was not run. |
| 176 | + */ |
| 177 | +export function readableIdsDriverRefusal( |
| 178 | + driver: string, |
| 179 | + site: MisplacedReadableIds, |
| 180 | + // Why this driver does not compile the composed leaf — read for `unsupported` only. |
| 181 | + unsupportedReason = 'no pipeline form of the tenant-walled semi-join is measured for this driver yet', |
| 182 | +): ReadableIdsDriverRefusalError { |
| 183 | + const leaf = `${site.at} holds a ReadableIds leaf, "the ids of '${site.object}' this caller may read"`; |
| 184 | + let message: string; |
| 185 | + switch (site.kind) { |
| 186 | + case 'uncomposed': |
| 187 | + message = |
| 188 | + `[${driver}] ${leaf}, UNCOMPOSED: it reached the driver without passing the engine's composition ` + |
| 189 | + 'seam, and the driver refuses it rather than read it as no restriction. The query was NOT run. ' + |
| 190 | + 'Read through the ObjectQL engine (find / findOne / count / aggregate), which composes every ' + |
| 191 | + 'leaf with the caller\'s read scope before any driver call; never hand a driver ReadableIds.of() directly.'; |
| 192 | + break; |
| 193 | + case 'misplaced': |
| 194 | + message = |
| 195 | + `[${driver}] ${leaf}, composed, outside a $in comparand. The leaf is legal only as the comparand ` + |
| 196 | + 'of $in on a pointer field ({ record_id: { $in: leaf } }); the query was NOT run. Place it there, ' + |
| 197 | + 'or write the predicate without it.'; |
| 198 | + break; |
| 199 | + case 'unsupported': |
| 200 | + message = |
| 201 | + `[${driver}] ${leaf}, composed, and ${driver} does not compile it: ${unsupportedReason}. ` + |
| 202 | + 'The query was NOT run, rather than ' + |
| 203 | + 'answer a short or an unscoped list. Bind the object and its parent to a SQL-family or memory ' + |
| 204 | + 'datasource, or read this object through its bounded probe, chosen before the leaf is written.'; |
| 205 | + break; |
| 206 | + } |
| 207 | + const err = new Error(message) as ReadableIdsDriverRefusalError; |
| 208 | + err.code = 'INTERNAL_ERROR'; |
| 209 | + err.status = 500; |
| 210 | + err.httpStatus = 500; |
| 211 | + return err; |
| 212 | +} |
| 213 | + |
| 214 | +/** |
| 215 | + * The slice of a driver's logger the refusal needs: the message alone, because |
| 216 | + * the drivers' loggers disagree on what follows it (`SqlDriver`'s takes a meta |
| 217 | + * object, core's `Logger` an `Error`). `error` is optional because a driver's |
| 218 | + * injectable logger declares it so (`SqlDriver`'s does); `warn` is REQUIRED, |
| 219 | + * the guaranteed fallback, so a sink that carries no `error` gets the line at |
| 220 | + * `warn` — every value of this type prints it (the optional-`error` sink |
| 221 | + * contract, AGENTS.md → "Degradation log levels"). |
| 222 | + */ |
| 223 | +export interface ReadableIdsRefusalLog { |
| 224 | + error?(message: string): void; |
| 225 | + warn(message: string): void; |
| 226 | +} |
| 227 | + |
| 228 | +/** |
| 229 | + * The refusal for `site`, logged at `error` the FIRST time `driver`'s |
| 230 | + * instance refuses (`state.logged` is that instance's own flag), and returned |
| 231 | + * for the caller to throw. A composition fault means every such read on this |
| 232 | + * driver fails, so one line names it; the caller sees every refusal. |
| 233 | + */ |
| 234 | +export function refuseReadableIdsOnce( |
| 235 | + driver: string, |
| 236 | + site: MisplacedReadableIds, |
| 237 | + log: ReadableIdsRefusalLog | undefined, |
| 238 | + state: { logged: boolean }, |
| 239 | + unsupportedReason?: string, |
| 240 | +): ReadableIdsDriverRefusalError { |
| 241 | + const err = readableIdsDriverRefusal(driver, site, unsupportedReason); |
| 242 | + if (!state.logged) { |
| 243 | + state.logged = true; |
| 244 | + const line = `${err.message} Logged once for this driver; later refusals answer the caller the same way.`; |
| 245 | + if (typeof log?.error === 'function') log.error(line); |
| 246 | + else log?.warn(line); |
| 247 | + } |
| 248 | + return err; |
| 249 | +} |
0 commit comments