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
28 changes: 28 additions & 0 deletions .changeset/20822-driver-sql-turso-copies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@objectstack/driver-sql': minor
'@objectstack/driver-sqlite-wasm': patch
'@objectstack/driver-turso': patch
'@objectstack/plugin-security': patch
'@objectstack/spec': patch
---

fix(driver-sql, driver-turso, plugin-security, spec)!: `SqlDriver` and `TursoDriver` compile the filter they are handed — their copies of the whole-day bound and of the NULL-safe `$not` rewrite are deleted, and the RLS compile seam lowers type-blind when it cannot read the declared types (ADR-0053 D-D1 items 5, 7 and 9, #20822)

Clause-②: no (narrowing)

<!-- adr-0087: registered driver-sql-calendar-day-methods-removed -->

**BREAKING**: `SqlDriver` in `@objectstack/driver-sql` loses three `protected` methods: `calendarDayExclusiveUpperBound`, `calendarDayUpperBoundRewrite` and `calendarDayBetweenRewrite`. They were the driver's copy of the whole-day bound, which the shared lowering now applies once, at the seams, before a driver sees the filter. A subclass of `SqlDriver` that calls one of them, or overrides one with the `override` modifier, no longer compiles (TS2339, TS4113). That includes a subclass of `SqliteWasmDriver` or `TursoDriver`, which extend `SqlDriver`. A subclass that re-declares one without `override` still compiles, but the driver never calls it, so the rule it carried stops applying. It ships as `minor` under the launch-window convention. The class's public methods are unchanged.

FROM → TO: a `SqlDriver` subclass that called `this.calendarDayUpperBoundRewrite(table, field, op, value)`, `this.calendarDayBetweenRewrite(table, field, value)` or `this.calendarDayExclusiveUpperBound(table, field, value)`, or overrode one of them, lowers the filter with `lowerFilterCondition` from `@objectstack/spec/data` instead, before the driver compiles it: `lowerFilterCondition(where, { isDatetimeColumn })`, where `isDatetimeColumn` answers which columns get the whole-day bound.

**Supersedes two sentences of this release's shared-lowering entry** (`lowerFilterCondition`, #5930), which this change makes false:

- "A guard without that set treats no column as `datetime`." Now: when the RLS compile seam has no field guard, or one without a `datetime` set, it cannot read which columns are `datetime`, so it applies the whole-day rule to every column (the `@objectstack/plugin-security` entry below).
- "Each driver keeps its own copy of these rules, and every copy gives the same answer on lowered input." Now: `SqlDriver`, `SqliteWasmDriver` and `TursoDriver` keep no copy of the whole-day bound or of the NULL-safe `$not` rewrite. A read through the engine or the RLS compile seam gets the lowered answer, and a call on the driver itself gets the comparison it wrote (the `@objectstack/driver-sql` and `@objectstack/driver-turso` entries below).

- **`@objectstack/plugin-security` — an RLS policy compiled with no field guard is lowered type-blind.** The RLS compile seam runs the shared `lowerFilterCondition` on every compiled `using` and `check` filter. When the security plugin could not resolve the object's declared fields (no field guard), or a caller of `RLSCompiler.compileFilter` passes a guard without a `datetime` set, the seam cannot read which columns are `datetime`, and it now applies the whole-day rule to every column (a bare-day upper bound becomes `$lt` the next day), as ADR-0053 D-D1 item 7 rules for a seam that cannot read the type. It used to read no column as `datetime`, which left the bound to each driver's own copy of the rule. Visible on a `using` policy such as `record.signed_on <= '2026-01-05'` on such an object: every row of that day is kept on every driver, including `InMemoryDriver`, which had compared it as written since its own copy was deleted. A guard with a `datetime` set is unchanged, and so are the NULL-polarity guards.
- **`@objectstack/driver-sql` — `SqlDriver` keeps no copy of the rules the seams apply.** Deleted: the whole-day rewrite of a bare-day `$lte` and of a `$between` maximum on a `datetime` column, on the plain and the legacy-normalised column paths, including the last supported day (the protected methods `calendarDayExclusiveUpperBound`, `calendarDayUpperBoundRewrite` and `calendarDayBetweenRewrite` are removed from the class); and the NULL-safe rewrite of a `$not` operand (`nullSafeNegationOperand` and its polarity tables, module-private). A read through the engine or the RLS compile seam is unchanged: the seam hands the driver a filter the shared lowering has already rewritten, and the deleted copies gave the same answer on that input. A caller that passes no seam — `find`, `findOne`, `count`, `aggregate`, `distinct`, `updateMany`, `deleteMany` or `findWithWindowFunctions` called on the driver itself — now gets the comparison it wrote: a bare-day `$lte` compares against that day's midnight, a `$between` is inclusive at both ends, `$lte '9999-12-31'` compares against that midnight, and a `$not` is SQL's three-valued negation, so a row whose compared column is NULL is not returned by it. `$ne`, `$nin` and `$notContains` keep their NULL-safe form, which this emitter spells for the operator itself. The refusal of an `undefined` comparand (`INVALID_FILTER` / 400) is kept: without it some positions would answer instead of refusing. To keep the seam's reading on a direct call, lower the filter first: `driver.find(object, { where: lowerFilterCondition(where, { isDatetimeColumn }) })`, with `lowerFilterCondition` from `@objectstack/spec/data`. A subclass that called or overrode one of the three removed methods: see **BREAKING** above.
- **`@objectstack/driver-sqlite-wasm` — `SqliteWasmDriver` inherits the `SqlDriver` change above**, with the same answers on a seamed read and on a direct call.
- **`@objectstack/driver-turso` — both faces of `TursoDriver` compile the filter they are handed.** Local and replica mode inherit the `SqlDriver` change. Remote mode: `toRemoteFilter` no longer widens a bare-day `$lte` or a `$between` maximum (it still splits a two-bound `$between` into the `$gte` / `$lte` pair the remote transport compiles, both ends inclusive, and still converts each comparand to storage form), and `RemoteTransport` no longer rewrites a `$not` operand (its copy of the polarity tables is deleted). The two faces still answer every filter alike, on a seamed read and on a direct call. The remote transport keeps its refusal of an `undefined` comparand, worded as `driver-sql`'s, so both faces refuse it in one sentence. The same one line keeps the seam's reading on a direct call.
- **`@objectstack/spec` — the ADR-0087 ledger records the removal.** The protocol-18 step of `MIGRATIONS_BY_MAJOR` gains the semantic entry `driver-sql-calendar-day-methods-removed`, which names the three removed methods with their replacement and acceptance criteria. Every upgrade channel that projects protocol 18 carries it. `spec-changes.json` and the generated upgrade guide stop at the current protocol, 17, so neither changes in this release. A subclass that re-declares one of the methods without `override` still compiles and is never called, so the ledger, not the compiler, is the notice that reaches it.
15 changes: 11 additions & 4 deletions content/docs/protocol/objectql/query-syntax.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -568,10 +568,17 @@ const businessHours: FilterCondition = {

A bare `YYYY-MM-DD` bound is a **calendar day**. As a lower bound (`$gte`) it
means the start of that day (midnight UTC); as an upper bound (`$lte`, or the
max of a `$between`) it covers the **whole** day — on a `datetime` column the
driver compiles it half-open (`< next day`), so the `$between` above includes
everything that happened on Dec 31. A full ISO timestamp keeps exact-instant
semantics on every operator.
max of a `$between`) it covers the **whole** day. On a `date` column that is
plain comparison, so the `$between` above includes Dec 31. On a `datetime`
column it is the shared filter lowering (`lowerFilterCondition` from
`@objectstack/spec/data`, ADR-0053 D-D1 as amended): the engine's `where` seam
and the RLS compile seam rewrite a bare-day upper bound on a `datetime` column
half-open (`$lt` the next day) before any driver sees the filter, and a seam
that cannot read the declared types applies that rewrite to every column. A
filter handed directly to `SqlDriver` or a driver built on it
(`SqliteWasmDriver`, `TursoDriver`), past both seams, is compared as written: a
bare-day `$lte` on a `datetime` column compares against that day's midnight. A
full ISO timestamp keeps exact-instant semantics on every operator.

### Null Checks

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,24 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Knex } from 'knex';
import type { DriverOptions, FilterCondition } from '@objectstack/spec/data';
import { NON_TEXT_STORED_VALUE_TYPES } from '@objectstack/spec/data';
import { lowerFilterCondition, NON_TEXT_STORED_VALUE_TYPES } from '@objectstack/spec/data';
import { SqlDriver, type SqlDriverConfig } from './sql-driver.js';
import {
DIALECT_CELLS,
declareUnprovisionedCell,
type DialectCell,
} from './live-dialect-matrix.testkit.js';

