Skip to content

Commit ceee88f

Browse files
fix(plugin-security,driver-sql,driver-turso): lower type-blind at the RLS seam without a guard, then delete the F1/F2 whole-day and NOT-rewrite copies (#5930 step 4, group 2) (#20988)
Part of #20822 Clause-②: no (narrowing) #5930 step 4, **group 2**: `driver-sql` F1, `driver-turso` remote F2, and the `plugin-security` `rlsLowering` absent-guard twin (the seat's answer 5918373748, A). Ruled by 5902355785 (D4 (b)) and ADR-0053 D-D1 items 5, 7 and 9 as amended. The order follows group 1b's pattern: make the seam type-blind first, then delete. Seven commits on `main` at `5f6b63a6fd`: | # | Commit | What it does | |:--|:--|:--| | 1 | `e20e17e929` fix(plugin-security) | `rlsLowering` hands the lowering no reader when the guard carries no `datetime` set (no guard, or a guard built without types). The RLS compile seam is now type-blind there (item 7). The typed branch is byte-identical. The pin "a guard with no types reads no column as datetime" is rewritten to the new reading. A new pin file covers the no-guard compile for both clauses, and a `using` policy through `SecurityPlugin.getReadFilter` handed to `SqlDriver.find`. | | 2 | `a9b1f79282` refactor(driver-sql, driver-turso) | Deletes the whole-day copies. F1: `calendarDayExclusiveUpperBound`, `calendarDayUpperBoundRewrite` and `calendarDayBetweenRewrite`, plus their two call blocks in the emitter. F2: `toRemoteFilter`'s whole-day arms (`toRemoteUpperBound` and the `$lte` / `$between`-max rewrites). The direct-call temporal suites are routed through `lowerFilterCondition` with each fixture's declared-datetime reader. New item-5 pins on both drivers. | | 3 | `d5277f98e6` refactor(driver-sql, driver-turso) | Deletes the NULL-safe `$not` copies: F1 `nullSafeNegationOperand` and its three polarity tables, and F2 `RemoteTransport`'s copy of the same. The `$not` direct-call suites are routed, including two `service-analytics` `find()` faces that stand for the engine. Refusal pins stay direct calls. New pins on both drivers. | | 4 | `4658e72939` docs(changeset) | `.changeset/20822-driver-sql-turso-copies.md`, first draft. | | 5 | `6ec47a8770` docs(changeset, spec) | Patch round (the seat's answers 5922273550 and 5922490848): `driver-sql` `minor`, `Clause-②: no (narrowing)`, a BREAKING paragraph with a FROM → TO line, both step-2 sentences superseded, and `@objectstack/spec` `patch` for the ledger entry `18.driver-sql-calendar-day-methods-removed.ts` with the regenerated `registry.ts`. The other three packages stay `patch`. | | 6 | `d8b8f9095a` fix(spec) | The ledger entry's printed reason states the decision in words instead of citing tracker ids. CI's `migrate-meta-engine-guidance` test was red on commit 5 for this. | | 7 | `0e8378cf54` docs(objectql) | `content/docs/protocol/objectql/query-syntax.mdx` states the whole-day rule as the shared lowering's (the engine's `where` seam and the RLS compile seam), and a direct call to `SqlDriver` or a driver built on it as compared as written (contract review 5923464846, FAIL 1). | 49 files, +1501 / -977 against `5f6b63a6fd` at `0e8378cf54`, under the 5000-line human-merge threshold. No governed path. ## The answers that move, named All rows were measured on SQLite (better-sqlite3 `:memory:`, and the libsql stub for Turso remote). | Path | Filter | Before | After | Ruled by | |:--|:--|:--|:--|:--| | Any seamed read: engine `where` positions, `aggregations[i].filter`, RLS compile seam with a typed guard | any | — | **no move** (ablations below) | item 9 | | RLS `using` / `check` compiled with **no guard**, or with a guard without a `datetime` set | `record.signed_on` before-or-on `'2026-01-05'` | `{ signed_on: { $lte: '2026-01-05' } }`, as written | `{ signed_on: { $lt: '2026-01-06' } }`; SQLite keeps every row of the day (pin §B); `InMemoryDriver` keeps the whole day too | item 7 (seat answer A) | | Direct `SqlDriver` / `SqliteWasmDriver` / `TursoDriver` call (both faces), `datetime` column | `$lte` a bare day | the whole day | that day's midnight, as written | item 5 | | same | `$between` with a bare-day max | the whole max day | inclusive at both ends, as written | item 5 | | same | `$lte '9999-12-31'` | every row with a value | that midnight, as written | item 5 | | Direct call, both faces | `$not` over `=`, an ordering operator, `$in`, `$contains`, a `$or` | a row with a NULL column was returned (#5146) | SQL's three-valued `NOT`: that row is not returned | item 5 | | same | `$not` over `$ne` / `$nin` / `$notContains`, and `$ne` / `$nin` / `$notContains` without a `$not` | NULL-safe | **unchanged**: each emitter spells these NULL-safe itself (`applyNullSafeNegative` / `nullSafeNegative`, #5298); they are not copies of the `$not` rewrite | — | | Direct `RemoteTransport` call with an **author**-marked filter | a refusal raised inside a `$not` operand (for example `$or: [null]`) | withheld even for the author, because the rewrite's nodes carried no provenance mark | resolves against the author's own mark and names the branch, as it does outside a `$not` | #8220 contract (a refusal message, not an answer) | Nothing moved beyond item 5 or item 7, so no site stopped on that clause. ## Two stops, measured (H3, and the `$between` split) - **`assertDefinedComparands` is kept in both faces.** - *Seamed positions:* the engine's comparand door refuses an `undefined` comparand on `find`, `findOne`, `count`, `update`, `delete`, `aggregations[i].filter`, `having` and `$not` positions (`engine-comparand-type-door.test.ts`, `engine-aggregate-filter.test.ts`, `engine-aggregate-having-comparand-shape.test.ts`, all green). The RLS compile seam refuses through `normalizeFilterComparandTypes` in `judgeCompiledComparands`. - *F1, a direct caller after deletion* (both call sites disabled, `sql-driver-undefined-comparand-refusal.test.ts`, 22 positions): - 5 positions move from `INVALID_FILTER` / 400 to **an answer**: `$contains`, `$notContains`, `$startsWith` and `$endsWith` with `undefined`, and `undefined` beside the TRUE identity `{}`. The LIKE family binds `String(undefined)`. - The other 17 move to a bare `DATABASE_ERROR`. - That is H3's stop. F1's copy is kept. - *F2:* with its pre-walk removed, all 19 positions still refuse with `INVALID_FILTER` / 400, through `serializeComparand`'s unbindable-comparand refusal. No answer moved. But `turso-local-remote-null-parity.test.ts`, which the dispatch names as a suite to keep green, holds both faces to driver-sql's one sentence (#6050, #5240): 13 parity cases (the #6050 block), 14 refusal-seam rows (13 `undefinedComparand` rows plus the closed-class enumeration) and 20 `undefined`-refusal pins went red. F2's copy is kept with F1's. Its comment now says why. - **`toRemoteFilter` keeps its structural `$between` split.** The remote transport has no `$between` arm, and it refuses an unsplit range. The typed lowering leaves a `$between` on a non-`datetime` column whole. So removing the split moves seamed answers from rows to a refusal. Measured by ablation (S1): 6 red. They are the numeric `$between` control, two `Field.time` `$between` conformance cells, `$not` over a numeric `$between`, and the two `$between` item-5 cells. Only the whole-day half of those arms is deleted. ## Hypotheses - **H1 (sites): held**, at `75519e1c0a`, the first base. `sql-driver.ts` is unchanged by the rebase onto `5f6b63a6fd`. - F1: - the quartet is lines 5054 to 5298: `NullGuard` 5064, `nullValueSatisfiesOperator` 5077, `operatorIsNullTotal` 5141, `nullGuardForFieldSpec` 5180, `nullSafeNegationOperand` 5265; - it is called from the `$not` branch at 16943; - `assertDefinedComparands` is at 4707, called at 4929 and 16588; - `calendarDayExclusiveUpperBound` is at 15476, `calendarDayUpperBoundRewrite` at 15498 and `calendarDayBetweenRewrite` at 15521, called at 17036 and 17053. - F2: - `toRemoteFilter` is at 2567 (the dispatch said about 2566); - the `$between` arm is at 2610 and the `$lte` arm at 2638; - `toRemoteUpperBound` is at 2685, and it calls `calendarDayUpperBoundRewrite` at 2691; - `remote-transport.ts` has its quartet at 379 to 614, called at 3116, and `assertDefinedComparands` at 4364, called at 2961. - **H2 (deleting moves no seamed answer): held for every deleted copy.** The ablations restored each copy on the committed head. Every routed suite stayed green with the copy back, and only the new direct-call pins turned red. The `$between` split is the exception above: it is structural, not a copy. - **H3: held as a stop for both faces.** See above. - **H4 (direct callers): no caller to route.** Grep over production `src` (`driver.find` / `count` / `aggregate` / `updateMany` / `deleteMany` / `distinct`, `getDriverForObject`, `_find` / `_count`): - `metadata` `DatabaseLoader` in driver mode builds equality filters only (`baseFilter`, `nextEventSeq`). `queryHistory` is already lowered (group 1b), and its §A pin stays green on SQLite with F1 gone. - `metadata` `history-cleanup` uses `recorded_at $lt` an instant, and `type $nin`. - `objectql` `LifecycleService`'s archive pass uses `hot.find` / `cold.deleteMany`, through `getDriverForObject` / `datasource`. Its filters are `$lt` an ISO instant, an `organization_id` equality, and `$or: [organization_id $nin, organization_id null]`. - The CLI's `secret-reference-union` / `secret orphans` use `{ type: 'datasource' }` or no `where`. - `metadata-protocol`'s migrations run raw SQL. - None of these builds a bare-day `$lte`, a `$between`, a `$not` or an `undefined` comparand. `$lt` an instant is never widened. `$nin` outside a `$not` keeps the emitter's own NULL-safe form. - **H5 (the RLS twin): held.** `rlsLowering(undefined)` and a guard without `datetime` both return `{}`. The existing pin flipped as predicted. The new pin goes through a `using` policy: SQLite keeps the whole day. The memory half is the filter shape (driver-agnostic) plus `driver-memory`'s own §C pin of a type-blind lowered filter. A plugin-security test that imports `@objectstack/driver-memory` is an arrival outside the driver-memory census ledger. - **H6: NOT MEASURED here.** A live PostgreSQL / MySQL matrix and a live remote Turso server were not measured. The live matrix is `Temporal Conformance (live PG + MySQL)` in CI. Remote Turso was measured against the libsql stub only. ## For the review - **`packages/spec` is touched for the ledger only:** one semantic entry in `src/migrations/entries/semantic/` and the regenerated `registry.ts`. `spec-changes` and the upgrade guide stay byte-identical, because the generators stop at protocol 17. It is a declared cross-lane surface (the seat's answer 5922490848). - **`SqlDriver`'s published class surface shrinks.** `calendarDayExclusiveUpperBound`, `calendarDayUpperBoundRewrite` and `calendarDayBetweenRewrite` were `protected` members of the exported class, so they are declared in `dist/index.d.ts`. Measured against the published `.d.ts` (tsc 6.0.3): a call gets TS2339, and an `override` re-declaration gets TS4113. A plain re-declaration still compiles, but the driver never calls it. The replacement is `lowerFilterCondition` from `@objectstack/spec/data`. The changeset carries it as BREAKING (`minor`), with the ADR-0087 ledger entry `driver-sql-calendar-day-methods-removed`. - In-repo, `TursoDriver` was the one caller, and it is updated. The test probe subclass is rewritten. - `objectui` / `cloud` at their pins: NOT MEASURED (no sibling checkout in this container). - The changeset states the removal. - **One sentence of this release's step-2 changeset is superseded.** `.changeset/5930-shared-filter-lowering.md` says "A guard without that set treats no column as `datetime`" and "Each driver keeps its own copy of these rules". The new changeset has a Supersedes paragraph naming both sentences. The step-2 file is not edited here, because the foreign-changeset rule in `check-empty-changeset` (#17712) forbids it. - **Expected values changed in three places; all other edits wrap arguments.** 1. The H5 flip in `rls-shared-lowering-seam.test.ts`. 2. The `$not` refusal-provenance flip in `remote-transport-not-operator.test.ts` (e). 3. `sql-driver-calendar-day-upper-bound.test.ts`'s probe matrix. It called the deleted methods. It now pins the lowered bound's physical form per dialect, with the same values as before: `2026-07-29T00:00:00.000Z`, and `2026-07-29 00:00:00.000` on MySQL. The calendar arithmetic and the scope rows are the lowering's (`spec`'s `filter-lowering.test.ts`). Every other changed `expect(` line wraps its filter in `seamed(...)`, and its expected value is unchanged. ## Verification at head `4658e72939` (driver code; unchanged since. The patch round's gates at `d8b8f9095a`: 94 derived, 94 run, every one exit 0, under a stale-tree warning for 5 files `main` changed outside this diff) **Suites**, each with `vitest run --maxWorkers=2`. Package baselines are from `75519e1c0a`. | Package | Files | Tests | Baseline | |:--|:--|:--|:--| | `driver-sql` | 203 passed, 11 skipped | 3281 passed, 188 skipped | 3266 passed / 188 skipped | | `driver-turso` | 81 | 2210 passed, 33 skipped | 2195 / 33 | | `driver-sqlite-wasm` | 36 | 675 | 675 | | `plugin-security` | 151 | 3272 passed, 23 skipped | 3227 / 23 | | `service-analytics` | 148 | 3425 | — | | `metadata` | 56 | 836 | — | | `objectql`, `--project local` | 348 | 6821 | — | | `objectql`, `--project repo` | 1 | 5 | — | | `rest` | 249 | 4964 passed, 114 skipped | — | The typechecks for `driver-sql`, `driver-turso`, `driver-sqlite-wasm`, `plugin-security` (including `check:test-typecheck`) and `service-analytics` all exit 0. `driver-sql`'s tsc program lists all 214 of its test files. **Ablations.** Each restores the deleted copy on the committed head through `scripts/ablation-replace.mjs`. Each is restored with blob equal to HEAD and `git diff HEAD` empty. | # | Restored | Red (expected direction) | Everything else | |:--|:--|:--|:--| | A1 | F1 whole-day | 8 of 3281: exactly the 8 §A cells (4 canonical, 4 legacy-normalised) | green | | A2 | F1 `$not` rewrite | 6: the 4 §B direct cells, plus 2 SQL-text pins that compile the lowered operand. The restored copy guards it a second time: diagnostics grow, same rows. | green, including every row-result suite | | A3 | F1 + F2 whole-day | 8: the turso §A cells, 4 per face | green | | A4 | F2 transport `$not` rewrite | 17: the 4 §B remote cells, 12 transport SQL-text pins (the double guard again), and the (e) provenance pin (the rewrite's new nodes withhold again) | green, including filter-logic remote and local/remote NULL parity | | A5 | the RLS twin reverted | 8: 4 §A compile cells, both §B cells (the SQLite rows fall to midnight-only), and the 2 flipped seam pins | green | | S1 | the `$between` split removed | the 6 cells named above | green | A3 rebuilt `driver-sql`, and `ablation-dist-preflight` showed the marker present in `dist/`. The restore leg rebuilt it again, and `--absent` showed the marker gone from all 6 built files with the tree clean. **Gates.** - `node scripts/pm/dispatch-gates.mjs --commands` derives 67 families: 46 paths against merge base `5f6b63a6f`, with no stale-tree warning after the rebase. - All 67 were run, and every one exits 0. That includes `check:driver-conformance` (OK, 50 covered cells, 0 DEBT), `check:driver-memory-census`, `check:engine-double-contract`, `check:query-options-erasure`, `check:nul-bytes`, `check:dual-build-cjs-loads` and `check:i18n`. The last two first exited 3 (`PREREQUISITE NOT MET`) and were rerun after a full workspace build (72 tasks). - `dispatch-gates --ran`: 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN. **Lint (narrowed).** - `eslint --no-inline-config --format json` over the 45 changed `.ts` files: 45 files, 0 errors, 0 warnings. - Every file resolves under `--print-config`. - `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`), so no untouched file's verdict can move. - The full `pnpm lint` is CI's. ## Acceptance notes - **Stale pointers outside this file surface,** for the last group PR, which per landing record 5920439482 corrects the stale pointers outside `driver-memory`: - `objectql` `having-filter.ts` around line 1365, and `spec` `filter.zod.ts` around lines 434, 734 and 750, still name `calendarDayUpperBoundRewrite` / `calendarDayBetweenRewrite`. - `service-analytics` `read-scope-shared-lowering-seam.test.ts` has a case titled "RLS using, as written (a guard without types)". Its rows still hold, but the RLS seam no longer emits that shape. - **One copy is left in F1 on purpose: the `undefined` refusal.** The seat answered A (5922273550): it stays in both faces as a door, not a copy of the lowering's meaning, because without it 5 of F1's 22 direct-call positions move from 400 to an answer. - `applyNullSafeNegative` (F1) and `nullSafeNegative` (F2) are not in the design's census quartet. They are the emitters' own NULL-safe spelling of `$ne` / `$nin` / `$notContains`, and they stay. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b8191f7 commit ceee88f

49 files changed

Lines changed: 1501 additions & 977 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/driver-sql': minor
3+
'@objectstack/driver-sqlite-wasm': patch
4+
'@objectstack/driver-turso': patch
5+
'@objectstack/plugin-security': patch
6+
'@objectstack/spec': patch
7+
---
8+
9+
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)
10+
11+
Clause-②: no (narrowing)
12+
13+
<!-- adr-0087: registered driver-sql-calendar-day-methods-removed -->
14+
15+
**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.
16+
17+
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.
18+
19+
**Supersedes two sentences of this release's shared-lowering entry** (`lowerFilterCondition`, #5930), which this change makes false:
20+
21+
- "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).
22+
- "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).
23+
24+
- **`@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.
25+
- **`@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.
26+
- **`@objectstack/driver-sqlite-wasm` — `SqliteWasmDriver` inherits the `SqlDriver` change above**, with the same answers on a seamed read and on a direct call.
27+
- **`@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.
28+
- **`@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.

