Skip to content
Merged
23 changes: 23 additions & 0 deletions .changeset/23639-readable-ids-drivers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/spec': minor
'@objectstack/core': minor
'@objectstack/driver-sql': minor
'@objectstack/driver-sqlite-wasm': minor
'@objectstack/driver-turso': minor
'@objectstack/driver-memory': minor
'@objectstack/driver-mongodb': minor
---

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)

Clause-②: yes

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.

- **`@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.
- **`@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.
- **`@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.
- **`@objectstack/driver-mongodb`** refuses the leaf, composed or not, until a pipeline form is measured (ADR-0139 open item O3).
- **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.
- **`@objectstack/core`** exports the shared pieces: `findMisplacedReadableIds`, `replaceComposedReadableIds`, `readableIdsDriverRefusal`, `refuseReadableIdsOnce` and the `MisplacedReadableIds`, `ReadableIdsDriverRefusalError` and `ReadableIdsRefusalLog` types.
- **`@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.
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ export * from './utils/multi-value-storage-form.js';
// evaluates itself — one set and one sentence, here for the reason the entry
// above gives.
export * from './utils/json-column-operator-refusal.js';
export * from './utils/readable-ids-driver-refusal.js';

// [#12350 / ADR-0126 §4] THE activation-ledger row contract, parameterized by
// `metadata_type`. Same reason as the two entries above: its consumers —
Expand Down
88 changes: 88 additions & 0 deletions packages/core/src/utils/readable-ids-driver-refusal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [ADR-0139 D3] The shared walk, rewrite and refusal every driver uses for a
* `ReadableIds` leaf. The drivers' own conformance suites
* (`READABLE_IDS_CASES`) measure what each compiles; this pins the three
* pieces they all stand on: which leaf the walk finds, that the rewrite never
* edits the caller's tree, and the envelope.
*/

import { describe, it, expect, vi } from 'vitest';
import { ReadableIds, filterSubtreeProvenanceOf, markFilterSubtreeProvenance } from '@objectstack/spec/data';
import {
findMisplacedReadableIds,
readableIdsDriverRefusal,
refuseReadableIdsOnce,
replaceComposedReadableIds,
} from './readable-ids-driver-refusal.js';

const composed = () => ReadableIds.compose(ReadableIds.of('p'), { owner: 'u1' });

describe('findMisplacedReadableIds', () => {
it('admits a composed $in comparand on a compiling driver, at any depth', () => {
expect(findMisplacedReadableIds({ $or: [{ a: 1 }, { $not: { r: { $in: composed() } } }] }, true)).toBeNull();
});

it('finds an uncomposed leaf at any position, on any driver', () => {
expect(findMisplacedReadableIds({ $and: [{ r: { $in: ReadableIds.of('p') } }] }, true)).toEqual({
at: 'where.$and[0].r.$in',
object: 'p',
kind: 'uncomposed',
});
});

it('finds a composed leaf outside a $in comparand', () => {
expect(findMisplacedReadableIds({ r: { $nin: composed() } }, true)?.kind).toBe('misplaced');
expect(findMisplacedReadableIds({ r: composed() }, true)?.kind).toBe('misplaced');
});

it('finds every composed leaf on a refusing driver', () => {
expect(findMisplacedReadableIds({ r: { $in: composed() } }, false)?.kind).toBe('unsupported');
});

it('answers null for a tree with no leaf, cycles included', () => {
const node: Record<string, unknown> = { a: { $in: [1, 2] } };
node.self = { $and: [node] };
expect(findMisplacedReadableIds(node, true)).toBeNull();
});
});

