Skip to content

Commit 53ed3d1

Browse files
fix(driver-mongodb): MongoDBDriver compiles the whole-day comparison it is handed; formula's copy stops on a measured answer move (#21105)
Part of #20822 Clause-②: no Group 3a of #20822 (#5930 step 4, the per-face deletions), under ruling 5902355785 (D4 (b): "drivers receive the seams' lowered input; no permanent local guards on faces that bypass a seam") and ADR-0053 D-D1 as amended. This PR does not close the card: group 3b (F8 `having`, serial on `having-filter.ts`) does. ## What this PR does - **F6 `driver-mongodb`: deleted.** `translateFieldOperators` (`mongodb-filter.ts`) no longer applies the whole-day upper bound itself. `MongoDBDriver` compiles the comparison it is handed. - **F7 `formula` `lteBound`: stopped and reported, not deleted.** Deleting it moves an answer on a seam-fed path (the card's stop rule). The measurements are below, and the decision is asked in the report. `matches-filter.ts` has no net change in this PR. - **#20987's engine faces move to group 3b** (triage pointer 5922592744). These are the mongodb `$contains` `$regex`, formula's `case '$contains'` and the `engine.ts` delete-probe docblock. The shared membership module (`json-membership-sql.ts` in `@objectstack/core`) is not on `origin/main`: at `e07566b73`, `git ls-tree -r` names 0 paths matching `json-membership`. Also, `@objectstack/formula` depends on `@objectstack/spec` alone, so an import from core would add a package edge. ## H1: the sites, at `origin/main` `6f578888a` - **F6.** `nextUtcCalendarDay` occurs 4 times in `mongodb-filter.ts`: the import (`:27`), the `translateFieldOperators` docblock (`:1123`), and 2 call sites. The call sites are the `$lte` arm (`:1211`) and the `$between` arm (`:1305`). The card's "4 sites" are these 4 occurrences. All 4 are gone. The `@objectstack/core` import is gone with them, and the clobber-class table in `assembleLoweredWrites`' docblock loses its `$lt` row. - **F7.** `lteBound` has 2 call sites, the `$lte` arm (`:668`) and the `$between` arm (`:673`), plus the function itself (`:867`). All 3 are kept. ## H2: direct callers, with their answers before and after - **F6.** No in-repo production caller hands `MongoDBDriver` a bare-day `$lte` or `$between` outside a seam: - the CLI's `secret-reference-union.ts` (`where: { type: 'datasource' }`, an equality) and `secret orphans`; - objectql `LifecycleService` archive (`$lt` an ISO instant); - `DatabaseLoader.queryHistory` in driver mode, which has been its own seam since group 1b. Their answers do not move. The answer that moves belongs to a hypothetical direct call such as `driver.find('task', { where: { created_at: { $lte: '2026-07-28' } } })` on a declared `datetime` column: - before: `$lt` 2026-07-29 midnight, so the whole day was kept; - after: `$lte` 2026-07-28 midnight, the comparison as written (item 5). Through the engine seam the answer is `$lt` the next day both before and after. - **F7.** Its callers were measured with the copy deleted (`d045f1d6c`), then the copy was restored (`394df163a`): - **The RLS write `check`** (`security-plugin.ts`, step 3.6). It is fed by the RLS compile seam, which lowers only the guard's `datetime` columns (item 7). **Its answer moves**, as the table below shows. - **The explain engine** (`explain-engine.ts`). Its filters come from `computeLayeredRlsFilter`, so they too are compiled through the RLS seam. It evaluates the STORED record, where a `date` column holds day text. No answer move was found. - **The tenant check** (`security-plugin.ts`, step 3.7). It evaluates the organization wall only, with no temporal bound, so its answer is unchanged. - **F8's cross-field comparison** (`having-filter.ts`, `compareWithReference`). It hands F7 `{ $field }` comparands, which the lowering never rewrites. **Its answer moves** for a referent that resolves to a bare day, as the second finding below shows. - **Any external caller of the published `matchesFilterCondition`.** Under item 5 it would get the comparison it wrote. ### F7: the measured answer move The probe is temporary and not committed. It runs through the public insert door: ObjectQL, SecurityPlugin and `SqlDriver` on SQLite. The object has `due_on: date` and `signed_on: datetime`. One policy carries `using` and `check`, both `record.due_on` at most `'2026-01-05'`. The typed RLS seam leaves the `date` column as written, `{ due_on: { $lte: '2026-01-05' } }`, as `rls-shared-lowering-seam.test.ts` pins. A member inserts each value. The same value is also written by the system and read back by the member. | post-image `due_on` | stored | the member's read of the stored row | insert, F7 copy kept | insert, F7 copy deleted | |:--|:--|:--|:--|:--| | `'2026-01-05'` | `2026-01-05` | visible | admitted | admitted | | `'2026-01-05T10:00:00.000Z'` | `2026-01-05` | visible | admitted | **refused `PERMISSION_DENIED` / 403** | | `new Date('2026-01-05T10:00:00Z')` | `2026-01-05` | visible | admitted | **refused `PERMISSION_DENIED` / 403** | | `'2026-01-06'` (control) | `2026-01-06` | not visible | refused 403 | refused 403 | The record validator accepts both non-day spellings for a `date` field: a `Date`, and a string with a leading `YYYY-MM-DD`. The driver stores them as that day. The `check` judges the raw image before the driver converts it. `lteBound` is what reads the bound as the whole day there, on a column the typed seam does not rewrite. With it deleted, the write is refused although the read shows the stored row. ADR-0053 D-D1 item 9 says a copy is "idempotent on lowered input". That premise does not hold for this face. A second answer moves too. A `$lte` against a `{ $field, addDays }` reference whose referent resolves to a bare day gets the whole day only from `lteBound`. The lowering keeps any `{ $field }` comparand as written, so no seam carries this rule. `matches-filter-field-reference-offset.test.ts` pins it ("a calendar day stays a calendar day, so a `$lte` still covers the whole shifted day"), and that pin goes red with the copy deleted. **Existing suites with the F7 copy deleted** (`d045f1d6c`, formula rebuilt): - formula: 25 of 1241 red. Of these, 24 are direct-call cells that could be routed through the lowering: 20 in `matches-filter-temporal-conformance.test.ts` and 4 in `matches-filter.test.ts`. The 25th is the `$field` offset pin above, which cannot be routed. - plugin-security: 153 files, 3287 passed, 23 skipped. No suite there pins the `date` column case, so only the probe shows it. - objectql `having` / aggregate (`src/having`, `src/engine-aggregate`): 15 files, 573 passed. ## F6: what moves, and what does not - **Seam-fed reads do not move.** The engine's `where`, `aggregations[i].filter` and the RLS compile seam hand this driver `$lt` the next day, a split `$between`, and `$null: false` on `9999-12-31`. The deleted copy answered that input identically, as ablations A1 and A2 below show. A `date` column answers as before, because its stored day text orders the same either way. - **Two answers converge on `SqlDriver`'s** (item 7's scope; seat answer 5915193659: "declare it in the changeset; it is not a decision"). On a registered object, a bare-day `$lte` or `$between` maximum on a column that is not declared `datetime` is now compared as written. That covers a `text` column holding ISO instant text and an undeclared column. - **A direct call compares as written** (item 5). The changeset gives the one-line way to keep the seam's reading: `lowerFilterCondition(where, { isDatetimeColumn })`. - **Real mongod: NOT MEASURED.** There is no `mongod` binary in this container, and `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED` is off, as the card's recorded gaps state. The gated suites that call `driver.find` directly are routed through a typed seam helper, as the sibling drivers' suites are: `mongodb-temporal-conformance.test.ts` (both `TEMPORAL_CASES` and `TEMPORAL_TIME_CASES`, tokens resolved first) and `mongodb-datetime-storage.test.ts` (its 3 bare-day cells). Neither ran here. ## Pins, routed and flipped - **`mongodb-filter.test.ts`.** The calendar-day block is split in two. The first half is the direct call, compiled as written: a bare-day `$lte`, a bare-day `$between`, the authored array form, and the last supported day for both. The second half is the seam-fed control, every former cell through `lowerFilterCondition` with `created_at` declared `datetime`, with the same documents as before. - **`mongodb-operator-key-clobber.test.ts`.** The enumeration rows change: a bare-day `$lte` now writes `$lte`, a bare-day `$between` now writes `$gte` and `$lte`, and the `$lt` contested row is removed. The "fourth member" cell is now two-sided: - a direct `$lte` beside `$lt` is uncontested; - seam-fed, the lowering's `$lt` arrives as its own conjunct, and both bounds survive in either key order. - **`mongodb-filter-shape-refusal.test.ts`.** A bare-day range is pinned as written, plus the seam-fed control. - **`mongodb-time-storage.test.ts`.** A comment only. ## Ablations Each ablation runs on the committed head through `scripts/ablation-replace.mjs` in WRAP mode, nested: the import anchor first, then the arm. The driver-mongodb suite imports `./mongodb-filter.js` from `src`, so no dist is involved. - **A1, the `$lte` arm restored:** 5 red, exactly the direct-call `$lte` cells (3 in `mongodb-filter.test.ts`, 2 in the clobber file). Every seam-fed cell stayed green. - **A2, the `$between` arm restored:** 4 red, exactly the direct-call `$between` cells (2 in `mongodb-filter.test.ts`, 1 in the shape-refusal file, 1 clobber row). Every seam-fed cell stayed green. - **Restore, both legs:** the blob equals HEAD `de35f1ee395c` and `git diff HEAD` is empty. - **The first attempt was a no-op.** Its import replacement contained its own anchor, and the tool refused it: "the anchor count moved 1 -> 1". It was rerun with a corrected anchor. ## Compile surfaces - **F6 `driver-mongodb` `translateFieldOperators`:** the whole-day copy is deleted. It compiles as handed, and seam-fed answers are unchanged (A1, A2). - **F7 `formula` `matchesFilterCondition`:** unchanged. The stop is reported, and `lteBound` stays until the decision. - **`engine.ts` delete-probe docblock (`referenceProbeFilter`):** unchanged. It moves to group 3b with the membership items. ## Tests The full figures are in the report comment on #20822, measured at `d1137d7ea`. - driver-mongodb: 30 files passed, 5 skipped (the gated real-mongod suites); 675 tests passed, 172 skipped. At the base: 671 passed, 172 skipped. - `pnpm --filter @objectstack/driver-mongodb typecheck`: exit 0, including `check:test-typecheck`. - `check:driver-conformance`, before and after: OK, 50 covered cells, 0 DEBT, 0 exempt. - Narrowed lint: `eslint --no-inline-config --format json` over the 8 changed `.ts` files gives 8 files, 0 errors and 0 warnings. `eslint.config.mjs` never enables type-aware linting, so no untouched file's verdict can move. The full `pnpm lint` is CI's. ## Acceptance notes (noted, not filed) - **`@objectstack/core` is now test-only in `driver-mongodb`.** Its last `src` import was the deleted `nextUtcCalendarDay` one, and the remaining importer is `mongodb-temporal-conformance.test.ts`. The manifest still lists it under `dependencies`. Moving it to `devDependencies` is a manifest and lockfile edit outside this card. Carrier: none. - **Stale pointers that name F1, not F6 / F7, are not carried here:** `filter.zod.ts` around `:434`, `:734` and `:750` names `SqlDriver.calendarDayUpperBoundRewrite` / `calendarDayBetweenRewrite`, and the case title in `read-scope-shared-lowering-seam.test.ts` concerns the RLS change of group 2. Release 5924791165 places them with the last group PR, so the carrier is group 3b. - **`query-syntax.mdx`'s sentence on direct calls names only the `SqlDriver` family.** It reads: "A filter handed directly to `SqlDriver` or a driver built on it … is compared as written". `MongoDBDriver` (this PR) and `InMemoryDriver` (group 1b) now answer the same way. The sentence is incomplete but not false. Carrier: group 3b. --- _Generated by [Claude Code](https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 12fbb2f commit 53ed3d1

