Skip to content

Commit 9814035

Browse files
feat(spec,objectql): ReadableIds, the branded $in comparand leaf no request can spell, refused by the engine until composed (ADR-0139 D1, E1) (#22809)
Fixes #22787 Clause-②: yes (widening) ADR-0139 E1: the `ReadableIds` leaf is declared in `@objectstack/spec`, no request can spell it, and the engine's comparand door admits the branded value and refuses the unbranded one. Until E2 composes the leaf, the engine refuses an admitted leaf with `INTERNAL_ERROR` / 500 before any driver is called, so no branded leaf reaches a driver through the door uncompiled. ## What lands - **`packages/spec/src/data/readable-ids.ts`** (exported from the data barrel): - `READABLE_IDS_BRAND`: `Symbol.for('objectstack.filter.readableIds')`. - `ReadableIds`: an interface with `readonly object: string` and the brand, so an object literal does not type-check as one. It is also a frozen const with `of(object)`. - `isReadableIds(value)`: answers true only for the brand as an own key with the value `true`, on a non-array object whose `object` is a non-empty string. - `ReadableIds.of(object)` returns a frozen `{ object }`. The brand is defined non-enumerable, non-writable and non-configurable, the same way `markFilterSubtreeProvenance` defines its mark. JSON, `structuredClone`, a spread and `Object.assign` all produce an unbranded `{ object }`, and the tests check each one. `of()` throws a `TypeError` for an object name that is not a non-empty string. - The leaf carries only the object name. That is all D1 declares; what D2 needs is left to E2. - **`packages/spec/src/data/filter-comparand-shape.ts`**: `assertListComparandShapes(node, context?, path?, options?)` takes a new optional `ListComparandShapeOptions`. With `{ admitReadableIds: true }`, a branded leaf is admitted as the comparand of `$in`, and nowhere else (`$nin` admits nothing). Without the option the leaf is refused as a non-list, exactly as before. Every existing caller passes no option. An unbranded copy is refused `INVALID_FILTER` / 400 even when the option is set. - **`packages/objectql/src/filter-comparand-shape.ts`**: the engine's binding of the door is the one caller that passes the option. Right after the door, `refuseUncomposedReadableIds` walks the node. If it finds a branded leaf anywhere (the `$in` comparand, implicit equality, `$eq`, a list member, a `$between` endpoint or a nested-relation condition), it throws `INTERNAL_ERROR` / 500. The message names the position, the leaf's object, and that no driver was asked. The binding is called by all six verbs' `where`, by an aggregation's `filter` and by `having`. **This is the refusal E2 replaces with composition**: E2 drops it at the read-verb `$in` position and keeps it everywhere else, as D1's last paragraph says. - No operator is added to `FieldOperatorsSchema` or `VALID_AST_OPERATORS`. The schema doors refuse `{ $in: leaf }` (pinned). - The `api-surface/data.json` and `export-origins/data.json` diffs are generator output (`check:generated --fix`): five added names and nothing else. - Two ADR-0139 anchors are added (`scripts/adr-anchors/`), one each for `readable-ids.ts` and objectql's `filter-comparand-shape.ts`. ## The window between E1 and E2 What each driver does with a leaf-shaped (non-array object) `$in` comparand, handed to it directly. Measured at `a2e94c2a05` with the built drivers: plain, under `$or`, under `$not`, and as the comparand of `$nin`. | driver | answer | |:--|:--| | `driver-memory` | raw `TypeError: input[(input.length - 1)] is not iterable`, with no `code` and no `status` | | `driver-sql` (better-sqlite3) | `500 DATABASE_ERROR`, a syntax error at the bound `?` | | `driver-mongodb` `translateFilter` | emitted unchanged as `{ $in: { object } }` (`$nor` under `$not`). What a live server answers is not measured. | None of them widened a read, and none answered the way the leaf means. So the door's admission is paired with the engine-side refusal above. Ablation AB2 measures the window that refusal closes: with the refusal deleted, a branded `$in` leaf passes the door and the read resolves against the driver. Where the door is (PM item 2): `assertListComparandShapes` in spec is the rule's only implementation. objectql's function of the same name is a wrapper that delegates to it and adds the `find('x')` prefix. The engine calls the wrapper at three sites (`lowerWhereFilterArray`'s object branch, `aggregations[i].filter` and `having`). The spec door is also called by `parseFilterAST` (the authoring lowering, which includes the engine's array branch), the save door, the analytics `where` and read-scope doors, the RLS compiler, the lint rule and memory analytics. It is the same check at every one of those callers, not a separate ingress-time check. That is why admission is opt-in: if it were universal, `parseFilterAST` and memory analytics would let the leaf through to a driver. ## Tests (head `5a4e282ffb`) - `packages/spec/src/data/readable-ids.test.ts`: 15 tests (the declaration, the copies and forgeries, and the door with and without the option). - `packages/objectql/src/engine-readable-ids-door.test.ts`: 7 tests, with a recording driver. - An unbranded copy (JSON round-trip, structured clone, spread) and the plain-object spelling are refused `INVALID_FILTER` / 400 on every read verb. - A branded leaf is refused `INTERNAL_ERROR` / 500 under `$and` / `$or` / `$not`, on all six verbs, in an aggregation's `filter`, and at five positions the shape door does not judge. - Every refusal is checked to record zero driver calls. - A lit control checks that a list comparand does reach the driver. - Full suites, after the merge of `origin/main` at `5fc57b382e`: - `@objectstack/spec` test: 647 files, 19,327 passed. - `@objectstack/objectql` test: 399 files, 7,802 passed (run at `dea2c0abc1`; the merge that followed brought no objectql change). - `typecheck` passed for both packages, `check:test-typecheck` included. **Ablations.** Each one went through `scripts/ablation-replace.mjs`, which checks on disk that the anchor hit and that the file was restored, plus a trap. Spec mutations were rebuilt and checked with `scripts/ablation-dist-preflight.mjs`: marker present in `dist/` while mutated, absent after the restore rebuild, tree clean. The red/green pattern was predicted before each run. | ablation | spec suite | objectql suite | |:--|:--|:--| | AB1: door admission disabled (`... && op === 'ABLATED_AB1'`) | 1 red / 14 green (the "admits" pin) | 2 red / 5 green (the branded pins get 400 where 500 is expected) | | AB2: `refuseUncomposedReadableIds(...)` call deleted | not run (objectql source only) | 3 red / 4 green (the branded `$in` read resolves; other positions get 400) | | AB3: guard accepts by shape, brand check removed | 6 red / 9 green (copies, forgeries, JSON round-trip, clone, plain spelling) | 3 red / 4 green (JSON round-trip, clone/spread and the plain spelling get 500 where 400 is expected) | Every run matched its prediction. The first AB3 attempt was a no-op and is not counted: its replacement kept the anchor text, so the tool refused the mutation (anchor 1 → 1) before running anything. It was rerun with an anchor the mutation removes. **Gates.** `dispatch-gates --commands` was derived on this diff and run in full on `5a4e282ffb` (the gate list is in the report on the card). Green includes `check:generated` (all 14 artifacts), `check:adr-anchors`, `check:doc-authoring`, `check:nul-bytes`, `check:engine-double-contract`, `check:driver-memory-census`, `check:query-options-erasure`, `check-spec-docblock-symbol-anchors` and `check-adr-0087-registration`. That last one reads "this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)": a widening needs no disposition marker. Not measured: `check:dual-build-cjs-loads` (PREREQUISITE NOT MET, it needs every package's `dist/`) and `check:type-check-debt` (its full build was killed at `plugin-audit#build`, exit 137, and the gate exited 3). Both are left to CI. Lint was narrowed and the narrowing is proven: - **Population:** the config's lintable population is `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`. - **What ran:** `eslint --no-inline-config --format json` over the 6 lintable files in the diff: 6 files, 0 errors, 0 warnings, at `5a4e282ffb`. - **Why that covers the rest:** the config enables no type-aware linting (no `parserOptions.project`) and reads only two baseline files, neither touched here, so this diff cannot change the verdict on any file it does not touch. ## Acceptance notes - **For E2, the D2 seam comes before the D4 emitters.** `engine.ts#find` runs `resolveWhereTokens`, which includes `lowerRelationConditions` (the seam D2 names), before `executeWithMiddleware`. The D4 consumers write their filter into `ctx.ast.where` from inside that chain (`activity-read-visibility.ts#installActivityReadVisibility` → `engine.registerMiddleware`). A leaf a consumer emits there therefore never meets this door or that seam. E2 has to compose after the middleware chain, or the emitters have to write before the seam. This is reported, not built: it needs a change in `engine.ts` beyond this file. - **For E2, the array branch.** The array-form `where` branch of `lowerWhereFilterArray` reaches the spec door only through `parseFilterAST`, which does not opt in, so a `$in` leaf there is refused 400. A leaf at another position in the array form never meets `refuseUncomposedReadableIds`. D1's position refusal has to cover that lowered condition too. - **For E3 and E4.** The direct-driver answers in the table above are the shapes that D2's "refused by the driver as uncomposed" replaces. - #22788 remains open; this PR delivers its E1 line only. --- _Generated by [Claude Code](https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 27f0d83 commit 9814035

11 files changed

Lines changed: 699 additions & 4 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
---
5+
6+
feat(spec): `ReadableIds`, the branded `$in` comparand leaf meaning "the ids of object X this caller may read" (ADR-0139 D1)
7+
8+
Clause-②: yes (widening)
9+
10+
- **What is new in `@objectstack/spec/data`.** `ReadableIds.of(object)` builds a frozen leaf, `{ object }`, placed as the comparand of `$in` on a pointer field: `{ record_id: { $in: ReadableIds.of('cpz_doc') } }`. `isReadableIds(value)` answers whether a value is one. `READABLE_IDS_BRAND` is the `Symbol.for('objectstack.filter.readableIds')` key the brand lives under. The `ReadableIds` type carries the brand, so an object literal does not type-check as one. `ReadableIds.of()` throws a `TypeError` for an object name that is not a non-empty string.
11+
- **No request can spell it.** No filter operator is added. The brand is a non-enumerable symbol-keyed property, so JSON, a structured clone or a spread produces an unbranded `{ object }`, and a plain object as the comparand of `$in` is refused `INVALID_FILTER` / 400, as it was before.
12+
- **The comparand-shape door admits it only for a caller that composes it.** `assertListComparandShapes` takes an optional fourth parameter, `ListComparandShapeOptions`. With `{ admitReadableIds: true }` a branded leaf is admitted as the comparand of `$in`, and nowhere else. Without it, which is every existing caller (`parseFilterAST`, the save door, the analytics doors, the RLS compiler), the leaf is refused as a non-list comparand, unchanged.
13+
- **`@objectstack/objectql` does not compose the leaf yet.** The engine's comparand door passes the option, then refuses a branded leaf found anywhere in a `where`, an aggregation's `filter` or a `having` with `INTERNAL_ERROR` / 500, before any driver is asked. Do not emit the leaf until the engine composes it. Until then, read the parent object as the caller and match the ids that read returns with a list.
14+
- Nothing emits the leaf yet, and no read that ran before this change answers differently.
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0139 D1, E1] The engine's comparand door and a `ReadableIds` leaf.
5+
*
6+
* Three answers, each before any driver is asked:
7+
*
8+
* - **An unbranded copy is refused `INVALID_FILTER` / 400** — a `where` holding
9+
* the leaf after a JSON round-trip or a structured clone, and the
10+
* plain-object spelling a request could write, are a plain object in a list
11+
* slot. That refusal existed before the leaf did; it is THE refusal.
12+
* - **A branded leaf passes the door and is refused `INTERNAL_ERROR` / 500** —
13+
* the engine does not compose the leaf yet (ADR-0139 D2), and no driver
14+
* evaluates it as written: measured at `a2e94c2a05` with the leaf handed
15+
* straight to each driver, `driver-memory` threw a raw `TypeError`,
16+
* `driver-sql` (SQLite) answered `500 DATABASE_ERROR` and
17+
* `driver-mongodb`'s `translateFilter` emitted it unchanged. So the leaf
18+
* stops at the door's seam, on every verb and at every position.
19+
* - **A list keeps working** — the lit control: the recording driver IS
20+
* reachable, so a refusal's "no read" means the refusal, not a dead harness.
21+
*
22+
* The recording driver is this package's witness shape (the in-memory
23+
* driver's test consumers are a closed census): the door answers before a
24+
* driver is resolved, so what matters is that no read is recorded.
25+
*/
26+
27+
import { describe, it, expect, beforeEach } from 'vitest';
28+
import { ReadableIds } from '@objectstack/spec/data';
29+
import { ObjectQL } from './engine.js';
30+
31+
interface SeenCall { verb: string; object: string; ast: any }
32+
33+
function makeRecordingDriver() {
34+
const calls: SeenCall[] = [];
35+
const driver: any = {
36+
name: 'recording', version: '0.0.0', supports: {},
37+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
38+
async find(object: string, ast: any) { calls.push({ verb: 'find', object, ast }); return []; },
39+
async findOne(object: string, ast: any) { calls.push({ verb: 'findOne', object, ast }); return null; },
40+
async count(object: string, ast: any) { calls.push({ verb: 'count', object, ast }); return 0; },
41+
async aggregate(object: string, ast: any) { calls.push({ verb: 'aggregate', object, ast }); return []; },
42+
async create(object: string, data: Record<string, unknown>) { calls.push({ verb: 'create', object, ast: data }); return data; },
43+
async update(object: string, _id: string, data: Record<string, unknown>) { calls.push({ verb: 'update', object, ast: data }); return data; },
44+
async updateMany(object: string, ast: any) { calls.push({ verb: 'updateMany', object, ast }); return 0; },
45+
async delete(object: string) { calls.push({ verb: 'delete', object, ast: null }); return true; },
46+
async deleteMany(object: string, ast: any) { calls.push({ verb: 'deleteMany', object, ast }); return 0; },
47+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
48+
async commit() {}, async rollback() {},
49+
};
50+
return { driver, calls };
51+
}
52+
53+
const CHILD = {
54+
name: 'rid_child',
55+
label: 'ReadableIds probe child',
56+
fields: {
57+
object_name: { name: 'object_name', type: 'text' },
58+
record_id: { name: 'record_id', type: 'text' },
59+
note: { name: 'note', type: 'text' },
60+
amount: { name: 'amount', type: 'number' },
61+
parent: { name: 'parent', type: 'lookup', reference: 'rid_parent' },
62+
},
63+
};
64+
const PARENT = {
65+
name: 'rid_parent',
66+
label: 'ReadableIds probe parent',
67+
fields: { title: { name: 'title', type: 'text' } },
68+
};
69+
70+
type Thrown = (Error & { code?: string; status?: number }) | null;
71+
const refusalOf = async (p: Promise<unknown>): Promise<Thrown> => p.then(() => null, (e: any) => e);
72+
const envelopeOf = (err: Thrown) => ({ code: err?.code, status: err?.status });
73+
74+
const INVALID_FILTER = { code: 'INVALID_FILTER', status: 400 };
75+
const UNCOMPOSED = { code: 'INTERNAL_ERROR', status: 500 };
76+
77+
describe('[ADR-0139 D1] the engine comparand door and a ReadableIds leaf', () => {
78+
let engine: ObjectQL;
79+
let calls: SeenCall[];
80+
81+
beforeEach(async () => {
82+
const rec = makeRecordingDriver();
83+
calls = rec.calls;
84+
engine = new ObjectQL();
85+
engine.registerDriver(rec.driver, true);
86+
await engine.init();
87+
engine.registry.registerObject(PARENT as any, 'test');
88+
engine.registry.registerObject(CHILD as any, 'test');
89+
calls.length = 0;
90+
});
91+
92+
it('a JSON round-trip of a where holding the leaf is refused INVALID_FILTER / 400 — no driver call', async () => {
93+
const where = { object_name: 'rid_parent', record_id: { $in: ReadableIds.of('rid_parent') } };
94+
const wire = JSON.parse(JSON.stringify(where));
95+
const err = await refusalOf(engine.find('rid_child', { where: wire }));
96+
expect(envelopeOf(err)).toEqual(INVALID_FILTER);
97+
expect(err!.message).toMatch(/^find\('rid_child'\): Operator "\$in" on field "record_id" requires an ARRAY of values\. Received object/);
98+
expect(calls, 'the refusal precedes the driver').toHaveLength(0);
99+
});
100+
101+
it('a structured clone and a spread of the leaf are refused INVALID_FILTER / 400 — no driver call', async () => {
102+
const leaf = ReadableIds.of('rid_parent');
103+
for (const copy of [structuredClone(leaf), { ...leaf }]) {
104+
const err = await refusalOf(engine.find('rid_child', { where: { record_id: { $in: copy } } }));
105+
expect(envelopeOf(err)).toEqual(INVALID_FILTER);
106+
}
107+
expect(calls).toHaveLength(0);
108+
});
109+
110+
it('the plain-object spelling is refused INVALID_FILTER / 400 on every read verb — no driver call', async () => {
111+
const where = { record_id: { $in: { object: 'rid_parent' } } };
112+
const answers = await Promise.all([
113+
refusalOf(engine.find('rid_child', { where })),
114+
refusalOf(engine.findOne('rid_child', { where })),
115+
refusalOf(engine.count('rid_child', { where })),
116+
refusalOf(engine.aggregate('rid_child', { where, aggregations: [{ function: 'count', alias: 'n' }] })),
117+
]);
118+
for (const err of answers) expect(envelopeOf(err)).toEqual(INVALID_FILTER);
119+
expect(calls).toHaveLength(0);
120+
});
121+
122+
it('a branded leaf passes the door and is refused INTERNAL_ERROR / 500 as uncomposed, naming its position and object — no driver call', async () => {
123+
const err = await refusalOf(engine.find('rid_child', {
124+
where: { object_name: 'rid_parent', record_id: { $in: ReadableIds.of('rid_parent') } },
125+
}));
126+
expect(envelopeOf(err)).toEqual(UNCOMPOSED);
127+
expect(err!.message).toMatch(
128+
/^find\('rid_child'\): where\.record_id\.\$in holds a ReadableIds leaf, "the ids of 'rid_parent' this caller may read", and this engine does not compose that leaf yet\. The query was NOT run and no driver was asked/,
129+
);
130+
expect(calls, 'the leaf never reaches a driver').toHaveLength(0);
131+
});
132+
133+
it('the uncomposed leaf is refused under $and / $or / $not and on every verb — no driver call', async () => {
134+
const leaf = () => ReadableIds.of('rid_parent');
135+
const cases: ReadonlyArray<readonly [string, () => Promise<unknown>, string]> = [
136+
['find $or', () => engine.find('rid_child', { where: { $or: [{ note: 'x' }, { record_id: { $in: leaf() } }] } }), 'where.$or[1].record_id.$in'],
137+
['find $not', () => engine.find('rid_child', { where: { $not: { record_id: { $in: leaf() } } } }), 'where.$not.record_id.$in'],
138+
['find $and', () => engine.find('rid_child', { where: { $and: [{ object_name: 'rid_parent' }, { record_id: { $in: leaf() } }] } }), 'where.$and[1].record_id.$in'],
139+
['findOne', () => engine.findOne('rid_child', { where: { record_id: { $in: leaf() } } }), 'where.record_id.$in'],
140+
['count', () => engine.count('rid_child', { where: { record_id: { $in: leaf() } } }), 'where.record_id.$in'],
141+
['aggregate where', () => engine.aggregate('rid_child', { where: { record_id: { $in: leaf() } }, aggregations: [{ function: 'count', alias: 'n' }] }), 'where.record_id.$in'],
142+
['aggregate aggregation filter', () => engine.aggregate('rid_child', { aggregations: [{ function: 'count', alias: 'n', filter: { record_id: { $in: leaf() } } }] }), 'aggregations[0].filter.record_id.$in'],
143+
['update multi', () => engine.update('rid_child', { note: 'y' }, { where: { record_id: { $in: leaf() } }, multi: true }), 'where.record_id.$in'],
144+
['delete multi', () => engine.delete('rid_child', { where: { record_id: { $in: leaf() } }, multi: true }), 'where.record_id.$in'],
145+
];
146+
for (const [label, run, at] of cases) {
147+
const err = await refusalOf(run());
148+
expect(envelopeOf(err), label).toEqual(UNCOMPOSED);
149+
expect(err!.message, label).toContain(`: ${at} holds a ReadableIds leaf`);
150+
}
151+
expect(calls, 'no verb reached the driver').toHaveLength(0);
152+
});
153+
154+
it('a leaf at a position the shape door does not judge is refused too — equality, a list member, a range endpoint, a nested-relation condition', async () => {
155+
const leaf = () => ReadableIds.of('rid_parent');
156+
const cases: ReadonlyArray<readonly [Record<string, unknown>, string]> = [
157+
[{ record_id: leaf() }, 'where.record_id'],
158+
[{ record_id: { $eq: leaf() } }, 'where.record_id.$eq'],
159+
[{ record_id: { $in: ['p1', leaf()] } }, 'where.record_id.$in[1]'],
160+
[{ amount: { $between: [1, leaf()] } }, 'where.amount.$between[1]'],
161+
[{ parent: { title: leaf() } }, 'where.parent.title'],
162+
];
163+
for (const [where, at] of cases) {
164+
const err = await refusalOf(engine.find('rid_child', { where }));
165+
expect(envelopeOf(err), at).toEqual(UNCOMPOSED);
166+
expect(err!.message, at).toContain(`: ${at} holds a ReadableIds leaf`);
167+
}
168+
expect(calls).toHaveLength(0);
169+
});
170+
171+
it('LIT CONTROL — a list comparand on the same field reaches the driver', async () => {
172+
await engine.find('rid_child', { where: { record_id: { $in: ['p1', 'p2'] } } });
173+
expect(calls.map((c) => c.verb)).toEqual(['find']);
174+
expect(calls[0].ast.where).toEqual({ record_id: { $in: ['p1', 'p2'] } });
175+
});
176+
});