describe('replaceComposedReadableIds', () => {
it('rewrites copy-on-write, carrying the provenance mark of the node it replaces', () => {
const leaf = composed();
const branch = markFilterSubtreeProvenance({ r: { $in: leaf } }, 'author');
const untouched = { b: 2 };
const where = { $and: [branch, untouched] };
const out = replaceComposedReadableIds(where, () => ['p1']) as any;
expect(out).toEqual({ $and: [{ r: { $in: ['p1'] } }, { b: 2 }] });
expect(where.$and[0]).toBe(branch);
expect((branch as any).r.$in).toBe(leaf);
expect(out.$and[1]).toBe(untouched);
expect(filterSubtreeProvenanceOf(out.$and[0])).toBe('author');
});

it('returns a tree with no leaf by reference', () => {
const where = { a: { $in: [1] } };
expect(replaceComposedReadableIds(where, () => [])).toBe(where);
});
});

describe('the refusal', () => {
it('is INTERNAL_ERROR / 500, never INVALID_FILTER, and names the driver and the leaf', () => {
const err = readableIdsDriverRefusal('driver-x', { at: 'where.r.$in', object: 'p', kind: 'uncomposed' });
expect(err).toMatchObject({ code: 'INTERNAL_ERROR', status: 500, httpStatus: 500 });
expect(err.message).toMatch(/^\[driver-x\] where\.r\.\$in holds a ReadableIds leaf/);
});

it('is logged at error once per state, and returned every time', () => {
const log = { error: vi.fn(), warn: vi.fn() };
const state = { logged: false };
const site = { at: 'where.r.$in', object: 'p', kind: 'unsupported' } as const;
const a = refuseReadableIdsOnce('driver-x', site, log, state);
const b = refuseReadableIdsOnce('driver-x', site, log, state);
expect([a.code, b.code]).toEqual(['INTERNAL_ERROR', 'INTERNAL_ERROR']);
expect(log.error).toHaveBeenCalledTimes(1);
expect(log.warn).not.toHaveBeenCalled();
});
});
249 changes: 249 additions & 0 deletions packages/core/src/utils/readable-ids-driver-refusal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [ADR-0139 D3] The one refusal every driver answers for a `ReadableIds` leaf
* it will not evaluate, and the walk that finds such a leaf.
*
* ## What a driver is handed
*
* The engine composes every `ReadableIds` leaf at its executor boundary,
* immediately before the driver call (D2), so a driver that compiles the leaf
* receives the COMPOSED form (`isComposedReadableIds`) and only as the
* comparand of `$in`. Anything else is a composition fault, not an author
* error, and every driver answers it with the same envelope (ADR-0112):
* `INTERNAL_ERROR` / 500, a message naming the driver, the leaf and the remedy.
* ⛔ Not `INVALID_FILTER`: no author can spell the leaf, so no author can fix
* the filter.
*
* - an UNCOMPOSED leaf (`isReadableIds`) at any position — a direct driver
* call that bypassed the seam. Fail closed: ⛔ never evaluated as "no
* restriction", which would serve every row of the child;
* - a composed leaf anywhere but a `$in` comparand;
* - a composed leaf on a driver that does not compile it (`driver-mongodb`
* until a pipeline form is measured, ADR-0139 open item O3).
*
* ## Why here
*
* Four compilers answer it — `driver-sql` (and the two drivers that inherit
* its compiler), Turso's remote transport, `driver-memory` and
* `driver-mongodb` — and none of them imports another. A copy each is how one
* refusal comes to answer one fault four ways, so the walk and the words live
* on the floor all four already stand on, beside
* `json-column-operator-refusal.ts`, which the drivers share for the same
* reason. Each driver keeps its own logger: the refusal is logged at `error`
* once per driver instance (see {@link ReadableIdsRefusalLog}).
*
* ⛔ No `DriverCapabilities` bit: a driver answers by compiling or refusing,
* so there is nothing for the engine to read.
*/

import {
filterSubtreeProvenanceOf,
isComposedReadableIds,
isReadableIds,
markFilterSubtreeProvenance,
type ComposedReadableIds,
} from '@objectstack/spec/data';

