Skip to content

Commit 462dff3

Browse files
feat(drivers): ADR-0139 E3 + E4 — the five drivers compile the composed ReadableIds leaf or refuse it with D3's envelope, with the conformance case-set (#23656)
Fixes #23639 Clause-②: yes ADR-0139 plan rows **E3** and **E4** in one PR, as `domain:spec` seat 3 ruled for the case-set: "the case-set lands in the same PR as the five driver suites that run it, with zero ledger rows." D3 is the spec. #22788 is the parent card. ## What changes Every driver now either compiles the composed `ReadableIds` leaf or refuses it. The engine has composed the leaf at its executor boundary since E2. | Driver | D3 obligation | Where it lands | |:--|:--|:--| | `driver-sql` | compiles `field IN (SELECT id FROM P_TABLE WHERE SCOPE AND TENANT)` | the `$in` arm of `SqlDriver#applyFilterCondition`, plus the new `SqlDriver#readableIdsSubquery`. The subquery is walled by `SqlDriver#applyTenantScope` on the parent table, using the outer read's own options. | | `driver-sqlite-wasm`, `driver-turso` local face | inherit that compile | only the driver name the refusal prints is overridden (`readableIdsDriverName`) | | `driver-turso` remote face | compiles the same semi-join with its own compiler | `TursoDriver#toRemoteWhere` resolves each leaf into the parent's remote table, its scope (in storage form) and its tenant wall, built by the same chokepoint, `remoteTenantScope` → `applyTenantScope`. `RemoteTransport`'s `$in` arm then compiles `"field" IN (SELECT "id" FROM … WHERE (scope) AND (tenant))`. | | `driver-memory` | implements it in process | `convertToMongoQuery`, the single funnel for `find`, `count`, `aggregate` and `distinct`, rewrites each composed leaf into the ids of the parent's rows under the scope (`readableParentIds`) before the shape gate. No tenant wall is owed, because memory refuses every tenant-scoped call. | | `driver-mongodb` | refuses | `translateFilter`, the funnel every door and the aggregation `$match` go through, refuses ahead of the shape gate (which would otherwise answer `INVALID_FILTER`) | **The refusal is one envelope on every driver** (ADR-0112): `INTERNAL_ERROR` / 500, never `INVALID_FILTER`. A driver refuses: - an uncomposed leaf at any position, judged on `isReadableIds` alone; - a composed leaf outside a `$in` comparand; - every leaf, on `driver-mongodb`. The message names the driver, the leaf (its path and parent object) and the remedy. It is logged at `error` once per driver. No `DriverCapabilities` bit is added. **Landing point outside the claim's file surface, and why: `@objectstack/core`.** The walk, the copy-on-write rewrite and the refusal live in `packages/core/src/utils/readable-ids-driver-refusal.ts`. Four compilers need them: SQL, Turso remote, memory and mongodb. None of them imports another, but all of them already import `@objectstack/core`. This follows the precedent of `json-column-operator-refusal.ts`, shared between the drivers in the same place for the same reason. The alternative was four hand copies of one refusal. **The case-set** was carried from the parked `e641ab6d8` with `git restore --source` and no branch checkout. The files are `packages/spec/src/data/readable-ids-conformance.ts`, its oracle test (`readable-ids-conformance.test.ts`, 18 tests), the `data/index.ts` export line and the `CASE_SETS` row. `api-surface/data.json` and `export-origins/data.json` were regenerated with `check:generated --fix`, which proved those two (and only those two) stale. One edit to the carried file: its header linked a symbol that does not exist (`readableIdsCaseHoldsComposedLeaf`). It now links `ReadableIdsMatchesCase.holdsComposedLeaf`. `SqlDriver#applyFilters` and `#applyFilterCondition` (both `protected`) take the read's `DriverOptions` as a new optional last parameter. The lazily invoked knex group callbacks capture it, so the subquery under a `$or` or `$not` is walled with the outer read's options and never reads them from instance state. Every in-file caller passes the options it already handed `applyTenantScope`. ## Pins (red before, green after) | Pin | sql (sqlite cell) | turso local | turso remote | sqlite-wasm | memory | mongodb | |:--|:--|:--|:--|:--|:--|:--| | `READABLE_IDS_CASES` `matches` (find + count) | 9/9 | 9/9 | 9/9 | 9/9 | 8/8 + the tenant-wall case records memory's tenant refusal | the one non-leaf case answers rows; the 8 leaf cases answer the envelope | | `refuses-uncomposed` → `INTERNAL_ERROR` / 500 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | more than 2,000 readable candidates → every visible row; `count` equals rows served | 2,400 ✓ | n/a | 2,100 ✓ | n/a | 2,400 ✓ | n/a | | an unreadable row stays hidden | ✓ | | ✓ | | ✓ | | | a deleted parent is excluded (find, count, aggregate) | ✓ | | ✓ | | ✓ | | | refusal logged at `error` once per driver | ✓ | | ✓ | | ✓ | once per process (no injectable logger in this driver) | **E4, the conformance table**: `node scripts/check-driver-conformance.mjs` records `READABLE_IDS` `ok` for all five drivers. **Ablation**: each implementation was mutated after its commit, using `scripts/ablation-replace.mjs` (anchor hit 1 → 0, blob compared to HEAD after restore, `git diff HEAD` empty). - `driver-sql`, the `$in` composed arm disabled: **13 failed** / 4 passed. - `RemoteTransport`, the semi-join arm disabled: **12 failed** / 13 passed. The local face stays green, as it should. - `driver-memory`, the rewrite disabled: **10 failed** / 4 passed. - `driver-mongodb`, the refusal disabled: **11 failed** / 1 passed. ## O1: what the subquery binds The statements below were captured from knex's `query` event on the SQLite cell, where `org_a` is the tenant. ```text find, no scope: … where (organization_id = ? or organization_id is null) and record_id in (select id from p where (organization_id = ? or organization_id is null)) bindings: 2 find, owner=u1: … and record_id in (select id from p where (…tenant…) and owner = ?) bindings: 3 $not, untenanted: … where not (((record_id is not null) and (record_id in (select id from p)))) bindings: 0 ``` - The form binds **no per-id parameter**: two walls (outer and subquery) plus the scope's own comparands. - The E3 pin with 2,400 readable parents binds ≤ 3. - The ceiling moves into the scope, as the ADR's consequences section says. A scope carrying a pre-resolved id list (`rlsMembership`, record shares) still binds its ids, now inside the subquery. The measured and documented ceilings: - **SQLite: measured.** better-sqlite3's bundled SQLite 3.53.4 accepts 32,766 bound variables and refuses 32,767 with `too many SQL variables`. - **Postgres, MySQL: NOT MEASURED.** This container has no server. The documented ceiling is 65,535 for both. The live PG and MySQL cells of this suite are declared unprovisioned locally. ## Verification record (measured at the commits named below; the PR head is `52dc068bfc`) - **Driver conformance ledger**, the lane's standing reading: - before (`23e689dc7d`): `OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt` - after: `OK — 55 covered cell(s), 0 in the DEBT ledger, 0 exempt`. The new suite runs the matrix (`9 conformance suite(s) … 8 run the matrix`). - Zero ledger rows were added. - **Tests**, each run as `pnpm --filter PKG test`: | Package | Passed | Skipped | |:--|--:|--:| | core | 2,482 | 0 | | driver-sql | 3,659 | 208 (the live cells) | | driver-turso | 2,399 | 33 | | driver-sqlite-wasm | 685 | 0 | | driver-memory | 1,732 | 0 | | driver-mongodb | 702 | 182 (mongod opt-in) | The spec files `readable-ids-conformance.test.ts` and `readable-ids.test.ts` pass 47/47. - **Typecheck:** `pnpm --filter PKG typecheck` exits 0 for core, driver-sql, driver-sqlite-wasm, driver-turso, driver-memory and driver-mongodb. driver-mongodb's test layer needed `@objectstack/objectql` built first; locally its `dist` had no `.d.ts`. - **Spec:** `pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated`: 12 of 14 were up to date, and the 2 stale ones were regenerated. - **Gate families** from `node scripts/pm/dispatch-gates.mjs --commands`: all 111 derived families were run and reconciled with `--ran` (`111 derived famil(ies) accounted for — 110 run, 1 NOT-MEASURED`). Two results need saying: - **`pnpm check:query-options-erasure` was red** on the first pass: the new suites carried `as any` on query arguments, and the test-surface count went 236 → 242. Fixed by typing the queries in `813e08cd73`, then re-run green. - **`pnpm check:dual-build-cjs-loads` is NOT MEASURED.** It exits 3 with `PREREQUISITE NOT MET` because it reads every package's `dist`, which needs a full-repo build. CI runs it. - The list was taken at `813e08cd73`. On that head I re-ran driver-conformance, query-options-erasure, test-source-alias, cross-package-test-inputs, type-check-coverage, nul-bytes and doc-authoring, all green. - `origin/main` was then merged in (`52dc068bfc`) because `packages/spec` had moved. On the merged tree: spec rebuilt, `check:generated` reports all 14 artifacts up to date, the `driver-sqlite-wasm` suite (the package main touched) passes 695/695, the five new suites are green again, the driver conformance gate holds at `55 covered, 0 DEBT`, and the derived gate list is unchanged. - **Size:** 1,806 changed lines (1,777 additions + 29 deletions), generated files included. ## Acceptance notes - `driver-mongodb` has no injectable logger, and its refusal is raised inside the pure `translateFilter`. So the once-per-driver `error` line goes to `console.error` once per process. The caller still sees every refusal. - The Turso remote face resolves the leaf on its write doors (`updateMany` / `deleteMany`) too, matching the local face, which compiles wherever `applyFilters` runs. The engine refuses the leaf on writes before any driver is asked (D1). - `driver-mongodb`'s suite runs server-free at `translateFilter`, which every door and the aggregation pipeline call before any I/O. The mongod-gated suites in that package are opt-in (`test-mongod.ts`). --- _Generated by [Claude Code](https://claude.ai/code/session_01B1r5eJEPgvn7bUsfBvK2tS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 57994c3 commit 462dff3

