From 4099123982c9ea027c13c8a2186fefffb9a7db02 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 08:43:00 +0000 Subject: [PATCH 1/7] =?UTF-8?q?wip(driver-sql,=20core,=20spec):=20ADR-0139?= =?UTF-8?q?=20D3=20=E2=80=94=20the=20SQL=20compile=20of=20the=20composed?= =?UTF-8?q?=20ReadableIds=20leaf,=20the=20shared=20driver=20refusal,=20and?= =?UTF-8?q?=20the=20parked=20case-set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01B1r5eJEPgvn7bUsfBvK2tS Co-authored-by: Claude --- packages/core/src/index.ts | 1 + .../src/utils/readable-ids-driver-refusal.ts | 182 ++++++++++++ ...ql-driver-readable-ids-conformance.test.ts | 191 +++++++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 84 +++++- packages/spec/src/data/index.ts | 3 + .../src/data/readable-ids-conformance.test.ts | 202 +++++++++++++ .../spec/src/data/readable-ids-conformance.ts | 268 ++++++++++++++++++ scripts/check-driver-conformance.mjs | 5 + 8 files changed, 921 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/utils/readable-ids-driver-refusal.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-readable-ids-conformance.test.ts create mode 100644 packages/spec/src/data/readable-ids-conformance.test.ts create mode 100644 packages/spec/src/data/readable-ids-conformance.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5a54a95a88f..131fb58cdf4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 — diff --git a/packages/core/src/utils/readable-ids-driver-refusal.ts b/packages/core/src/utils/readable-ids-driver-refusal.ts new file mode 100644 index 00000000000..3e2c64ebf7e --- /dev/null +++ b/packages/core/src/utils/readable-ids-driver-refusal.ts @@ -0,0 +1,182 @@ +// 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 { isComposedReadableIds, isReadableIds } 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(); + 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)[key], `${at}.${key}`, key === '$in'); + if (found) return found; + } + } + return null; + } finally { + onPath.delete(value); + } + }; + return visit(where, path, false); +} + +/** 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): 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: no pipeline form of the ` + + 'tenant-walled semi-join is measured for this driver yet. 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. */ +export interface ReadableIdsRefusalLog { + error(message: string, meta?: Record): 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 }, +): ReadableIdsDriverRefusalError { + const err = readableIdsDriverRefusal(driver, site); + if (!state.logged) { + state.logged = true; + log?.error(`${err.message} Logged once for this driver; later refusals answer the caller the same way.`, { + code: err.code, + object: site.object, + at: site.at, + kind: site.kind, + }); + } + return err; +} diff --git a/packages/drivers/driver-sql/src/sql-driver-readable-ids-conformance.test.ts b/packages/drivers/driver-sql/src/sql-driver-readable-ids-conformance.test.ts new file mode 100644 index 00000000000..9b7b367f56c --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-readable-ids-conformance.test.ts @@ -0,0 +1,191 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0139 D3, plan row E3] `driver-sql` compiles the composed `ReadableIds` + * leaf to `field IN (SELECT id FROM P_TABLE WHERE SCOPE AND TENANT)`, the + * subquery walled by `applyTenantScope` with the outer read's options, and + * refuses an uncomposed leaf with D3's envelope. + * + * Two halves: + * + * - **the shared case-set** (`READABLE_IDS_CASES`, `@objectstack/spec/data`), + * on every dialect cell — no per-driver hand copy; + * - **E3's pins** — more than 2,000 readable candidates all served, an + * unreadable row hidden, a deleted parent excluded, `count` equal to the rows + * served — plus the O1 reading: what the statement binds. + * + * Every `where` goes through the shared lowering first (`lowerFilterCondition`), + * the seam a real read passes, so the NULL-safe `$not` case measures the + * lowering's guard composed with the leaf rather than a three-valued `NOT IN`. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { + READABLE_IDS_CASES, + READABLE_IDS_CHILD_OBJECT, + READABLE_IDS_CHILD_ROWS, + READABLE_IDS_PARENT_OBJECT, + READABLE_IDS_PARENT_ROWS, + READABLE_IDS_REFUSAL, + ReadableIds, + lowerFilterCondition, +} from '@objectstack/spec/data'; +import { SqlDriver } from './sql-driver.js'; +import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; + +const seamed = (where: unknown): any => lowerFilterCondition(where, { isDatetimeColumn: () => false }); + +const PARENT_FIELDS = { owner: { type: 'text' }, organization_id: { type: 'text' } }; +const CHILD_FIELDS = { + object_name: { type: 'text' }, + record_id: { type: 'text' }, + label: { type: 'text' }, + organization_id: { type: 'text' }, +}; + +const BIG_PARENT = 'rid_big_parent'; +const BIG_CHILD = 'rid_big_child'; +/** Past the 2,000-row window the pre-scan this leaf replaces was capped at. */ +const BIG_READABLE = 2_400; +const BIG_UNREADABLE = 300; + +const sortedIds = (rows: any[]): string[] => rows.map((r) => String(r.id)).sort((a, b) => a.localeCompare(b)); + +async function insertChunked(driver: SqlDriver, table: string, rows: Record[]): Promise { + const knex = (driver as any).knex; + for (let i = 0; i < rows.length; i += 100) await knex(table).insert(rows.slice(i, i + 100)); +} + +function declareReadableIdsSweep(cell: DialectCell): void { + describe(`[ADR-0139 D3] driver-sql — ReadableIds conformance (${cell.label})`, () => { + let driver: SqlDriver; + + beforeAll(async () => { + driver = new SqlDriver(cell.config()); + for (const t of [READABLE_IDS_CHILD_OBJECT, READABLE_IDS_PARENT_OBJECT, BIG_CHILD, BIG_PARENT]) { + await driver.execute(`drop table if exists ${t}`).catch(() => {}); + } + await driver.initObjects([ + { name: READABLE_IDS_PARENT_OBJECT, fields: { ...PARENT_FIELDS } }, + { name: READABLE_IDS_CHILD_OBJECT, fields: { ...CHILD_FIELDS } }, + { name: BIG_PARENT, fields: { ...PARENT_FIELDS } }, + { name: BIG_CHILD, fields: { ...CHILD_FIELDS } }, + ] as any); + await insertChunked(driver, READABLE_IDS_PARENT_OBJECT, READABLE_IDS_PARENT_ROWS.map((r) => ({ ...r }))); + await insertChunked(driver, READABLE_IDS_CHILD_OBJECT, READABLE_IDS_CHILD_ROWS.map((r) => ({ ...r }))); + + // E3's population: BIG_READABLE parents the scope admits, BIG_UNREADABLE + // it does not, one child each. + const parents: Record[] = []; + const children: Record[] = []; + for (let i = 0; i < BIG_READABLE + BIG_UNREADABLE; i++) { + const id = `bp${String(i).padStart(5, '0')}`; + parents.push({ id, owner: i < BIG_READABLE ? 'reader' : 'someone_else', organization_id: 'org_a' }); + children.push({ id: `bc${String(i).padStart(5, '0')}`, object_name: BIG_PARENT, record_id: id, label: 'x', organization_id: 'org_a' }); + } + await insertChunked(driver, BIG_PARENT, parents); + await insertChunked(driver, BIG_CHILD, children); + }, 60_000); + + afterAll(async () => { + for (const t of [READABLE_IDS_CHILD_OBJECT, READABLE_IDS_PARENT_OBJECT, BIG_CHILD, BIG_PARENT]) { + await driver?.execute(`drop table if exists ${t}`).catch(() => {}); + } + await driver?.disconnect?.(); + }); + + describe('shared conformance cases', () => { + for (const c of READABLE_IDS_CASES) { + if (c.verdict === 'matches') { + it(c.name, async () => { + const rows = await driver.find(READABLE_IDS_CHILD_OBJECT, { where: seamed(c.where()) }, c.options); + expect(sortedIds(rows), c.note).toEqual(c.expected); + // One statement: `count` answers the same population as `find`. + const n = await driver.count(READABLE_IDS_CHILD_OBJECT, { where: seamed(c.where()) }, c.options); + expect(n, c.note).toBe(c.expected.length); + }); + } else { + it(c.name, async () => { + await expect( + driver.find(READABLE_IDS_CHILD_OBJECT, { where: seamed(c.where()) }), + c.note, + ).rejects.toMatchObject(READABLE_IDS_REFUSAL); + }); + } + } + }); + + describe('E3 pins', () => { + const scoped = () => ({ record_id: { $in: ReadableIds.compose(ReadableIds.of(BIG_PARENT), { owner: 'reader' }) } }); + + it(`a reader with ${BIG_READABLE} readable candidates gets every visible row, and count equals the rows served`, async () => { + const rows = await driver.find(BIG_CHILD, { where: seamed(scoped()) }, { tenantId: 'org_a' }); + expect(rows).toHaveLength(BIG_READABLE); + const n = await driver.count(BIG_CHILD, { where: seamed(scoped()) }, { tenantId: 'org_a' }); + expect(n).toBe(rows.length); + }); + + it('an unreadable row stays hidden', async () => { + const rows = await driver.find(BIG_CHILD, { where: seamed(scoped()) }, { tenantId: 'org_a' }); + const ids = new Set(rows.map((r: any) => String(r.record_id))); + expect(ids.has(`bp${String(BIG_READABLE).padStart(5, '0')}`)).toBe(false); + expect([...ids].every((id) => Number(id.slice(2)) < BIG_READABLE)).toBe(true); + }); + + it('a deleted parent is excluded', async () => { + await driver.delete(BIG_PARENT, 'bp00000', { bypassTenantAudit: true } as any); + const rows = await driver.find(BIG_CHILD, { where: seamed(scoped()) }, { tenantId: 'org_a' }); + expect(rows).toHaveLength(BIG_READABLE - 1); + expect(rows.some((r: any) => r.record_id === 'bp00000')).toBe(false); + expect(await driver.count(BIG_CHILD, { where: seamed(scoped()) }, { tenantId: 'org_a' })).toBe(BIG_READABLE - 1); + }); + + it('aggregate agrees with find: the leaf is one statement on every read verb', async () => { + const out = await driver.aggregate( + BIG_CHILD, + { where: seamed(scoped()), aggregations: [{ function: 'count', alias: 'n' }] } as any, + { tenantId: 'org_a' }, + ); + expect(Number((out[0] as any).n)).toBe(BIG_READABLE - 1); + }); + + it('O1: the statement binds no per-id parameter, whatever the parent\'s size', async () => { + const knex = (driver as any).knex; + const bindings: number[] = []; + const onQuery = (q: { sql: string; bindings?: unknown[] }) => { + if (/rid_big_parent/.test(q.sql)) bindings.push(q.bindings?.length ?? 0); + }; + knex.on('query', onQuery); + try { + await driver.find(BIG_CHILD, { where: seamed(scoped()) }, { tenantId: 'org_a' }); + } finally { + knex.removeListener('query', onQuery); + } + // The scope's one comparand and the two tenant walls (outer and + // subquery) — ⛔ never one parameter per readable parent. + expect(bindings).toHaveLength(1); + expect(bindings[0]).toBeLessThanOrEqual(3); + }); + + it('the refusal is logged at error once per driver, and every refusal reaches the caller', async () => { + const fresh = new SqlDriver(cell.config()); + const error = vi.spyOn((fresh as any).logger, 'error'); + const uncomposed = () => ({ record_id: { $in: ReadableIds.of(BIG_PARENT) } }); + for (let i = 0; i < 2; i++) { + await expect(fresh.find(BIG_CHILD, { where: uncomposed() as any })).rejects.toMatchObject(READABLE_IDS_REFUSAL); + } + expect(error).toHaveBeenCalledTimes(1); + await fresh.disconnect?.(); + }); + + it('a composed leaf outside a $in comparand is refused with the same envelope', async () => { + const leaf = ReadableIds.compose(ReadableIds.of(BIG_PARENT), undefined); + await expect(driver.find(BIG_CHILD, { where: { record_id: { $nin: leaf } } as any })).rejects.toMatchObject( + READABLE_IDS_REFUSAL, + ); + }); + }); + }); +} + +for (const cell of DIALECT_CELLS) declareDialectCell(cell, 'ReadableIds conformance', declareReadableIdsSweep); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index ad156001e04..5a9827b5ec5 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -172,6 +172,11 @@ import { jsonColumnOperatorRefusalText, type JsonColumnFieldClass, } from '@objectstack/core'; +// [ADR-0139 D3] The walk that finds a `ReadableIds` leaf this compiler will not +// evaluate, and the one refusal every driver answers it with — shared with +// Turso's remote transport, `driver-memory` and `driver-mongodb`. +import { findMisplacedReadableIds, refuseReadableIdsOnce } from '@objectstack/core'; +import { isComposedReadableIds, type ComposedReadableIds } from '@objectstack/spec/data'; /** * [#20768] The async scope of a driver's own PRE-DDL question: the ADR-0104 @@ -5699,6 +5704,8 @@ export class SqlDriver implements IDataDriver { /** Throttle table for missing-tenantId warnings ({object}:{op}). */ protected tenantAuditWarned: Set = new Set(); + /** [ADR-0139 D3] Whether this instance has logged its `ReadableIds` refusal (once per driver). */ + protected readonly readableIdsRefusalLog = { logged: false }; /** * Optional logger sink for security-audit warnings. Tests inject a spy; @@ -7313,7 +7320,7 @@ export class SqlDriver implements IDataDriver { // WHERE if (query.where) { - this.applyFilters(b, query.where); + this.applyFilters(b, query.where, options); } // ORDER BY @@ -10133,7 +10140,7 @@ export class SqlDriver implements IDataDriver { const issue = (candidate: Record, issueOptions?: DriverOptions): Promise => { const builder = this.getBuilder(target, issueOptions); this.applyTenantScope(builder, object, issueOptions); - if (query.where) this.applyFilters(builder, query.where); + if (query.where) this.applyFilters(builder, query.where, issueOptions); return builder.update(candidate) as unknown as Promise; }; total += @@ -10150,7 +10157,7 @@ export class SqlDriver implements IDataDriver { for (const target of this.rotationShardsOf(object) ?? [object]) { const builder = this.getBuilder(target, options); this.applyTenantScope(builder, object, options); - if (query.where) this.applyFilters(builder, query.where); + if (query.where) this.applyFilters(builder, query.where, options); total += (await builder.delete()) || 0; } return total; @@ -10375,7 +10382,7 @@ export class SqlDriver implements IDataDriver { this.applyTenantScope(builder, object, options); if (query?.where) { - this.applyFilters(builder, query.where); + this.applyFilters(builder, query.where, options); } // [#8790] `count()` has no recovery ladder and needs none — it carries no @@ -10638,7 +10645,7 @@ export class SqlDriver implements IDataDriver { this.applyTenantScope(builder, object, options); if (query.where) { - this.applyFilters(builder, query.where); + this.applyFilters(builder, query.where, options); } // The same coercion key `applyFilters` just used, so every part of this @@ -11189,7 +11196,7 @@ export class SqlDriver implements IDataDriver { this.applyTenantScope(builder, object, options); if (filters) { - this.applyFilters(builder, filters); + this.applyFilters(builder, filters, options); } builder.distinct(field); @@ -11459,7 +11466,7 @@ export class SqlDriver implements IDataDriver { builder.select('*'); if (query.where) { - this.applyFilters(builder, query.where); + this.applyFilters(builder, query.where, options); } if (query.windowFunctions && Array.isArray(query.windowFunctions)) { @@ -11591,7 +11598,7 @@ export class SqlDriver implements IDataDriver { } if (query.where) { - this.applyFilters(builder, query.where); + this.applyFilters(builder, query.where, options); } if (query.orderBy && Array.isArray(query.orderBy)) { @@ -15591,6 +15598,32 @@ export class SqlDriver implements IDataDriver { }); } + /** + * [ADR-0139 D3] The subquery a composed `ReadableIds` leaf compiles to: + * `SELECT id FROM P_TABLE WHERE SCOPE AND TENANT`, which the `$in` arm of + * {@link applyFilterCondition} embeds as `field IN (…)`. + * + * - **TENANT** is {@link applyTenantScope} on the PARENT table with the + * OUTER read's options — the engine never composes the wall (D2 step 3), + * so a parent row in another tenant is never readable through the leaf; + * - **SCOPE** is the composed scope, compiled by this same compiler against + * the parent table (`undefined` is no row restriction). It arrives lowered + * and `'policy'`-marked, so a refusal inside it withholds its operands + * exactly as an injected RLS predicate's does. + * + * One statement, so `find`, `count` and `aggregate` agree by construction, + * and the subquery binds only the scope's and the wall's own parameters — + * no per-id parameter, whatever the parent's size. The subquery carries no + * transaction of its own: it is part of the outer statement, which does. + */ + protected readableIdsSubquery(leaf: ComposedReadableIds, options?: DriverOptions): Knex.QueryBuilder { + const parent = leaf.object; + const sub = this.getBuilder(parent).select(this.remoteColumn(parent, 'id', 'id')); + this.applyTenantScope(sub, parent, options); + if (leaf.scope !== undefined) this.applyFilters(sub, leaf.scope, options); + return sub; + } + /** * Auto-inject the tenant column on insert rows when: * 1. `options.tenantId` is provided, AND @@ -16686,9 +16719,15 @@ export class SqlDriver implements IDataDriver { * `code` and `status` are the redacted refusal's own, and the diagnostic * stays on the symbol key where nothing that serialises an error can reach it. */ - protected applyFilters(builder: Knex.QueryBuilder, filters: any) { + protected applyFilters(builder: Knex.QueryBuilder, filters: any, options?: DriverOptions) { + // [ADR-0139 D3] A `ReadableIds` leaf this compiler will not evaluate — an + // UNCOMPOSED one anywhere, a composed one outside a `$in` comparand — is + // refused before anything compiles: INTERNAL_ERROR / 500, logged at + // `error` once per driver. ⛔ Never evaluated as "no restriction". + const misplaced = findMisplacedReadableIds(filters, true); + if (misplaced) throw refuseReadableIdsOnce('driver-sql', misplaced, this.logger, this.readableIdsRefusalLog); try { - this.compileFilters(builder, filters); + this.compileFilters(builder, filters, options); } catch (err) { throw this.resolveWithheldFilterRefusal(err, filters); } @@ -16798,7 +16837,7 @@ export class SqlDriver implements IDataDriver { } } - private compileFilters(builder: Knex.QueryBuilder, filters: any) { + private compileFilters(builder: Knex.QueryBuilder, filters: any, options?: DriverOptions) { if (!filters) return; // [#5158] `where` is a `FilterCondition` OBJECT. It always was — the spec @@ -16834,7 +16873,7 @@ export class SqlDriver implements IDataDriver { ); if (hasMongoOperators) { - this.applyFilterCondition(builder, filters, 'and', table, filters); + this.applyFilterCondition(builder, filters, 'and', table, filters, options); return; } @@ -17153,6 +17192,12 @@ export class SqlDriver implements IDataDriver { // merge boundaries actually marked. Defaults to this frame's own // `condition` for any direct caller compiling a standalone tree. rootFilter?: unknown, + // [ADR-0139 D3] The OUTER read's options, threaded down every combinator so + // a composed `ReadableIds` leaf's subquery is tenant-walled by + // {@link applyTenantScope} with exactly the options the outer read was + // scoped with — captured by the lazily invoked group callbacks below, + // never read from instance state. + options?: DriverOptions, ) { if (!condition || typeof condition !== 'object') return; const root = rootFilter ?? condition; @@ -17185,7 +17230,7 @@ export class SqlDriver implements IDataDriver { (builder as any)[method]((qb: any) => { for (const sub of branches) { qb.where((subQb: any) => { - this.withWithheldFilterLog(root, () => this.applyFilterCondition(subQb, sub, 'and', table, root)); + this.withWithheldFilterLog(root, () => this.applyFilterCondition(subQb, sub, 'and', table, root, options)); }); } }); @@ -17210,7 +17255,7 @@ export class SqlDriver implements IDataDriver { // read scope of the shape `{$or:[{owner,status},{shared_with}]}` // returned rows the scope excluded — see sql-driver-or-filter.test.ts. qb.orWhere((subQb: any) => { - this.withWithheldFilterLog(root, () => this.applyFilterCondition(subQb, sub, 'and', table, root)); + this.withWithheldFilterLog(root, () => this.applyFilterCondition(subQb, sub, 'and', table, root, options)); }); } }); @@ -17239,7 +17284,7 @@ export class SqlDriver implements IDataDriver { // refusal raised inside a `$not` resolves against `root` like any // other; the walk-side refusals ran on the same nodes, eagerly. this.withWithheldFilterLog(root, () => - this.applyFilterCondition(qb, value as Record, 'and', table, root), + this.applyFilterCondition(qb, value as Record, 'and', table, root, options), ); }); } else if (isFilterNode(value)) { @@ -17268,6 +17313,15 @@ export class SqlDriver implements IDataDriver { const columnExpr = this.filterColumnExpr(table, localField, field); for (const [rawOp, opValue] of Object.entries(value as Record)) { const method = logicalOp === 'or' ? 'orWhere' : 'where'; + // [ADR-0139 D3] The composed `ReadableIds` leaf — the one non-list a + // `$in` may hold — compiles to the semi-join, ahead of every comparand + // gate below (none of which has a reading for it). Only `$in` reaches + // here: `applyFilters` refused it at every other position. + if (rawOp === '$in' && isComposedReadableIds(opValue)) { + const mIn = logicalOp === 'or' ? 'orWhereIn' : 'whereIn'; + (builder as any)[mIn](field, this.readableIdsSubquery(opValue, options)); + continue; + } // #5222 — a `{ $field }` that is the WHOLE comparand of a scalar // comparison compiles to a same-table column-to-column comparison // (or refuses inside the emitter, with the specific boundary named). diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index 2159a68ca9c..0a4b7cb05c6 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -50,6 +50,9 @@ export * from './filter-subtree-provenance'; // the composed leaf `{ object, scope }` (`ReadableIds.compose`, // `isComposedReadableIds`, D2 step 3). export * from './readable-ids'; +// [ADR-0139 D3] Canonical conformance cases for the composed `ReadableIds` leaf +// — the per-driver case-set a driver compiles (measured) or refuses (recorded). +export * from './readable-ids-conformance'; // [#8371] The FILTER axis' dotted-path verdict — ONE head-type classification // consumed by both doors (metadata-protocol's ingress gate and objectql's // engine seam), so the two cannot drift into answering one spelling two ways. diff --git a/packages/spec/src/data/readable-ids-conformance.test.ts b/packages/spec/src/data/readable-ids-conformance.test.ts new file mode 100644 index 00000000000..dc1b474f669 --- /dev/null +++ b/packages/spec/src/data/readable-ids-conformance.test.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0139 D3] `READABLE_IDS_CASES` lands ahead of every driver that runs it + * (E3 and E4 enrol the drivers), so between now and then nothing executes the + * table — and a miscounted `expected` list would sit quietly wrong until a + * driver author trusted it. This file executes it, against a reference + * evaluator written from the declared semantics: the composed leaf is the + * semi-join `field IN (SELECT id FROM P WHERE scope AND tenant)`, `$not` is + * NULL-safe, and an uncomposed leaf is refused. + * + * The second job is proving the table is not VACUOUS: each of the three ways a + * driver can get the leaf wrong — evaluating it as "no restriction", ignoring + * its scope, compiling its subquery without the tenant wall — must fail at + * least one case, so a driver that makes that mistake cannot pass the table. + */ + +import { describe, it, expect } from 'vitest'; +import type { FilterCondition } from './filter.zod'; +import { isComposedReadableIds, isReadableIds, type ComposedReadableIds } from './readable-ids'; +import { + READABLE_IDS_CASES, + READABLE_IDS_CHILD_ROWS, + READABLE_IDS_PARENT_ROWS, + READABLE_IDS_REFUSAL, + type ReadableIdsCase, + type ReadableIdsMatchesCase, +} from './readable-ids-conformance'; + +// ── A reference evaluator, from the declared semantics ─────────────────────── + +type Row = Record; + +/** The fixture, as rows the evaluator reads by column name. */ +const PARENTS: readonly Row[] = READABLE_IDS_PARENT_ROWS.map((row) => ({ ...row })); +const CHILDREN: readonly Row[] = READABLE_IDS_CHILD_ROWS.map((row) => ({ ...row })); + +/** The ways a driver can get the composed leaf wrong — `faithful` is the declared semantics. */ +type Reading = 'faithful' | 'leaf-as-no-restriction' | 'scope-ignored' | 'subquery-unwalled'; + +interface EvalContext { + readonly tenantId?: string; + readonly reading: Reading; +} + +class UncomposedLeafRefusal extends Error { + readonly code = READABLE_IDS_REFUSAL.code; + readonly status = READABLE_IDS_REFUSAL.status; +} + +/** The ids of the parent rows the composed leaf admits under `ctx`. */ +function readableParentIds(leaf: ComposedReadableIds, ctx: EvalContext): Set { + const walled = ctx.tenantId !== undefined && ctx.reading !== 'subquery-unwalled'; + const ids = new Set(); + for (const parent of PARENTS) { + if (walled && parent.organization_id !== ctx.tenantId) continue; + if (leaf.scope !== undefined && ctx.reading !== 'scope-ignored' && !matches(parent, leaf.scope, ctx)) continue; + ids.add(parent.id); + } + return ids; +} + +/** One field constraint against one row: the subset of operators the table uses. */ +function fieldMatches(row: Row, field: string, spec: unknown, ctx: EvalContext): boolean { + const value = row[field]; + if (spec !== null && typeof spec === 'object' && !Array.isArray(spec) && '$in' in (spec as Row)) { + const comparand = (spec as Row).$in; + if (isReadableIds(comparand)) throw new UncomposedLeafRefusal(`uncomposed ReadableIds leaf at ${field}.$in`); + if (isComposedReadableIds(comparand)) { + if (ctx.reading === 'leaf-as-no-restriction') return true; + return value != null && readableParentIds(comparand, ctx).has(value); + } + if (Array.isArray(comparand)) return comparand.includes(value); + throw new Error(`the reference evaluator has no reading for ${field}.$in = ${String(comparand)}`); + } + // Implicit equality, the null predicate included. + return spec === null ? value == null : value === spec; +} + +function matches(row: Row, where: FilterCondition, ctx: EvalContext): boolean { + for (const [key, spec] of Object.entries(where)) { + if (key === '$and') { + if (!(spec as FilterCondition[]).every((child) => matches(row, child, ctx))) return false; + } else if (key === '$or') { + if (!(spec as FilterCondition[]).some((child) => matches(row, child, ctx))) return false; + } else if (key === '$not') { + // NULL-safe: "not P" holds wherever P is not TRUE, a row with no value included. + if (matches(row, spec as FilterCondition, ctx)) return false; + } else if (!fieldMatches(row, key, spec, ctx)) { + return false; + } + } + return true; +} + +/** What `find` on the child object answers for `c` under `reading`: the ascending ids. */ +function answer(c: ReadableIdsMatchesCase, reading: Reading): string[] { + const ctx: EvalContext = { tenantId: c.options?.tenantId, reading }; + const where = c.where(); + return CHILDREN + // The OUTER read's tenant wall, which every driver already applies. + .filter((row) => ctx.tenantId === undefined || row.organization_id === ctx.tenantId) + .filter((row) => matches(row, where, ctx)) + .map((row) => String(row.id)) + .sort(); +} + +const matchesCases = READABLE_IDS_CASES.filter((c): c is ReadableIdsMatchesCase => c.verdict === 'matches'); + +/** Does `where` hold a composed leaf anywhere? */ +const holdsComposedLeaf = (where: unknown): boolean => { + if (isComposedReadableIds(where)) return true; + if (where === null || typeof where !== 'object') return false; + return (Array.isArray(where) ? where : Object.values(where)).some(holdsComposedLeaf); +}; + +// ── The table, executed ────────────────────────────────────────────────────── + +describe('READABLE_IDS_CASES — the table agrees with the declared semantics', () => { + for (const c of matchesCases) { + it(c.name, () => { + expect(answer(c, 'faithful')).toEqual([...c.expected]); + }); + } + + for (const c of READABLE_IDS_CASES.filter((x) => x.verdict === 'refuses-uncomposed')) { + it(c.name, () => { + const where = c.where(); + let refusal: unknown; + try { + CHILDREN.forEach((row) => matches(row, where, { reading: 'faithful' })); + } catch (e) { + refusal = e; + } + expect(refusal).toBeInstanceOf(UncomposedLeafRefusal); + expect({ code: (refusal as UncomposedLeafRefusal).code, status: (refusal as UncomposedLeafRefusal).status }) + .toEqual({ code: 'INTERNAL_ERROR', status: 500 }); + }); + } +}); + +describe('READABLE_IDS_CASES — each wrong reading of the leaf fails the table', () => { + const failing = (reading: Reading) => + matchesCases.filter((c) => JSON.stringify(answer(c, reading)) !== JSON.stringify([...c.expected])).map((c) => c.name); + + it('a driver that evaluates the composed leaf as "no restriction" fails, on the deleted and the absent parent', () => { + expect(failing('leaf-as-no-restriction')).toContain('no row restriction: every child whose parent exists'); + }); + + it('a driver that compiles the subquery without its scope fails', () => { + expect(failing('scope-ignored')).toContain('a scope narrows the parents whose children match'); + expect(failing('scope-ignored')).toContain('a scope that admits no parent matches no child'); + }); + + it('a driver that compiles the subquery without its tenant wall fails exactly the tenant case', () => { + expect(failing('subquery-unwalled')).toEqual( + matchesCases.filter((c) => c.requiresTenantWall === true).map((c) => c.name), + ); + expect(failing('subquery-unwalled')).toHaveLength(1); + }); +}); + +describe('READABLE_IDS_CASES — the table is well-formed', () => { + it('names are unique, expected ids are ascending and name fixture rows', () => { + const names = READABLE_IDS_CASES.map((c) => c.name); + expect(new Set(names).size).toBe(names.length); + const childIds = new Set(READABLE_IDS_CHILD_ROWS.map((r) => r.id)); + for (const c of matchesCases) { + expect([...c.expected].sort(), c.name).toEqual([...c.expected]); + for (const id of c.expected) expect(childIds.has(id), `${c.name}: ${id}`).toBe(true); + } + }); + + it('holdsComposedLeaf says what each where holds, so a refusing driver refuses exactly those cases', () => { + for (const c of matchesCases) expect(holdsComposedLeaf(c.where()), c.name).toBe(c.holdsComposedLeaf); + expect(matchesCases.filter((c) => !c.holdsComposedLeaf).map((c) => c.name)).toEqual([ + 'the engine\'s denial answer, $in: [], matches no child', + ]); + }); + + it('the refusal case holds an uncomposed leaf and no composed one', () => { + const refusals = READABLE_IDS_CASES.filter((c) => c.verdict === 'refuses-uncomposed'); + expect(refusals).toHaveLength(1); + const where = refusals[0].where() as { record_id: { $in: unknown } }; + expect(isReadableIds(where.record_id.$in)).toBe(true); + expect(holdsComposedLeaf(where)).toBe(false); + }); + + it('every where factory builds fresh leaves: no two suites judge one object', () => { + for (const c of READABLE_IDS_CASES as readonly ReadableIdsCase[]) { + const a = c.where(); + const b = c.where(); + expect(a, c.name).not.toBe(b); + expect(a, c.name).toEqual(b); + } + }); + + it('the refusal envelope is D3\'s composition fault, not an author error', () => { + expect(READABLE_IDS_REFUSAL).toEqual({ code: 'INTERNAL_ERROR', status: 500 }); + expect(Object.isFrozen(READABLE_IDS_REFUSAL)).toBe(true); + }); +}); diff --git a/packages/spec/src/data/readable-ids-conformance.ts b/packages/spec/src/data/readable-ids-conformance.ts new file mode 100644 index 00000000000..91d98b823ee --- /dev/null +++ b/packages/spec/src/data/readable-ids-conformance.ts @@ -0,0 +1,268 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Canonical conformance cases for the **composed `ReadableIds` leaf** — ADR-0139 + * D3: "The leaf joins the per-driver conformance case-sets, so a driver that + * compiles it is measured and one that refuses is recorded as refusing." + * + * # The property + * + * `{ record_id: { $in: ReadableIds.compose(ReadableIds.of(P), scope) } }` + * matches exactly the rows whose `record_id` is the id of a row of `P` that + * `scope` admits, inside the driver's own tenant wall: the semi-join + * `record_id IN (SELECT id FROM P WHERE scope AND tenant)` (D3, `driver-sql`), + * however a driver evaluates it. `scope: undefined` is "no row restriction", + * and the rows of `P` that exist are then the whole set. + * + * # Who builds the leaf, and why a driver never sees `ReadableIds.of` + * + * The engine composes every leaf at its executor boundary, immediately before + * the driver call (D2), so a driver is handed composed leaves only. An + * UNCOMPOSED leaf reaching a driver means something bypassed that seam, and + * the driver refuses it with D3's envelope ({@link READABLE_IDS_REFUSAL}) — + * ⛔ never evaluated as "no restriction". The cases build both shapes with the + * spec's own factory (`./readable-ids.ts`), exactly as the engine does, and a + * driver tells them apart with `isComposedReadableIds` / `isReadableIds`, + * which answer disjointly. + * + * A caller the parent denies is answered `$in: []` by the engine (D2 steps 1 + * and 2, the deny sentinel included) and never reaches a driver as a composed + * leaf, so the table carries that answer as an ordinary empty list. + * + * # How a driver suite consumes this table + * + * Seed {@link READABLE_IDS_PARENT_ROWS} as {@link READABLE_IDS_PARENT_OBJECT} + * and {@link READABLE_IDS_CHILD_ROWS} as {@link READABLE_IDS_CHILD_OBJECT}, both + * declaring `organization_id` as their tenant field, then for each case call + * the driver's own `find` on the child object with `where: c.where()` and the + * case's `options`: + * + * - a driver that COMPILES the leaf (D3: the SQL family, both Turso + * transports, `driver-memory`) asserts the child ids of each `matches` case + * — ascending — and D3's envelope on the `refuses-uncomposed` case; + * - a driver that REFUSES the leaf (D3: `driver-mongodb`, until a card + * measures a pipeline form) asserts D3's envelope on every case that holds a + * composed leaf ({@link readableIdsCaseHoldsComposedLeaf}), and the row ids on + * the rest. That is what "recorded as refusing" means. + * + * A case with {@link ReadableIdsMatchesCase.requiresTenantWall} reads under a + * tenant: it is the one that fails when the subquery is compiled without the + * driver's tenant scope. A driver that refuses multi-tenant boot + * (`driver-memory`, `memory-tenancy-guard.ts`) owes no wall (D3) and records + * that refusal for the case instead of rows. + * + * # What belongs here + * + * Only what a driver decides: which rows the composed leaf admits, how it + * composes under `$and` / `$or` / `$not`, and the refusal of the uncomposed + * leaf. What the ENGINE decides — the composition itself, the write-verb and + * `having` refusals, D5's three classes — is pinned in `@objectstack/objectql`. + * The cases build every leaf from a factory, because the leaf is frozen and + * branded and one shared instance across driver suites would let one suite's + * run judge another's object. + * + * @see ./readable-ids.ts for the leaf, the composed leaf and the deny sentinel + */ + +import type { FilterCondition } from './filter.zod'; +import { ReadableIds } from './readable-ids'; + +/** The parent object — `P` in ADR-0139's notation. */ +export const READABLE_IDS_PARENT_OBJECT = 'rid_parent'; + +/** The child object every case reads; its `record_id` points at the parent. */ +export const READABLE_IDS_CHILD_OBJECT = 'rid_child'; + +/** A row of the parent object. `owner` is what the cases' scopes narrow on. */ +export interface ReadableIdsParentRow { + id: string; + owner: string; + organization_id: string; +} + +/** + * A row of the child object: the ActivityPointer pair the six consumers key on + * (`object_name`, `record_id`), plus a column for the `$and` case to narrow on. + */ +export interface ReadableIdsChildRow { + id: string; + object_name: string; + record_id: string | null; + label: string; + organization_id: string; +} + +/** + * Four parents. `p4` belongs to another tenant, so only a read under a tenant + * can tell a walled subquery from an unwalled one. + */ +export const READABLE_IDS_PARENT_ROWS: readonly ReadableIdsParentRow[] = [ + { id: 'p1', owner: 'u1', organization_id: 'org_a' }, + { id: 'p2', owner: 'u2', organization_id: 'org_a' }, + { id: 'p3', owner: 'u1', organization_id: 'org_a' }, + { id: 'p4', owner: 'u1', organization_id: 'org_b' }, +]; + +/** + * Seven children, all in `org_a`, so the OUTER read's tenant wall excludes none + * of them and every difference a tenant makes comes from the subquery's wall. + * + * - `c4` points at a parent that no longer exists (excluded: the subquery + * finds no such id); + * - `c5` names no parent (excluded by `$in`, admitted by the NULL-safe `$not`); + * - `c6` points across the tenant wall, at `p4`; + * - `c7` names ANOTHER object with an id that `rid_parent` also carries: the + * leaf matches ids, and pairing them with `object_name` is the consumer's + * branch (ADR-0139 D4), so `c7` is in every case that does not say + * `object_name`. + */ +export const READABLE_IDS_CHILD_ROWS: readonly ReadableIdsChildRow[] = [ + { id: 'c1', object_name: 'rid_parent', record_id: 'p1', label: 'keep', organization_id: 'org_a' }, + { id: 'c2', object_name: 'rid_parent', record_id: 'p2', label: 'keep', organization_id: 'org_a' }, + { id: 'c3', object_name: 'rid_parent', record_id: 'p3', label: 'drop', organization_id: 'org_a' }, + { id: 'c4', object_name: 'rid_parent', record_id: 'p9', label: 'keep', organization_id: 'org_a' }, + { id: 'c5', object_name: 'rid_parent', record_id: null, label: 'keep', organization_id: 'org_a' }, + { id: 'c6', object_name: 'rid_parent', record_id: 'p4', label: 'keep', organization_id: 'org_a' }, + { id: 'c7', object_name: 'rid_other', record_id: 'p1', label: 'keep', organization_id: 'org_a' }, +]; + +/** + * D3's refusal envelope (ADR-0112): a composition fault, not an author error, + * so ⛔ not `INVALID_FILTER`. A suite asserts `code` AND `status`; the message + * (it names the driver, the leaf and the remedy) is not pinned here. + */ +export const READABLE_IDS_REFUSAL = Object.freeze({ code: 'INTERNAL_ERROR', status: 500 } as const); + +interface ReadableIdsCaseBase { + /** Stable identifier, usable as a test name. */ + readonly name: string; + /** + * Builds the `where` for a `find` on {@link READABLE_IDS_CHILD_OBJECT}. A + * FACTORY: every call builds fresh leaves. + */ + readonly where: () => FilterCondition; + /** Why the case is here — surfaced in failure output. */ + readonly note?: string; +} + +/** A case whose `where` the driver must EVALUATE, matching exactly {@link expected}. */ +export interface ReadableIdsMatchesCase extends ReadableIdsCaseBase { + readonly verdict: 'matches'; + /** Ids of the matching child rows, ascending. */ + readonly expected: readonly string[]; + /** + * `where` holds a composed leaf. `false` only where the case carries the + * engine's denial answer instead; a driver that refuses the leaf answers + * {@link READABLE_IDS_REFUSAL} exactly on the cases where this is `true`. + */ + readonly holdsComposedLeaf: boolean; + /** The read's driver options; absent means an unscoped read. */ + readonly options?: { readonly tenantId: string }; + /** + * The read is under a tenant, and the case fails when the subquery is + * compiled without the driver's tenant scope (D3). + */ + readonly requiresTenantWall?: true; +} + +/** A case the driver must REFUSE with {@link READABLE_IDS_REFUSAL}: an uncomposed leaf reached it. */ +export interface ReadableIdsRefusesUncomposedCase extends ReadableIdsCaseBase { + readonly verdict: 'refuses-uncomposed'; +} + +export type ReadableIdsCase = ReadableIdsMatchesCase | ReadableIdsRefusesUncomposedCase; + +const P = READABLE_IDS_PARENT_OBJECT; + +/** The composed leaf for {@link READABLE_IDS_PARENT_OBJECT} under `scope`, built as the engine builds it. */ +const composed = (scope: FilterCondition | undefined) => ReadableIds.compose(ReadableIds.of(P), scope); + +/** + * The cases: the leaf alone (no restriction, a narrowing scope, a scope that + * admits nothing, the engine's denial answer), the leaf under each combinator, + * the tenant wall, the consumers' branch shape, and the uncomposed leaf. + */ +export const READABLE_IDS_CASES: readonly ReadableIdsCase[] = [ + { + name: 'no row restriction: every child whose parent exists', + where: () => ({ record_id: { $in: composed(undefined) } }), + verdict: 'matches', + holdsComposedLeaf: true, + expected: ['c1', 'c2', 'c3', 'c6', 'c7'], + note: 'c4 points at a parent that no longer exists and c5 names none: neither id is in the subquery. ' + + 'A driver that evaluates the leaf as "no restriction" returns them.', + }, + { + name: 'a scope narrows the parents whose children match', + where: () => ({ record_id: { $in: composed({ owner: 'u1' }) } }), + verdict: 'matches', + holdsComposedLeaf: true, + expected: ['c1', 'c3', 'c6', 'c7'], + }, + { + name: 'a scope that admits no parent matches no child', + where: () => ({ record_id: { $in: composed({ owner: 'nobody' }) } }), + verdict: 'matches', + holdsComposedLeaf: true, + expected: [], + }, + { + name: 'the engine\'s denial answer, $in: [], matches no child', + where: () => ({ record_id: { $in: [] } }), + verdict: 'matches', + holdsComposedLeaf: false, + expected: [], + note: 'ADR-0139 D2 steps 1 and 2: a caller the parent denies (canReadObject false, or the deny sentinel ' + + 'as its scope) is answered with an empty list, never a composed leaf. Every driver already answers it.', + }, + { + name: 'the leaf ANDs with a predicate on the child', + where: () => ({ $and: [{ label: 'keep' }, { record_id: { $in: composed({ owner: 'u1' }) } }] }), + verdict: 'matches', + holdsComposedLeaf: true, + expected: ['c1', 'c6', 'c7'], + }, + { + name: 'the leaf is one arm of an $or', + where: () => ({ $or: [{ record_id: null }, { record_id: { $in: composed({ owner: 'u2' }) } }] }), + verdict: 'matches', + holdsComposedLeaf: true, + expected: ['c2', 'c5'], + }, + { + name: '$not over the leaf is NULL-safe: it admits the child that names no parent', + where: () => ({ $not: { record_id: { $in: composed(undefined) } } }), + verdict: 'matches', + holdsComposedLeaf: true, + expected: ['c4', 'c5'], + note: 'ADR-0139 D1: the NULL-safe $not of the shared lowering applies to the leaf unchanged. ' + + 'A three-valued NOT IN drops c5.', + }, + { + name: 'the tenant wall binds the subquery: a parent in another tenant is not readable', + where: () => ({ record_id: { $in: composed(undefined) } }), + options: { tenantId: 'org_a' }, + requiresTenantWall: true, + verdict: 'matches', + holdsComposedLeaf: true, + expected: ['c1', 'c2', 'c3', 'c7'], + note: 'ADR-0139 D2 step 3: the tenant wall is not in the scope; the driver applies its own tenant scope to ' + + 'the parent table with the outer read\'s options. c6 points at p4, in org_b: an unwalled subquery admits it.', + }, + { + name: 'the consumers\' branch: the leaf paired with the object it names', + where: () => ({ object_name: P, record_id: { $in: composed(undefined) } }), + verdict: 'matches', + holdsComposedLeaf: true, + expected: ['c1', 'c2', 'c3', 'c6'], + note: 'The branch shape ADR-0139 D4 has each consumer emit per parent object. c7 carries an id that ' + + 'rid_parent also carries, under another object name.', + }, + { + name: 'an uncomposed leaf is refused, never evaluated as no restriction', + where: () => ({ record_id: { $in: ReadableIds.of(P) } }), + verdict: 'refuses-uncomposed', + note: 'ADR-0139 D2\'s last paragraph and D3: a leaf that reached the driver without passing the engine\'s ' + + 'seam fails closed, loudly.', + }, +]; diff --git a/scripts/check-driver-conformance.mjs b/scripts/check-driver-conformance.mjs index 13d9cb1b3e2..a4ecf1145ae 100644 --- a/scripts/check-driver-conformance.mjs +++ b/scripts/check-driver-conformance.mjs @@ -280,6 +280,11 @@ const CASE_SETS = [ marker: 'VALUE_ROUNDTRIP_CASES', what: 'value storage round-trip: what you wrote is what you read back, type included — #12393', }, + { + file: 'readable-ids-conformance.ts', + marker: 'READABLE_IDS_CASES', + what: 'the composed ReadableIds leaf: compiled as a tenant-walled semi-join, or refused loudly — ADR-0139 D3', + }, ]; // ── The ledger ────────────────────────────────────────────────────────────── From c6b0ef90a607103e6a35b5c0d9d9cf9076124825 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 08:54:27 +0000 Subject: [PATCH 2/7] wip(driver-turso): the remote face resolves and compiles the composed ReadableIds leaf Claude-Session: https://claude.ai/code/session_01B1r5eJEPgvn7bUsfBvK2tS Co-authored-by: Claude --- .../src/utils/readable-ids-driver-refusal.ts | 71 +++++++- packages/drivers/driver-sql/src/sql-driver.ts | 8 +- .../src/sqlite-wasm-driver.ts | 2 + .../driver-turso/src/remote-transport.ts | 90 ++++++++++ .../drivers/driver-turso/src/turso-driver.ts | 53 ++++-- .../turso-readable-ids-conformance.test.ts | 163 ++++++++++++++++++ 6 files changed, 369 insertions(+), 18 deletions(-) create mode 100644 packages/drivers/driver-turso/src/turso-readable-ids-conformance.test.ts diff --git a/packages/core/src/utils/readable-ids-driver-refusal.ts b/packages/core/src/utils/readable-ids-driver-refusal.ts index 3e2c64ebf7e..4b2893e22c2 100644 --- a/packages/core/src/utils/readable-ids-driver-refusal.ts +++ b/packages/core/src/utils/readable-ids-driver-refusal.ts @@ -37,7 +37,13 @@ * so there is nothing for the engine to read. */ -import { isComposedReadableIds, isReadableIds } from '@objectstack/spec/data'; +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 { @@ -107,6 +113,55 @@ export function findMisplacedReadableIds( 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(where: T, answer: (leaf: ComposedReadableIds) => unknown): T { + const onPath = new Set(); + 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 | undefined; + for (const key of Object.keys(value)) { + const item = (value as Record)[key]; + const next = key === '$in' && isComposedReadableIds(item) ? answer(item) : rewrite(item); + if (next !== item) (out ??= { ...(value as Record) })[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'; @@ -119,7 +174,12 @@ export type ReadableIdsDriverRefusalError = Error & { * `driver-sql`). The message names the driver, the leaf and the remedy; the * read was not run. */ -export function readableIdsDriverRefusal(driver: string, site: MisplacedReadableIds): ReadableIdsDriverRefusalError { +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) { @@ -138,8 +198,8 @@ export function readableIdsDriverRefusal(driver: string, site: MisplacedReadable break; case 'unsupported': message = - `[${driver}] ${leaf}, composed, and ${driver} does not compile it: no pipeline form of the ` + - 'tenant-walled semi-join is measured for this driver yet. The query was NOT run, rather than ' + + `[${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; @@ -167,8 +227,9 @@ export function refuseReadableIdsOnce( site: MisplacedReadableIds, log: ReadableIdsRefusalLog | undefined, state: { logged: boolean }, + unsupportedReason?: string, ): ReadableIdsDriverRefusalError { - const err = readableIdsDriverRefusal(driver, site); + const err = readableIdsDriverRefusal(driver, site, unsupportedReason); if (!state.logged) { state.logged = true; log?.error(`${err.message} Logged once for this driver; later refusals answer the caller the same way.`, { diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 5a9827b5ec5..8d6fdad4ac5 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -5706,6 +5706,12 @@ export class SqlDriver implements IDataDriver { protected tenantAuditWarned: Set = new Set(); /** [ADR-0139 D3] Whether this instance has logged its `ReadableIds` refusal (once per driver). */ protected readonly readableIdsRefusalLog = { logged: false }; + /** + * [ADR-0139 D3] The driver its `ReadableIds` refusal names — the package a + * subclass that inherits this compiler ships in (`driver-sqlite-wasm`, + * `driver-turso`), so the operator reading the line looks in the right place. + */ + protected readonly readableIdsDriverName: string = 'driver-sql'; /** * Optional logger sink for security-audit warnings. Tests inject a spy; @@ -16725,7 +16731,7 @@ export class SqlDriver implements IDataDriver { // refused before anything compiles: INTERNAL_ERROR / 500, logged at // `error` once per driver. ⛔ Never evaluated as "no restriction". const misplaced = findMisplacedReadableIds(filters, true); - if (misplaced) throw refuseReadableIdsOnce('driver-sql', misplaced, this.logger, this.readableIdsRefusalLog); + if (misplaced) throw refuseReadableIdsOnce(this.readableIdsDriverName, misplaced, this.logger, this.readableIdsRefusalLog); try { this.compileFilters(builder, filters, options); } catch (err) { diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver.ts index 837bc0a97b9..818a626f286 100644 --- a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver.ts +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver.ts @@ -67,6 +67,8 @@ export interface SqliteWasmDriverConfig { export class SqliteWasmDriver extends SqlDriver { public override readonly name: string = 'com.objectstack.driver.sqlite-wasm'; public override readonly version: string = '1.0.0'; + /** [ADR-0139 D3] The inherited `ReadableIds` compile and refusal name this package. */ + protected override readonly readableIdsDriverName: string = 'driver-sqlite-wasm'; /** * Force the SQLite branch in {@link SqlDriver}. The base class detects diff --git a/packages/drivers/driver-turso/src/remote-transport.ts b/packages/drivers/driver-turso/src/remote-transport.ts index 8deefdb0598..09f9430b86f 100644 --- a/packages/drivers/driver-turso/src/remote-transport.ts +++ b/packages/drivers/driver-turso/src/remote-transport.ts @@ -59,6 +59,11 @@ import { jsonMembershipPredicate, type JsonColumnFieldClass, } from '@objectstack/core'; +// [ADR-0139 D3] The walk that finds a `ReadableIds` leaf this compiler will not +// evaluate and the one refusal every driver answers it with, shared with the +// local face (`SqlDriver.applyFilters`), `driver-memory` and `driver-mongodb`. +import { findMisplacedReadableIds, readableIdsDriverRefusal } from '@objectstack/core'; +import { isComposedReadableIds, type FilterCondition } from '@objectstack/spec/data'; // [#8413] What a `unique: true` FIELD becomes, from the one place that decides // it. `uniqueIndexesFromFields`' own contract is that it is "the ONLY place // field-level uniqueness becomes an index, so the create-table, alter-table, @@ -978,6 +983,41 @@ export interface RemoteTenantScope { readonly args: readonly unknown[]; } +const REMOTE_READABLE_IDS_SEMI_JOIN: unique symbol = Symbol('objectstack.driver-turso.remoteReadableIdsSemiJoin'); + +/** + * [ADR-0139 D3] A composed `ReadableIds` leaf, RESOLVED for this transport by + * `TursoDriver`: the parent's remote table and its tenant wall, compiled with + * the OUTER read's options by the local face's own chokepoint + * (`SqlDriver.applyTenantScope`, through `TursoDriver.remoteTenantScope`). + * This class compiles it — `field IN (SELECT "id" FROM P_TABLE WHERE SCOPE AND + * TENANT)` — and never decides the wall, as with {@link RemoteTenantScope}. + * Built only by {@link remoteReadableIdsSemiJoin}; branded, so no JSON can + * spell one. + */ +export interface RemoteReadableIdsSemiJoin { + /** The parent object `P`; its declared columns key the scope's compile. */ + readonly object: string; + /** `P`'s remote table. */ + readonly table: string; + /** The composed scope (`undefined` is no row restriction), compiled against `P`. */ + readonly scope: FilterCondition | undefined; + /** `P`'s tenant wall under the outer read's options; absent means none applies. */ + readonly tenant: RemoteTenantScope | undefined; +} + +/** [ADR-0139 D3] Brand a resolved semi-join for {@link RemoteTransport}'s `$in` arm. */ +export function remoteReadableIdsSemiJoin(join: RemoteReadableIdsSemiJoin): RemoteReadableIdsSemiJoin { + const branded = { object: join.object, table: join.table, scope: join.scope, tenant: join.tenant }; + Object.defineProperty(branded, REMOTE_READABLE_IDS_SEMI_JOIN, { value: true, enumerable: false }); + return Object.freeze(branded); +} + +function isRemoteReadableIdsSemiJoin(value: unknown): value is RemoteReadableIdsSemiJoin { + return value !== null && typeof value === 'object' + && (value as Record)[REMOTE_READABLE_IDS_SEMI_JOIN] === true; +} + /** * Remote transport that executes all queries via @libsql/client. * @@ -2839,6 +2879,31 @@ export class RemoteTransport { }; } + /** + * [ADR-0139 D3] `SELECT "id" FROM P_TABLE WHERE SCOPE AND TENANT` for a + * resolved semi-join — the local face's `SqlDriver.readableIdsSubquery`, + * compiled by this transport's own compiler. The scope is compiled against + * the PARENT object as a `where` of its own (its refusals resolve against + * the scope's own marks, `'policy'` as the engine composed it); the tenant + * wall is the fragment the driver handed over. It binds the scope's and the + * wall's own values only — no per-id parameter. + */ + private readableIdsSubquerySQL(join: RemoteReadableIdsSemiJoin): { sql: string; args: any[] } { + const parts: string[] = []; + const args: any[] = []; + const scope = this.buildWhereSQL(join.object, join.scope); + if (scope.whereClauses) { + parts.push(`(${scope.whereClauses})`); + args.push(...scope.args); + } + if (join.tenant) { + parts.push(`(${join.tenant.sql})`); + args.push(...join.tenant.args); + } + const where = parts.length > 0 ? ` WHERE ${parts.join(' AND ')}` : ''; + return { sql: `SELECT "id" FROM ${this.tableSql(join.table)}${where}`, args }; + } + private buildWhereSQL( object: string, filters: any, @@ -2853,6 +2918,13 @@ export class RemoteTransport { // seam: 'author' swaps in the full text; 'policy', unmarked, unreachable // and ambiguous all keep the redaction. if (path === 'where') { + // [ADR-0139 D3] An UNCOMPOSED `ReadableIds` leaf anywhere, or a composed + // one outside a `$in` comparand, is refused before anything compiles: + // INTERNAL_ERROR / 500, ⛔ never evaluated as "no restriction". + // `TursoDriver` asks the same walk first, and logs; a resolved + // semi-join is not a leaf, so this answers `null` for every tree it hands over. + const misplaced = findMisplacedReadableIds(filters, true); + if (misplaced) throw readableIdsDriverRefusal('driver-turso', misplaced); try { return this.compileWhereSQL(object, filters, path); } catch (err) { @@ -3210,6 +3282,24 @@ export class RemoteTransport { // degrades exactly like a scalar operator's object comparand did, // just one row of the IN list at a time. case '$in': { + // [ADR-0139 D3] The semi-join, ahead of the list arm (an object + // is no list). A composed leaf `TursoDriver` did not resolve — + // this class driven standalone — is refused: only the driver + // knows the parent's table and wall. + if (isRemoteReadableIdsSemiJoin(opValue)) { + const sub = this.readableIdsSubquerySQL(opValue); + clauses.push(`${column} IN (${sub.sql})`); + args.push(...sub.args); + break; + } + if (isComposedReadableIds(opValue)) { + throw readableIdsDriverRefusal( + 'driver-turso', + { at: `${path}.${key}.$in`, object: opValue.object, kind: 'unsupported' }, + 'the remote transport compiles it only as TursoDriver resolved it, with the parent\'s table ' + + 'and tenant wall; drive the read through TursoDriver', + ); + } const inVals = opValue as any[]; const binds = inVals.map((v: any, i: number) => this.serializeComparand(object, key, `${op}[${i}]`, v), diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index fca85eaa8ad..d49e052c151 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -40,7 +40,10 @@ import { StandardErrorCode } from '@objectstack/spec/api'; import type { DriverQuery } from '@objectstack/spec/contracts'; import type { DriverOptions, FilterCondition } from '@objectstack/spec/data'; import type { Client } from '@libsql/client'; -import { RemoteTransport, type RemoteTenantScope } from './remote-transport.js'; +import { RemoteTransport, remoteReadableIdsSemiJoin, type RemoteTenantScope } from './remote-transport.js'; +// [ADR-0139 D3] The walk, the rewrite and the refusal for a `ReadableIds` leaf — +// shared with the local face this class inherits, `driver-memory` and `driver-mongodb`. +import { findMisplacedReadableIds, refuseReadableIdsOnce, replaceComposedReadableIds } from '@objectstack/core'; import { backfillRemoteCanonicalColumns, type RemoteBackfillClient, @@ -1590,6 +1593,8 @@ export class TursoDriver extends SqlDriver { // IDataDriver metadata public override readonly name: string = 'com.objectstack.driver.turso'; public override readonly version: string = '1.0.0'; + /** [ADR-0139 D3] The inherited `ReadableIds` compile and refusal name this package. */ + protected override readonly readableIdsDriverName: string = 'driver-turso'; public override get supports() { // Inherit the SqlDriver capability baseline (incl. autonumber via the @@ -2156,7 +2161,7 @@ export class TursoDriver extends SqlDriver { this.assertRemoteTransactionUnsupported(options, 'find'); if (this.isRemote) { const table = this.remoteTableFor(object, 'find'); - const remoteQuery = this.toRemoteReadQuery(object, query); + const remoteQuery = this.toRemoteReadQuery(object, query, undefined, options); // [#21226] The caller's tenant scope, as the local face scopes `findRows`. const scope = this.remoteTenantScope(object, options); return this.formatRemoteRows( @@ -2179,7 +2184,7 @@ export class TursoDriver extends SqlDriver { this.assertRemoteTransactionUnsupported(options, 'findOne'); if (this.isRemote) { const table = this.remoteTableFor(object, 'findOne'); - const remoteQuery = this.toRemoteReadQuery(object, query, { singleRowLookup: true }); + const remoteQuery = this.toRemoteReadQuery(object, query, { singleRowLookup: true }, options); const scope = this.remoteTenantScope(object, options); return this.formatRemoteRow( object, @@ -2868,7 +2873,7 @@ export class TursoDriver extends SqlDriver { this.assertRemoteTransactionUnsupported(options, 'count'); if (this.isRemote) { const table = this.remoteTableFor(object, 'count'); - const remoteQuery = this.toRemoteQuery(object, query); + const remoteQuery = this.toRemoteQuery(object, query, options); const scope = this.remoteTenantScope(object, options); return this.remoteReadExit(object, { where: query?.where }, () => this.remoteTransport!.count(object, remoteQuery, table, scope), @@ -2911,7 +2916,7 @@ export class TursoDriver extends SqlDriver { // are grouped, as `SqlDriver.aggregate` scopes its builder. const scope = this.remoteTenantScope(object, options); return this.remoteReadExit(object, query, () => - this.remoteTransport!.aggregate(object, this.toRemoteQuery(object, query), table, scope), + this.remoteTransport!.aggregate(object, this.toRemoteQuery(object, query, options), table, scope), ); } return super.aggregate(object, query, options); @@ -3088,10 +3093,33 @@ export class TursoDriver extends SqlDriver { return out; } - /** A query with its `where` compiled through {@link toRemoteFilter}. */ - private toRemoteQuery(object: string, query?: DriverQuery): any { + /** A query with its `where` compiled through {@link toRemoteWhere}. */ + private toRemoteQuery(object: string, query?: DriverQuery, options?: DriverOptions): any { if (!query || typeof query !== 'object' || query.where == null) return query; - return { ...query, where: this.toRemoteFilter(object, query.where) }; + return { ...query, where: this.toRemoteWhere(object, query.where, options) }; + } + + /** + * [ADR-0139 D3] A caller's `where` as the remote transport receives it: every + * composed `ReadableIds` leaf RESOLVED into the transport's semi-join — the + * parent's remote table, its scope in storage form, and its tenant wall + * compiled with the OUTER read's options by the chokepoint the local face + * uses ({@link remoteTenantScope}) — then {@link toRemoteFilter}. The + * transport compiles `field IN (SELECT "id" FROM P_TABLE WHERE SCOPE AND + * TENANT)` and decides nothing about the wall, so the two faces answer one + * row set. An uncomposed leaf, or a composed one outside a `$in`, is refused + * first — INTERNAL_ERROR / 500, logged at `error` once per driver. + */ + private toRemoteWhere(object: string, where: unknown, options: DriverOptions | undefined): unknown { + const misplaced = findMisplacedReadableIds(where, true); + if (misplaced) throw refuseReadableIdsOnce(this.readableIdsDriverName, misplaced, this.logger, this.readableIdsRefusalLog); + const resolved = replaceComposedReadableIds(where, (leaf) => remoteReadableIdsSemiJoin({ + object: leaf.object, + table: this.remoteTableFor(leaf.object, 'ReadableIds'), + scope: leaf.scope === undefined ? undefined : (this.toRemoteFilter(leaf.object, leaf.scope) as FilterCondition), + tenant: this.remoteTenantScope(leaf.object, options), + })); + return this.toRemoteFilter(object, resolved); } /** @@ -3131,13 +3159,14 @@ export class TursoDriver extends SqlDriver { object: string, query: DriverQuery, opts?: { singleRowLookup?: boolean }, + options?: DriverOptions, ): any { if (!query || typeof query !== 'object') return query; const orderBy = this.orderKeysFor(object, query, opts).map((key) => ({ field: key.field, order: key.direction, })); - return this.toRemoteQuery(object, { ...query, orderBy }); + return this.toRemoteQuery(object, { ...query, orderBy }, options); } /** @@ -3490,7 +3519,7 @@ export class TursoDriver extends SqlDriver { // [#21226] The caller's filter AND tenant scope, as `SqlDriver.updateMany`. return this.remoteTransport!.updateMany( object, - this.toRemoteQuery(object, query), + this.toRemoteQuery(object, query, options), this.toRemoteWriteForms(object, data), table, this.remoteTenantScope(object, options), @@ -3506,7 +3535,7 @@ export class TursoDriver extends SqlDriver { // [#21226] The caller's filter AND tenant scope, as `SqlDriver.deleteMany`. return this.remoteTransport!.deleteMany( object, - this.toRemoteQuery(object, query), + this.toRemoteQuery(object, query, options), table, this.remoteTenantScope(object, options), ); @@ -3816,7 +3845,7 @@ export class TursoDriver extends SqlDriver { } // Compiled outside the classifier, so a filter refusal keeps its envelope. const table = this.remoteTableFor(object, 'distinct'); - const { sql, args } = this.remoteTransport!.compileDistinct(object, field, this.toRemoteFilter(object, filters), table); + const { sql, args } = this.remoteTransport!.compileDistinct(object, field, this.toRemoteWhere(object, filters, options), table); let rows: unknown; try { rows = await this.remoteTransport!.execute(sql, args); diff --git a/packages/drivers/driver-turso/src/turso-readable-ids-conformance.test.ts b/packages/drivers/driver-turso/src/turso-readable-ids-conformance.test.ts new file mode 100644 index 00000000000..9a863a430ca --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-readable-ids-conformance.test.ts @@ -0,0 +1,163 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0139 D3, plan row E3] `TursoDriver` compiles the composed `ReadableIds` + * leaf on BOTH faces, and refuses an uncomposed one with D3's envelope: + * + * - **local** inherits `SqlDriver`'s compile (`readableIdsSubquery`, walled by + * `applyTenantScope` with the outer read's options); + * - **remote** is an independent compiler: `TursoDriver.toRemoteWhere` + * resolves the leaf — the parent's table and its tenant wall, compiled by the + * same chokepoint — and `RemoteTransport` compiles the semi-join. + * + * The shared case-set (`READABLE_IDS_CASES`) runs on each face through the + * shared lowering a real read passes, then E3's pins on the remote face, the + * one that inherits nothing. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { + READABLE_IDS_CASES, + READABLE_IDS_CHILD_OBJECT, + READABLE_IDS_CHILD_ROWS, + READABLE_IDS_PARENT_OBJECT, + READABLE_IDS_PARENT_ROWS, + READABLE_IDS_REFUSAL, + ReadableIds, + lowerFilterCondition, +} from '@objectstack/spec/data'; +import { TursoDriver } from './turso-driver.js'; +import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; + +const seamed = (where: unknown): any => lowerFilterCondition(where, { isDatetimeColumn: () => false }); + +const PARENT = { + name: READABLE_IDS_PARENT_OBJECT, + fields: { owner: { type: 'string' }, organization_id: { type: 'string' } }, +}; +const CHILD = { + name: READABLE_IDS_CHILD_OBJECT, + fields: { + object_name: { type: 'string' }, + record_id: { type: 'string' }, + label: { type: 'string' }, + organization_id: { type: 'string' }, + }, +}; +const BIG_PARENT = { ...PARENT, name: 'rid_big_parent' }; +const BIG_CHILD = { ...CHILD, name: 'rid_big_child' }; +/** Past the 2,000-row window the pre-scan this leaf replaces was capped at. */ +const BIG_READABLE = 2_100; +const BIG_UNREADABLE = 50; + +const sortedIds = (rows: Array>): string[] => + rows.map((r) => String(r.id)).sort((a, b) => a.localeCompare(b)); + +type Face = 'local' | 'remote'; + +function declareFace(face: Face): void { + describe(`[ADR-0139 D3] TursoDriver — ReadableIds conformance (${face} face)`, () => { + let driver: TursoDriver; + let stub: LibsqlSqliteStub | undefined; + + beforeAll(async () => { + if (face === 'remote') { + stub = makeLibsqlSqliteStub(); + driver = new TursoDriver({ url: 'libsql://readable-ids.turso.io', client: stub as never }); + await driver.connect(); + expect(driver.transportMode).toBe('remote'); + for (const obj of [PARENT, CHILD, BIG_PARENT, BIG_CHILD]) await driver.syncSchema(obj.name, obj); + } else { + driver = new TursoDriver({ url: ':memory:' }); + expect(driver.transportMode).toBe('local'); + await driver.initObjects([PARENT, CHILD, BIG_PARENT, BIG_CHILD]); + } + const opts = { bypassTenantAudit: true } as const; + for (const row of READABLE_IDS_PARENT_ROWS) await driver.create(PARENT.name, { ...row }, opts); + for (const row of READABLE_IDS_CHILD_ROWS) await driver.create(CHILD.name, { ...row }, opts); + if (face === 'remote') { + for (let i = 0; i < BIG_READABLE + BIG_UNREADABLE; i++) { + const id = `bp${String(i).padStart(5, '0')}`; + await driver.create(BIG_PARENT.name, { + id, owner: i < BIG_READABLE ? 'reader' : 'someone_else', organization_id: 'org_a', + }, opts); + await driver.create(BIG_CHILD.name, { + id: `bc${String(i).padStart(5, '0')}`, object_name: BIG_PARENT.name, record_id: id, label: 'x', organization_id: 'org_a', + }, opts); + } + } + }, 120_000); + + afterAll(async () => { + await driver?.disconnect(); + stub?.close(); + }); + + describe('shared conformance cases', () => { + for (const c of READABLE_IDS_CASES) { + if (c.verdict === 'matches') { + it(c.name, async () => { + const rows = await driver.find(CHILD.name, { where: seamed(c.where()) }, c.options); + expect(sortedIds(rows), c.note).toEqual(c.expected); + expect(await driver.count(CHILD.name, { where: seamed(c.where()) }, c.options), c.note).toBe(c.expected.length); + }); + } else { + it(c.name, async () => { + await expect(driver.find(CHILD.name, { where: seamed(c.where()) }), c.note).rejects.toMatchObject( + READABLE_IDS_REFUSAL, + ); + }); + } + } + }); + + if (face !== 'remote') return; + + describe('E3 pins (remote face)', () => { + const scoped = () => ({ record_id: { $in: ReadableIds.compose(ReadableIds.of(BIG_PARENT.name), { owner: 'reader' }) } }); + + it(`a reader with ${BIG_READABLE} readable candidates gets every visible row, and count equals the rows served`, async () => { + const rows = await driver.find(BIG_CHILD.name, { where: seamed(scoped()) }, { tenantId: 'org_a' }); + expect(rows).toHaveLength(BIG_READABLE); + expect(await driver.count(BIG_CHILD.name, { where: seamed(scoped()) }, { tenantId: 'org_a' })).toBe(rows.length); + }); + + it('an unreadable row stays hidden', async () => { + const rows = await driver.find(BIG_CHILD.name, { where: seamed(scoped()) }, { tenantId: 'org_a' }); + expect(rows.every((r) => Number(String(r.record_id).slice(2)) < BIG_READABLE)).toBe(true); + }); + + it('a deleted parent is excluded', async () => { + await driver.delete(BIG_PARENT.name, 'bp00000', { bypassTenantAudit: true } as any); + const rows = await driver.find(BIG_CHILD.name, { where: seamed(scoped()) }, { tenantId: 'org_a' }); + expect(rows).toHaveLength(BIG_READABLE - 1); + expect(rows.some((r) => r.record_id === 'bp00000')).toBe(false); + expect(await driver.count(BIG_CHILD.name, { where: seamed(scoped()) }, { tenantId: 'org_a' })).toBe(BIG_READABLE - 1); + }); + + it('aggregate agrees with find', async () => { + const out = await driver.aggregate( + BIG_CHILD.name, + { where: seamed(scoped()), aggregations: [{ function: 'count', alias: 'n' }] } as any, + { tenantId: 'org_a' }, + ); + expect(Number((out[0] as any).n)).toBe(BIG_READABLE - 1); + }); + + it('the refusal is logged at error once per driver, and every refusal reaches the caller', async () => { + const error = vi.spyOn((driver as any).logger, 'error'); + const uncomposed = () => ({ record_id: { $in: ReadableIds.of(BIG_PARENT.name) } }); + for (let i = 0; i < 2; i++) { + await expect(driver.find(BIG_CHILD.name, { where: uncomposed() as any })).rejects.toMatchObject(READABLE_IDS_REFUSAL); + await expect(driver.count(BIG_CHILD.name, { where: uncomposed() as any })).rejects.toMatchObject(READABLE_IDS_REFUSAL); + } + // Once for this instance — the shared cases above already refused once. + expect(error.mock.calls.length).toBeLessThanOrEqual(1); + error.mockRestore(); + }); + }); + }); +} + +declareFace('local'); +declareFace('remote'); From 67ab24b79f4f62b49103a2457223e0d851ae4d03 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 09:00:22 +0000 Subject: [PATCH 3/7] wip(drivers): sqlite-wasm, memory and mongodb ReadableIds suites; memory compiles in process, mongodb refuses Claude-Session: https://claude.ai/code/session_01B1r5eJEPgvn7bUsfBvK2tS Co-authored-by: Claude --- .../src/utils/readable-ids-driver-refusal.ts | 19 ++- .../driver-memory/src/memory-driver.ts | 34 ++++- .../memory-readable-ids-conformance.test.ts | 137 ++++++++++++++++++ .../driver-mongodb/src/mongodb-filter.ts | 20 +++ .../mongodb-readable-ids-conformance.test.ts | 92 ++++++++++++ ...lite-wasm-readable-ids-conformance.test.ts | 74 ++++++++++ 6 files changed, 366 insertions(+), 10 deletions(-) create mode 100644 packages/drivers/driver-memory/src/memory-readable-ids-conformance.test.ts create mode 100644 packages/drivers/driver-mongodb/src/mongodb-readable-ids-conformance.test.ts create mode 100644 packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-readable-ids-conformance.test.ts diff --git a/packages/core/src/utils/readable-ids-driver-refusal.ts b/packages/core/src/utils/readable-ids-driver-refusal.ts index 4b2893e22c2..9ad7824a18f 100644 --- a/packages/core/src/utils/readable-ids-driver-refusal.ts +++ b/packages/core/src/utils/readable-ids-driver-refusal.ts @@ -211,9 +211,14 @@ export function readableIdsDriverRefusal( return err; } -/** The slice of a driver's logger the refusal needs. */ +/** + * The slice of a driver's logger the refusal needs. `error` is optional because + * a driver's injectable logger declares it so (`SqlDriver`'s does); a sink that + * carries none gets the line at `warn`, never no line. + */ export interface ReadableIdsRefusalLog { - error(message: string, meta?: Record): void; + error?(message: string, meta?: Record): void; + warn?(message: string, meta?: Record): void; } /** @@ -232,12 +237,10 @@ export function refuseReadableIdsOnce( const err = readableIdsDriverRefusal(driver, site, unsupportedReason); if (!state.logged) { state.logged = true; - log?.error(`${err.message} Logged once for this driver; later refusals answer the caller the same way.`, { - code: err.code, - object: site.object, - at: site.at, - kind: site.kind, - }); + const line = `${err.message} Logged once for this driver; later refusals answer the caller the same way.`; + const meta = { code: err.code, object: site.object, at: site.at, kind: site.kind }; + if (typeof log?.error === 'function') log.error(line, meta); + else log?.warn?.(line, meta); } return err; } diff --git a/packages/drivers/driver-memory/src/memory-driver.ts b/packages/drivers/driver-memory/src/memory-driver.ts index ac823bf8fa9..4f091158381 100644 --- a/packages/drivers/driver-memory/src/memory-driver.ts +++ b/packages/drivers/driver-memory/src/memory-driver.ts @@ -19,6 +19,10 @@ import { expandEmptyOperator, type ValueShapeFieldDef } from '@objectstack/spec/ import { STRUCTURED_JSON_TYPES, isMultiValueField } from '@objectstack/spec/data'; import type { DriverQuery, IDataDriver } from '@objectstack/spec/contracts'; import { Logger, createLogger, compensatedSum } from '@objectstack/core'; +// [ADR-0139 D3] The walk, the rewrite and the refusal for a `ReadableIds` leaf — +// one source for every driver. +import { findMisplacedReadableIds, refuseReadableIdsOnce, replaceComposedReadableIds } from '@objectstack/core'; +import type { ComposedReadableIds } from '@objectstack/spec/data'; import { Query, Aggregator } from 'mingo'; import { assertSingleTenantPosture, @@ -496,6 +500,8 @@ export class InMemoryDriver implements IDataDriver { readonly version = '1.0.0'; private config: InMemoryDriverConfig; private logger: Logger; + /** [ADR-0139 D3] Whether this instance has logged its `ReadableIds` refusal (once per driver). */ + private readonly readableIdsRefusalLog = { logged: false }; private idCounters: Map = new Map(); /** @@ -1449,9 +1455,17 @@ export class InMemoryDriver implements IDataDriver { // [#21066] With the object's declarations, so a scalar comparison on a // declared JSON-stored field is refused there too, before mingo answers // it per element. - assertFilterConditionShape(filters, 'filter', DRIVER_FILTER_CAPABILITIES, this.filterFieldDeclarations(object)); + // [ADR-0139 D3] A composed `ReadableIds` leaf is evaluated in process — + // read the parent under the composed scope, then member-test — BEFORE the + // shape gate, which then sees an ordinary `$in` list. An uncomposed leaf, + // or a composed one outside a `$in`, is refused first: INTERNAL_ERROR / + // 500, logged at `error` once per driver, ⛔ never read as no restriction. + const misplaced = findMisplacedReadableIds(filters, true); + if (misplaced) throw refuseReadableIdsOnce('driver-memory', misplaced, this.logger, this.readableIdsRefusalLog); + const resolved = replaceComposedReadableIds(filters, (leaf) => this.readableParentIds(leaf)); + assertFilterConditionShape(resolved, 'filter', DRIVER_FILTER_CAPABILITIES, this.filterFieldDeclarations(object)); // Translate non-standard operators ($contains, $notContains, etc.) to Mingo-compatible format - return this.normalizeFilterCondition(filters, object); + return this.normalizeFilterCondition(resolved, object); } // A truthy non-object, non-array `where` emits no predicate. Pre-existing @@ -1460,6 +1474,22 @@ export class InMemoryDriver implements IDataDriver { return {}; } + /** + * [ADR-0139 D3] The ids of the parent's rows the composed scope admits + * (`undefined`: every row that exists) — the semi-join's subquery, run in + * process through this driver's own filter path, so the scope meets the same + * gate and translation a direct read of the parent does. Unbounded: there is + * no parameter ceiling in process. No tenant wall is owed: this driver + * refuses a tenant-scoped call and a multi-tenant boot + * (`memory-tenancy-guard.ts`), so a read that reaches here has none. + */ + private readableParentIds(leaf: ComposedReadableIds): unknown[] { + let rows: Record[] = this.getTable(leaf.object); + const mongoQuery = this.convertToMongoQuery(leaf.scope, leaf.object); + if (mongoQuery && Object.keys(mongoQuery).length > 0) rows = new Query(mongoQuery).find(rows).all() as Record[]; + return rows.map((row) => row.id); + } + /** * Convert a single ObjectQL condition to MongoDB operator format. */ diff --git a/packages/drivers/driver-memory/src/memory-readable-ids-conformance.test.ts b/packages/drivers/driver-memory/src/memory-readable-ids-conformance.test.ts new file mode 100644 index 00000000000..4085e65a9c8 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-readable-ids-conformance.test.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0139 D3, plan row E4] `driver-memory` implements the composed + * `ReadableIds` leaf in process — it reads the parent under the composed + * scope, then member-tests — and refuses an uncomposed leaf with D3's + * envelope. It owes no tenant wall: it refuses a tenant-scoped call + * (`memory-tenancy-guard.ts`), so the case-set's tenant-wall case records that + * refusal instead of rows, as the case-set prescribes. + * + * The shared case-set (`READABLE_IDS_CASES`) runs through the shared lowering + * a real read passes, then E3's pins, which hold on every compiling driver. + */ + +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { + READABLE_IDS_CASES, + READABLE_IDS_CHILD_OBJECT, + READABLE_IDS_CHILD_ROWS, + READABLE_IDS_PARENT_OBJECT, + READABLE_IDS_PARENT_ROWS, + READABLE_IDS_REFUSAL, + ReadableIds, + lowerFilterCondition, +} from '@objectstack/spec/data'; +import { InMemoryDriver } from './memory-driver.js'; +import { MULTI_TENANT_UNSUPPORTED_CODE } from './memory-tenancy-guard.js'; + +const seamed = (where: unknown): any => lowerFilterCondition(where, { isDatetimeColumn: () => false }); + +const TEXT = (name: string) => ({ type: 'text', name }); +const PARENT_FIELDS = { id: TEXT('id'), owner: TEXT('owner'), organization_id: TEXT('organization_id') }; +const CHILD_FIELDS = { + id: TEXT('id'), + object_name: TEXT('object_name'), + record_id: TEXT('record_id'), + label: TEXT('label'), + organization_id: TEXT('organization_id'), +}; +const BIG_PARENT = 'rid_big_parent'; +const BIG_CHILD = 'rid_big_child'; +/** Past the 2,000-row window the pre-scan this leaf replaces was capped at. */ +const BIG_READABLE = 2_400; +const BIG_UNREADABLE = 300; + +const sortedIds = (rows: Array>): string[] => + rows.map((r) => String(r.id)).sort((a, b) => a.localeCompare(b)); + +describe('[ADR-0139 D3] InMemoryDriver — ReadableIds conformance', () => { + let driver: InMemoryDriver; + + beforeAll(async () => { + driver = new InMemoryDriver({ persistence: false }); + await driver.connect(); + await driver.syncSchema(READABLE_IDS_PARENT_OBJECT, { fields: PARENT_FIELDS }); + await driver.syncSchema(READABLE_IDS_CHILD_OBJECT, { fields: CHILD_FIELDS }); + await driver.syncSchema(BIG_PARENT, { fields: PARENT_FIELDS }); + await driver.syncSchema(BIG_CHILD, { fields: CHILD_FIELDS }); + for (const row of READABLE_IDS_PARENT_ROWS) await driver.create(READABLE_IDS_PARENT_OBJECT, { ...row }); + for (const row of READABLE_IDS_CHILD_ROWS) await driver.create(READABLE_IDS_CHILD_OBJECT, { ...row }); + for (let i = 0; i < BIG_READABLE + BIG_UNREADABLE; i++) { + const id = `bp${String(i).padStart(5, '0')}`; + await driver.create(BIG_PARENT, { id, owner: i < BIG_READABLE ? 'reader' : 'someone_else', organization_id: 'org_a' }); + await driver.create(BIG_CHILD, { + id: `bc${String(i).padStart(5, '0')}`, object_name: BIG_PARENT, record_id: id, label: 'x', organization_id: 'org_a', + }); + } + }); + + describe('shared conformance cases', () => { + for (const c of READABLE_IDS_CASES) { + if (c.verdict === 'refuses-uncomposed') { + it(c.name, async () => { + await expect(driver.find(READABLE_IDS_CHILD_OBJECT, { where: seamed(c.where()) }), c.note).rejects.toMatchObject( + READABLE_IDS_REFUSAL, + ); + }); + } else if (c.requiresTenantWall) { + // D3: memory refuses multi-tenant boot and every tenant-scoped call, so + // it owes no wall — the case records that refusal. + it(`${c.name} — refused: driver-memory answers no tenant-scoped read`, async () => { + await expect( + driver.find(READABLE_IDS_CHILD_OBJECT, { where: seamed(c.where()) }, c.options), + ).rejects.toMatchObject({ code: MULTI_TENANT_UNSUPPORTED_CODE }); + }); + } else { + it(c.name, async () => { + const rows = await driver.find(READABLE_IDS_CHILD_OBJECT, { where: seamed(c.where()) }, c.options); + expect(sortedIds(rows), c.note).toEqual(c.expected); + expect(await driver.count(READABLE_IDS_CHILD_OBJECT, { where: seamed(c.where()) }, c.options), c.note).toBe( + c.expected.length, + ); + }); + } + } + }); + + describe('E3 pins', () => { + const scoped = () => ({ record_id: { $in: ReadableIds.compose(ReadableIds.of(BIG_PARENT), { owner: 'reader' }) } }); + + it(`a reader with ${BIG_READABLE} readable candidates gets every visible row, and count equals the rows served`, async () => { + const rows = await driver.find(BIG_CHILD, { where: seamed(scoped()) }); + expect(rows).toHaveLength(BIG_READABLE); + expect(await driver.count(BIG_CHILD, { where: seamed(scoped()) })).toBe(rows.length); + }); + + it('an unreadable row stays hidden', async () => { + const rows = await driver.find(BIG_CHILD, { where: seamed(scoped()) }); + expect(rows.every((r) => Number(String(r.record_id).slice(2)) < BIG_READABLE)).toBe(true); + }); + + it('a deleted parent is excluded, on find, count and aggregate alike', async () => { + await driver.delete(BIG_PARENT, 'bp00000'); + const rows = await driver.find(BIG_CHILD, { where: seamed(scoped()) }); + expect(rows).toHaveLength(BIG_READABLE - 1); + expect(rows.some((r) => r.record_id === 'bp00000')).toBe(false); + expect(await driver.count(BIG_CHILD, { where: seamed(scoped()) })).toBe(BIG_READABLE - 1); + const out = await driver.aggregate(BIG_CHILD, { + where: seamed(scoped()), + aggregations: [{ function: 'count', alias: 'n' }], + } as any); + expect(Number((out[0] as any).n)).toBe(BIG_READABLE - 1); + }); + + it('the refusal is logged at error once per driver, and every refusal reaches the caller', async () => { + const fresh = new InMemoryDriver({ persistence: false }); + await fresh.connect(); + const error = vi.spyOn((fresh as any).logger, 'error'); + for (let i = 0; i < 2; i++) { + await expect( + fresh.find(BIG_CHILD, { where: { record_id: { $in: ReadableIds.of(BIG_PARENT) } } as any }), + ).rejects.toMatchObject(READABLE_IDS_REFUSAL); + } + expect(error).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/drivers/driver-mongodb/src/mongodb-filter.ts b/packages/drivers/driver-mongodb/src/mongodb-filter.ts index 2c4ba4f8bab..c3b43c14be0 100644 --- a/packages/drivers/driver-mongodb/src/mongodb-filter.ts +++ b/packages/drivers/driver-mongodb/src/mongodb-filter.ts @@ -58,6 +58,9 @@ import { STRUCTURED_JSON_TYPES, isMultiValueField } from '@objectstack/spec/data // candidate rule every SQL dialect binds (`@objectstack/core`), read here as // JSON values instead of as JSON text. See {@link containsMembers}. import { jsonMembershipCandidates } from '@objectstack/core'; +// [ADR-0139 D3] The walk that finds a `ReadableIds` leaf and the one refusal +// every driver answers it with — this driver refuses every one. +import { findMisplacedReadableIds, refuseReadableIdsOnce } from '@objectstack/core'; import { coerceTemporalValue, type TemporalFieldKind, @@ -898,6 +901,14 @@ function safeShapePreview(value: unknown): string { } } +/** + * [ADR-0139 D3] Whether this process has logged the `ReadableIds` refusal: once + * per driver. This driver carries no injectable logger, and the refusal is + * raised in this module's pure translation, so the line goes to the console + * once per process; every refusal still reaches the caller. + */ +const READABLE_IDS_REFUSAL_LOG = { logged: false }; + /** * Translate an ObjectStack `where` clause into a MongoDB filter document. * @@ -933,6 +944,15 @@ export function translateFilter( if (typeof where !== 'object') return {}; + // [ADR-0139 D3] This driver REFUSES the `ReadableIds` leaf — composed or not, + // at any position — until a card measures a pipeline form of the + // tenant-walled semi-join (ADR-0139 open item O3): INTERNAL_ERROR / 500, + // ahead of the shape gate, which would answer the leaf `INVALID_FILTER` as + // an author error. Every door and the aggregation pipeline translate their + // `where` here, so one check covers them all. ⛔ No `DriverCapabilities` bit. + const leaf = findMisplacedReadableIds(where, false); + if (leaf) throw refuseReadableIdsOnce('driver-mongodb', leaf, console, READABLE_IDS_REFUSAL_LOG); + // [#5239] Shape gate + structural reduction FIRST, over the WHOLE tree. Only // a `'clause'` node reaches the emitter, so `translateCondition` never has to // ask whether what it built came out empty. diff --git a/packages/drivers/driver-mongodb/src/mongodb-readable-ids-conformance.test.ts b/packages/drivers/driver-mongodb/src/mongodb-readable-ids-conformance.test.ts new file mode 100644 index 00000000000..c0b9b50a36b --- /dev/null +++ b/packages/drivers/driver-mongodb/src/mongodb-readable-ids-conformance.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0139 D3, plan row E4] `driver-mongodb` REFUSES the `ReadableIds` leaf, + * and the conformance table records it as refusing: D3's envelope + * (`INTERNAL_ERROR` / 500) on every case that holds a composed leaf and on the + * uncomposed case, and the row answer on the one case that holds none (the + * engine's `$in: []` denial). It refuses until a card measures a pipeline form + * of the tenant-walled semi-join (ADR-0139 open item O3). + * + * Server-free, at `translateFilter` — the translation every door (`find`, + * `findOne`, `count`, `updateMany`, `deleteMany`) and the aggregation pipeline + * hand their `where` to, before any I/O. This fleet cannot fetch a mongod + * binary (see `test-mongod.ts`), and a refusal raised before the collection is + * asked needs none. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + READABLE_IDS_CASES, + READABLE_IDS_REFUSAL, + ReadableIds, + lowerFilterCondition, +} from '@objectstack/spec/data'; +import { translateFilter } from './mongodb-filter.js'; +import { buildAggregationPipeline } from './mongodb-aggregation.js'; + +const seamed = (where: unknown): any => lowerFilterCondition(where, { isDatetimeColumn: () => false }); + +/** The refusal `translateFilter` raised for `where`, or a failure naming what it returned instead. */ +function refusalOf(where: unknown): unknown { + try { + const out = translateFilter(where); + return { notRefused: out }; + } catch (err) { + return err; + } +} + +/** + * The rows a translated filter of the one shape the non-leaf case produces — + * a single `{ k: { $in: [] } }` — admits: none. Any other shape is not modelled + * here and fails the case rather than being approximated. + */ +function rowsAdmittedByEmptyIn(filter: Record): string[] { + const entries = Object.entries(filter); + expect(entries).toHaveLength(1); + const [, spec] = entries[0]; + expect(spec).toEqual({ $in: [] }); + return []; +} + +describe('[ADR-0139 D3] driver-mongodb — ReadableIds conformance (refusing driver)', () => { + for (const c of READABLE_IDS_CASES) { + if (c.verdict === 'refuses-uncomposed' || c.holdsComposedLeaf) { + it(`${c.name} — refused with D3's envelope`, () => { + expect(refusalOf(seamed(c.where())), c.note).toMatchObject(READABLE_IDS_REFUSAL); + }); + } else { + it(c.name, () => { + expect(rowsAdmittedByEmptyIn(translateFilter(seamed(c.where())) as Record), c.note).toEqual( + c.expected, + ); + }); + } + } + + it('the aggregation pipeline refuses the leaf in its $match the same way', () => { + const where = { record_id: { $in: ReadableIds.compose(ReadableIds.of('rid_parent'), undefined) } }; + let caught: unknown; + try { + buildAggregationPipeline({ where, aggregations: [{ function: 'count', alias: 'n' }] } as any); + } catch (err) { + caught = err; + } + expect(caught).toMatchObject(READABLE_IDS_REFUSAL); + }); + + it('the refusal is never INVALID_FILTER, and it is logged at error once per process', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + for (let i = 0; i < 2; i++) { + const err = refusalOf({ record_id: { $in: ReadableIds.of('rid_parent') } }) as { code?: string }; + expect(err.code).toBe('INTERNAL_ERROR'); + } + // The cases above already refused in this worker, so the one line was written there. + expect(error.mock.calls.length).toBeLessThanOrEqual(1); + } finally { + error.mockRestore(); + } + }); +}); diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-readable-ids-conformance.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-readable-ids-conformance.test.ts new file mode 100644 index 00000000000..3cea75c3100 --- /dev/null +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-readable-ids-conformance.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0139 D3] `SqliteWasmDriver` inherits `SqlDriver`'s compile of the + * composed `ReadableIds` leaf — `field IN (SELECT id FROM P_TABLE WHERE SCOPE + * AND TENANT)`, walled by `applyTenantScope` with the outer read's options — + * and its refusal of an uncomposed leaf. Measured here on the sql.js engine + * through the shared case-set (`READABLE_IDS_CASES`), not assumed from the + * inheritance: this driver hands `SqlDriver` a dialect class rather than a + * client string. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + READABLE_IDS_CASES, + READABLE_IDS_CHILD_OBJECT, + READABLE_IDS_CHILD_ROWS, + READABLE_IDS_PARENT_OBJECT, + READABLE_IDS_PARENT_ROWS, + READABLE_IDS_REFUSAL, + lowerFilterCondition, +} from '@objectstack/spec/data'; +import { SqliteWasmDriver } from './index.js'; + +const seamed = (where: unknown): any => lowerFilterCondition(where, { isDatetimeColumn: () => false }); + +describe('[ADR-0139 D3] driver-sqlite-wasm — ReadableIds conformance', () => { + let driver: SqliteWasmDriver; + + beforeAll(async () => { + driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.initObjects([ + { name: READABLE_IDS_PARENT_OBJECT, fields: { owner: { type: 'string' }, organization_id: { type: 'string' } } }, + { + name: READABLE_IDS_CHILD_OBJECT, + fields: { + object_name: { type: 'string' }, + record_id: { type: 'string' }, + label: { type: 'string' }, + organization_id: { type: 'string' }, + }, + }, + ]); + for (const row of READABLE_IDS_PARENT_ROWS) { + await driver.create(READABLE_IDS_PARENT_OBJECT, { ...row }, { bypassTenantAudit: true }); + } + for (const row of READABLE_IDS_CHILD_ROWS) { + await driver.create(READABLE_IDS_CHILD_OBJECT, { ...row }, { bypassTenantAudit: true }); + } + }); + + afterAll(async () => { + await driver.disconnect(); + }); + + for (const c of READABLE_IDS_CASES) { + if (c.verdict === 'matches') { + it(c.name, async () => { + const rows = await driver.find(READABLE_IDS_CHILD_OBJECT, { where: seamed(c.where()) } as any, c.options); + const got = (rows as any[]).map((r) => String(r.id)).sort((x, y) => x.localeCompare(y)); + expect(got, c.note).toEqual(c.expected); + expect(await driver.count(READABLE_IDS_CHILD_OBJECT, { where: seamed(c.where()) } as any, c.options), c.note) + .toBe(c.expected.length); + }); + } else { + it(c.name, async () => { + await expect( + driver.find(READABLE_IDS_CHILD_OBJECT, { where: seamed(c.where()) } as any), + c.note, + ).rejects.toMatchObject(READABLE_IDS_REFUSAL); + }); + } + } +}); From 9f66851f049cb8fe0777999c1c560822bef3e1a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 09:13:26 +0000 Subject: [PATCH 4/7] chore(spec, core): regenerate api-surface and export-origins; the shared helper's unit pins; changeset Claude-Session: https://claude.ai/code/session_01B1r5eJEPgvn7bUsfBvK2tS Co-authored-by: Claude --- .changeset/23639-readable-ids-drivers.md | 23 +++++ .../utils/readable-ids-driver-refusal.test.ts | 88 +++++++++++++++++++ packages/spec/api-surface/data.json | 11 +++ packages/spec/export-origins/data.json | 11 +++ .../spec/src/data/readable-ids-conformance.ts | 2 +- 5 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 .changeset/23639-readable-ids-drivers.md create mode 100644 packages/core/src/utils/readable-ids-driver-refusal.test.ts diff --git a/.changeset/23639-readable-ids-drivers.md b/.changeset/23639-readable-ids-drivers.md new file mode 100644 index 00000000000..206a1b7347a --- /dev/null +++ b/.changeset/23639-readable-ids-drivers.md @@ -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: } }` to `field IN (SELECT id FROM WHERE AND )`. 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. diff --git a/packages/core/src/utils/readable-ids-driver-refusal.test.ts b/packages/core/src/utils/readable-ids-driver-refusal.test.ts new file mode 100644 index 00000000000..9987df63b73 --- /dev/null +++ b/packages/core/src/utils/readable-ids-driver-refusal.test.ts @@ -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 = { 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(); + }); +}); diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 784b17525b7..b9b937716c6 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -606,6 +606,12 @@ "QueryWithTransportSchema (const)", "RAW_FILE_VALUES_CONTEXT_KEY (const)", "READABLE_IDS_BRAND (const)", + "READABLE_IDS_CASES (const)", + "READABLE_IDS_CHILD_OBJECT (const)", + "READABLE_IDS_CHILD_ROWS (const)", + "READABLE_IDS_PARENT_OBJECT (const)", + "READABLE_IDS_PARENT_ROWS (const)", + "READABLE_IDS_REFUSAL (const)", "READ_ONLY_BELONGS_ON_DATASOURCE (const)", "RECORD_CONTEXT_TOKENS (const)", "RECORD_SURFACE_PAGE_THRESHOLD (const)", @@ -619,6 +625,11 @@ "RangeOperatorSchema (const)", "ReadableIds (const)", "ReadableIds (interface)", + "ReadableIdsCase (type)", + "ReadableIdsChildRow (interface)", + "ReadableIdsMatchesCase (interface)", + "ReadableIdsParentRow (interface)", + "ReadableIdsRefusesUncomposedCase (interface)", "RecordContextToken (type)", "RecordFlow (type)", "RecordFlowContainer (type)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 81db50bcb9a..d07e6ad53c8 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -595,6 +595,12 @@ "QueryWithTransportSchema": "src/data/data-engine.zod.ts#QueryWithTransportSchema (const)", "RAW_FILE_VALUES_CONTEXT_KEY": "src/data/field-value.zod.ts#RAW_FILE_VALUES_CONTEXT_KEY (const)", "READABLE_IDS_BRAND": "src/data/readable-ids.ts#READABLE_IDS_BRAND (const)", + "READABLE_IDS_CASES": "src/data/readable-ids-conformance.ts#READABLE_IDS_CASES (const)", + "READABLE_IDS_CHILD_OBJECT": "src/data/readable-ids-conformance.ts#READABLE_IDS_CHILD_OBJECT (const)", + "READABLE_IDS_CHILD_ROWS": "src/data/readable-ids-conformance.ts#READABLE_IDS_CHILD_ROWS (const)", + "READABLE_IDS_PARENT_OBJECT": "src/data/readable-ids-conformance.ts#READABLE_IDS_PARENT_OBJECT (const)", + "READABLE_IDS_PARENT_ROWS": "src/data/readable-ids-conformance.ts#READABLE_IDS_PARENT_ROWS (const)", + "READABLE_IDS_REFUSAL": "src/data/readable-ids-conformance.ts#READABLE_IDS_REFUSAL (const)", "READ_ONLY_BELONGS_ON_DATASOURCE": "src/data/driver/common.zod.ts#READ_ONLY_BELONGS_ON_DATASOURCE (const)", "RECORD_CONTEXT_TOKENS": "src/data/context-tokens.zod.ts#RECORD_CONTEXT_TOKENS (const)", "RECORD_SURFACE_PAGE_THRESHOLD": "src/data/record-surface.ts#RECORD_SURFACE_PAGE_THRESHOLD (const)", @@ -607,6 +613,11 @@ "RUNTIME_OWNED_FIELD_TYPES": "src/data/field.zod.ts#RUNTIME_OWNED_FIELD_TYPES (const)", "RangeOperatorSchema": "src/data/filter.zod.ts#RangeOperatorSchema (const)", "ReadableIds": "src/data/readable-ids.ts#ReadableIds (interface)", + "ReadableIdsCase": "src/data/readable-ids-conformance.ts#ReadableIdsCase (type)", + "ReadableIdsChildRow": "src/data/readable-ids-conformance.ts#ReadableIdsChildRow (interface)", + "ReadableIdsMatchesCase": "src/data/readable-ids-conformance.ts#ReadableIdsMatchesCase (interface)", + "ReadableIdsParentRow": "src/data/readable-ids-conformance.ts#ReadableIdsParentRow (interface)", + "ReadableIdsRefusesUncomposedCase": "src/data/readable-ids-conformance.ts#ReadableIdsRefusesUncomposedCase (interface)", "RecordContextToken": "src/data/context-tokens.zod.ts#RecordContextToken (type)", "RecordFlow": "src/data/record-surface.ts#RecordFlow (type)", "RecordFlowContainer": "src/data/record-surface.ts#RecordFlowContainer (type)", diff --git a/packages/spec/src/data/readable-ids-conformance.ts b/packages/spec/src/data/readable-ids-conformance.ts index 91d98b823ee..57c95972c5d 100644 --- a/packages/spec/src/data/readable-ids-conformance.ts +++ b/packages/spec/src/data/readable-ids-conformance.ts @@ -42,7 +42,7 @@ * — ascending — and D3's envelope on the `refuses-uncomposed` case; * - a driver that REFUSES the leaf (D3: `driver-mongodb`, until a card * measures a pipeline form) asserts D3's envelope on every case that holds a - * composed leaf ({@link readableIdsCaseHoldsComposedLeaf}), and the row ids on + * composed leaf ({@link ReadableIdsMatchesCase.holdsComposedLeaf}), and the row ids on * the rest. That is what "recorded as refusing" means. * * A case with {@link ReadableIdsMatchesCase.requiresTenantWall} reads under a From cf49cc032203ec927f78c4ce3fec428819478155 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 09:23:10 +0000 Subject: [PATCH 5/7] fix(core): the ReadableIds refusal logs the message alone, so every driver's logger shape accepts it Claude-Session: https://claude.ai/code/session_01B1r5eJEPgvn7bUsfBvK2tS Co-authored-by: Claude --- .../src/utils/readable-ids-driver-refusal.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/core/src/utils/readable-ids-driver-refusal.ts b/packages/core/src/utils/readable-ids-driver-refusal.ts index 9ad7824a18f..4e49a69329f 100644 --- a/packages/core/src/utils/readable-ids-driver-refusal.ts +++ b/packages/core/src/utils/readable-ids-driver-refusal.ts @@ -212,13 +212,15 @@ export function readableIdsDriverRefusal( } /** - * The slice of a driver's logger the refusal needs. `error` is optional because - * a driver's injectable logger declares it so (`SqlDriver`'s does); a sink that - * carries none gets the line at `warn`, never no line. + * 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); a sink that carries + * none gets the line at `warn`, never no line. */ export interface ReadableIdsRefusalLog { - error?(message: string, meta?: Record): void; - warn?(message: string, meta?: Record): void; + error?(message: string): void; + warn?(message: string): void; } /** @@ -238,9 +240,8 @@ export function refuseReadableIdsOnce( if (!state.logged) { state.logged = true; const line = `${err.message} Logged once for this driver; later refusals answer the caller the same way.`; - const meta = { code: err.code, object: site.object, at: site.at, kind: site.kind }; - if (typeof log?.error === 'function') log.error(line, meta); - else log?.warn?.(line, meta); + if (typeof log?.error === 'function') log.error(line); + else log?.warn?.(line); } return err; } From 813e08cd734447ecfe8753a1591238469b66d069 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 10:25:04 +0000 Subject: [PATCH 6/7] =?UTF-8?q?test(drivers):=20the=20ReadableIds=20suites?= =?UTF-8?q?=20pass=20typed=20queries=20=E2=80=94=20no=20query-options=20er?= =?UTF-8?q?asure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01B1r5eJEPgvn7bUsfBvK2tS Co-authored-by: Claude --- .../src/memory-readable-ids-conformance.test.ts | 4 ++-- .../src/sql-driver-readable-ids-conformance.test.ts | 9 +++++---- .../src/sqlite-wasm-readable-ids-conformance.test.ts | 6 +++--- .../src/turso-readable-ids-conformance.test.ts | 8 ++++---- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/drivers/driver-memory/src/memory-readable-ids-conformance.test.ts b/packages/drivers/driver-memory/src/memory-readable-ids-conformance.test.ts index 4085e65a9c8..2707b1a2893 100644 --- a/packages/drivers/driver-memory/src/memory-readable-ids-conformance.test.ts +++ b/packages/drivers/driver-memory/src/memory-readable-ids-conformance.test.ts @@ -118,7 +118,7 @@ describe('[ADR-0139 D3] InMemoryDriver — ReadableIds conformance', () => { const out = await driver.aggregate(BIG_CHILD, { where: seamed(scoped()), aggregations: [{ function: 'count', alias: 'n' }], - } as any); + }); expect(Number((out[0] as any).n)).toBe(BIG_READABLE - 1); }); @@ -128,7 +128,7 @@ describe('[ADR-0139 D3] InMemoryDriver — ReadableIds conformance', () => { const error = vi.spyOn((fresh as any).logger, 'error'); for (let i = 0; i < 2; i++) { await expect( - fresh.find(BIG_CHILD, { where: { record_id: { $in: ReadableIds.of(BIG_PARENT) } } as any }), + fresh.find(BIG_CHILD, { where: { record_id: { $in: ReadableIds.of(BIG_PARENT) } } }), ).rejects.toMatchObject(READABLE_IDS_REFUSAL); } expect(error).toHaveBeenCalledTimes(1); diff --git a/packages/drivers/driver-sql/src/sql-driver-readable-ids-conformance.test.ts b/packages/drivers/driver-sql/src/sql-driver-readable-ids-conformance.test.ts index 9b7b367f56c..d209ace1e3b 100644 --- a/packages/drivers/driver-sql/src/sql-driver-readable-ids-conformance.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-readable-ids-conformance.test.ts @@ -29,6 +29,7 @@ import { READABLE_IDS_REFUSAL, ReadableIds, lowerFilterCondition, + type FilterCondition, } from '@objectstack/spec/data'; import { SqlDriver } from './sql-driver.js'; import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; @@ -133,7 +134,7 @@ function declareReadableIdsSweep(cell: DialectCell): void { }); it('a deleted parent is excluded', async () => { - await driver.delete(BIG_PARENT, 'bp00000', { bypassTenantAudit: true } as any); + await driver.delete(BIG_PARENT, 'bp00000', { bypassTenantAudit: true }); const rows = await driver.find(BIG_CHILD, { where: seamed(scoped()) }, { tenantId: 'org_a' }); expect(rows).toHaveLength(BIG_READABLE - 1); expect(rows.some((r: any) => r.record_id === 'bp00000')).toBe(false); @@ -143,7 +144,7 @@ function declareReadableIdsSweep(cell: DialectCell): void { it('aggregate agrees with find: the leaf is one statement on every read verb', async () => { const out = await driver.aggregate( BIG_CHILD, - { where: seamed(scoped()), aggregations: [{ function: 'count', alias: 'n' }] } as any, + { where: seamed(scoped()), aggregations: [{ function: 'count', alias: 'n' }] }, { tenantId: 'org_a' }, ); expect(Number((out[0] as any).n)).toBe(BIG_READABLE - 1); @@ -172,7 +173,7 @@ function declareReadableIdsSweep(cell: DialectCell): void { const error = vi.spyOn((fresh as any).logger, 'error'); const uncomposed = () => ({ record_id: { $in: ReadableIds.of(BIG_PARENT) } }); for (let i = 0; i < 2; i++) { - await expect(fresh.find(BIG_CHILD, { where: uncomposed() as any })).rejects.toMatchObject(READABLE_IDS_REFUSAL); + await expect(fresh.find(BIG_CHILD, { where: uncomposed() })).rejects.toMatchObject(READABLE_IDS_REFUSAL); } expect(error).toHaveBeenCalledTimes(1); await fresh.disconnect?.(); @@ -180,7 +181,7 @@ function declareReadableIdsSweep(cell: DialectCell): void { it('a composed leaf outside a $in comparand is refused with the same envelope', async () => { const leaf = ReadableIds.compose(ReadableIds.of(BIG_PARENT), undefined); - await expect(driver.find(BIG_CHILD, { where: { record_id: { $nin: leaf } } as any })).rejects.toMatchObject( + await expect(driver.find(BIG_CHILD, { where: { record_id: { $nin: leaf } } as unknown as FilterCondition })).rejects.toMatchObject( READABLE_IDS_REFUSAL, ); }); diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-readable-ids-conformance.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-readable-ids-conformance.test.ts index 3cea75c3100..29cad1a32fd 100644 --- a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-readable-ids-conformance.test.ts +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-readable-ids-conformance.test.ts @@ -56,16 +56,16 @@ describe('[ADR-0139 D3] driver-sqlite-wasm — ReadableIds conformance', () => { for (const c of READABLE_IDS_CASES) { if (c.verdict === 'matches') { it(c.name, async () => { - const rows = await driver.find(READABLE_IDS_CHILD_OBJECT, { where: seamed(c.where()) } as any, c.options); + const rows = await driver.find(READABLE_IDS_CHILD_OBJECT, { where: seamed(c.where()) }, c.options); const got = (rows as any[]).map((r) => String(r.id)).sort((x, y) => x.localeCompare(y)); expect(got, c.note).toEqual(c.expected); - expect(await driver.count(READABLE_IDS_CHILD_OBJECT, { where: seamed(c.where()) } as any, c.options), c.note) + expect(await driver.count(READABLE_IDS_CHILD_OBJECT, { where: seamed(c.where()) }, c.options), c.note) .toBe(c.expected.length); }); } else { it(c.name, async () => { await expect( - driver.find(READABLE_IDS_CHILD_OBJECT, { where: seamed(c.where()) } as any), + driver.find(READABLE_IDS_CHILD_OBJECT, { where: seamed(c.where()) }), c.note, ).rejects.toMatchObject(READABLE_IDS_REFUSAL); }); diff --git a/packages/drivers/driver-turso/src/turso-readable-ids-conformance.test.ts b/packages/drivers/driver-turso/src/turso-readable-ids-conformance.test.ts index 9a863a430ca..661bf12704b 100644 --- a/packages/drivers/driver-turso/src/turso-readable-ids-conformance.test.ts +++ b/packages/drivers/driver-turso/src/turso-readable-ids-conformance.test.ts @@ -128,7 +128,7 @@ function declareFace(face: Face): void { }); it('a deleted parent is excluded', async () => { - await driver.delete(BIG_PARENT.name, 'bp00000', { bypassTenantAudit: true } as any); + await driver.delete(BIG_PARENT.name, 'bp00000', { bypassTenantAudit: true }); const rows = await driver.find(BIG_CHILD.name, { where: seamed(scoped()) }, { tenantId: 'org_a' }); expect(rows).toHaveLength(BIG_READABLE - 1); expect(rows.some((r) => r.record_id === 'bp00000')).toBe(false); @@ -138,7 +138,7 @@ function declareFace(face: Face): void { it('aggregate agrees with find', async () => { const out = await driver.aggregate( BIG_CHILD.name, - { where: seamed(scoped()), aggregations: [{ function: 'count', alias: 'n' }] } as any, + { where: seamed(scoped()), aggregations: [{ function: 'count', alias: 'n' }] }, { tenantId: 'org_a' }, ); expect(Number((out[0] as any).n)).toBe(BIG_READABLE - 1); @@ -148,8 +148,8 @@ function declareFace(face: Face): void { const error = vi.spyOn((driver as any).logger, 'error'); const uncomposed = () => ({ record_id: { $in: ReadableIds.of(BIG_PARENT.name) } }); for (let i = 0; i < 2; i++) { - await expect(driver.find(BIG_CHILD.name, { where: uncomposed() as any })).rejects.toMatchObject(READABLE_IDS_REFUSAL); - await expect(driver.count(BIG_CHILD.name, { where: uncomposed() as any })).rejects.toMatchObject(READABLE_IDS_REFUSAL); + await expect(driver.find(BIG_CHILD.name, { where: uncomposed() })).rejects.toMatchObject(READABLE_IDS_REFUSAL); + await expect(driver.count(BIG_CHILD.name, { where: uncomposed() })).rejects.toMatchObject(READABLE_IDS_REFUSAL); } // Once for this instance — the shared cases above already refused once. expect(error.mock.calls.length).toBeLessThanOrEqual(1); From 45a871de4d1735f9c4bbaa5855f87c4c3039a023 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 11:07:50 +0000 Subject: [PATCH 7/7] fix(core): the ReadableIds refusal sink guarantees its warn fallback (optional-error sink contract) Claude-Session: https://claude.ai/code/session_01B1r5eJEPgvn7bUsfBvK2tS Co-authored-by: Claude --- packages/core/src/utils/readable-ids-driver-refusal.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core/src/utils/readable-ids-driver-refusal.ts b/packages/core/src/utils/readable-ids-driver-refusal.ts index 4e49a69329f..4a3828cc3e0 100644 --- a/packages/core/src/utils/readable-ids-driver-refusal.ts +++ b/packages/core/src/utils/readable-ids-driver-refusal.ts @@ -215,12 +215,14 @@ export function readableIdsDriverRefusal( * 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); a sink that carries - * none gets the line at `warn`, never no line. + * 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; + warn(message: string): void; } /** @@ -241,7 +243,7 @@ export function refuseReadableIdsOnce( 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); + else log?.warn(line); } return err; }