/** Where a leaf a driver will not evaluate sits, and which one it is. */
export interface MisplacedReadableIds {
/** The path of the leaf in the filter, `where.record_id.$in` style. */
readonly at: string;
/** The parent object the leaf names. */
readonly object: string;
/**
* - `uncomposed` — a `ReadableIds.of(...)` leaf that never passed the
* engine's composition seam;
* - `misplaced` — a composed leaf outside a `$in` comparand;
* - `unsupported` — a composed leaf on a driver that does not compile it.
*/
readonly kind: 'uncomposed' | 'misplaced' | 'unsupported';
}

/**
* The first leaf in `where` this driver will not evaluate, or `null`.
*
* `compilesComposed` is the driver's own answer: `true` for a driver that
* compiles the composed leaf (a composed `$in` comparand is then legal and
* not descended into — its `scope` is the PARENT's filter, compiled against
* the parent), `false` for one that refuses it (every composed leaf is then
* `unsupported`).
*
* The walk is the engine's own (`collectComposableReadableIds`): plain
* objects and arrays only, cycle-safe, no other prototype descended into.
*/
export function findMisplacedReadableIds(
where: unknown,
compilesComposed: boolean,
path = 'where',
): MisplacedReadableIds | null {
const onPath = new Set<object>();
const visit = (value: unknown, at: string, asInComparand: boolean): MisplacedReadableIds | null => {
if (value === null || typeof value !== 'object') return null;
if (isReadableIds(value)) return { at, object: value.object, kind: 'uncomposed' };
if (isComposedReadableIds(value)) {
if (!compilesComposed) return { at, object: value.object, kind: 'unsupported' };
return asInComparand ? null : { at, object: value.object, kind: 'misplaced' };
}
if (onPath.has(value)) return null;
const isArray = Array.isArray(value);
if (!isArray) {
const proto = Object.getPrototypeOf(value);
if (proto !== Object.prototype && proto !== null) return null;
}
onPath.add(value);
try {
if (isArray) {
const list = value as unknown[];
for (let i = 0; i < list.length; i++) {
const found = visit(list[i], `${at}[${i}]`, false);
if (found) return found;
}
} else {
for (const key of Object.keys(value)) {
const found = visit((value as Record<string, unknown>)[key], `${at}.${key}`, key === '$in');
if (found) return found;
}
}
return null;
} finally {
onPath.delete(value);
}
};
return visit(where, path, false);
}

/**
* `where` with the comparand of every composed `$in` leaf replaced by
* `answer(leaf)`, copy-on-write: a subtree holding no leaf is returned by
* reference, the caller's tree is never edited (the leaf is frozen, and the
* hooks' AST is the caller's), and a rewritten node carries the
* filter-subtree provenance mark of the node it replaces, so a refusal raised
* inside it resolves to the same author / policy attribution it did before.
*
* Asked only after {@link findMisplacedReadableIds} answered `null` for the
* same `where`, so every composed leaf the walk meets sits in a `$in`.
* For a driver that evaluates the leaf by rewriting it — `driver-memory`
* into the readable id list, Turso's remote face into its resolved semi-join.
*/
export function replaceComposedReadableIds<T>(where: T, answer: (leaf: ComposedReadableIds) => unknown): T {
const onPath = new Set<object>();
const rewrite = (value: unknown): unknown => {
if (value === null || typeof value !== 'object' || isComposedReadableIds(value)) return value;
if (onPath.has(value)) return value;
const isArray = Array.isArray(value);
if (!isArray) {
const proto = Object.getPrototypeOf(value);
if (proto !== Object.prototype && proto !== null) return value;
}
onPath.add(value);
try {
if (isArray) {
let out: unknown[] | undefined;
(value as unknown[]).forEach((item, i) => {
const next = rewrite(item);
if (next !== item) (out ??= [...(value as unknown[])])[i] = next;
});
return out ?? value;
}
let out: Record<string, unknown> | undefined;
for (const key of Object.keys(value)) {
const item = (value as Record<string, unknown>)[key];
const next = key === '$in' && isComposedReadableIds(item) ? answer(item) : rewrite(item);
if (next !== item) (out ??= { ...(value as Record<string, unknown>) })[key] = next;
}
if (out === undefined) return value;
const mark = filterSubtreeProvenanceOf(value);
return mark === null ? out : markFilterSubtreeProvenance(out, mark);
} finally {
onPath.delete(value);
}
};
return rewrite(where) as T;
}