21 files changed

Lines changed: 1779 additions & 29 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': minor
4+
'@objectstack/driver-sql': minor
5+
'@objectstack/driver-sqlite-wasm': minor
6+
'@objectstack/driver-turso': minor
7+
'@objectstack/driver-memory': minor
8+
'@objectstack/driver-mongodb': minor
9+
---
10+
11+
feat(drivers): the five drivers compile the composed `ReadableIds` leaf or refuse it with one envelope, and the leaf's conformance case-set lands with the suites that run it (ADR-0139 D3)
12+
13+
Clause-②: yes
14+
15+
The engine already composes every `ReadableIds` leaf at its executor boundary (ADR-0139 D2). Until now no driver evaluated the composed leaf; each driver now either compiles it or refuses it.
16+
17+
- **`@objectstack/driver-sql`** compiles `{ field: { $in: <composed leaf> } }` to `field IN (SELECT id FROM <parent table> WHERE <scope> AND <tenant wall>)`. The subquery's tenant wall is `applyTenantScope` on the parent table with the outer read's own options. `find`, `count`, `aggregate` and the other read doors issue one statement, so they agree. The statement binds only the scope's own values and the two walls, never one parameter per readable parent: a reader with more than 2,000 readable parents gets every row they may see. `applyFilters` and `applyFilterCondition` (both `protected`) take the read's `DriverOptions` as a new optional last parameter, which the subquery needs.
18+
- **`@objectstack/driver-sqlite-wasm`** and the local face of **`@objectstack/driver-turso`** inherit that compile. **Turso's remote face** has a compiler of its own. `TursoDriver` resolves the leaf into the parent's remote table, its scope and its tenant wall, using the same chokepoint, and `RemoteTransport` compiles the same semi-join.
19+
- **`@objectstack/driver-memory`** evaluates the leaf in process. It reads the parent under the composed scope, then tests membership. It owes no tenant wall, because it refuses every tenant-scoped call.
20+
- **`@objectstack/driver-mongodb`** refuses the leaf, composed or not, until a pipeline form is measured (ADR-0139 open item O3).
21+
- **The refusal is the same on every driver**: `INTERNAL_ERROR` / 500, never `INVALID_FILTER`. A driver gives it for an uncomposed leaf at any position (the direct driver call that bypassed the engine), a composed leaf outside a `$in` comparand, and every leaf on `driver-mongodb`. The message names the driver, the leaf and the remedy. It is logged at `error` once per driver. No `DriverCapabilities` bit is added.
22+
- **`@objectstack/core`** exports the shared pieces: `findMisplacedReadableIds`, `replaceComposedReadableIds`, `readableIdsDriverRefusal`, `refuseReadableIdsOnce` and the `MisplacedReadableIds`, `ReadableIdsDriverRefusalError` and `ReadableIdsRefusalLog` types.
23+
- **`@objectstack/spec`** exports the leaf's driver conformance case-set from `@objectstack/spec/data`: `READABLE_IDS_CASES`, the fixture (`READABLE_IDS_PARENT_OBJECT`, `READABLE_IDS_CHILD_OBJECT`, `READABLE_IDS_PARENT_ROWS`, `READABLE_IDS_CHILD_ROWS`), the refusal envelope `READABLE_IDS_REFUSAL`, and their types. Every driver package runs the case-set, and the driver conformance gate records that with no ledger rows.