/**
* [#20822 · ADR-0053 D-D1 items 5 and 9, as amended] The `$not` operand a seam
* hands this driver: the shared lowering's rule 3 guards each leaf in the
* direction its operator answers for a row with no value. `SqlDriver` no longer
* carries its own copy of that rewrite (`nullSafeNegationOperand`), so the
* compile pin below reads the SQL for the operand a seam hands it.
*/
const seamedNot = (where: FilterCondition): FilterCondition =>
lowerFilterCondition(where, { isDatetimeColumn: () => false }) as FilterCondition;

/** Issue-prefixed: the live cells share one database with every other suite here. */
const TEMPORAL_OBJECT = 'os15683_temporal_text';

Expand Down Expand Up @@ -263,10 +273,10 @@ describe('[#15683] the per-dialect construct, compiled', () => {

it('composes with the NULL-safe $not rewrite: NOT over the constant is total', () => {
const d = typed(DIALECTS[1][1]);
const notContains = d.compileWhere({ $not: { on_day: { $contains: '2026' } } });
const notContains = d.compileWhere(seamedNot({ $not: { on_day: { $contains: '2026' } } }));
expect(notContains).toMatch(/not \(.*is not null.*1 = 0/);
expect(notContains).not.toMatch(/LIKE/);
const notNotContains = d.compileWhere({ $not: { on_day: { $notContains: '2026' } } });
const notNotContains = d.compileWhere(seamedNot({ $not: { on_day: { $notContains: '2026' } } }));
expect(notNotContains).toMatch(/not \(.*is null.*1 = 1/);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,24 @@

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Knex } from 'knex';
import type { FilterCondition } from '@objectstack/spec/data';
import { lowerFilterCondition, type FilterCondition } from '@objectstack/spec/data';
import { SqlDriver, withheldFilterDiagnosticOf } from './sql-driver.js';
import { DIALECT_CELLS, declareDialectCell, dialectCell, type DialectCell } from './live-dialect-matrix.testkit.js';

/**
* [#20822 · ADR-0053 D-D1 items 5 and 9, as amended] What a seam hands this
* driver: the filter through the shared lowering, whose rule 3 makes each
* `$not` leaf total in the direction its operator answers for a row with no
* value (#5146, #5298). The driver no longer carries its own copy of that
* rewrite, so the answers below are the ones every seamed read gets,
* unchanged. The tables here declare no `datetime` column, so the whole-day rule has
* nothing to rewrite. A binary comparand never passes a seam (the platform
* doors refuse it), so the binary block reads rule 3 applied as a seam would;
* a direct caller's three-valued `NOT` is pinned in
* `sql-driver-20822-comparison-as-written.test.ts` §B.
*/
const seamed = (where: FilterCondition): FilterCondition => lowerFilterCondition(where, { isDatetimeColumn: () => false }) as FilterCondition;

/** Issue-prefixed: the live cells share one server with every other suite here. */
const OBJECT = 'os19885_nested_comparand';

Expand Down Expand Up @@ -158,7 +172,9 @@ const PRE_FIX_SQLITE_SQL: ReadonlyArray<readonly [FilterCondition, string, unkno
[{ $or: [{ tags: 'a' }] }, `select \`id\` from \`${OBJECT}\` where ((\`tags\` = ?))`, ['a']],
[{ $and: [{ tags: 'a' }] }, `select \`id\` from \`${OBJECT}\` where ((\`tags\` = ?))`, ['a']],
[
{ $not: { tags: 'a' } },
// [#20822] The `$not` operand a seam hands the driver (the lowering's rule 3
// guard); the driver compiles it as handed, to the SQL it compiled before.
seamed({ $not: { tags: 'a' } }),
`select \`id\` from \`${OBJECT}\` where not (((\`tags\` is not null) and (\`tags\` = ?)))`,
['a'],
],
Expand Down Expand Up @@ -207,7 +223,7 @@ function declareNestedBareComparandSuite(cell: DialectCell): void {
const find = (where: FilterCondition) => driver.find(OBJECT, { fields: ['id'], where });

const ids = async (where: FilterCondition): Promise<string[]> =>
((await find(where)) as Array<{ id: unknown }>).map((r) => String(r.id)).sort();
((await find(seamed(where))) as Array<{ id: unknown }>).map((r) => String(r.id)).sort();

const refusalOf = async (where: FilterCondition): Promise<WireBearingError> => {
let rows: unknown[];
Expand Down Expand Up @@ -317,7 +333,7 @@ describe('[#19885] SqlDriver — a binary comparand nested under a combinator (s
});

const ids = async (where: FilterCondition): Promise<string[]> =>
((await driver.find(BINARY_OBJECT, { fields: ['id'], where })) as Array<{ id: unknown }>)
((await driver.find(BINARY_OBJECT, { fields: ['id'], where: seamed(where) })) as Array<{ id: unknown }>)
.map((r) => String(r.id))
.sort();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,21 @@

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Knex } from 'knex';
import type { DriverOptions, FilterCondition } from '@objectstack/spec/data';
import { lowerFilterCondition, type DriverOptions, type FilterCondition } from '@objectstack/spec/data';
import { matchesFilterCondition } from '@objectstack/formula';
import { SqlDriver } from './sql-driver.js';

/**
* [#20822 · ADR-0053 D-D1 items 5 and 9, as amended] What a seam hands this
* driver: the filter through the shared lowering, whose rule 3 makes each
* `$not` leaf total in the direction its operator answers for a row with no
* value (#5146, #5298). The driver no longer carries its own copy of that
* rewrite, so the answers below are the ones every seamed read gets,
* unchanged. The tables here declare no `datetime` column, so the whole-day rule has
* nothing to rewrite.
*/
const seamed = (where: FilterCondition): FilterCondition => lowerFilterCondition(where, { isDatetimeColumn: () => false }) as FilterCondition;

const NUL = String.fromCharCode(0x00);
const BYPASS: DriverOptions = { bypassTenantAudit: true };
const TABLE = 'nul_text_match';
Expand Down Expand Up @@ -151,7 +162,7 @@ describe('[#19999] SqlDriver on better-sqlite3 — a comparand holding U+0000 is
let driver: SqlDriver;

const labelsWhere = async (where: FilterCondition): Promise<string[]> => {
const rows = (await driver.find(TABLE, { where }, BYPASS)) as Array<{ label: string }>;
const rows = (await driver.find(TABLE, { where: seamed(where) }, BYPASS)) as Array<{ label: string }>;
return rows.map((r) => r.label).sort();
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,21 @@

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Knex } from 'knex';
import type { DriverOptions, FilterCondition } from '@objectstack/spec/data';
import { lowerFilterCondition, type DriverOptions, type FilterCondition } from '@objectstack/spec/data';
import { matchesFilterCondition } from '@objectstack/formula';
import { SqlDriver } from './sql-driver.js';

/**
* [#20822 · ADR-0053 D-D1 items 5 and 9, as amended] What a seam hands this
* driver: the filter through the shared lowering, whose rule 3 makes each
* `$not` leaf total in the direction its operator answers for a row with no
* value (#5146, #5298). The driver no longer carries its own copy of that
* rewrite, so the answers below are the ones every seamed read gets,
* unchanged. The tables here declare no `datetime` column, so the whole-day rule has
* nothing to rewrite.
*/
const seamed = (where: FilterCondition): FilterCondition => lowerFilterCondition(where, { isDatetimeColumn: () => false }) as FilterCondition;

const NUL = String.fromCharCode(0x00);
const BYPASS: DriverOptions = { bypassTenantAudit: true };
const TABLE = 'stored_nul_text_match';
Expand Down Expand Up @@ -197,7 +208,7 @@ describe('[#20024] SqlDriver on better-sqlite3 — a comparand without U+0000 re
let driver: SqlDriver;

const labelsWhere = async (where: FilterCondition): Promise<string[]> => {
const rows = (await driver.find(TABLE, { where }, BYPASS)) as Array<{ label: string }>;
const rows = (await driver.find(TABLE, { where: seamed(where) }, BYPASS)) as Array<{ label: string }>;
return rows.map((r) => r.label).sort();
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,21 @@

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Knex } from 'knex';
import type { DriverOptions, FilterCondition } from '@objectstack/spec/data';
import { lowerFilterCondition, type DriverOptions, type FilterCondition } from '@objectstack/spec/data';
import { matchesFilterCondition } from '@objectstack/formula';
import { SqlDriver } from './sql-driver.js';

/**
* [#20822 · ADR-0053 D-D1 items 5 and 9, as amended] What a seam hands this
* driver: the filter through the shared lowering, whose rule 3 makes each
* `$not` leaf total in the direction its operator answers for a row with no
* value (#5146, #5298). The driver no longer carries its own copy of that
* rewrite, so the answers below are the ones every seamed read gets,
* unchanged. The tables here declare no `datetime` column, so the whole-day rule has
* nothing to rewrite.
*/
const seamed = (where: FilterCondition): FilterCondition => lowerFilterCondition(where, { isDatetimeColumn: () => false }) as FilterCondition;

const NUL = String.fromCharCode(0x00);
const SOH = String.fromCharCode(0x01);
const BYPASS: DriverOptions = { bypassTenantAudit: true };
Expand Down Expand Up @@ -168,7 +179,7 @@ describe('[#20024] SqlDriver on better-sqlite3 — $like / $ilike read the whole
let driver: SqlDriver;

const labelsWhere = async (where: FilterCondition): Promise<string[]> => {
const rows = (await driver.find(TABLE, { where }, BYPASS)) as Array<{ label: string }>;
const rows = (await driver.find(TABLE, { where: seamed(where) }, BYPASS)) as Array<{ label: string }>;
return rows.map((r) => r.label).sort();
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,21 @@

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { Knex } from 'knex';
import { markFilterSubtreeProvenance, type DriverOptions, type FilterCondition } from '@objectstack/spec/data';
import { lowerFilterCondition, markFilterSubtreeProvenance, type DriverOptions, type FilterCondition } from '@objectstack/spec/data';
import { matchesFilterCondition } from '@objectstack/formula';
import { SqlDriver, type SqlDriverConfig } from './index.js';

/**
* [#20822 · ADR-0053 D-D1 items 5 and 9, as amended] What a seam hands this
* driver: the filter through the shared lowering, whose rule 3 makes each
* `$not` leaf total in the direction its operator answers for a row with no
* value (#5146, #5298). The driver no longer carries its own copy of that
* rewrite, so the answers below are the ones every seamed read gets,
* unchanged. The tables here declare no `datetime` column, so the whole-day rule has
* nothing to rewrite.
*/
const seamed = (where: FilterCondition): FilterCondition => lowerFilterCondition(where, { isDatetimeColumn: () => false }) as FilterCondition;

interface WireBearingError extends Error {
code?: string;
status?: number;
Expand Down Expand Up @@ -120,7 +131,7 @@ describe('[#20041] SqlDriver (better-sqlite3): a $like / $ilike pattern holding
};

const labels = async (where: FilterCondition): Promise<string[]> =>
((await driver.find(TABLE, { where }, BYPASS)) as Array<{ label: string }>).map((r) => r.label).sort();
((await driver.find(TABLE, { where: seamed(where) }, BYPASS)) as Array<{ label: string }>).map((r) => r.label).sort();

const formulaLabels = (where: FilterCondition): string[] =>
Object.entries(ROWS)
Expand Down
Loading
Loading