/** The ADR-0112 envelope of the refusal: a composition fault, never `INVALID_FILTER`. */
export type ReadableIdsDriverRefusalError = Error & {
code: 'INTERNAL_ERROR';
status: 500;
httpStatus: 500;
};

/**
* The refusal for `site`, raised by `driver` (its package name, e.g.
* `driver-sql`). The message names the driver, the leaf and the remedy; the
* read was not run.
*/
export function readableIdsDriverRefusal(
driver: string,
site: MisplacedReadableIds,
// Why this driver does not compile the composed leaf — read for `unsupported` only.
unsupportedReason = 'no pipeline form of the tenant-walled semi-join is measured for this driver yet',
): ReadableIdsDriverRefusalError {
const leaf = `${site.at} holds a ReadableIds leaf, "the ids of '${site.object}' this caller may read"`;
let message: string;
switch (site.kind) {
case 'uncomposed':
message =
`[${driver}] ${leaf}, UNCOMPOSED: it reached the driver without passing the engine's composition ` +
'seam, and the driver refuses it rather than read it as no restriction. The query was NOT run. ' +
'Read through the ObjectQL engine (find / findOne / count / aggregate), which composes every ' +
'leaf with the caller\'s read scope before any driver call; never hand a driver ReadableIds.of() directly.';
break;
case 'misplaced':
message =
`[${driver}] ${leaf}, composed, outside a $in comparand. The leaf is legal only as the comparand ` +
'of $in on a pointer field ({ record_id: { $in: leaf } }); the query was NOT run. Place it there, ' +
'or write the predicate without it.';
break;
case 'unsupported':
message =
`[${driver}] ${leaf}, composed, and ${driver} does not compile it: ${unsupportedReason}. ` +
'The query was NOT run, rather than ' +
'answer a short or an unscoped list. Bind the object and its parent to a SQL-family or memory ' +
'datasource, or read this object through its bounded probe, chosen before the leaf is written.';
break;
}
const err = new Error(message) as ReadableIdsDriverRefusalError;
err.code = 'INTERNAL_ERROR';
err.status = 500;
err.httpStatus = 500;
return err;
}

/**
* The slice of a driver's logger the refusal needs: the message alone, because
* the drivers' loggers disagree on what follows it (`SqlDriver`'s takes a meta
* object, core's `Logger` an `Error`). `error` is optional because a driver's
* injectable logger declares it so (`SqlDriver`'s does); `warn` is REQUIRED,
* the guaranteed fallback, so a sink that carries no `error` gets the line at
* `warn` — every value of this type prints it (the optional-`error` sink
* contract, AGENTS.md → "Degradation log levels").
*/
export interface ReadableIdsRefusalLog {
error?(message: string): void;
warn(message: string): void;
}

/**
* The refusal for `site`, logged at `error` the FIRST time `driver`'s
* instance refuses (`state.logged` is that instance's own flag), and returned
* for the caller to throw. A composition fault means every such read on this
* driver fails, so one line names it; the caller sees every refusal.
*/
export function refuseReadableIdsOnce(
driver: string,
site: MisplacedReadableIds,
log: ReadableIdsRefusalLog | undefined,
state: { logged: boolean },
unsupportedReason?: string,
): ReadableIdsDriverRefusalError {
const err = readableIdsDriverRefusal(driver, site, unsupportedReason);
if (!state.logged) {
state.logged = true;
const line = `${err.message} Logged once for this driver; later refusals answer the caller the same way.`;
if (typeof log?.error === 'function') log.error(line);
else log?.warn(line);
}
return err;
}
Loading
Loading