Skip to content

Commit 8a2ea6c

Browse files
os-zhuangclaude
andauthored
fix(driver-turso): remote 模式对齐 NULL-safe 语义 —— $not/$ne/$nin/$notContains 四算子 + $exists 拒收闸 (#5903) (#6047)
* fix(driver-turso): remote 模式对齐 NULL-safe 语义 —— $not/$ne/$nin/$notContains 四算子 + $exists 拒收闸 (#5903) TursoDriver 有两个 filter 编译器:local/replica 继承 SqlDriver.applyFilterCondition, remote 走 RemoteTransport.buildWhereSQL 这个独立发射器。#5146(PR #5296)与 #5298 (PR #5962)两次裁决只落在前者,于是同一个 driver 对同一条 filter 按 url 给两个答案。 在共享 fixture(1-2 行有值、3-4 行 NULL)上实测 origin/main @ b5bdf48: {d:{$ne:'v1'}} / {d:{$nin:['v1']}} / {d:{$notContains:'v1'}} / {$not:{d:'v1'}} local ['2','3','4'] remote ['2'] {d:{$exists:'yes'}} local INVALID_FILTER remote ['1','2'] 本次改动(全部在 remote 侧,local 一行未动): - $not:先把操作数逐叶 totalise 再取反,NOT(…) 对每一行都是 TRUE/FALSE 而不落入 UNKNOWN。守卫挂在叶子而不是 NOT 上,因为 De Morgan 只对二值叶子成立;极性按算子 各自的答案,不是一律 OR col IS NULL —— 否则 {$not:{a:{$ne:5}}} 会把它排除的行送回来。 - $ne/$nin/$notContains:非否定路径发射 (col IS NULL OR <原谓词>),统一 OR 展开, 不用 IS DISTINCT FROM / IS NOT / <=>(NOT LIKE 无此形式;SQLite 拼写依赖本仓不 pin 的引擎版本;实测执行计划相同)。$ne:null 保持 IS NOT NULL 不变 —— 极性看比较数, 不看算子名。括号不可省:buildWhereSQL 用裸 AND 拼接同级子句,裸 OR 会结合得更松。 - $exists:非布尔比较值按 #5347-A 拒收(INVALID_FILTER / 400)。#1116 当初留的栅栏 写明了自己的解除条件(#5299 裁决 + 不制造 local/remote 分叉),两条今天都已满足, 且 PR #5962 让 local 先严格之后,栅栏本身变成了分叉。 polarity 表在本包内第三次实现(driver-sql / read-scope-sql 之后),理由与 read-scope-sql 相同并写在文件头:本模块声明不依赖 knex,且每张表钉的是各自发射器的 拼写。互钉由 turso-local-remote-null-parity.test.ts 承担 —— 同一 driver 两个 transport 跑同一批 filter,先断言两边相等,再断言等于裁决要求的行集(只断相等会被"两边一起错" 满足)。 FILTER_LOGIC_CASES 入表 N1($ne)/N4($not) 两行:本单是它们共同的最后一个 blocker (Cube 面已由 #5977 清除),表文档的「RULED but not yet enrolled」family 2 与 blocker 矩阵随之作废,改写为毕业记录。11 个 harness 全绿。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WyvqvKMG6asi9aXjKE6xtx * ci: empty commit to retrigger a clean CI wave after the GitHub Actions outage The 15:40-17:00 UTC actions-resolution outage left this PR's checks in a tangle of superseded generations; a fresh head starts one clean wave. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WyvqvKMG6asi9aXjKE6xtx * ci: second retrigger — the 20:01 push event was lost in the Actions outage Scheduler is draining again (close-event workflow ran at 21:30 with ~30min lag); a fresh push event enters the recovered queue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WyvqvKMG6asi9aXjKE6xtx * ci: retrigger on recovered scheduler — both prior push events were swallowed by the outage Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WyvqvKMG6asi9aXjKE6xtx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fc77cfb commit 8a2ea6c

8 files changed

Lines changed: 976 additions & 120 deletions
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
'@objectstack/driver-turso': patch
3+
---
4+
5+
driver-turso: remote mode answers the NULL / no-value family the way local mode does
6+
7+
`TursoDriver` compiles filters two different ways: local (and replica) mode
8+
inherits `SqlDriver.applyFilterCondition`, remote mode uses
9+
`RemoteTransport.buildWhereSQL`, an independent emitter. The NULL rulings landed
10+
only on the first, so ONE driver gave one filter two answers depending on the
11+
`url` it was constructed with. Measured against a fixture with two valued rows
12+
and two no-value rows:
13+
14+
| filter | local | remote (before) |
15+
|---|---|---|
16+
| `{ d: { $ne: 'v1' } }` | rows 2,3,4 | row 2 |
17+
| `{ d: { $nin: ['v1'] } }` | rows 2,3,4 | row 2 |
18+
| `{ d: { $notContains: 'v1' } }` | rows 2,3,4 | row 2 |
19+
| `{ $not: { d: 'v1' } }` | rows 2,3,4 | row 2 |
20+
| `{ d: { $exists: 'yes' } }` | `INVALID_FILTER` | rows 1,2 |
21+
22+
Remote mode now matches local on all five:
23+
24+
- **`$not` is NULL-safe** (#5146). Each leaf of the negated condition is made
25+
total before the negation, so `NOT (…)` is TRUE or FALSE for every row instead
26+
of vanishing into SQL's UNKNOWN. A row whose column has no value does not
27+
satisfy the negated condition, so it IS returned.
28+
- **`$ne`, `$nin` and `$notContains` are NULL-safe** (#5298), emitted as
29+
`(col IS NULL OR <test>)`. `$ne: null` is unchanged and still compiles to
30+
`IS NOT NULL` — polarity follows the comparand, not the operator's name — and
31+
no positive comparison changes shape.
32+
- **A non-boolean `$exists` comparand is refused** with `INVALID_FILTER` / 400
33+
(#5369), as `$null` already was. `@objectstack/spec`'s `FieldOperatorsSchema`
34+
declares `$exists` as a boolean, and the emitter's `=== false` test sent every
35+
other value — including the truthy string `"false"` — to the `IS NOT NULL`
36+
side. `$exists: true` / `$exists: false` are unchanged.
37+
38+
Why it matters beyond a row count: a CEL `!expr` in a permission rule lowers to
39+
`{ $not: {…} }`, so this was one RLS read scope admitting different row sets per
40+
connection mode. The `$ne` and `$not` cases are now enrolled in the shared
41+
`FILTER_LOGIC_CASES` conformance table, which all eleven filter backends run.
42+
43+
**Upgrade note:** a query that relied on remote mode silently dropping no-value
44+
rows from a negative filter will now see them. Spell that intent explicitly —
45+
`{ $and: [{ d: { $ne: 'v1' } }, { d: { $null: false } }] }` — which is what it
46+
already had to be on every other backend.

packages/drivers/driver-turso/src/remote-transport-node-operator-refusal.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,12 +328,19 @@ describe('[#5769] RemoteTransport refuses a $-key in a node position', () => {
328328
expect((await compile({ $and: [{ a: 1 }, { b: 2 }] })).sql).toBe(
329329
`${BARE_SCAN} WHERE (("a" = ?) AND ("b" = ?))`,
330330
);
331+
// [#5903] The `$not` operand is NULL-safe since #5146 landed on this
332+
// face, so the negated predicate now carries the `IS NOT NULL` guard that
333+
// makes it TOTAL. That is a change THIS suite must not read as a #5769
334+
// regression: what #5769 pins is that the node gate compiles the three
335+
// declared combinators rather than refusing them, and it still does.
331336
expect((await compile({ $not: { stage: 'won' } })).sql).toBe(
332-
`${BARE_SCAN} WHERE NOT ("stage" = ?)`,
337+
`${BARE_SCAN} WHERE NOT ((("stage" IS NOT NULL) AND ("stage" = ?)))`,
333338
);
334339
expect(
335340
(await compile({ $and: [{ $or: [{ stage: 'won' }] }, { $not: { stage: 'lost' } }] })).sql,
336-
).toBe(`${BARE_SCAN} WHERE (((("stage" = ?))) AND (NOT ("stage" = ?)))`);
341+
).toBe(
342+
`${BARE_SCAN} WHERE (((("stage" = ?))) AND (NOT ((("stage" IS NOT NULL) AND ("stage" = ?)))))`,
343+
);
337344
});
338345

339346
it('keeps the boolean identities of #1073 / #1076 exactly where they were', async () => {

packages/drivers/driver-turso/src/remote-transport-not-operator.test.ts

Lines changed: 125 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,35 @@ import type { QueryAST } from '@objectstack/spec/data';
4242
* one RLS scope answered correctly on a local SqlDriver and broke on Turso
4343
* remote — a local/remote divergence on a declared spec construct.
4444
*
45-
* Two semantics are deliberate and pinned below, because the three in-tree
46-
* implementations do not agree on them (filed as objectstack#5146):
45+
* Two semantics are deliberate and pinned below. One of them has since been
46+
* RULED and reversed; both are recorded here because the reversal is the point.
4747
*
48-
* - **NULL rows follow the SQL family.** `NOT ("stage" = ?)` is UNKNOWN when
49-
* `stage` is NULL, so that row is not returned — exactly what Knex's
50-
* `whereNot` emits for local mode (`select … where (not (\`stage\` = ?))`,
51-
* measured). `driver-memory`/`matchesFilterCondition` return it. Remote mode
52-
* is pinned to the family it belongs to, so local and remote SQL agree.
53-
* - **`$not: {}` is FALSE.** The inner filter compiles to no SQL, which under
54-
* the #1073 invariant can only mean "vacuously TRUE", and `NOT TRUE` is
55-
* FALSE. `driver-memory` and `matchesFilterCondition` agree; driver-sql
56-
* returns every row there, but only because Knex drops an empty group — the
57-
* widening direction of the very bug family #2704 closed.
48+
* - **NULL rows are RETURNED (#5146, landed on this face by #5903).** This
49+
* bullet used to say the opposite — "NULL rows follow the SQL family:
50+
* `NOT ("stage" = ?)` is UNKNOWN when `stage` is NULL, so that row is not
51+
* returned, exactly what Knex's `whereNot` emits for local mode;
52+
* `driver-memory`/`matchesFilterCondition` return it; remote mode is pinned to
53+
* the family it belongs to, so local and remote SQL agree." Every clause of
54+
* that was true when it was written and the CONCLUSION expired eleven months
55+
* later: objectstack#5146 ruled the two-valued answer canonical and PR #5296
56+
* landed it on `SqlDriver.applyFilterCondition`, so local mode — which
57+
* inherits it — started returning the NULL row while this independent
58+
* compiler did not. The sentence that justified the pin ("local and remote SQL
59+
* agree") became the reason to flip it. Measured on `origin/main` on
60+
* 2026-08-06 against the shared conformance fixture: LOCAL `['2','3','4']`,
61+
* REMOTE `['2']`.
62+
*
63+
* The predicate is made TOTAL leaf by leaf before the negation, so
64+
* `NOT (…)` is TRUE or FALSE for every row and never UNKNOWN. That is why the
65+
* compiled SQL below carries an `IS NOT NULL` conjunct that was not there
66+
* before, and why the polarity is per operator rather than a blanket
67+
* `OR col IS NULL`: `{ $not: { a: { $ne: 5 } } }` means "a is 5", which a
68+
* no-value row must NOT satisfy (pinned in section (g) below).
69+
* - **`$not: {}` is FALSE.** UNCHANGED. The inner filter compiles to no SQL,
70+
* which under the #1073 invariant can only mean "vacuously TRUE", and
71+
* `NOT TRUE` is FALSE. `driver-memory` and `matchesFilterCondition` agree;
72+
* driver-sql returns every row there, but only because Knex drops an empty
73+
* group — the widening direction of the very bug family #2704 closed.
5874
*/
5975
function transportWithCapturingClient() {
6076
const calls: Array<{ sql: string; args: any[] }> = [];
@@ -98,7 +114,11 @@ describe('RemoteTransport $not (#1076)', () => {
98114
// Pre-fix: THREW `Filter on 'deal.$not' has an object comparand whose key
99115
// "stage" is not an operator`.
100116
const call = await compile({ $not: { stage: 'won' } });
101-
expect(call.sql).toBe(`${BARE_SCAN} WHERE NOT ("stage" = ?)`);
117+
// [#5903] The `IS NOT NULL` conjunct is #5146's leaf-totalising guard: a
118+
// row with no `stage` does not satisfy `stage = 'won'`, so it must satisfy
119+
// the negation. Without it `NOT (NULL = ?)` is UNKNOWN and the row
120+
// vanishes.
121+
expect(call.sql).toBe(`${BARE_SCAN} WHERE NOT ((("stage" IS NOT NULL) AND ("stage" = ?)))`);
102122
expect(call.args).toEqual(['won']);
103123
expectBindsBalanced(call);
104124
});
@@ -123,7 +143,11 @@ describe('RemoteTransport $not (#1076)', () => {
123143
// `NOT (a AND b)`, never `NOT (a) AND b` — the inner object is one
124144
// condition and De Morgan is not the caller's intent.
125145
const call = await compile({ $not: { stage: 'won', amount: 10 } });
126-
expect(call.sql).toBe(`${BARE_SCAN} WHERE NOT ("stage" = ? AND "amount" = ?)`);
146+
// Each leaf is guarded independently and the four conjuncts stay inside
147+
// the ONE negated group — `NOT (a AND b)`, never `NOT (a) AND b`.
148+
expect(call.sql).toBe(
149+
`${BARE_SCAN} WHERE NOT ((("stage" IS NOT NULL) AND ("stage" = ?) AND ("amount" IS NOT NULL) AND ("amount" = ?)))`,
150+
);
127151
expect(call.args).toEqual(['won', 10]);
128152
});
129153

@@ -202,27 +226,44 @@ describe('RemoteTransport $not (#1076)', () => {
202226
it('compiles `$not: { $not: {…} }` as two nested negations', async () => {
203227
// Pre-fix: THREW `Unsupported filter operator "$not" on 'deal.$not'`.
204228
const call = await compile({ $not: { $not: { stage: 'won' } } });
205-
expect(call.sql).toBe(`${BARE_SCAN} WHERE NOT (NOT ("stage" = ?))`);
229+
// The INNER `$not` is left un-rewritten by the outer one on purpose: its
230+
// own branch totalises its operand, and `NOT <total>` is itself total, so
231+
// recursing would stack a redundant guard on the same column. The guard
232+
// therefore appears exactly once, innermost.
233+
expect(call.sql).toBe(`${BARE_SCAN} WHERE NOT (NOT ((("stage" IS NOT NULL) AND ("stage" = ?))))`);
206234
expect(call.args).toEqual(['won']);
207235
expectBindsBalanced(call);
208236
});
209237

210238
it('compiles `$not` over a nested `$or`', async () => {
211239
const call = await compile({ $not: { $or: [{ stage: 'won' }, { stage: 'lost' }] } });
212-
expect(call.sql).toBe(`${BARE_SCAN} WHERE NOT ((("stage" = ?) OR ("stage" = ?)))`);
240+
// De Morgan is sound over two-valued leaves, which is the whole reason the
241+
// guard rides each LEAF rather than the `NOT`: hoisting it above this
242+
// `$or` would let a NULL `stage` satisfy the negation even when the other
243+
// disjunct is satisfied.
244+
expect(call.sql).toBe(
245+
`${BARE_SCAN} WHERE NOT ((((("stage" IS NOT NULL) AND ("stage" = ?))) OR ((("stage" IS NOT NULL) AND ("stage" = ?)))))`,
246+
);
213247
expect(call.args).toEqual(['won', 'lost']);
214248
expectBindsBalanced(call);
215249
});
216250

217251
it('compiles `$not` over a nested `$and`', async () => {
218252
const call = await compile({ $not: { $and: [{ stage: 'won' }, { amount: 10 }] } });
219-
expect(call.sql).toBe(`${BARE_SCAN} WHERE NOT ((("stage" = ?) AND ("amount" = ?)))`);
253+
expect(call.sql).toBe(
254+
`${BARE_SCAN} WHERE NOT ((((("stage" IS NOT NULL) AND ("stage" = ?))) AND ((("amount" IS NOT NULL) AND ("amount" = ?)))))`,
255+
);
220256
expect(call.args).toEqual(['won', 10]);
221257
});
222258

223259
it('carries operator maps through the negation with their binds in order', async () => {
224260
const call = await compile({ $not: { amount: { $gte: 10, $lt: 100 } } });
225-
expect(call.sql).toBe(`${BARE_SCAN} WHERE NOT ("amount" >= ? AND "amount" < ?)`);
261+
// ONE guard for the whole field constraint, not one per operator: the
262+
// constraint is the AND of its operators, so a NULL column satisfies it
263+
// only if it satisfies all of them — and it satisfies neither bound.
264+
expect(call.sql).toBe(
265+
`${BARE_SCAN} WHERE NOT ((("amount" IS NOT NULL) AND ("amount" >= ? AND "amount" < ?)))`,
266+
);
226267
expect(call.args).toEqual([10, 100]);
227268
expectBindsBalanced(call);
228269
});
@@ -243,20 +284,29 @@ describe('RemoteTransport $not (#1076)', () => {
243284
// 'lost')`. Pre-fix the whole read THREW, so the scope was unusable on
244285
// Turso remote while working locally.
245286
const call = await compile({ $and: [{ owner_id: 'u1' }, { $not: { stage: 'lost' } }] });
246-
expect(call.sql).toBe(`${BARE_SCAN} WHERE (("owner_id" = ?) AND (NOT ("stage" = ?)))`);
287+
expect(call.sql).toBe(
288+
`${BARE_SCAN} WHERE (("owner_id" = ?) AND (NOT ((("stage" IS NOT NULL) AND ("stage" = ?)))))`,
289+
);
247290
expect(call.args).toEqual(['u1', 'lost']);
248291
expectBindsBalanced(call);
249292
});
250293

251294
it('compiles a `$not` inside an `$or` branch', async () => {
252295
const call = await compile({ $or: [{ $not: { stage: 'lost' } }, { owner_id: 'u1' }] });
253-
expect(call.sql).toBe(`${BARE_SCAN} WHERE ((NOT ("stage" = ?)) OR ("owner_id" = ?))`);
296+
expect(call.sql).toBe(
297+
`${BARE_SCAN} WHERE ((NOT ((("stage" IS NOT NULL) AND ("stage" = ?)))) OR ("owner_id" = ?))`,
298+
);
254299
expect(call.args).toEqual(['lost', 'u1']);
255300
});
256301

257302
it('keeps a `$not` sibling of plain field keys at the same level', async () => {
258303
const call = await compile({ owner_id: 'u1', $not: { stage: 'lost' } });
259-
expect(call.sql).toBe(`${BARE_SCAN} WHERE "owner_id" = ? AND NOT ("stage" = ?)`);
304+
// The guard stays INSIDE the negated group. This is the assertion that
305+
// fails if it were ever spliced into the node's bare ` AND ` join, where
306+
// a stray `OR` would bind looser than the AND and widen the whole filter.
307+
expect(call.sql).toBe(
308+
`${BARE_SCAN} WHERE "owner_id" = ? AND NOT ((("stage" IS NOT NULL) AND ("stage" = ?)))`,
309+
);
260310
expect(call.args).toEqual(['u1', 'lost']);
261311
});
262312

@@ -351,9 +401,16 @@ describe('RemoteTransport $not (#1076)', () => {
351401
await t.count('deal', { where } as unknown as QueryAST);
352402
await t.deleteMany('deal', { where } as any);
353403
await t.updateMany('deal', { where } as any, { stage: 'lost' });
354-
expect(calls[0].sql).toMatch(/COUNT\(\*\).+WHERE NOT \("stage" = \?\)/i);
355-
expect(calls[1].sql).toBe('DELETE FROM "deal" WHERE NOT ("stage" = ?)');
356-
expect(calls[2].sql).toMatch(/^UPDATE "deal" SET .* WHERE NOT \("stage" = \?\)$/);
404+
// The one negated predicate all three statements must carry, verbatim —
405+
// a WHERE that is right for `find` and wrong for `deleteMany` is how a
406+
// filter fix becomes a data-loss bug.
407+
const NEGATION = 'WHERE NOT ((("stage" IS NOT NULL) AND ("stage" = ?)))';
408+
expect(calls[0].sql).toMatch(/COUNT\(\*\)/i);
409+
expect(calls[0].sql).toContain(NEGATION);
410+
expect(calls[1].sql).toBe(`DELETE FROM "deal" ${NEGATION}`);
411+
expect(calls[2].sql).toMatch(
412+
/^UPDATE "deal" SET .* WHERE NOT \(\(\("stage" IS NOT NULL\) AND \("stage" = \?\)\)\)$/,
413+
);
357414
for (const call of calls) expectBindsBalanced(call);
358415
});
359416

@@ -425,21 +482,34 @@ describe('TursoDriver remote — $not on real rows (#1076)', () => {
425482

426483
it('`$not: { stage: "won" }` returns the other rows, not a `no such column` error', async () => {
427484
// Pre-fix: threw before reaching SQLite; had it compiled, SQLite would have
428-
// answered `no such column: $not`.
429-
expect(await ids({ $not: { stage: 'won' } })).toEqual(['d_lost', 'd_open']);
485+
// answered `no such column: $not`. `d_null` joins the answer at #5903 — see
486+
// the next case.
487+
expect(await ids({ $not: { stage: 'won' } })).toEqual(['d_lost', 'd_null', 'd_open']);
430488
});
431489

432-
it('drops the NULL-`stage` row — SQL three-valued logic, as `whereNot` does locally', async () => {
490+
it('RETURNS the NULL-`stage` row — the #5146 ruling, on this face since #5903', async () => {
491+
// The direction of this case is REVERSED, deliberately. It read: "drops the
492+
// NULL-`stage` row — SQL three-valued logic, as `whereNot` does locally.
433493
// `d_null` is absent above and here. This is the SQL family's answer:
434494
// `NOT (NULL = 'won')` is UNKNOWN, not TRUE. `driver-memory` and
435495
// `matchesFilterCondition` would return it. Remote mode is pinned to local
436496
// SqlDriver so the two SQL transports cannot disagree; the cross-family
437-
// divergence is objectstack#5146.
497+
// divergence is objectstack#5146."
498+
//
499+
// #5146 ruled that cross-family divergence — the JS answer is canonical, a
500+
// column with no value does not satisfy the negated condition — and PR
501+
// #5296 landed it on `SqlDriver`. The last clause of the old comment is
502+
// what makes this a flip rather than a regression: "the two SQL transports
503+
// cannot disagree" stopped being true the moment local inherited the fix,
504+
// and it is true again only with `d_null` on this side of the answer.
438505
const rows = await ids({ $not: { stage: 'won' } });
439-
expect(rows).not.toContain('d_null');
440-
// …and the row IS reachable — it is excluded by the negation's semantics,
441-
// not missing from the table.
506+
expect(rows).toContain('d_null');
507+
// …and the row is still identifiable as the no-value row, so this is not
508+
// passing because the seed lost its NULL.
442509
expect(await ids({ stage: null })).toEqual(['d_null']);
510+
// The complement still excludes it: negation is not a licence to return
511+
// everything.
512+
expect(await ids({ $not: { stage: { $ne: 'won' } } })).toEqual(['d_won']);
443513
});
444514

445515
it('`$not: {}` returns ZERO rows', async () => {
@@ -455,7 +525,12 @@ describe('TursoDriver remote — $not on real rows (#1076)', () => {
455525
});
456526

457527
it('negates a nested `$or` (De Morgan on real rows)', async () => {
458-
expect(await ids({ $not: { $or: [{ stage: 'won' }, { stage: 'lost' }] } })).toEqual(['d_open']);
528+
// `d_null` is in the answer for the same reason it is above: it satisfies
529+
// neither disjunct, so it satisfies the negation.
530+
expect(await ids({ $not: { $or: [{ stage: 'won' }, { stage: 'lost' }] } })).toEqual([
531+
'd_null',
532+
'd_open',
533+
]);
459534
});
460535

461536
it('answers the RLS shape — owner AND NOT lost', async () => {
@@ -488,7 +563,19 @@ describe('TursoDriver remote — $not on real rows (#1076)', () => {
488563
});
489564
// `d_lost` closed WITHIN 2025-01-15, so the negation excludes it; `d_won`
490565
// closed later, so the negation keeps it. Un-lowered, both come back.
491-
expect(await ids({ $not: { closed_at: { $lte: '2025-01-15' } } })).toEqual(['d_won']);
566+
//
567+
// [#5903] `d_open` and `d_null` never got a `closed_at`, and a row with no
568+
// value does not satisfy `closed_at <= …`, so the negation now returns
569+
// them. That is the SAME ruling as the case above applied to a range
570+
// operator — the guard is `requireValue` for every positive comparison —
571+
// and it is what LOCAL mode has answered since PR #5296. The lowering
572+
// assertion is unaffected: `d_lost` is still excluded, which is the only
573+
// way to tell a lowered upper bound from an un-lowered one.
574+
expect(await ids({ $not: { closed_at: { $lte: '2025-01-15' } } })).toEqual([
575+
'd_null',
576+
'd_open',
577+
'd_won',
578+
]);
492579
});
493580

494581
it('lowers `$between` inside `$not` instead of refusing it', async () => {
@@ -501,7 +588,11 @@ describe('TursoDriver remote — $not on real rows (#1076)', () => {
501588
});
502589

503590
it('`count` agrees with `find`', async () => {
504-
expect(await driver.count('deal', { object: 'deal', where: { $not: { stage: 'won' } } })).toBe(2);
591+
// Three since #5903 — `d_null` is inside the negation now, and a count that
592+
// disagreed with the list under it is exactly the local/remote split this
593+
// issue closed, wearing a total instead of a row set.
594+
expect(await driver.count('deal', { object: 'deal', where: { $not: { stage: 'won' } } })).toBe(3);
595+
expect((await ids({ $not: { stage: 'won' } })).length).toBe(3);
505596
expect(await driver.count('deal', { object: 'deal', where: { $not: {} } })).toBe(0);
506597
});
507598

0 commit comments

Comments
 (0)