‎packages/objectql/src/filter-comparand-shape.ts‎

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ import {
4343
assertListComparandShapes as assertListComparandShapesAt,
4444
isVirtualSearchField,
4545
classifyDottedFilterHead,
46+
isReadableIds,
47+
type ReadableIds,
4648
} from '@objectstack/spec/data';
4749

4850
/**
@@ -99,14 +101,129 @@ export function invalidFilterError(message: string): Error {
99101
* Read-only and allocation-free on the overwhelmingly common path (a filter
100102
* with no list operator walks its own keys and returns). Runs on every engine
101103
* read and write, so it stays a walk rather than a schema parse.
104+
*
105+
* [ADR-0139 D1] This is the one caller that passes `admitReadableIds`: the
106+
* engine's seam is where a branded `ReadableIds` leaf is meant to be composed,
107+
* so the door admits it as a `$in` comparand here and nowhere else. Until the
108+
* engine composes the leaf (ADR-0139 D2), {@link refuseUncomposedReadableIds}
109+
* runs right after the door and refuses every branded leaf in the node, so an
110+
* admitted leaf never reaches a driver as written. An UNBRANDED copy never gets
111+
* that far: the door refuses it as a non-list comparand (`INVALID_FILTER` / 400).
102112
*/
103113
export function assertListComparandShapes(
104114
object: string,
105115
operation: string,
106116
node: unknown,
107117
path = 'where',
108118
): void {
109-
assertListComparandShapesAt(node, `${operation}('${object}')`, path);
119+
assertListComparandShapesAt(node, `${operation}('${object}')`, path, { admitReadableIds: true });
120+
refuseUncomposedReadableIds(object, operation, node, path);
121+
}
122+
123+
/**
124+
* [ADR-0139 D1, E1] Refuse every branded `ReadableIds` leaf in `node`, at any
125+
* position, before a driver is asked — the engine does not compose the leaf
126+
* yet.
127+
*
128+
* ## Why a refusal, and why here
129+
*
130+
* The comparand door above admits a branded leaf as `$in`'s comparand, because
131+
* the engine's seam is where it is to be composed (ADR-0139 D2). No composition
132+
* exists yet, and no driver evaluates the leaf as written. Measured on
133+
* `a2e94c2a05`, with the leaf handed straight to each driver as `$in`'s
134+
* comparand, plain and under `$or` / `$not`, and as `$nin`'s:
135+
*
136+
* | driver | answer |
137+
* |:--|:--|
138+
* | `driver-memory` | a raw `TypeError` from the matcher, no `code`, no `status` |
139+
* | `driver-sql` (SQLite) | `500 DATABASE_ERROR`, a syntax error at the bound `?` |
140+
* | `driver-mongodb` (`translateFilter`) | emitted unchanged as `{ $in: { object } }`; the server's answer is not measured |
141+
*
142+
* None widened a read, and none answered as the leaf means. So the leaf stops
143+
* HERE, on every verb and at every position, with the composition-fault
144+
* envelope ADR-0139 D3 gives a driver's own refusal: `INTERNAL_ERROR` / 500,
145+
* ⛔ not `INVALID_FILTER`. No request can spell the leaf, so the fault is the
146+
* in-process code that wrote it, not the caller's filter; and a declared 5xx
147+
* keeps its prose off the wire at the HTTP doors. When the engine composes the
148+
* leaf, the read-verb `$in` position stops being refused here and every other
149+
* position keeps a refusal (ADR-0139 D1's last paragraph).
150+
*
151+
* ## The walk
152+
*
153+
* EVERY position, not only `$in`'s: an implicit-equality value, a `$between`
154+
* endpoint, a list member and a nested-relation condition are all places the
155+
* door does not judge a plain object, and a leaf there must not reach a driver
156+
* either. Arrays and plain objects are descended into; a `Date` or class
157+
* instance holds no filter. `onPath` stops a self-referential in-process
158+
* `where` instead of overflowing the stack.
159+
*/
160+
function refuseUncomposedReadableIds(
161+
object: string,
162+
operation: string,
163+
node: unknown,
164+
path = 'where',
165+
): void {
166+
const found = findReadableIds(node, path, new Set<object>());
167+
if (found) throw uncomposedReadableIdsError(object, operation, found.leaf, found.at);
168+
}
169+
170+
function findReadableIds(
171+
node: unknown,
172+
at: string,
173+
onPath: Set<object>,
174+
): { leaf: ReadableIds; at: string } | undefined {
175+
if (node === null || typeof node !== 'object') return undefined;
176+
if (isReadableIds(node)) return { leaf: node, at };
177+
if (onPath.has(node)) return undefined;
178+
const isArray = Array.isArray(node);
179+
if (!isArray) {
180+
const proto = Object.getPrototypeOf(node);
181+
if (proto !== Object.prototype && proto !== null) return undefined;
182+
}
183+
onPath.add(node);
184+
try {
185+
if (isArray) {
186+
for (let i = 0; i < (node as unknown[]).length; i++) {
187+
const hit = findReadableIds((node as unknown[])[i], `${at}[${i}]`, onPath);
188+
if (hit) return hit;
189+
}
190+
} else {
191+
for (const key of Object.keys(node)) {
192+
const hit = findReadableIds((node as Record<string, unknown>)[key], `${at}.${key}`, onPath);
193+
if (hit) return hit;
194+
}
195+
}
196+
} finally {
197+
onPath.delete(node);
198+
}
199+
return undefined;
200+
}
201+
202+
/**
203+
* The composition-fault envelope for an uncomposed `ReadableIds` leaf —
204+
* ADR-0112's `INTERNAL_ERROR` / 500, the code and status ADR-0139 D3 gives a
205+
* driver's refusal of the same leaf. The message is addressed to the
206+
* in-process author who wrote the leaf: where it sits, what it stands for,
207+
* that no driver was asked, and what to write instead today.
208+
*/
209+
function uncomposedReadableIdsError(
210+
object: string,
211+
operation: string,
212+
leaf: ReadableIds,
213+
at: string,
214+
): Error {
215+
const err = new Error(
216+
`${operation}('${object}'): ${at} holds a ReadableIds leaf, "the ids of '${leaf.object}' `
217+
+ 'this caller may read", and this engine does not compose that leaf yet. The query was '
218+
+ 'NOT run and no driver was asked: no driver evaluates the leaf as written. Until the '
219+
+ `engine composes it, read '${leaf.object}' as the caller and match the ids that read `
220+
+ 'returns with a list.',
221+
) as Error & { code?: string; status?: number; httpStatus?: number; object?: string };
222+
err.code = StandardErrorCode.enum.INTERNAL_ERROR;
223+
err.status = 500;
224+
err.httpStatus = 500;
225+
err.object = object;
226+
return err;
110227
}
111228

