From 615db28456e0d6e94358e5ca11b7f55902ef95b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 15:53:05 +0000 Subject: [PATCH 1/2] fix(driver-turso): the remote filter compiler applies the JSON-column gate and answers $contains by membership RemoteTransport.buildWhereSQL now refuses every operator in @objectstack/core's JSON_COLUMN_INCOMPATIBLE_OPERATORS on a column the driver stores as JSON text, with the shared jsonColumnOperatorRefusalText sentence, and answers $contains / $notContains there through jsonMembershipPredicate('sqlite'), the negated form NULL-safe. The JSON-column population is the driver's own jsonFields registry, handed down through a new optional RemoteTransport.setJsonColumnResolver that TursoDriver wires to the inherited SqlDriver.isJsonColumn, beside setDeclaredValueShapeResolver. A local/remote parity suite holds both faces to one answer over the whole shared set, the membership pair, the bare and null equality spellings and a scalar text-field control. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- .../driver-turso/src/remote-transport.ts | 186 +++++++++++ .../drivers/driver-turso/src/turso-driver.ts | 11 + ...so-local-remote-json-column-parity.test.ts | 299 ++++++++++++++++++ 3 files changed, 496 insertions(+) create mode 100644 packages/drivers/driver-turso/src/turso-local-remote-json-column-parity.test.ts diff --git a/packages/drivers/driver-turso/src/remote-transport.ts b/packages/drivers/driver-turso/src/remote-transport.ts index 92d023ba4fc..f574993660f 100644 --- a/packages/drivers/driver-turso/src/remote-transport.ts +++ b/packages/drivers/driver-turso/src/remote-transport.ts @@ -46,6 +46,18 @@ import { resolveFilterSubtreeProvenance } from '@objectstack/spec/data'; // `AggregationNodeSchema.function` admits, nor from the local driver's twin. import { AggregationFunction, emptyGroupValueFor } from '@objectstack/spec/data'; import type { DriverQuery } from '@objectstack/spec/contracts'; +// [#21178] The JSON-column half of the filter contract, from the one home both +// faces of this driver stand on: the operator set a JSON-stored column refuses +// and the words of that refusal (`json-column-operator-refusal.ts`), and the +// `$contains` membership construct (`json-membership-sql.ts`). `SqlDriver` — +// this driver's LOCAL face — reads the same three, so the two faces cannot fork +// on which operators a JSON column refuses, what the refusal says, or what +// `$contains` means there. ⛔ Never a copy of any of them in this file. +import { + JSON_COLUMN_INCOMPATIBLE_OPERATORS, + jsonColumnOperatorRefusalText, + jsonMembershipPredicate, +} from '@objectstack/core'; // [#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, @@ -921,6 +933,22 @@ export type NonTextColumnResolver = (object: string, field: string) => boolean; */ export type DeclaredValueShapeResolver = (object: string, field: string) => ValueShapeFieldDef | undefined; +/** + * [#21178] Is this field stored as a JSON TEXT column — a `multiple: true` + * field, an inherently multi-value option type, or a structured-JSON type? + * Injected by TursoDriver exactly the way {@link NonTextColumnResolver} is, and + * answered by `SqlDriver.isJsonColumn` from the `jsonFields` registry that + * `registerRemoteFieldMetadata` → `SqlDriver.registerExternalObject` fills in + * remote mode — the registry the LOCAL face's gate and membership reading ask — + * so this transport and its local twin read one population. ⛔ Never re-derived + * here from a field's type: that would be a second list of "which columns are + * JSON" beside the driver's, drifting on its aliases and on the ADR-0104 media + * deployment fact. Absent (a transport driven standalone), every column reads + * as not-JSON — the local face's own answer for a table it was never told + * about — and the gate and the membership reading stay off. + */ +export type JsonColumnResolver = (object: string, field: string) => boolean; + /** * Remote transport that executes all queries via @libsql/client. * @@ -969,6 +997,13 @@ export class RemoteTransport { */ private declaredValueShape: DeclaredValueShapeResolver | null = null; + /** + * [#21178] The driver's JSON-column rule — see {@link setJsonColumnResolver}. + * Absent means "no column is known to be JSON", which is what this transport + * could say before it was handed the rule. + */ + private jsonColumn: JsonColumnResolver | null = null; + /** * [#7929] Where the withheld half of a redacted refusal is written. * @@ -1137,6 +1172,21 @@ export class RemoteTransport { this.declaredValueShape = resolver; } + /** + * [#21178] Hand this transport the driver's answer to "is this field stored + * as a JSON TEXT column?", so {@link buildWhereSQL} applies the JSON-column + * half of the filter contract exactly as `SqlDriver` does locally: the + * operators in `JSON_COLUMN_INCOMPATIBLE_OPERATORS` are refused on such a + * column ({@link jsonColumnOperator}), and `$contains` / `$notContains` answer + * MEMBERSHIP rather than a substring of the serialization + * ({@link pushJsonMembership}). Same shape as + * {@link setNonTextColumnResolver} and for the same reason: the declaration + * lives on the driver, and this transport asks rather than re-deriving it. + */ + setJsonColumnResolver(resolver: JsonColumnResolver): void { + this.jsonColumn = resolver; + } + /** * Get the current @libsql/client instance. */ @@ -2934,6 +2984,23 @@ export class RemoteTransport { // widening the statement to every row in the table. const clausesBefore = clauses.length; for (const [op, opValue] of Object.entries(value as Record)) { + // [#21178] The column-type gate, on the operator AS WRITTEN and ahead + // of every arm — `SqlDriver.assertOperatorAppliesToColumn`'s position + // on the local face. A JSON column holds the serialization + // `["u1","u2"]`, so every operator in the shared set compares or + // matches THAT text: measured on this transport before the gate, + // `$nin` and `$ne` returned the rows holding the excluded member + // (fail-open), `$eq` / `$in` returned none, `$lt` / `$lte` answered + // lexicographically over the serialization, and `$startsWith: '['` + // matched every row — while the local face refused each with + // `INVALID_FILTER` / 400. The arms below never see such an operator + // on such a column. + if (JSON_COLUMN_INCOMPATIBLE_OPERATORS.has(op) && this.isJsonColumn(object, key)) { + // [#8220] `value` — this field's operator map — is the node the + // entry seam resolves the refusal's provenance against, as the + // local face hands its gate the same map. + throw this.jsonColumnOperator(key, op, false, value); + } switch (op) { case '$eq': // [#6050] `=== null` only. `undefined` used to share this arm and @@ -3024,6 +3091,9 @@ export class RemoteTransport { case '$contains': { const bind = this.serializeComparand(object, key, op, opValue); if (this.pushTextOverNonTextColumn(clauses, object, key, op)) break; + // [#21178] The MEMBERSHIP reading on a JSON column, ahead of the + // substring emitter every scalar string column keeps. + if (this.pushJsonMembership(clauses, args, object, key, column, opValue, false)) break; this.pushLike(clauses, args, column, bind, 'contains'); break; } @@ -3048,6 +3118,9 @@ export class RemoteTransport { // single emission point. const bind = this.serializeComparand(object, key, op, opValue); if (this.pushTextOverNonTextColumn(clauses, object, key, op)) break; + // [#21178] The exact complement of `$contains`' membership arm, + // on the same population and the same construct, NULL-safe. + if (this.pushJsonMembership(clauses, args, object, key, column, opValue, true)) break; this.pushLike(clauses, args, column, bind, 'contains', true, true); break; } @@ -3187,6 +3260,18 @@ export class RemoteTransport { // stored with `organization_id IS NULL`; emitting `= ?` here is what // made every env-wide draft read come back empty even though the row // was written. (Knex special-cases this; this hand-rolled builder did not.) + // + // [#21178] Still the bare equality spelling, so a JSON column refuses + // it here exactly as it refuses `{ field: 'u1' }` below and + // `{ field: { $eq: null } }` above: the local face's bare-value + // positions ask the column-type gate whatever the comparand, `null` + // included. Measured on this harness: local refused `{ owners: null }` + // with `INVALID_FILTER` / 400 while this branch answered the NULL row — + // a different answer from one driver by connection string. `$null: true` + // is the presence spelling, and both faces answer it. + if (this.isJsonColumn(object, key)) { + throw this.jsonColumnOperator(key, '=', true, filters); + } const column = `"${this.mapSortField(key)}"`; clauses.push(`${column} IS NULL`); } else { @@ -3203,6 +3288,14 @@ export class RemoteTransport { // map above and a bad one already threw (#1004). const column = `"${this.mapSortField(key)}"`; const bind = this.serializeComparand(object, key, '$eq', value); + // [#21178] The bare `{ field: value }` spelling is an implicit `=`, so + // the column-type gate applies here too — after the comparand gate, the + // order the local face's bare-value positions run their two gates in. + // [#8220] A bare comparand is usually a primitive, so `filters` — the + // node carrying `key` — is what carries the mark. + if (this.isJsonColumn(object, key)) { + throw this.jsonColumnOperator(key, '=', true, refusalNode(value, filters)); + } clauses.push(`${this.comparisonColumn(object, key, column)} = ?`); args.push(bind); } @@ -3244,6 +3337,99 @@ export class RemoteTransport { return true; } + /** + * [#21178] Is `field` on `object` stored as a JSON TEXT column, per the + * driver's injected {@link JsonColumnResolver}? `false` when no rule was + * injected — never a guess from the value. + */ + private isJsonColumn(object: string, field: string): boolean { + return this.jsonColumn !== null && this.jsonColumn(object, field); + } + + /** + * [#21178] The refusal an operator in `JSON_COLUMN_INCOMPATIBLE_OPERATORS` + * gets on a JSON column — the local face's `jsonColumnOperatorError`, one + * package over. ADR-0112 class 1, `INVALID_FILTER` / 400. + * + * Both texts are `@objectstack/core`'s {@link jsonColumnOperatorRefusalText}, + * byte for byte what the local face prints, with no `[RemoteTransport]` + * prefix: one mistake reads one sentence whichever face answered it. The + * caller-visible `message` withholds the field and the operator (on a read + * scope the predicate is an administrator's, #7929 / #8197); the `diagnostic` + * naming both goes to the diagnostic sink, and the entry seam swaps it back + * onto the wire only for a positively `'author'`-marked `subtree` (#8220) — + * the local face's `withheldFilterError` contract, with the same carrier keys. + * + * `bare` is the implicit-equality spelling `{ field: value }`, whose operator + * the diagnostic names as `=`. + */ + private jsonColumnOperator(field: string, op: string, bare: boolean, subtree: unknown): Error { + const { message, diagnostic } = jsonColumnOperatorRefusalText(this.mapSortField(field), op, bare); + return this.withheldRefusal(message, subtree, diagnostic); + } + + /** + * [#21178] Emit the MEMBERSHIP reading of `$contains` / `$notContains` when + * the column they were aimed at is a JSON column, and say whether it did — + * the local face's `SqlDriver.applyJsonMembership`, one package over. + * `false` leaves the caller on its substring emitter, which is what every + * scalar string column keeps: on such a column `$contains` IS the substring + * test (the spec's `FILTER_OPERATORS.$contains` docblock states both halves). + * + * The construct is `@objectstack/core`'s {@link jsonMembershipPredicate}, + * `'sqlite'` dialect — libSQL is SQLite — so it asks whether the comparand's + * JSON value is an ELEMENT of the stored array: `u1` no longer answers the + * row holding `["u10"]`, and a stored object or scalar answers no member at + * all, exactly as locally. Measured before this arm on the libsql stub: the + * substring reading matched `["u10"]` for `u1`, `$notContains: 'u1'` dropped + * that row, and a `json`-typed field's `$contains` matched text inside the + * serialized object — three row sets the local face did not return. + * + * The column is the PLAIN quoted identifier (no storage-form rewrite applies + * to a JSON column), emitted by reference; each candidate value is bound + * through `?` in placeholder order, so `args` stays aligned with the SQL. The + * comparand is handed over AS WRITTEN — the caller has already run it through + * {@link serializeComparand}'s gate — because the construct reads its text + * rendering itself, exactly as the local face hands it the raw comparand. + * + * The negated spelling composes with the NULL rule rather than replacing it: + * a row with no value satisfies `$notContains` (#5298), and the `json_each` + * scan answers NULL — not FALSE — for a NULL column, so {@link nullSafeNegative} + * is doing real work here. + */ + private pushJsonMembership( + clauses: string[], + args: any[], + object: string, + field: string, + column: string, + value: unknown, + negate: boolean, + ): boolean { + if (!this.isJsonColumn(object, field)) return false; + const bound: unknown[] = []; + const sql = jsonMembershipPredicate( + 'sqlite', + { + column: () => column, + value: (v) => { + bound.push(v); + return '?'; + }, + }, + value, + ); + if (sql === null) { + // Unreachable: `'sqlite'` always has a construct, and only `'unknown'` + // answers `null`. Said out loud rather than falling back to the substring + // emitter, which would be the very answer this arm exists to replace. + throw new Error('[RemoteTransport] jsonMembershipPredicate returned no construct for the sqlite dialect'); + } + clauses.push(negate ? this.nullSafeNegative(column, `NOT ${sql}`) : sql); + args.push(...bound); + return true; + } + /** * Append one parameterized text-match predicate for the `$contains` family * and `$icontains`. diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index 4bc2c26e157..a9affc723ec 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -1772,6 +1772,17 @@ export class TursoDriver extends SqlDriver { this.declaredValueShape(object, field), ); + // [#21178] The JSON-column rule, handed down the same way: + // `registerRemoteFieldMetadata` → `registerExternalObject` fills the SAME + // `jsonFields` registry the local compiler's gate and `$contains` + // membership read, keyed by object name, so a filter on a multi-value or + // structured-JSON field is refused, or answered by membership, alike on + // both transports (`turso-local-remote-json-column-parity` holds them to + // one answer). + this.remoteTransport.setJsonColumnResolver((object, field) => + this.isJsonColumn(object, field), + ); + // [#7929] The server-side half of a REDACTED filter refusal. The remote // compiler withholds the operands of a cross-field comparison for the // same reason the inherited local one does — an RLS rule's columns are diff --git a/packages/drivers/driver-turso/src/turso-local-remote-json-column-parity.test.ts b/packages/drivers/driver-turso/src/turso-local-remote-json-column-parity.test.ts new file mode 100644 index 00000000000..60bfee36a8d --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-local-remote-json-column-parity.test.ts @@ -0,0 +1,299 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21178] ONE `TursoDriver`, ONE answer on a JSON-stored column — the two + * transports held against each other on the JSON-column half of the filter + * contract: the operators such a column refuses, and what `$contains` / + * `$notContains` mean there. + * + * # The defect this file pins closed + * + * LOCAL mode inherits `SqlDriver`, whose filter compiler refuses every operator + * in `@objectstack/core`'s `JSON_COLUMN_INCOMPATIBLE_OPERATORS` on a column it + * stores as JSON TEXT (#7398, the set widened by #21009) and answers + * `$contains` / `$notContains` by MEMBERSHIP through `jsonMembershipPredicate` + * (#17590 / #20987). REMOTE mode compiles in `RemoteTransport.buildWhereSQL`, + * an independent emitter that read neither, so over the same multi-value + * lookup holding `["u1","u2"]`, `["u2"]`, `["u3","u1"]` and `["u10"]` it + * answered — measured on this harness before the change: + * + * | filter | remote, before | local (the contract) | + * |---|---|---| + * | `$contains: 'u1'` | r1, r3 AND r4 (`u10` by substring) | r1, r3 | + * | `$notContains: 'u1'` | r2, r5 (dropped r4) | r2, r4, r5 | + * | `$nin: ['u1']`, `$ne: 'u1'` | every row (fail-OPEN) | `INVALID_FILTER` / 400 | + * | `$eq` / `$in` / bare equality | no row | `INVALID_FILTER` / 400 | + * | `$lt` / `$lte` `'u1'` | r1-r4 (lexicographic) | `INVALID_FILTER` / 400 | + * | `$startsWith: '['`, `$endsWith: ']'` | r1-r4 (the serialization) | `INVALID_FILTER` / 400 | + * | `json` field `$contains: 'u1'` | text inside the object | no row (array-only) | + * + * Hosted tenant databases run only on the remote transport, so every one of + * those rows was the answer a deployment got by holding a `libsql://` URL. + * + * # The invariant, and why it is asserted twice + * + * The remote face answers the same row set as the local face's `find()`, or + * refuses with `INVALID_FILTER` / 400 — never a third, quieter answer. Each + * case is held to that PARITY and, separately, to the canonical answer the + * contract requires: parity alone is satisfiable by breaking both faces the + * same way. + * + * # Read from the shared module, never from the card + * + * The refused set is iterated from `JSON_COLUMN_INCOMPATIBLE_OPERATORS` as it + * stands, so a member added there is pinned on both faces with no edit here. + * Refusals are asserted by the ADR-0112 `code` and `status` and by EQUALITY + * with `jsonColumnOperatorRefusalText`'s output — never by the sentence's + * literal words, which belong to that builder (#21067 rewrites them; these + * pins move with it, not against it). + * + * The population is the driver's: `owners` is `multiple: true` on a + * multi-capable type, `tags` an inherently multi-value option type, `payload` + * a structured-JSON type — the three ways a field becomes a JSON column — and + * `title` is the scalar control, whose answers must not move. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { DriverQuery } from '@objectstack/spec/contracts'; +import { JSON_COLUMN_INCOMPATIBLE_OPERATORS, jsonColumnOperatorRefusalText } from '@objectstack/core'; +import { TursoDriver } from './turso-driver.js'; +import { asLibsqlClient, makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; + +const OBJECT = { + name: 'json_gate_21178', + fields: { + owners: { type: 'lookup', reference: 'sys_user', multiple: true }, + tags: { type: 'tags' }, + payload: { type: 'json' }, + title: { type: 'text' }, + }, +} as const; + +const ROWS = [ + { id: 'r1', owners: ['u1', 'u2'], tags: ['a'], payload: { k: 'u1' }, title: 'u1' }, + { id: 'r2', owners: ['u2'], tags: ['b'], payload: { k: 'u2' }, title: 'u2' }, + { id: 'r3', owners: ['u3', 'u1'], tags: ['a', 'b'], payload: { k: 'u3' }, title: 'u3' }, + { id: 'r4', owners: ['u10'], tags: ['c'], payload: { k: 'u10' }, title: 'u10' }, + { id: 'r5', owners: null, tags: null, payload: null, title: null }, +]; + +type Face = 'local' | 'remote'; +const FACES: readonly Face[] = ['local', 'remote']; + +/** What one face answered: the matched ids, or the refusal's wire identity. */ +type Answer = + | { rows: string[] } + | { refused: { code: unknown; status: unknown; message: string } }; + +interface WireBearingError { + code?: unknown; + status?: unknown; + message: string; +} + +const answerOf = async (driver: TursoDriver, where: unknown): Promise => { + try { + const rows = await driver.find(OBJECT.name, { where } as DriverQuery); + return { rows: rows.map((r) => String(r.id)).sort() }; + } catch (e) { + const err = e as WireBearingError; + return { refused: { code: err.code, status: err.status, message: err.message } }; + } +}; + +/** The refusal the shared builder words for `op` on `field` — the expected wire identity. */ +const jsonRefusal = (field: string, op: string, bare = false): Answer => ({ + refused: { + code: 'INVALID_FILTER', + status: 400, + message: jsonColumnOperatorRefusalText(field, op, bare).message, + }, +}); + +/** A comparand each refused operator can bind, so its arm is reached rather than a comparand gate. */ +const comparandFor = (op: string): unknown => { + if (op === '$in' || op === '$nin' || op === 'in' || op === 'nin' || op === 'not_in' || op === 'notin') return ['u1']; + if (op === '$between' || op === 'between') return ['a', 'z']; + if (op === '$like' || op === '$ilike') return 'u1%'; + return 'u1'; +}; + +describe('[#21178] a JSON-stored column gets ONE answer on both TursoDriver faces', () => { + let drivers: Record; + let stub: LibsqlSqliteStub; + + beforeAll(async () => { + const local = new TursoDriver({ url: ':memory:' }); + expect(local.transportMode).toBe('local'); + + stub = makeLibsqlSqliteStub(); + const remote = new TursoDriver({ url: 'libsql://json-gate-21178.turso.io', client: asLibsqlClient(stub) }); + await remote.connect(); + expect(remote.transportMode).toBe('remote'); + + drivers = { local, remote }; + for (const driver of Object.values(drivers)) { + await driver.initObjects([{ ...OBJECT, fields: { ...OBJECT.fields } }] as never); + for (const row of ROWS) { + await driver.create(OBJECT.name, { ...row }, { bypassTenantAudit: true } as never); + } + } + }, 60_000); + + afterAll(async () => { + await drivers.local.disconnect(); + await drivers.remote.disconnect(); + stub.close(); + }); + + /** Assert `expected` on BOTH faces, and that the two faces agree. */ + const expectBothFaces = async (where: unknown, expected: Answer): Promise => { + const local = await answerOf(drivers.local, where); + const remote = await answerOf(drivers.remote, where); + expect(remote, `remote must answer what local answers for ${JSON.stringify(where)}`).toEqual(local); + expect(local, `local, ${JSON.stringify(where)}`).toEqual(expected); + }; + + // ─── §1 The refused set, iterated from the shared module ───────────────── + + describe('every operator in JSON_COLUMN_INCOMPATIBLE_OPERATORS is refused on a JSON column', () => { + it('the set is non-empty and carries the card\'s operators (a vacuous loop is not a pin)', () => { + for (const op of ['$eq', '$ne', '$in', '$nin', '$lt', '$lte', '$startsWith', '$endsWith']) { + expect(JSON_COLUMN_INCOMPATIBLE_OPERATORS.has(op), op).toBe(true); + } + // The membership pair is the load-bearing ABSENCE: it is answered, not refused. + expect(JSON_COLUMN_INCOMPATIBLE_OPERATORS.has('$contains')).toBe(false); + expect(JSON_COLUMN_INCOMPATIBLE_OPERATORS.has('$notContains')).toBe(false); + }); + + for (const op of JSON_COLUMN_INCOMPATIBLE_OPERATORS) { + if (!op.startsWith('$')) { + // The bare infix spellings (`=`, `in`, …) are members because + // `driver-sql`'s normalised arms answer them; inside an operator MAP + // neither face reads them as operators, and both refuse the map as an + // object comparand before any column question is asked. The pin is + // that this stays a refusal on both faces — never rows. + it(`${op} (bare infix spelling) in an operator map is refused on both faces`, async () => { + for (const face of FACES) { + const answer = await answerOf(drivers[face], { owners: { [op]: comparandFor(op) } }); + expect('refused' in answer, `${face} answered rows for ${op}`).toBe(true); + if ('refused' in answer) { + expect(answer.refused.code, face).toBe('INVALID_FILTER'); + expect(answer.refused.status, face).toBe(400); + } + } + }); + continue; + } + it(`${op} on a multi-value lookup is refused with the shared sentence on both faces`, async () => { + await expectBothFaces({ owners: { [op]: comparandFor(op) } }, jsonRefusal('owners', op)); + }); + } + + it('the bare equality spelling { field: value } is refused with the shared bare-spelling sentence', async () => { + await expectBothFaces({ owners: 'u1' }, jsonRefusal('owners', '=', true)); + }); + + it('the gate holds at every depth: under $and, $or and $not', async () => { + const refusal = jsonRefusal('owners', '$nin'); + await expectBothFaces({ $and: [{ title: 'u1' }, { owners: { $nin: ['u1'] } }] }, refusal); + await expectBothFaces({ $or: [{ title: 'u1' }, { owners: { $nin: ['u1'] } }] }, refusal); + await expectBothFaces({ $not: { owners: { $nin: ['u1'] } } }, refusal); + }); + + it('the population is the driver\'s: an option-array type and a structured-JSON type refuse too', async () => { + await expectBothFaces({ tags: { $nin: ['a'] } }, jsonRefusal('tags', '$nin')); + await expectBothFaces({ payload: { $eq: 'u1' } }, jsonRefusal('payload', '$eq')); + }); + + it('a door other than find() refuses alike: count()', async () => { + for (const face of FACES) { + const err = await drivers[face] + .count(OBJECT.name, { where: { owners: { $nin: ['u1'] } } } as DriverQuery) + .then(() => null, (e: unknown) => e as WireBearingError); + expect(err, `${face} counted a refused filter`).not.toBeNull(); + expect(err!.code, face).toBe('INVALID_FILTER'); + expect(err!.status, face).toBe(400); + expect(err!.message, face).toBe(jsonColumnOperatorRefusalText('owners', '$nin', false).message); + } + }); + }); + + // ─── §2 The membership pair ────────────────────────────────────────────── + + describe('$contains / $notContains answer membership on a JSON column', () => { + it('u1 is a member of [u1,u2] and [u3,u1] — and NOT of [u10] (substring vs membership)', async () => { + await expectBothFaces({ owners: { $contains: 'u1' } }, { rows: ['r1', 'r3'] }); + }); + + it('u10 answers only the row holding it', async () => { + await expectBothFaces({ owners: { $contains: 'u10' } }, { rows: ['r4'] }); + }); + + it('$notContains is the exact complement, NULL row included', async () => { + await expectBothFaces({ owners: { $notContains: 'u1' } }, { rows: ['r2', 'r4', 'r5'] }); + }); + + it('an inherently multi-value option type answers membership the same way', async () => { + await expectBothFaces({ tags: { $contains: 'a' } }, { rows: ['r1', 'r3'] }); + }); + + it('a structured-JSON object has no members: its serialization is not searched', async () => { + await expectBothFaces({ payload: { $contains: 'u1' } }, { rows: [] }); + }); + + it('the binds stay aligned when membership composes with sibling predicates', async () => { + // The membership construct binds its candidates before the sibling's + // comparand; a misaligned bind list answers a different row or none. + await expectBothFaces({ $and: [{ owners: { $contains: 'u1' } }, { title: 'u3' }] }, { rows: ['r3'] }); + await expectBothFaces({ $or: [{ owners: { $contains: 'u10' } }, { title: 'u2' }] }, { rows: ['r2', 'r4'] }); + await expectBothFaces({ $not: { owners: { $contains: 'u1' } } }, { rows: ['r2', 'r4', 'r5'] }); + }); + + it('count() answers the same membership', async () => { + for (const face of FACES) { + const n = await drivers[face].count(OBJECT.name, { where: { owners: { $contains: 'u1' } } } as DriverQuery); + expect(n, face).toBe(2); + } + }); + }); + + // ─── §3 The scalar control ─────────────────────────────────────────────── + + describe('control: a scalar text field keeps every answer it had', () => { + it('$contains is still a substring test there', async () => { + await expectBothFaces({ title: { $contains: 'u1' } }, { rows: ['r1', 'r4'] }); + }); + + it('$nin / $eq / $startsWith still compile there', async () => { + await expectBothFaces({ title: { $nin: ['u1'] } }, { rows: ['r2', 'r3', 'r4', 'r5'] }); + await expectBothFaces({ title: { $eq: 'u1' } }, { rows: ['r1'] }); + await expectBothFaces({ title: 'u1' }, { rows: ['r1'] }); + await expectBothFaces({ title: { $startsWith: 'u1' } }, { rows: ['r1', 'r4'] }); + }); + }); + + // ─── §4 The presence questions stay answerable ────────────────────────── + + describe('presence on a JSON column is not a comparison: $null / $exists are answered alike', () => { + it('$null and $exists answer the NULL row', async () => { + await expectBothFaces({ owners: { $null: true } }, { rows: ['r5'] }); + await expectBothFaces({ owners: { $exists: true } }, { rows: ['r1', 'r2', 'r3', 'r4'] }); + }); + + it('the EQUALITY spellings of a null comparand stay in the refused family, as locally', async () => { + // The local face's gate reads the operator, not the comparand, so + // `$eq: null`, `$ne: null` and the bare `{ field: null }` are refused on + // a JSON column there; before #21178 the remote face answered the bare + // one with the NULL row. `$null` / `$exists` above are the presence + // spellings, and both faces answer them. + await expectBothFaces({ owners: null }, jsonRefusal('owners', '=', true)); + await expectBothFaces({ owners: { $eq: null } }, jsonRefusal('owners', '$eq')); + await expectBothFaces({ owners: { $ne: null } }, jsonRefusal('owners', '$ne')); + // Control: on the scalar field the same three are answered, on both faces. + await expectBothFaces({ title: null }, { rows: ['r5'] }); + await expectBothFaces({ title: { $eq: null } }, { rows: ['r5'] }); + await expectBothFaces({ title: { $ne: null } }, { rows: ['r1', 'r2', 'r3', 'r4'] }); + }); + }); +}); From c67a136e94b876497ea177a6b49cd12fd3b3eefe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 15:57:08 +0000 Subject: [PATCH 2/2] test(driver-turso): drive the JSON-column refusal through the compile-refusal seam table; add the changeset The seam enumeration requires every refusal method that goes through the withheld seam to have a row: jsonColumnOperator gets one per position (operator map, bare value, bare null), on a half-2 transport told that exactly one column is JSON-stored. The class is read from jsonColumnOperatorRefusalText, never spelled in the test. The bare-null position of buildWhereSQL now asks the gate too, as the local face's bare-value positions do. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- .changeset/21178-remote-json-column-gate.md | 25 +++++++++++++++ ...ote-transport-compile-refusal-seam.test.ts | 32 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 .changeset/21178-remote-json-column-gate.md diff --git a/.changeset/21178-remote-json-column-gate.md b/.changeset/21178-remote-json-column-gate.md new file mode 100644 index 00000000000..aa35d6be9dd --- /dev/null +++ b/.changeset/21178-remote-json-column-gate.md @@ -0,0 +1,25 @@ +--- +"@objectstack/driver-turso": minor +--- + +fix(driver-turso)!: in remote mode, a filter on a declared JSON-stored field is refused with `INVALID_FILTER` / 400 for every operator the local face refuses there, and `$contains` / `$notContains` answer membership instead of a substring of the stored text (#21178) + +Clause-②: yes (narrowing) + + + +**BREAKING** (`@objectstack/driver-turso`, remote mode): this narrows what `TursoDriver` answers when its `url` is a remote libSQL endpoint (such as `libsql://` or `https://`), the transport every hosted tenant database runs on, for every door that compiles a `where`: `find`, `findOne`, `count`, `updateMany`, `deleteMany`, `aggregate` and distinct values. It ships as `minor` under the launch-window convention for accept-set narrowings. Local and embedded-replica mode inherit `driver-sql`'s compiler and already answered this way; nothing moves there. + +**What is refused.** On a field the object declares JSON-stored (a structured-JSON type such as `json` or `address`, an inherently multi-value option type such as `tags`, `multiselect` or `checkboxes`, or a `select`, `radio`, `lookup`, `user`, `file` or `image` field declared `multiple: true`), a `where` that aims any operator in `@objectstack/core`'s `JSON_COLUMN_INCOMPATIBLE_OPERATORS` at the field is refused with `INVALID_FILTER` / 400, at any depth under `$and` / `$or` / `$not`, before any statement runs: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$nin`, `$startsWith`, `$endsWith`, `$icontains`, `$like`, `$ilike`, and implicit equality (`{ "owners": "u1" }`), whatever the comparand, `null` included. + +**What `$contains` / `$notContains` answer now.** Membership: `{ "owners": { "$contains": "u1" } }` matches the rows whose stored list holds `u1` as an element, so it no longer matches a row holding only `u10`; `$notContains` is its exact complement, a row with no value included; and a structured-JSON object answers no member at all, instead of matching text inside its serialization. On a scalar text field both remain the substring test they were. + +**What an author sees now.** The body the local transport answers for the same filter, byte for byte: the filter WAS NOT APPLIED, the comparison can never equal one member of a stored list, and the spelling to use, `{ "FIELD": { "$contains": "a" } }` for membership, or an `$or` of `$contains` for any-of. The field and the operator are withheld from the message, and the full diagnostic, naming both, is written to the driver's logger at `warn`. + +**Why.** The remote transport compiles its own SQL and read neither the shared refused set nor the membership construct, so over a `multiple: true` lookup holding `["u1","u2"]`, `["u2"]`, `["u3","u1"]` and `["u10"]` it answered: `$nin: ["u1"]` and `$ne: "u1"` every row, the rows holding `u1` included; `$eq`, `$in` and implicit equality no row; `$lt` / `$lte` a lexicographic verdict over the serialization; `$startsWith: "["` and `$endsWith: "]"` every row with a value; `$contains: "u1"` the row holding only `u10` too. One driver gave two answers to one filter depending only on the connection string, and the exclusion operators failed open. + +**Who is affected.** A caller, saved filter, list view, report or read scope that reaches a remote-mode `TursoDriver` with one of those operators on a JSON-stored field and read the rows it got as the answer. Write `$contains` for "holds this member", an `$or` of `$contains` for "holds any of these", `$not` around either for the exclusion, and `$null` / `$exists` / `$empty` for presence. + +**New optional API.** `RemoteTransport.setJsonColumnResolver(resolver)` in `@objectstack/driver-turso`, which `TursoDriver` wires to its own `isJsonColumn`, beside `setDeclaredValueShapeResolver`. A `RemoteTransport` driven standalone without it treats no column as JSON-stored and compiles as before. + +**Unchanged.** `$contains` and `$notContains` on a scalar field, `$exists`, `$null` and `$empty`; every operator on a field that is not declared JSON-stored; and a table this driver holds no declaration for, where nothing is judged. diff --git a/packages/drivers/driver-turso/src/remote-transport-compile-refusal-seam.test.ts b/packages/drivers/driver-turso/src/remote-transport-compile-refusal-seam.test.ts index ec233260e30..0f204b8e241 100644 --- a/packages/drivers/driver-turso/src/remote-transport-compile-refusal-seam.test.ts +++ b/packages/drivers/driver-turso/src/remote-transport-compile-refusal-seam.test.ts @@ -38,6 +38,7 @@ import { readFileSync } from 'node:fs'; import ts from 'typescript'; import type { DriverQuery } from '@objectstack/spec/contracts'; import { lowerFilterCondition, markFilterSubtreeProvenance } from '@objectstack/spec/data'; +import { jsonColumnOperatorRefusalText } from '@objectstack/core'; import { RemoteTransport } from './remote-transport.js'; import { TursoDriver } from './turso-driver.js'; import { asLibsqlClient, makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; @@ -51,6 +52,11 @@ const POLICY_COL = 'secret_policy_col'; const SECRET = 'PSECRET_LITERAL'; const SECRET_NUM = 7770123; const UNDECLARED_KEY = '$psecret_combinator'; +/** + * [#21178] The one column the half-2 transport is told is stored as JSON — the + * JSON-column gate's refusal needs the driver's rule injected to be reachable. + */ +const POLICY_JSON_COL = 'secret_policy_json_col'; type Door = { /** The `RemoteTransport` method this row drives — asserted by the error's stack. */ @@ -171,6 +177,16 @@ const DOORS: readonly Door[] = [ secrets: [POLICY_COL, SECRET], klass: 'a value this transport cannot bind', }, + // ── #21178: the JSON-column gate, born in the seam ───────────────────────── + { + // The class is read from the shared builder, never spelled here: its words + // belong to `@objectstack/core`, and the operator it names in prose + // (`$nin`) is the class, not a secret — the FIELD is what is withheld. + builder: 'jsonColumnOperator', + where: () => ({ [POLICY_JSON_COL]: { $nin: [SECRET] } }), + secrets: [POLICY_JSON_COL], + klass: jsonColumnOperatorRefusalText(POLICY_JSON_COL, '$nin', false).message, + }, ]; /** @@ -238,6 +254,19 @@ const ARMS: readonly Door[] = [ klass: 'A comparand in this filter is undefined', label: "{ secret_policy_col: { $in: ['a', undefined] } }", }, + // [#21178] The gate's two bare-equality positions: a value and `null`. + { + builder: 'jsonColumnOperator', + where: () => ({ [POLICY_JSON_COL]: SECRET }), + secrets: [POLICY_JSON_COL], + klass: jsonColumnOperatorRefusalText(POLICY_JSON_COL, '=', true).message, + }, + { + builder: 'jsonColumnOperator', + where: () => ({ [POLICY_JSON_COL]: null }), + secrets: [POLICY_JSON_COL], + klass: jsonColumnOperatorRefusalText(POLICY_JSON_COL, '=', true).message, + }, ]; // ── Half 1: the enumeration ─────────────────────────────────────────────────── @@ -341,6 +370,9 @@ function transport() { const t = new RemoteTransport(); t.setClient(client as any); t.setDiagnosticSink((m) => sink.push(m)); + // [#21178] Only `POLICY_JSON_COL` is a JSON column, so every other row + // compiles exactly as it does on a transport handed no rule at all. + t.setJsonColumnResolver((_object, field) => field === POLICY_JSON_COL); return { t, sink, client }; }