‎packages/core/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ export * from './utils/multi-value-storage-form.js';
163163
// evaluates itself — one set and one sentence, here for the reason the entry
164164
// above gives.
165165
export * from './utils/json-column-operator-refusal.js';
166+
export * from './utils/readable-ids-driver-refusal.js';
166167

167168
// [#12350 / ADR-0126 §4] THE activation-ledger row contract, parameterized by
168169
// `metadata_type`. Same reason as the two entries above: its consumers —
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0139 D3] The shared walk, rewrite and refusal every driver uses for a
5+
* `ReadableIds` leaf. The drivers' own conformance suites
6+
* (`READABLE_IDS_CASES`) measure what each compiles; this pins the three
7+
* pieces they all stand on: which leaf the walk finds, that the rewrite never
8+
* edits the caller's tree, and the envelope.
9+
*/
10+
11+
import { describe, it, expect, vi } from 'vitest';
12+
import { ReadableIds, filterSubtreeProvenanceOf, markFilterSubtreeProvenance } from '@objectstack/spec/data';
13+
import {
14+
findMisplacedReadableIds,
15+
readableIdsDriverRefusal,
16+
refuseReadableIdsOnce,
17+
replaceComposedReadableIds,
18+
} from './readable-ids-driver-refusal.js';
19+
20+
const composed = () => ReadableIds.compose(ReadableIds.of('p'), { owner: 'u1' });
21+
22+
describe('findMisplacedReadableIds', () => {
23+
it('admits a composed $in comparand on a compiling driver, at any depth', () => {
24+
expect(findMisplacedReadableIds({ $or: [{ a: 1 }, { $not: { r: { $in: composed() } } }] }, true)).toBeNull();
25+
});
26+
27+
it('finds an uncomposed leaf at any position, on any driver', () => {
28+
expect(findMisplacedReadableIds({ $and: [{ r: { $in: ReadableIds.of('p') } }] }, true)).toEqual({
29+
at: 'where.$and[0].r.$in',
30+
object: 'p',
31+
kind: 'uncomposed',
32+
});
33+
});
34+
35+
it('finds a composed leaf outside a $in comparand', () => {
36+
expect(findMisplacedReadableIds({ r: { $nin: composed() } }, true)?.kind).toBe('misplaced');
37+
expect(findMisplacedReadableIds({ r: composed() }, true)?.kind).toBe('misplaced');
38+
});
39+
40+
it('finds every composed leaf on a refusing driver', () => {
41+
expect(findMisplacedReadableIds({ r: { $in: composed() } }, false)?.kind).toBe('unsupported');
42+
});
43+
44+
it('answers null for a tree with no leaf, cycles included', () => {
45+
const node: Record<string, unknown> = { a: { $in: [1, 2] } };
46+
node.self = { $and: [node] };
47+
expect(findMisplacedReadableIds(node, true)).toBeNull();
48+
});
49+
});
50+
51+
describe('replaceComposedReadableIds', () => {
52+
it('rewrites copy-on-write, carrying the provenance mark of the node it replaces', () => {
53+
const leaf = composed();
54+
const branch = markFilterSubtreeProvenance({ r: { $in: leaf } }, 'author');
55+
const untouched = { b: 2 };
56+
const where = { $and: [branch, untouched] };
57+
const out = replaceComposedReadableIds(where, () => ['p1']) as any;
58+
expect(out).toEqual({ $and: [{ r: { $in: ['p1'] } }, { b: 2 }] });
59+
expect(where.$and[0]).toBe(branch);
60+
expect((branch as any).r.$in).toBe(leaf);
61+
expect(out.$and[1]).toBe(untouched);
62+
expect(filterSubtreeProvenanceOf(out.$and[0])).toBe('author');
63+
});
64+
65+
it('returns a tree with no leaf by reference', () => {
66+
const where = { a: { $in: [1] } };
67+
expect(replaceComposedReadableIds(where, () => [])).toBe(where);
68+
});
69+
});
70+
71+
describe('the refusal', () => {
72+
it('is INTERNAL_ERROR / 500, never INVALID_FILTER, and names the driver and the leaf', () => {
73+
const err = readableIdsDriverRefusal('driver-x', { at: 'where.r.$in', object: 'p', kind: 'uncomposed' });
74+
expect(err).toMatchObject({ code: 'INTERNAL_ERROR', status: 500, httpStatus: 500 });
75+
expect(err.message).toMatch(/^\[driver-x\] where\.r\.\$in holds a ReadableIds leaf/);
76+
});
77+
78+
it('is logged at error once per state, and returned every time', () => {
79+
const log = { error: vi.fn(), warn: vi.fn() };
80+
const state = { logged: false };
81+
const site = { at: 'where.r.$in', object: 'p', kind: 'unsupported' } as const;
82+
const a = refuseReadableIdsOnce('driver-x', site, log, state);
83+
const b = refuseReadableIdsOnce('driver-x', site, log, state);
84+
expect([a.code, b.code]).toEqual(['INTERNAL_ERROR', 'INTERNAL_ERROR']);
85+
expect(log.error).toHaveBeenCalledTimes(1);
86+
expect(log.warn).not.toHaveBeenCalled();
87+
});
88+
});
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
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

Comments
 (0)