112229
/**

‎packages/spec/api-surface/data.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,7 @@
421421
"LifecycleClass (type)",
422422
"LifecycleClassSchema (const)",
423423
"LifecycleSchema (const)",
424+
"ListComparandShapeOptions (interface)",
424425
"LiteralDefaultValueVerdict (interface)",
425426
"LocalStoragePersistenceConfig (type)",
426427
"LocalStoragePersistenceConfigSchema (const)",
@@ -599,6 +600,7 @@
599600
"QueryWithTransportParsed (type)",
600601
"QueryWithTransportSchema (const)",
601602
"RAW_FILE_VALUES_CONTEXT_KEY (const)",
603+
"READABLE_IDS_BRAND (const)",
602604
"READ_ONLY_BELONGS_ON_DATASOURCE (const)",
603605
"RECORD_CONTEXT_TOKENS (const)",
604606
"RECORD_SURFACE_PAGE_THRESHOLD (const)",
@@ -608,6 +610,8 @@
608610
"RPC_QUERY_ALIAS_SLOTS (const)",
609611
"RUNTIME_OWNED_FIELD_TYPES (const)",
610612
"RangeOperatorSchema (const)",
613+
"ReadableIds (const)",
614+
"ReadableIds (interface)",
611615
"RecordContextToken (type)",
612616
"RecordFlow (type)",
613617
"RecordFlowContainer (type)",
@@ -883,6 +887,7 @@
883887
"isOrganizationUnique (function)",
884888
"isPlainRecord (function)",
885889
"isPublicSharingEnabled (function)",
890+
"isReadableIds (function)",
886891
"isRecordContextToken (function)",
887892
"isRefusedTextComparand (function)",
888893
"isRuntimeDefaultToken (function)",

0 commit comments

Comments
 (0)