Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/22787-readable-ids-leaf.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@objectstack/spec": minor
"@objectstack/objectql": minor
---

feat(spec): `ReadableIds`, the branded `$in` comparand leaf meaning "the ids of object X this caller may read" (ADR-0139 D1)

Clause-②: yes (widening)

- **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.
- **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.
- **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.
- **`@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.
- Nothing emits the leaf yet, and no read that ran before this change answers differently.
176 changes: 176 additions & 0 deletions packages/objectql/src/engine-readable-ids-door.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [ADR-0139 D1, E1] The engine's comparand door and a `ReadableIds` leaf.
*
* Three answers, each before any driver is asked:
*
* - **An unbranded copy is refused `INVALID_FILTER` / 400** — a `where` holding
* the leaf after a JSON round-trip or a structured clone, and the
* plain-object spelling a request could write, are a plain object in a list
* slot. That refusal existed before the leaf did; it is THE refusal.
* - **A branded leaf passes the door and is refused `INTERNAL_ERROR` / 500** —
* the engine does not compose the leaf yet (ADR-0139 D2), and no driver
* evaluates it as written: measured at `a2e94c2a05` with the leaf handed
* straight to each driver, `driver-memory` threw a raw `TypeError`,
* `driver-sql` (SQLite) answered `500 DATABASE_ERROR` and
* `driver-mongodb`'s `translateFilter` emitted it unchanged. So the leaf
* stops at the door's seam, on every verb and at every position.
* - **A list keeps working** — the lit control: the recording driver IS
* reachable, so a refusal's "no read" means the refusal, not a dead harness.
*
* The recording driver is this package's witness shape (the in-memory
* driver's test consumers are a closed census): the door answers before a
* driver is resolved, so what matters is that no read is recorded.
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { ReadableIds } from '@objectstack/spec/data';
import { ObjectQL } from './engine.js';

interface SeenCall { verb: string; object: string; ast: any }

function makeRecordingDriver() {
const calls: SeenCall[] = [];
const driver: any = {
name: 'recording', version: '0.0.0', supports: {},
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
async find(object: string, ast: any) { calls.push({ verb: 'find', object, ast }); return []; },
async findOne(object: string, ast: any) { calls.push({ verb: 'findOne', object, ast }); return null; },
async count(object: string, ast: any) { calls.push({ verb: 'count', object, ast }); return 0; },
async aggregate(object: string, ast: any) { calls.push({ verb: 'aggregate', object, ast }); return []; },
async create(object: string, data: Record<string, unknown>) { calls.push({ verb: 'create', object, ast: data }); return data; },
async update(object: string, _id: string, data: Record<string, unknown>) { calls.push({ verb: 'update', object, ast: data }); return data; },
async updateMany(object: string, ast: any) { calls.push({ verb: 'updateMany', object, ast }); return 0; },
async delete(object: string) { calls.push({ verb: 'delete', object, ast: null }); return true; },
async deleteMany(object: string, ast: any) { calls.push({ verb: 'deleteMany', object, ast }); return 0; },
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
async commit() {}, async rollback() {},
};
return { driver, calls };
}

const CHILD = {
name: 'rid_child',
label: 'ReadableIds probe child',
fields: {
object_name: { name: 'object_name', type: 'text' },
record_id: { name: 'record_id', type: 'text' },
note: { name: 'note', type: 'text' },
amount: { name: 'amount', type: 'number' },
parent: { name: 'parent', type: 'lookup', reference: 'rid_parent' },
},
};
const PARENT = {
name: 'rid_parent',
label: 'ReadableIds probe parent',
fields: { title: { name: 'title', type: 'text' } },
};

type Thrown = (Error & { code?: string; status?: number }) | null;
const refusalOf = async (p: Promise<unknown>): Promise<Thrown> => p.then(() => null, (e: any) => e);
const envelopeOf = (err: Thrown) => ({ code: err?.code, status: err?.status });

const INVALID_FILTER = { code: 'INVALID_FILTER', status: 400 };
const UNCOMPOSED = { code: 'INTERNAL_ERROR', status: 500 };

describe('[ADR-0139 D1] the engine comparand door and a ReadableIds leaf', () => {
let engine: ObjectQL;
let calls: SeenCall[];

beforeEach(async () => {
const rec = makeRecordingDriver();
calls = rec.calls;
engine = new ObjectQL();
engine.registerDriver(rec.driver, true);
await engine.init();
engine.registry.registerObject(PARENT as any, 'test');
engine.registry.registerObject(CHILD as any, 'test');
calls.length = 0;
});

it('a JSON round-trip of a where holding the leaf is refused INVALID_FILTER / 400 — no driver call', async () => {
const where = { object_name: 'rid_parent', record_id: { $in: ReadableIds.of('rid_parent') } };
const wire = JSON.parse(JSON.stringify(where));
const err = await refusalOf(engine.find('rid_child', { where: wire }));
expect(envelopeOf(err)).toEqual(INVALID_FILTER);
expect(err!.message).toMatch(/^find\('rid_child'\): Operator "\$in" on field "record_id" requires an ARRAY of values\. Received object/);
expect(calls, 'the refusal precedes the driver').toHaveLength(0);
});

it('a structured clone and a spread of the leaf are refused INVALID_FILTER / 400 — no driver call', async () => {
const leaf = ReadableIds.of('rid_parent');
for (const copy of [structuredClone(leaf), { ...leaf }]) {
const err = await refusalOf(engine.find('rid_child', { where: { record_id: { $in: copy } } }));
expect(envelopeOf(err)).toEqual(INVALID_FILTER);
}
expect(calls).toHaveLength(0);
});

it('the plain-object spelling is refused INVALID_FILTER / 400 on every read verb — no driver call', async () => {
const where = { record_id: { $in: { object: 'rid_parent' } } };
const answers = await Promise.all([
refusalOf(engine.find('rid_child', { where })),
refusalOf(engine.findOne('rid_child', { where })),
refusalOf(engine.count('rid_child', { where })),
refusalOf(engine.aggregate('rid_child', { where, aggregations: [{ function: 'count', alias: 'n' }] })),
]);
for (const err of answers) expect(envelopeOf(err)).toEqual(INVALID_FILTER);
expect(calls).toHaveLength(0);
});

it('a branded leaf passes the door and is refused INTERNAL_ERROR / 500 as uncomposed, naming its position and object — no driver call', async () => {
const err = await refusalOf(engine.find('rid_child', {
where: { object_name: 'rid_parent', record_id: { $in: ReadableIds.of('rid_parent') } },
}));
expect(envelopeOf(err)).toEqual(UNCOMPOSED);
expect(err!.message).toMatch(
/^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/,
);
expect(calls, 'the leaf never reaches a driver').toHaveLength(0);
});

it('the uncomposed leaf is refused under $and / $or / $not and on every verb — no driver call', async () => {
const leaf = () => ReadableIds.of('rid_parent');
const cases: ReadonlyArray<readonly [string, () => Promise<unknown>, string]> = [
['find $or', () => engine.find('rid_child', { where: { $or: [{ note: 'x' }, { record_id: { $in: leaf() } }] } }), 'where.$or[1].record_id.$in'],
['find $not', () => engine.find('rid_child', { where: { $not: { record_id: { $in: leaf() } } } }), 'where.$not.record_id.$in'],
['find $and', () => engine.find('rid_child', { where: { $and: [{ object_name: 'rid_parent' }, { record_id: { $in: leaf() } }] } }), 'where.$and[1].record_id.$in'],
['findOne', () => engine.findOne('rid_child', { where: { record_id: { $in: leaf() } } }), 'where.record_id.$in'],
['count', () => engine.count('rid_child', { where: { record_id: { $in: leaf() } } }), 'where.record_id.$in'],
['aggregate where', () => engine.aggregate('rid_child', { where: { record_id: { $in: leaf() } }, aggregations: [{ function: 'count', alias: 'n' }] }), 'where.record_id.$in'],
['aggregate aggregation filter', () => engine.aggregate('rid_child', { aggregations: [{ function: 'count', alias: 'n', filter: { record_id: { $in: leaf() } } }] }), 'aggregations[0].filter.record_id.$in'],
['update multi', () => engine.update('rid_child', { note: 'y' }, { where: { record_id: { $in: leaf() } }, multi: true }), 'where.record_id.$in'],
['delete multi', () => engine.delete('rid_child', { where: { record_id: { $in: leaf() } }, multi: true }), 'where.record_id.$in'],
];
for (const [label, run, at] of cases) {
const err = await refusalOf(run());
expect(envelopeOf(err), label).toEqual(UNCOMPOSED);
expect(err!.message, label).toContain(`: ${at} holds a ReadableIds leaf`);
}
expect(calls, 'no verb reached the driver').toHaveLength(0);
});

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 () => {
const leaf = () => ReadableIds.of('rid_parent');
const cases: ReadonlyArray<readonly [Record<string, unknown>, string]> = [
[{ record_id: leaf() }, 'where.record_id'],
[{ record_id: { $eq: leaf() } }, 'where.record_id.$eq'],
[{ record_id: { $in: ['p1', leaf()] } }, 'where.record_id.$in[1]'],
[{ amount: { $between: [1, leaf()] } }, 'where.amount.$between[1]'],
[{ parent: { title: leaf() } }, 'where.parent.title'],
];
for (const [where, at] of cases) {
const err = await refusalOf(engine.find('rid_child', { where }));
expect(envelopeOf(err), at).toEqual(UNCOMPOSED);
expect(err!.message, at).toContain(`: ${at} holds a ReadableIds leaf`);
}
expect(calls).toHaveLength(0);
});

it('LIT CONTROL — a list comparand on the same field reaches the driver', async () => {
await engine.find('rid_child', { where: { record_id: { $in: ['p1', 'p2'] } } });
expect(calls.map((c) => c.verb)).toEqual(['find']);
expect(calls[0].ast.where).toEqual({ record_id: { $in: ['p1', 'p2'] } });
});
});
119 changes: 118 additions & 1 deletion packages/objectql/src/filter-comparand-shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import {
assertListComparandShapes as assertListComparandShapesAt,
isVirtualSearchField,
classifyDottedFilterHead,
isReadableIds,
type ReadableIds,
} from '@objectstack/spec/data';

/**
Expand Down Expand Up @@ -99,14 +101,129 @@ export function invalidFilterError(message: string): Error {
* Read-only and allocation-free on the overwhelmingly common path (a filter
* with no list operator walks its own keys and returns). Runs on every engine
* read and write, so it stays a walk rather than a schema parse.
*
* [ADR-0139 D1] This is the one caller that passes `admitReadableIds`: the
* engine's seam is where a branded `ReadableIds` leaf is meant to be composed,
* so the door admits it as a `$in` comparand here and nowhere else. Until the
* engine composes the leaf (ADR-0139 D2), {@link refuseUncomposedReadableIds}
* runs right after the door and refuses every branded leaf in the node, so an
* admitted leaf never reaches a driver as written. An UNBRANDED copy never gets
* that far: the door refuses it as a non-list comparand (`INVALID_FILTER` / 400).
*/
export function assertListComparandShapes(
object: string,
operation: string,
node: unknown,
path = 'where',
): void {
assertListComparandShapesAt(node, `${operation}('${object}')`, path);
assertListComparandShapesAt(node, `${operation}('${object}')`, path, { admitReadableIds: true });
refuseUncomposedReadableIds(object, operation, node, path);
}

/**
* [ADR-0139 D1, E1] Refuse every branded `ReadableIds` leaf in `node`, at any
* position, before a driver is asked — the engine does not compose the leaf
* yet.
*
* ## Why a refusal, and why here
*
* The comparand door above admits a branded leaf as `$in`'s comparand, because
* the engine's seam is where it is to be composed (ADR-0139 D2). No composition
* exists yet, and no driver evaluates the leaf as written. Measured on
* `a2e94c2a05`, with the leaf handed straight to each driver as `$in`'s
* comparand, plain and under `$or` / `$not`, and as `$nin`'s:
*
* | driver | answer |
* |:--|:--|
* | `driver-memory` | a raw `TypeError` from the matcher, no `code`, no `status` |
* | `driver-sql` (SQLite) | `500 DATABASE_ERROR`, a syntax error at the bound `?` |
* | `driver-mongodb` (`translateFilter`) | emitted unchanged as `{ $in: { object } }`; the server's answer is not measured |
*
* None widened a read, and none answered as the leaf means. So the leaf stops
* HERE, on every verb and at every position, with the composition-fault
* envelope ADR-0139 D3 gives a driver's own refusal: `INTERNAL_ERROR` / 500,
* ⛔ not `INVALID_FILTER`. No request can spell the leaf, so the fault is the
* in-process code that wrote it, not the caller's filter; and a declared 5xx
* keeps its prose off the wire at the HTTP doors. When the engine composes the
* leaf, the read-verb `$in` position stops being refused here and every other
* position keeps a refusal (ADR-0139 D1's last paragraph).
*
* ## The walk
*
* EVERY position, not only `$in`'s: an implicit-equality value, a `$between`
* endpoint, a list member and a nested-relation condition are all places the
* door does not judge a plain object, and a leaf there must not reach a driver
* either. Arrays and plain objects are descended into; a `Date` or class
* instance holds no filter. `onPath` stops a self-referential in-process
* `where` instead of overflowing the stack.
*/
function refuseUncomposedReadableIds(
object: string,
operation: string,
node: unknown,
path = 'where',
): void {
const found = findReadableIds(node, path, new Set<object>());
if (found) throw uncomposedReadableIdsError(object, operation, found.leaf, found.at);
}

function findReadableIds(
node: unknown,
at: string,
onPath: Set<object>,
): { leaf: ReadableIds; at: string } | undefined {
if (node === null || typeof node !== 'object') return undefined;
if (isReadableIds(node)) return { leaf: node, at };
if (onPath.has(node)) return undefined;
const isArray = Array.isArray(node);
if (!isArray) {
const proto = Object.getPrototypeOf(node);
if (proto !== Object.prototype && proto !== null) return undefined;
}
onPath.add(node);
try {
if (isArray) {
for (let i = 0; i < (node as unknown[]).length; i++) {
const hit = findReadableIds((node as unknown[])[i], `${at}[${i}]`, onPath);
if (hit) return hit;
}
} else {
for (const key of Object.keys(node)) {
const hit = findReadableIds((node as Record<string, unknown>)[key], `${at}.${key}`, onPath);
if (hit) return hit;
}
}
} finally {
onPath.delete(node);
}
return undefined;
}

/**
* The composition-fault envelope for an uncomposed `ReadableIds` leaf —
* ADR-0112's `INTERNAL_ERROR` / 500, the code and status ADR-0139 D3 gives a
* driver's refusal of the same leaf. The message is addressed to the
* in-process author who wrote the leaf: where it sits, what it stands for,
* that no driver was asked, and what to write instead today.
*/
function uncomposedReadableIdsError(
object: string,
operation: string,
leaf: ReadableIds,
at: string,
): Error {
const err = new Error(
`${operation}('${object}'): ${at} holds a ReadableIds leaf, "the ids of '${leaf.object}' `
+ 'this caller may read", and this engine does not compose that leaf yet. The query was '
+ 'NOT run and no driver was asked: no driver evaluates the leaf as written. Until the '
+ `engine composes it, read '${leaf.object}' as the caller and match the ids that read `
+ 'returns with a list.',
) as Error & { code?: string; status?: number; httpStatus?: number; object?: string };
err.code = StandardErrorCode.enum.INTERNAL_ERROR;
err.status = 500;
err.httpStatus = 500;
err.object = object;
return err;
}