‎content/docs/protocol/objectql/query-syntax.mdx‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -568,10 +568,17 @@ const businessHours: FilterCondition = {
568568

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

576583
### Null Checks
577584

‎packages/drivers/driver-sql/src/sql-driver-15683-temporal-text-operator-gate.test.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,24 @@
5656
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
5757
import type { Knex } from 'knex';
5858
import type { DriverOptions, FilterCondition } from '@objectstack/spec/data';
59-
import { NON_TEXT_STORED_VALUE_TYPES } from '@objectstack/spec/data';
59+
import { lowerFilterCondition, NON_TEXT_STORED_VALUE_TYPES } from '@objectstack/spec/data';
6060
import { SqlDriver, type SqlDriverConfig } from './sql-driver.js';
6161
import {
6262
DIALECT_CELLS,
6363
declareUnprovisionedCell,
6464
type DialectCell,
6565
} from './live-dialect-matrix.testkit.js';
6666

67+
/**
68+
* [#20822 · ADR-0053 D-D1 items 5 and 9, as amended] The `$not` operand a seam
69+
* hands this driver: the shared lowering's rule 3 guards each leaf in the
70+
* direction its operator answers for a row with no value. `SqlDriver` no longer
71+
* carries its own copy of that rewrite (`nullSafeNegationOperand`), so the
72+
* compile pin below reads the SQL for the operand a seam hands it.
73+
*/
74+
const seamedNot = (where: FilterCondition): FilterCondition =>
75+
lowerFilterCondition(where, { isDatetimeColumn: () => false }) as FilterCondition;
76+
6777
/** Issue-prefixed: the live cells share one database with every other suite here. */
6878
const TEMPORAL_OBJECT = 'os15683_temporal_text';
6979

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

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

‎packages/drivers/driver-sql/src/sql-driver-19885-nested-bare-comparand.test.ts‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,24 @@
8585

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

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

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

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

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

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

‎packages/drivers/driver-sql/src/sql-driver-19999-glob-nul-comparand.test.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,21 @@
3535

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

42+
/**
43+
* [#20822 · ADR-0053 D-D1 items 5 and 9, as amended] What a seam hands this
44+
* driver: the filter through the shared lowering, whose rule 3 makes each
45+
* `$not` leaf total in the direction its operator answers for a row with no
46+
* value (#5146, #5298). The driver no longer carries its own copy of that
47+
* rewrite, so the answers below are the ones every seamed read gets,
48+
* unchanged. The tables here declare no `datetime` column, so the whole-day rule has
49+
* nothing to rewrite.
50+
*/
51+
const seamed = (where: FilterCondition): FilterCondition => lowerFilterCondition(where, { isDatetimeColumn: () => false }) as FilterCondition;
52+
4253
const NUL = String.fromCharCode(0x00);
4354
const BYPASS: DriverOptions = { bypassTenantAudit: true };
4455
const TABLE = 'nul_text_match';
@@ -151,7 +162,7 @@ describe('[#19999] SqlDriver on better-sqlite3 — a comparand holding U+0000 is
151162
let driver: SqlDriver;
152163

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

‎packages/drivers/driver-sql/src/sql-driver-20024-glob-stored-nul.test.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,21 @@
3232

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

39+
/**
40+
* [#20822 · ADR-0053 D-D1 items 5 and 9, as amended] What a seam hands this
41+
* driver: the filter through the shared lowering, whose rule 3 makes each
42+
* `$not` leaf total in the direction its operator answers for a row with no
43+
* value (#5146, #5298). The driver no longer carries its own copy of that
44+
* rewrite, so the answers below are the ones every seamed read gets,
45+
* unchanged. The tables here declare no `datetime` column, so the whole-day rule has
46+
* nothing to rewrite.
47+
*/
48+
const seamed = (where: FilterCondition): FilterCondition => lowerFilterCondition(where, { isDatetimeColumn: () => false }) as FilterCondition;
49+
3950
const NUL = String.fromCharCode(0x00);
4051
const BYPASS: DriverOptions = { bypassTenantAudit: true };
4152
const TABLE = 'stored_nul_text_match';
@@ -197,7 +208,7 @@ describe('[#20024] SqlDriver on better-sqlite3 — a comparand without U+0000 re
197208
let driver: SqlDriver;
198209

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

‎packages/drivers/driver-sql/src/sql-driver-20024-like-stored-nul.test.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,21 @@
2929

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

36+
/**
37+
* [#20822 · ADR-0053 D-D1 items 5 and 9, as amended] What a seam hands this
38+
* driver: the filter through the shared lowering, whose rule 3 makes each
39+
* `$not` leaf total in the direction its operator answers for a row with no
40+
* value (#5146, #5298). The driver no longer carries its own copy of that
41+
* rewrite, so the answers below are the ones every seamed read gets,
42+
* unchanged. The tables here declare no `datetime` column, so the whole-day rule has
43+
* nothing to rewrite.
44+
*/
45+
const seamed = (where: FilterCondition): FilterCondition => lowerFilterCondition(where, { isDatetimeColumn: () => false }) as FilterCondition;
46+
3647
const NUL = String.fromCharCode(0x00);
3748
const SOH = String.fromCharCode(0x01);
3849
const BYPASS: DriverOptions = { bypassTenantAudit: true };
@@ -168,7 +179,7 @@ describe('[#20024] SqlDriver on better-sqlite3 — $like / $ilike read the whole
168179
let driver: SqlDriver;
169180

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

‎packages/drivers/driver-sql/src/sql-driver-20041-like-nul-pattern.test.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,21 @@
3636

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

43+
/**
44+
* [#20822 · ADR-0053 D-D1 items 5 and 9, as amended] What a seam hands this
45+
* driver: the filter through the shared lowering, whose rule 3 makes each
46+
* `$not` leaf total in the direction its operator answers for a row with no
47+
* value (#5146, #5298). The driver no longer carries its own copy of that
48+
* rewrite, so the answers below are the ones every seamed read gets,
49+
* unchanged. The tables here declare no `datetime` column, so the whole-day rule has
50+
* nothing to rewrite.
51+
*/
52+
const seamed = (where: FilterCondition): FilterCondition => lowerFilterCondition(where, { isDatetimeColumn: () => false }) as FilterCondition;
53+
4354
interface WireBearingError extends Error {
4455
code?: string;
4556
status?: number;
@@ -120,7 +131,7 @@ describe('[#20041] SqlDriver (better-sqlite3): a $like / $ilike pattern holding
120131
};
121132

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

125136
const formulaLabels = (where: FilterCondition): string[] =>
126137
Object.entries(ROWS)

0 commit comments

Comments
 (0)