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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/21178-remote-json-column-gate.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- 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). -->

**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.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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. */
Expand Down Expand Up @@ -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,
},
];

/**
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────
Expand Down Expand Up @@ -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 };
}

Expand Down
Loading
Loading