Skip to content

Commit c67a136

Browse files
committed
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 <noreply@anthropic.com>
1 parent 615db28 commit c67a136

2 files changed

Lines changed: 57 additions & 0 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/driver-turso": minor
3+
---
4+
5+
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)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a QUERY shape at the remote transport's filter compiler: the operators refused on a JSON-stored column are exactly the ones driver-sql's where refuses there (and so this driver's local transport), read from the one JSON_COLUMN_INCOMPATIBLE_OPERATORS set @objectstack/core holds, and $contains / $notContains move from a substring test to the membership test the local transport already answers, through the same jsonMembershipPredicate. No authorable key, spelling or stored metadata shape moves: FilterConditionSchema and every object, view and dataset definition parse and save as before, and nothing reads or rewrites a stored row. There is nothing for objectstack migrate meta to rewrite, since what changes is which query this transport answers, not what any metadata says; the refusal itself names the spelling to use. The other categories are closed on facts: the bumped package publishes (not unpublished); no ADR-0087 id covers a filter operator on a JSON-stored column and this diff adds none (not registered / already-registered); and the change is runtime behaviour, with no published export or type narrowed or removed — the one interface change is an ADDED optional method, a widening (not runtime-interface-only / type-surface-only). -->
10+
11+
**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.
12+
13+
**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.
14+
15+
**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.
16+
17+
**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`.
18+
19+
**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.
20+
21+
**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.
22+
23+
**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.
24+
25+
**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.

‎packages/drivers/driver-turso/src/remote-transport-compile-refusal-seam.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { readFileSync } from 'node:fs';
3838
import ts from 'typescript';
3939
import type { DriverQuery } from '@objectstack/spec/contracts';
4040
import { lowerFilterCondition, markFilterSubtreeProvenance } from '@objectstack/spec/data';
41+
import { jsonColumnOperatorRefusalText } from '@objectstack/core';
4142
import { RemoteTransport } from './remote-transport.js';
4243
import { TursoDriver } from './turso-driver.js';
4344
import { asLibsqlClient, makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';
@@ -51,6 +52,11 @@ const POLICY_COL = 'secret_policy_col';
5152
const SECRET = 'PSECRET_LITERAL';
5253
const SECRET_NUM = 7770123;
5354
const UNDECLARED_KEY = '$psecret_combinator';
55+
/**
56+
* [#21178] The one column the half-2 transport is told is stored as JSON — the
57+
* JSON-column gate's refusal needs the driver's rule injected to be reachable.
58+
*/
59+
const POLICY_JSON_COL = 'secret_policy_json_col';
5460

5561
type Door = {
5662
/** The `RemoteTransport` method this row drives — asserted by the error's stack. */
@@ -171,6 +177,16 @@ const DOORS: readonly Door[] = [
171177
secrets: [POLICY_COL, SECRET],
172178
klass: 'a value this transport cannot bind',
173179
},
180+
// ── #21178: the JSON-column gate, born in the seam ─────────────────────────
181+
{
182+
// The class is read from the shared builder, never spelled here: its words
183+
// belong to `@objectstack/core`, and the operator it names in prose
184+
// (`$nin`) is the class, not a secret — the FIELD is what is withheld.
185+
builder: 'jsonColumnOperator',
186+
where: () => ({ [POLICY_JSON_COL]: { $nin: [SECRET] } }),
187+
secrets: [POLICY_JSON_COL],
188+
klass: jsonColumnOperatorRefusalText(POLICY_JSON_COL, '$nin', false).message,
189+
},
174190
];
175191

176192
/**
@@ -238,6 +254,19 @@ const ARMS: readonly Door[] = [
238254
klass: 'A comparand in this filter is undefined',
239255
label: "{ secret_policy_col: { $in: ['a', undefined] } }",
240256
},
257+
// [#21178] The gate's two bare-equality positions: a value and `null`.
258+
{
259+
builder: 'jsonColumnOperator',
260+
where: () => ({ [POLICY_JSON_COL]: SECRET }),
261+
secrets: [POLICY_JSON_COL],
262+
klass: jsonColumnOperatorRefusalText(POLICY_JSON_COL, '=', true).message,
263+
},
264+
{
265+
builder: 'jsonColumnOperator',
266+
where: () => ({ [POLICY_JSON_COL]: null }),
267+
secrets: [POLICY_JSON_COL],
268+
klass: jsonColumnOperatorRefusalText(POLICY_JSON_COL, '=', true).message,
269+
},
241270
];
242271

243272
// ── Half 1: the enumeration ───────────────────────────────────────────────────
@@ -341,6 +370,9 @@ function transport() {
341370
const t = new RemoteTransport();
342371
t.setClient(client as any);
343372
t.setDiagnosticSink((m) => sink.push(m));
373+
// [#21178] Only `POLICY_JSON_COL` is a JSON column, so every other row
374+
// compiles exactly as it does on a transport handed no rule at all.
375+
t.setJsonColumnResolver((_object, field) => field === POLICY_JSON_COL);
344376
return { t, sink, client };
345377
}
346378

0 commit comments

Comments
 (0)