9 files changed

Lines changed: 195 additions & 103 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/driver-mongodb': patch
3+
---
4+
5+
fix(driver-mongodb): `MongoDBDriver` compiles the whole-day comparison it is handed — its own copy of the bare-day upper bound is deleted (ADR-0053 D-D1 items 5, 7 and 9, #20822)
6+
7+
Clause-②: no
8+
9+
- **What is deleted.** `translateFilter` no longer widens a bare `YYYY-MM-DD` `$lte` to `$lt` the next day, no longer widens a `$between` maximum the same way, and no longer turns `$lte '9999-12-31'` into `$ne: null` or drops a `$between` maximum on that day. Every verb that translates a `where` inherits it: `find`, `findOne`, `count`, `updateMany`, `deleteMany`, `explain`, and the `$match` stage of `aggregate` / `buildAggregationPipeline`.
10+
- **A read through the engine or the RLS compile seam is unchanged.** The seam hands the driver a filter the shared `lowerFilterCondition` (`@objectstack/spec/data`) has already rewritten on the declared `datetime` columns: `$lt` the next day, a split `$between`, `$null: false` on the last supported day. The deleted copy gave the same answer on that input. A `date` column answers as before, because its stored calendar-day text orders the same either way.
11+
- **Two answers converge on what `SqlDriver` returns** (ADR-0053 D-D1 item 7's scope). On a registered object, a bare-day `$lte` or `$between` maximum is now compared as written on a column that is not declared `datetime`: a `text` column holding ISO instant text, or a column the object does not declare. This driver used to widen it to the whole day.
12+
- **A caller that passes no seam gets the comparison it wrote** (item 5): a `MongoDBDriver` verb or `translateFilter` called directly. A bare-day `$lte` compares against that day's midnight, a `$between` is inclusive at both ends, and `$lte '9999-12-31'` compares against that midnight. 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`.
13+
- No exported name changes.

‎packages/drivers/driver-mongodb/src/mongodb-datetime-storage.test.ts‎

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
*/
2121

2222
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
23-
import { parseFilterAST } from '@objectstack/spec/data';
23+
import { lowerFilterCondition, parseFilterAST } from '@objectstack/spec/data';
2424
import type { MongoMemoryServer } from 'mongodb-memory-server';
2525
import { MongoDBDriver } from './mongodb-driver.js';
2626
import { createTestMongod } from './test-mongod.js';
@@ -29,6 +29,23 @@ const sharedMongod: MongoMemoryServer | undefined = await createTestMongod('date
2929

3030
const ids = (rows: any[]) => rows.map((r: any) => r.id).sort();
3131

32+
/** The declared field map `task` is synced with — what a typed seam reads. */
33+
const TASK_FIELDS: Record<string, { type: string }> = {
34+
title: { type: 'string' },
35+
created_at: { type: 'datetime' },
36+
created_on: { type: 'date' },
37+
};
38+
39+
/**
40+
* [#20822 · ADR-0053 D-D1 items 5 and 9, as amended] What a TYPED seam hands
41+
* this driver: the filter through the shared lowering, `datetime` columns only.
42+
* The driver keeps no whole-day copy of its own, so a bare-day upper bound on
43+
* `created_at` keeps the whole day only on a seam-fed read — the cells below
44+
* that assert the whole day read through this, as every engine read does.
45+
*/
46+
const seamed = <T,>(where: T): T =>
47+
lowerFilterCondition(where, { isDatetimeColumn: (column) => TASK_FIELDS[column]?.type === 'datetime' });
48+
3249
describe.skipIf(!sharedMongod)('MongoDB Field.datetime storage (#4047)', () => {
3350
const mongod = sharedMongod as MongoMemoryServer;
3451
let driver: MongoDBDriver;
@@ -52,11 +69,7 @@ describe.skipIf(!sharedMongod)('MongoDB Field.datetime storage (#4047)', () => {
5269
// — the mongo analogue of `initObjects` populating `datetimeFields`.
5370
await driver.syncSchema('task', {
5471
name: 'task',
55-
fields: {
56-
title: { type: 'string' },
57-
created_at: { type: 'datetime' },
58-
created_on: { type: 'date' },
59-
},
72+
fields: TASK_FIELDS,
6073
});
6174
});
6275

@@ -96,7 +109,7 @@ describe.skipIf(!sharedMongod)('MongoDB Field.datetime storage (#4047)', () => {
96109
// The dashboard's shape: bare-day bounds as ISO strings, against a
97110
// system-injected datetime field. Pre-fix this returned [] on mongo.
98111
const found = await driver.find('task', {
99-
where: { created_at: { $gte: '2026-04-29', $lte: '2026-07-28' } },
112+
where: seamed({ created_at: { $gte: '2026-04-29', $lte: '2026-07-28' } }),
100113
} as any);
101114
expect(ids(found)).toEqual(['d_midnight', 'd_yesterday', 's_evening', 's_morning']);
102115
});
@@ -116,7 +129,8 @@ describe.skipIf(!sharedMongod)('MongoDB Field.datetime storage (#4047)', () => {
116129

117130
it('keeps #3777/#4042 bound semantics on top of the converged storage', async () => {
118131
await seedMixed();
119-
// Bare-day upper bound = the whole day (half-open), lower bound = midnight.
132+
// Lower bound = midnight. (The whole-day upper bound is the seam's: the
133+
// seamed cells above and below.)
120134
const gte = await driver.find('task', { where: { created_at: { $gte: '2026-07-28' } } } as any);
121135
expect(ids(gte)).toEqual(['d_midnight', 'd_next_day', 's_evening', 's_morning']);
122136

@@ -133,17 +147,17 @@ describe.skipIf(!sharedMongod)('MongoDB Field.datetime storage (#4047)', () => {
133147
it('$between and the array spelling take the same coercion', async () => {
134148
await seedMixed();
135149
const between = await driver.find('task', {
136-
where: { created_at: { $between: ['2026-04-29', '2026-07-28'] } },
150+
where: seamed({ created_at: { $between: ['2026-04-29', '2026-07-28'] } }),
137151
} as any);
138152
expect(ids(between)).toEqual(['d_midnight', 'd_yesterday', 's_evening', 's_morning']);
139153

140154
// [#5158] The authored array form, lowered the declared way. The INFIX
141155
// join (`[condA, 'and', condB]`) has no lowering at all and is refused at
142156
// the door; the declared spelling of "both bounds" is the prefix group.
143157
const array = await driver.find('task', {
144-
where: parseFilterAST(
158+
where: seamed(parseFilterAST(
145159
['and', ['created_at', '>=', '2026-04-29'], ['created_at', '<=', '2026-07-28']],
146-
) as any,
160+
)) as any,
147161
} as any);
148162
expect(ids(array)).toEqual(['d_midnight', 'd_yesterday', 's_evening', 's_morning']);
149163
});

‎packages/drivers/driver-mongodb/src/mongodb-filter-shape-refusal.test.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474

7575
import { describe, it, expect } from 'vitest';
7676
import { translateFilter } from './mongodb-filter.js';
77+
import { lowerFilterCondition } from '@objectstack/spec/data';
7778

7879
/** The shape `mapDataError` / `sendError` read off a thrown driver error. */
7980
interface WireBearingError extends Error {
@@ -164,11 +165,15 @@ describe('[#5346] driver-mongodb refuses a malformed $between comparand', () =>
164165
});
165166
});
166167

167-
it('the whole-day upper bound rule (#4042) is untouched', () => {
168-
// A bare-day max still compiles half-open, which is the behaviour the arm
169-
// carried before the `else` was added — the refusal must not have changed
170-
// what a VALID range means.
168+
it('a bare-day range is translated as written; the whole-day bound is the seam`s (#20822)', () => {
169+
// The refusal must not change what a VALID range means. Since #20822 a
170+
// bare-day max is compiled as written here (ADR-0053 D-D1 item 5): the
171+
// whole-day bound (#4042) is applied once, by the shared lowering at the
172+
// seams, which hands this arm `$gte` and `$lt` the next day instead.
171173
expect(translateFilter({ due: { $between: ['2026-07-01', '2026-07-28'] } })).toEqual({
174+
due: { $gte: '2026-07-01', $lte: '2026-07-28' },
175+
});
176+
expect(translateFilter(lowerFilterCondition({ due: { $between: ['2026-07-01', '2026-07-28'] } }))).toEqual({
172177
due: { $gte: '2026-07-01', $lt: '2026-07-29' },
173178
});
174179
});

‎packages/drivers/driver-mongodb/src/mongodb-filter.test.ts‎

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import { parseFilterAST } from '@objectstack/spec/data';
4+
import { lowerFilterCondition, parseFilterAST } from '@objectstack/spec/data';
55
import { translateFilter } from './mongodb-filter.js';
66

77
describe('MongoDB Filter Translator', () => {
@@ -43,74 +43,114 @@ describe('MongoDB Filter Translator', () => {
4343
});
4444
});
4545

46-
describe('calendar-day upper bounds (#4042; the SQL twin is #3777)', () => {
47-
it('a bare-day $lte compiles half-open — through the whole day', () => {
46+
/**
47+
* [#20822 · ADR-0053 D-D1 items 5 and 9, as amended] The whole-day upper bound
48+
* (#4042; the SQL twin is #3777) is applied once, by the shared lowering at
49+
* the seams that feed this driver, and this translator keeps no copy of it:
50+
* it compiles the comparison it is handed. One block per side of the seam.
51+
*/
52+
describe('calendar-day upper bounds — a direct call, compiled as written (item 5)', () => {
53+
it('a bare-day $lte is `<=` its midnight', () => {
4854
expect(translateFilter({ created_at: { $lte: '2026-07-28' } })).toEqual({
55+
created_at: { $lte: '2026-07-28' },
56+
});
57+
});
58+
59+
it('a bare-day $between is inclusive at both ends', () => {
60+
expect(translateFilter({ created_at: { $between: ['2026-04-29', '2026-07-31'] } })).toEqual({
61+
created_at: { $gte: '2026-04-29', $lte: '2026-07-31' },
62+
});
63+
});
64+
65+
it('the authored array `<=` is lowered by parseFilterAST only, not widened (#5158)', () => {
66+
expect(translateFilter(parseFilterAST([['created_at', '<=', '2026-07-28']]))).toEqual({
67+
created_at: { $lte: '2026-07-28' },
68+
});
69+
});
70+
71+
it('the last supported day is an ordinary bound, $lte and a $between max alike', () => {
72+
expect(translateFilter({ created_at: { $lte: '9999-12-31' } })).toEqual({
73+
created_at: { $lte: '9999-12-31' },
74+
});
75+
expect(translateFilter({ created_at: { $between: ['2026-04-29', '9999-12-31'] } })).toEqual({
76+
created_at: { $gte: '2026-04-29', $lte: '9999-12-31' },
77+
});
78+
});
79+
});
80+
81+
describe('calendar-day upper bounds — a seam-fed filter, through the shared lowering (the control)', () => {
82+
/** What a TYPED seam hands this driver: `created_at` is the declared `datetime`. */
83+
const seamed = <T,>(where: T): T =>
84+
lowerFilterCondition(where, { isDatetimeColumn: (column) => column === 'created_at' });
85+
86+
it('a bare-day $lte arrives half-open — through the whole day', () => {
87+
expect(translateFilter(seamed({ created_at: { $lte: '2026-07-28' } }))).toEqual({
4988
created_at: { $lt: '2026-07-29' },
5089
});
5190
});
5291

5392
it('a full-ISO $lte keeps instant semantics — only the bare day is widened', () => {
54-
expect(translateFilter({ created_at: { $lte: '2026-07-28T12:00:00.000Z' } })).toEqual({
93+
expect(translateFilter(seamed({ created_at: { $lte: '2026-07-28T12:00:00.000Z' } }))).toEqual({
5594
created_at: { $lte: '2026-07-28T12:00:00.000Z' },
5695
});
5796
});
5897

5998
it('bare-day $gte / $gt / $lt keep their midnight anchoring', () => {
60-
expect(translateFilter({ created_at: { $gte: '2026-07-28' } })).toEqual({
99+
expect(translateFilter(seamed({ created_at: { $gte: '2026-07-28' } }))).toEqual({
61100
created_at: { $gte: '2026-07-28' },
62101
});
63-
expect(translateFilter({ created_at: { $lt: '2026-07-28' } })).toEqual({
102+
expect(translateFilter(seamed({ created_at: { $lt: '2026-07-28' } }))).toEqual({
64103
created_at: { $lt: '2026-07-28' },
65104
});
66105
});
67106

68-
it('$between with a bare-day max decomposes half-open, rolling the month', () => {
69-
expect(translateFilter({ created_at: { $between: ['2026-04-29', '2026-07-31'] } })).toEqual({
107+
it('$between with a bare-day max arrives split and half-open, rolling the month', () => {
108+
expect(translateFilter(seamed({ created_at: { $between: ['2026-04-29', '2026-07-31'] } }))).toEqual({
70109
created_at: { $gte: '2026-04-29', $lt: '2026-08-01' },
71110
});
72111
});
73112

74113
it('the authored array `<=` takes the same rule, once lowered (#5158)', () => {
75-
// `translateFilter` no longer compiles the array spelling; the authored
76-
// shape reaches it through `parseFilterAST`, which is what both doors do.
77-
expect(translateFilter(parseFilterAST([['created_at', '<=', '2026-07-28']]))).toEqual({
114+
expect(translateFilter(seamed(parseFilterAST([['created_at', '<=', '2026-07-28']])))).toEqual({
78115
created_at: { $lt: '2026-07-29' },
79116
});
80117
expect(
81-
translateFilter(parseFilterAST([['created_at', '<=', '2026-07-28T12:00:00.000Z']])),
118+
translateFilter(seamed(parseFilterAST([['created_at', '<=', '2026-07-28T12:00:00.000Z']]))),
82119
).toEqual({
83120
created_at: { $lte: '2026-07-28T12:00:00.000Z' },
84121
});
85122
});
86123

87124
// [#20600] 9999-12-31, the last supported day, has no next day: every value
88-
// is inside its whole-day bound, so no upper bound is compiled. The helper
89-
// answered the five-digit '10000-01-01', a bound below every stored value.
125+
// is inside its whole-day bound, so the lowering keeps only `$null: false`.
90126
it('a bare-day $lte on the last supported day asks only for a value', () => {
91-
expect(translateFilter({ created_at: { $lte: '9999-12-31' } })).toEqual({
127+
expect(translateFilter(seamed({ created_at: { $lte: '9999-12-31' } }))).toEqual({
92128
created_at: { $ne: null },
93129
});
94130
// The control: the day before is an ordinary half-open bound.
95-
expect(translateFilter({ created_at: { $lte: '9999-12-30' } })).toEqual({
131+
expect(translateFilter(seamed({ created_at: { $lte: '9999-12-30' } }))).toEqual({
96132
created_at: { $lt: '9999-12-31' },
97133
});
98134
});
99135

100136
it('a $between whose max is the last supported day keeps its min alone', () => {
101-
expect(translateFilter({ created_at: { $between: ['2026-04-29', '9999-12-31'] } })).toEqual({
137+
expect(translateFilter(seamed({ created_at: { $between: ['2026-04-29', '9999-12-31'] } }))).toEqual({
102138
created_at: { $gte: '2026-04-29' },
103139
});
104-
expect(translateFilter({ created_at: { $between: ['2026-04-29', '9999-12-30'] } })).toEqual({
140+
expect(translateFilter(seamed({ created_at: { $between: ['2026-04-29', '9999-12-30'] } }))).toEqual({
105141
created_at: { $gte: '2026-04-29', $lt: '9999-12-31' },
106142
});
107143
});
108144

109-
it("the lowered $ne beside an author's own $ne keeps both (#13524)", () => {
110-
expect(translateFilter({ created_at: { $lte: '9999-12-31', $ne: '9999-12-31T10:00:00.000Z' } })).toEqual({
145+
it("the lowered value requirement beside an author's own $ne keeps both (#13524)", () => {
146+
// The `$ne` a value also takes the lowering's NULL escape (#5298), so the
147+
// two constraints arrive as conjuncts and both survive the translation.
148+
expect(
149+
translateFilter(seamed({ created_at: { $lte: '9999-12-31', $ne: '9999-12-31T10:00:00.000Z' } })),
150+
).toEqual({
111151
$and: [
112-
{ created_at: { $ne: '9999-12-31T10:00:00.000Z' } },
113152
{ created_at: { $ne: null } },
153+
{ $and: [{ $or: [{ created_at: { $eq: null } }, { created_at: { $ne: '9999-12-31T10:00:00.000Z' } }] }] },
114154
],
115155
});
116156
});

0 commit comments

Comments
 (0)