/**
Expand Down
5 changes: 5 additions & 0 deletions packages/spec/api-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@
"LifecycleClass (type)",
"LifecycleClassSchema (const)",
"LifecycleSchema (const)",
"ListComparandShapeOptions (interface)",
"LiteralDefaultValueVerdict (interface)",
"LocalStoragePersistenceConfig (type)",
"LocalStoragePersistenceConfigSchema (const)",
Expand Down Expand Up @@ -599,6 +600,7 @@
"QueryWithTransportParsed (type)",
"QueryWithTransportSchema (const)",
"RAW_FILE_VALUES_CONTEXT_KEY (const)",
"READABLE_IDS_BRAND (const)",
"READ_ONLY_BELONGS_ON_DATASOURCE (const)",
"RECORD_CONTEXT_TOKENS (const)",
"RECORD_SURFACE_PAGE_THRESHOLD (const)",
Expand All @@ -608,6 +610,8 @@
"RPC_QUERY_ALIAS_SLOTS (const)",
"RUNTIME_OWNED_FIELD_TYPES (const)",
"RangeOperatorSchema (const)",
"ReadableIds (const)",
"ReadableIds (interface)",
"RecordContextToken (type)",
"RecordFlow (type)",
"RecordFlowContainer (type)",
Expand Down Expand Up @@ -883,6 +887,7 @@
"isOrganizationUnique (function)",
"isPlainRecord (function)",
"isPublicSharingEnabled (function)",
"isReadableIds (function)",
"isRecordContextToken (function)",
"isRefusedTextComparand (function)",
"isRuntimeDefaultToken (function)",
Expand Down
Loading
Loading