Skip to content

Commit 8460592

Browse files
fix(metadata,objectql,driver-memory): route queryHistory through the lowering, lower type-blind without a field map, then delete driver-memory F3 (#5930 step 4, group 1b) (#20925)
Part of #20822 Clause-②: no #5930 step 4, **group 1b: F3** (`driver-memory`'s query path). This follows the seat's answer B (5915193659 on #20822), read from ADR-0053 D-D1 items 5, 7 and 10 as amended: first route the direct caller, then make the engine seam type-blind when it has no field map, then delete. It is one PR in three ordered commits on `main` at `4d0b9cd542`: | # | Commit | What it does | |:--|:--|:--| | 1 | `0bd0e6d4f7` fix(metadata) | `DatabaseLoader.queryHistory` in driver mode runs `lowerFilterCondition` on its own `where`. The reader is typed by the history object the loader syncs (`recorded_at` is `Field.datetime`). The loader becomes a seam (item 5). | | 2 | `5317b5aa22` fix(objectql) | `declaredDatetimeLowering`'s absent-map branch drops the reader, so an object with no field map is lowered type-blind (item 7). An object with a field map keeps the typed scope byte-identical. | | 3 | `e15606bae2` refactor(driver-memory) | F3's four whole-day sites are deleted. The 43 direct-call tests are routed through `lowerFilterCondition` with the declared-datetime reader. New pins cover item 5 (one cell per deleted site) and item 7's convergence. | | 4 | `86ccdc099e` docs(changeset) | The `driver-memory` bullet names the RLS no-guard path (review 5919688563, FAIL 1), and no longer says that every seam hands the driver a lowered filter. Changeset text only. | 13 files against `4d0b9cd542` (+662 / -102 at `e15606bae2`; commit 4 changes one changeset line). The changeset is `.changeset/20822-f3-route-then-delete.md`: `patch` for `@objectstack/metadata`, `@objectstack/objectql` and `@objectstack/driver-memory`, with the (b) convergence stated. ## The answers that move, named These were measured through the real engine (`ObjectQL` dist, `engine.find`) on `SqlDriver` (`driver-sqlite-wasm`) and `InMemoryDriver`. The table was synced through `driver.syncSchema`. The object was either registered in the engine with its field map ("registered") or not registered ("unregistered"). - Rows: `r1` = `2026-07-28T00:00:00.000Z`, `r2` = `…T12:00:00.000Z`, `r3` = `…T23:59:59.999Z`, `r4` = `2026-07-29T00:00:00.000Z`. The same instant is written into `at` (`datetime`), `txt` (`text`) and `extra` (not declared; memory only). `d` (`date`) holds the calendar day. - Filter: `{ col: { $lte: '2026-07-28' } }`. - Columns: BASE = `main` with commit 1 only; c2 = after commit 2; c3 = after commit 3. | Object · column | Driver | BASE | c2 | c3 | |:--|:--|:--|:--|:--| | registered · `at` (datetime) | both | r1,r2,r3 | r1,r2,r3 | r1,r2,r3 | | registered · `d` (date) | both | r1,r2,r3 | r1,r2,r3 | r1,r2,r3 | | registered · `txt` (text, ISO) | sqlite | none | none | none | | registered · `txt` (text, ISO) | memory | r1,r2,r3 | r1,r2,r3 | **none** | | registered · `extra` (undeclared) | memory | r1,r2,r3 | r1,r2,r3 | **none** | | unregistered · `at` / `d` | both | r1,r2,r3 | r1,r2,r3 | r1,r2,r3 | | unregistered · `txt` | sqlite | none | **r1,r2,r3** | r1,r2,r3 | | unregistered · `txt` / `extra` | memory | r1,r2,r3 | r1,r2,r3 | r1,r2,r3 | | any · `at` `$lte '9999-12-31'` | both | r1..r4 | r1..r4 | r1..r4 | | any · `at` `$between` the day | both | r1,r2,r3 | r1,r2,r3 | r1,r2,r3 | - **Commit 2 moves one answer, a widening, on `SqlDriver`.** Take an unregistered object's non-datetime column that holds ISO instant text. A bare-day `$lte` on it now keeps the whole day (none becomes r1,r2,r3). That is item 7's reading for a seam that cannot read the declared type: "applies the rewrite type-blind". The dispatch expected that `SqlDriver`'s answer for an unregistered object would not move yet, because its F1 copy still exists (H2). That holds for `datetime` and `date` columns only. F1 covers the columns the driver itself knows as `datetime`, and nothing else. - **Commit 3 moves the (b) cells, both narrowings on `driver-memory`, onto `SqlDriver`'s answer.** On a registered object: - a declared `text` column holding ISO text; - a column the object does not declare. The typed seam leaves both byte-identical, and the deleted copy used to widen them. The seat's answer calls this item 7's scope ("it is not a decision"). It is declared in the changeset. An unregistered object does **not** narrow on memory, because commit 2 now lowers it at the seam. - Neither move is a narrowing beyond what item 7 names, so nothing stopped. ## Commit 1: `queryHistory` becomes a seam (H1: held) These were measured with a scratch probe over the built `dist` of `@objectstack/metadata`, `driver-memory` and `driver-sqlite-wasm`. The mode is driver mode (`new DatabaseLoader({ driver })`), with two saves on one day and `until` / `since` = that day: | State | memory `until` | memory `since = until` | sqlite `until` | sqlite `since = until` | |:--|:--|:--|:--|:--| | `main` (F3 present) | 2 / 2 | 2 / 2 | 2 / 2 | 2 / 2 | | all three commits | 2 / 2 | 2 / 2 | 2 / 2 | 2 / 2 | | F3 deleted, loader lowering removed (dist ablation) | **0 / 0** | **0 / 0** | 2 / 2 (F1 still present) | 2 / 2 | - **Other direct driver callers in `packages/metadata`.** The other one is `utils/history-cleanup.ts` (`recorded_at: { $lt: cutoffISO }`, twice). That is an instant `$lt`, which no rule widens, so it is unaffected. The other `_find` / `_count` filters in the loader are equality only. No other temporal bound was found. - **H4.** Group 2 (`driver-sql` F1) meets the same `queryHistory` caller. Commit 1 lowers it for every driver, so **group 2 has no caller left to route** in `packages/metadata`. §A below is the answer group 2 must keep once F1 is gone. - **Pin:** `database-loader-20822-history-whole-day.test.ts`. It fakes `Date` only. - §A: the rows on real SQLite in driver mode. - §B: the `where` the driver's `find` / `count` receive (`recorded_at: { $lt: next day }`, lower bound kept, instant `until` and other columns byte-identical). §B is driver-agnostic. It is the half that goes red when the loader stops lowering. - **Not pinned on memory inside `@objectstack/metadata`.** A new test consumer of `@objectstack/driver-memory` needs a maintainer ruling (`scripts/driver-memory-census.ledger.json`, `RULED_CEILING = 2`). So the memory half is covered in two other ways: - §B (what every driver receives), plus `driver-memory`'s own pin of how it answers the lowered and the unlowered filter; - the dist measurement in the table above. - Engine mode is untouched: the engine's `where` seam lowers it, typed by the registered history object. ## Commit 2: an object with no field map is lowered type-blind (H2: held for datetime and date, falsified for text) The only change is `if (fields === null || typeof fields !== 'object') return {};`. The typed branch is unchanged. The control in the new pin (`engine-20822-no-field-map-type-blind-lowering.test.ts`) and the existing `engine-shared-filter-lowering-seam.test.ts` stay green. The new pin covers `find`, `findOne`, `count`, `aggregate`'s `where` and the judge on an unregistered object. `having` has its own aggregated-row reader (F8, group 3), which is untouched. ## Commit 3: F3 deleted (H3: held) - **Deleted:** - the `$lte` and `$between` arms of the FilterCondition translator; - the less-or-equal and `between` arms of the AST-node translator (`{ type: 'comparison' }`, which no seam emits; only direct callers reach it). `nextUtcCalendarDay` / `isUnboundedAbove` are no longer imported by `memory-driver.ts`. The clobber-class table in `assembleLoweredWrites`' docblock loses the `$lt` / `$ne` writers the rewrite added. No driver-local guard is kept. - **F3's typed reader** is the engine's `declaredDatetimeLowering`. It is typed when the object has a field map, and type-blind without one (commit 2). The driver's own `syncSchema` temporal index is not consulted by any seam. - **The 43 direct-call tests** are the same 43 that went red with the deletion alone: | Suite | Tests | |:--|:--| | temporal-conformance | 25 | | calendar-day-upper-bound | 5 | | analytics-20661 | 5 | | datetime-storage | 4 | | temporal-storage-form | 2 | | shared-lowering-door | 2 | Each now hands `find()` what a typed seam hands it: `lowerFilterCondition` with a reader over the fixture's own declared field map. In the 20661 file, the `undeclared` reading is lowered type-blind, which is commit 2's reading. **0 `expect(` lines changed** in the six routed files. - **New pin:** `memory-driver-20822-comparison-as-written.test.ts`. - §A (item 5): a direct call gets the comparison it wrote. There is one cell per deleted site. On a `datetime` column a bare day takes its storage form, the midnight instant, so `$lte` keeps the midnight row. - §B (item 7): the registered-object convergence cells. - §C: the type-blind reading. ## Ablations: each one committed first, restored and proven by blob hash, re-run at the final head `e15606bae2` Every mutation went through `scripts/ablation-replace.mjs` (anchor must hit, blob verified, restored blob equal to HEAD, `git diff HEAD` empty). | Commit | Mutation | Red | Green | |:--|:--|:--|:--| | 1 | loader lowering removed | 2 of 8 (§B's two bare-day cells) | §A stays green through `SqlDriver`'s F1 copy | | 2 | absent-map branch removed | 4 of 19 (the four unregistered lowering cells) | the control, the instant and the judge cells | | 3 | `$lte` arm restored (with its import) | 4 of 1424 | the other 1420 | | 3 | `$between` arm restored | 2 of 1424 | the other 1422 | | 3 | AST less-or-equal arm restored | 1 of 1424 | the other 1423 | | 3 | AST `between` arm restored | 1 of 1424 | the other 1423 | In every commit-3 row, the red cells are the matching cells of the new pin and nothing else. Each restored copy is idempotent on lowered input (item 9). The H1 dist counterfactual (the memory 2 / 2 to 0 / 0 row above) ran through `ablation-dist-preflight.mjs` for the restore leg: marker absent from all 30 built files and the tree clean. The mutate leg's arrival in `dist` is shown by the probe's answer moving. ## Tests, gates and lint, all at `e15606bae2` - `@objectstack/driver-memory` vitest: 66 files / 1424 passed. - `@objectstack/metadata` vitest: 56 files / 836 passed. - `@objectstack/objectql` vitest `--project local`: 348 files / 6807 passed. `--project repo`: 1 / 5 passed. - The three packages' `typecheck`: exit 0. That covers objectql's `check:test-typecheck` (OK, 234 errors / 65 signatures held in the ledger). driver-memory's `tsconfig.json` program lists all 66 test files. - `node scripts/pm/dispatch-gates.mjs --commands` (merge base `4d0b9cd54`) derived 66 families. **66 run, all exit 0.** The `--ran` verdict: "66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED (a DERIVED zero …)". This includes: - `check:driver-conformance`: "OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt". - `check:driver-memory-census`: "OK — every declaration is ledgered …". - `check:dual-build-cjs-loads` and `check:type-check-debt`, after a whole-workspace build. - `check:query-options-erasure`: back at 236 test sites. My first draft added one `{ where } as any`, and it is now typed. - **Lint, narrowed.** `eslint --no-inline-config --format json` over the 12 changed `.ts` files gave 12 files, 0 errors and 0 warnings. Each file resolves under `--print-config`. `eslint.config.mjs` enables no type-aware linting ("no `parserOptions.project`, no typed `@typescript-eslint` rules"), so no untouched file's verdict can move. The full `pnpm lint` is CI's. ## Acceptance notes - **The RLS compile seam's no-guard reading.** This is noted, not filed. `carrier:` #20822 group 2 (`driver-sql` F1), which removes the next copy standing behind this reading. - `plugin-security` `rls-compiler.ts` `rlsLowering` reads an absent guard as "no datetime column". The guard is absent when `getObjectFieldNames` cannot resolve the object. This is the same population, and the same reading, that commit 2 changed on the engine. - Its existing pin says so: "a guard with no types reads no column as datetime" gives `{ signed_on: { $lte: '2026-01-05' } }`. - After this PR, an RLS `using` policy with a bare-day upper bound, on an object whose declared fields the security plugin cannot resolve, reaches `driver-memory` as written, where the deleted copy used to widen it. (A `check` clause reaches `matchesFilterCondition`, not this driver, so it does not move here.) The changeset's `driver-memory` bullet names this path (`86ccdc099e`, after contract review 5919688563), and the seat's answer 5918373748 (A) carries the `rlsLowering` twin into group 2. - Item 5 covers a filter composed after the engine's seam. Item 7's general rule would read that seam type-blind. - Measured only at unit level (that pin and §A here), not through a public door. It is outside this card's file surface. - The metadata-side memory pin is replaced by §B plus the `driver-memory` pins because of the census ledger (above). - The branch was re-stacked twice before this PR opened, with `--force-with-lease` and all five conditions met: first to fold two WIP commits into commit 3, then onto `main` `4d0b9cd542` after #20911 landed in `engine.ts`. No merge commit remains. - Not done here: #20822 group 2 (F1, F2), group 3 (F6, F7, F8), and the stale matcher pointers the last group PR corrects. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f80e2a6 commit 8460592

13 files changed

Lines changed: 662 additions & 102 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/metadata': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/driver-memory': patch
5+
---
6+
7+
fix: the whole-day bound on a bare `YYYY-MM-DD` upper bound is applied at the seams only — `DatabaseLoader.queryHistory` in driver mode becomes one, the engine seam lowers type-blind for an object with no field map, and `InMemoryDriver` drops its own copy (ADR-0053 D-D1 items 5 and 7, #20822)
8+
9+
Clause-②: no
10+
11+
- **`@objectstack/metadata` — `DatabaseLoader.queryHistory` in driver mode lowers its own filter.** With a raw `IDataDriver` (`MetadataManager.setDatabaseDriver`) the history filter reaches the driver without passing any seam. The loader now runs the shared `lowerFilterCondition` (`@objectstack/spec/data`) on it, typed by the history object it syncs: `until: 'YYYY-MM-DD'` reads `recorded_at < next day`, so every version recorded on that day is kept on every driver, and an instant `until` is kept as written. Engine mode is unchanged (the engine's own `where` seam lowers it). Before this, the whole day was kept only by each driver's own copy of the rule; with `@objectstack/driver-memory`'s copy deleted below, `until` = today would have gone from every version of the day to none.
12+
- **`@objectstack/objectql` — an object with no field map is lowered type-blind.** The engine's `where` seam (on `find`, `findOne`, `count`, `update`, `delete` and `aggregate`'s `where` / `aggregations[i].filter`) reads the object's declared field map and rewrites a declared `datetime` column only. For an object the registry does not hold there is no declaration to read, and the seam now applies the whole-day rules to every column (a bare-day `$lte` becomes `$lt` the next day, a `$between` splits), as ADR-0053 D-D1 item 7 rules for a seam that cannot read the declared type. It used to leave such an object to each driver's own copy. Visible on `SqlDriver`: a bare-day `$lte` on a non-`datetime` column of an unregistered object that holds ISO instant text now keeps the whole day; a `datetime` or `date` column answers as before. An object with a field map is unchanged.
13+
- **`@objectstack/driver-memory` — `InMemoryDriver` compiles the comparison it is handed.** Its four copies of the whole-day rule are deleted (the `$lte` and `$between` arms of the filter translator, the `<=` and `between` arms of the AST-node translator). A read through the engine hands it a `where` the engine's seam has already lowered, so on that path a declared `datetime` column keeps the whole named day, and a declared `date` column answers as before. A row-level security `using` filter is not lowered by the engine's seam: the security middleware ANDs it into the query's `where` after that seam has run, and only the RLS compile seam lowers it, rewriting just the columns its field guard declares `datetime`. Two answers converge on what `SqlDriver` already returns (ADR-0053 D-D1 item 7's scope): on a registered object, a bare-day `$lte` / `$between` on a declared `text` column holding ISO instant text, or on a column the object does not declare, is now compared as written, where this driver used to widen it to the whole day. One path narrows outside those two: an RLS `using` policy with a bare-day upper bound, on an object whose declared fields the security plugin cannot resolve, is compiled with no field guard, so the RLS compile seam reads no column as `datetime` and the bound reaches this driver as written, where this driver used to widen it to the whole day; that holds until #20822 group 2 makes the RLS compile seam type-blind when it has no guard. A direct `find()` that passed no seam gets the comparison it wrote (item 5); lower the filter with `lowerFilterCondition` first to keep the whole-day reading.

‎packages/drivers/driver-memory/src/memory-analytics-20661-lte-whole-day-first.test.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
*/
1919

2020
import { describe, it, expect } from 'vitest';
21-
import type { Cube, FilterCondition } from '@objectstack/spec/data';
21+
import { lowerFilterCondition, type Cube, type FilterCondition, type FilterLoweringOptions } from '@objectstack/spec/data';
2222
import { InMemoryDriver } from './memory-driver.js';
2323
import { MemoryAnalyticsService } from './memory-analytics.js';
2424

@@ -44,13 +44,28 @@ const CUBE = {
4444

4545
type Declaration = 'undeclared' | 'declared';
4646

47+
const TASK_FIELDS: Record<string, { type: string }> = { created_at: { type: 'datetime' }, made_on: { type: 'date' } };
48+
49+
/**
50+
* [#20822 · ADR-0053 D-D1 items 5, 7 and 9, as amended] The `find()` reading is
51+
* what the engine's `where` seam hands this driver, since the driver keeps no
52+
* whole-day copy of its own: `declared` reads the declared field map (the
53+
* typed seam, `datetime` columns only); `undeclared` has no field map to read,
54+
* so the seam lowers type-blind (item 7) — the same reading the cube face's
55+
* own door gives both.
56+
*/
57+
const SEAM: Record<Declaration, FilterLoweringOptions> = {
58+
declared: { isDatetimeColumn: (column) => TASK_FIELDS[column]?.type === 'datetime' },
59+
undeclared: {},
60+
};
61+
4762
async function setup(declaration: Declaration, rows: ReadonlyArray<Record<string, unknown>> = CARD_ROWS) {
4863
const driver = new InMemoryDriver({});
4964
await driver.connect();
5065
if (declaration === 'declared') {
5166
await driver.syncSchema('task', {
5267
name: 'task',
53-
fields: { created_at: { type: 'datetime' }, made_on: { type: 'date' } },
68+
fields: TASK_FIELDS,
5469
});
5570
}
5671
for (const row of rows) await driver.create('task', { ...row });
@@ -72,7 +87,7 @@ const whereOf = (sql: string) => /WHERE (.*?)(?: GROUP BY|$)/.exec(sql)?.[1];
7287
async function answer(declaration: Declaration, where: FilterCondition, rows?: ReadonlyArray<Record<string, unknown>>) {
7388
const { driver, service } = await setup(declaration, rows);
7489
return {
75-
find: ids(await driver.find('task', { where })),
90+
find: ids(await driver.find('task', { where: lowerFilterCondition(where, SEAM[declaration]) })),
7691
cube: ids((await service.query(cubeQuery(where))).rows),
7792
echo: whereOf((await service.generateSql(cubeQuery(where))).sql),
7893
};
@@ -143,7 +158,9 @@ describe('[#20661] the siblings on this face', () => {
143158
dimensions: ['id'],
144159
timeDimensions: [{ dimension: 'created_at', dateRange: ['2026-07-01', '2026-07-28'] }],
145160
} as any);
146-
const found = ids(await driver.find('task', { where: { created_at: { $gte: '2026-07-01', $lte: '2026-07-28' } } }));
161+
const found = ids(await driver.find('task', {
162+
where: lowerFilterCondition({ created_at: { $gte: '2026-07-01', $lte: '2026-07-28' } }, SEAM.declared),
163+
}));
147164
expect(ids(result.rows)).toEqual(found);
148165
expect(ids(result.rows)).toEqual(['r27', 'r28']);
149166
});

‎packages/drivers/driver-memory/src/memory-analytics-shared-lowering-door.test.ts‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,13 @@
4040
*/
4141

4242
import { describe, it, expect } from 'vitest';
43-
import { FILTER_LOGIC_CASES, FILTER_LOGIC_ROWS, type Cube, type FilterCondition } from '@objectstack/spec/data';
43+
import {
44+
FILTER_LOGIC_CASES,
45+
FILTER_LOGIC_ROWS,
46+
lowerFilterCondition,
47+
type Cube,
48+
type FilterCondition,
49+
} from '@objectstack/spec/data';
4450
import { InMemoryDriver } from './memory-driver.js';
4551
import { MemoryAnalyticsService } from './memory-analytics.js';
4652

@@ -78,10 +84,13 @@ const DAY_ROWS = [
7884
{ id: 'r29', created_at: '2026-07-29T10:00:00.000Z' },
7985
];
8086

87+
/** The declared field map `logic_row` syncs — what the engine's typed seam reads. */
88+
const LOGIC_ROW_FIELDS: Record<string, { type: string }> = { created_at: { type: 'datetime' } };
89+
8190
async function setup(rows: ReadonlyArray<object>) {
8291
const driver = new InMemoryDriver({});
8392
await driver.connect();
84-
await driver.syncSchema('logic_row', { name: 'logic_row', fields: { created_at: { type: 'datetime' } } });
93+
await driver.syncSchema('logic_row', { name: 'logic_row', fields: LOGIC_ROW_FIELDS });
8594
for (const row of rows) await driver.create('logic_row', { ...(row as Record<string, unknown>) });
8695
const service = new MemoryAnalyticsService({ driver, cubes: [LOGIC_CUBE] });
8796
return { driver, service };
@@ -90,11 +99,21 @@ async function setup(rows: ReadonlyArray<object>) {
9099
const cubeQuery = (where?: FilterCondition) =>
91100
({ cube: 'logic', measures: ['count'], dimensions: ['id'], ...(where === undefined ? {} : { where }) }) as any;
92101

93-
/** The live query path, the cube's rows and the echo's WHERE, for one `where`. */
102+
/**
103+
* The live query path, the cube's rows and the echo's WHERE, for one `where`.
104+
*
105+
* [#20822 · ADR-0053 D-D1 items 5, 7 and 9, as amended] The live query path is
106+
* the engine's `where` seam and then this driver, which keeps no whole-day copy
107+
* of its own: so `find()` is handed what that TYPED seam hands it — the filter
108+
* through the shared lowering, reading {@link LOGIC_ROW_FIELDS}.
109+
*/
94110
async function answer(rows: ReadonlyArray<object>, where?: FilterCondition) {
95111
const { driver, service } = await setup(rows);
112+
const seamed = lowerFilterCondition(where, {
113+
isDatetimeColumn: (column) => LOGIC_ROW_FIELDS[column]?.type === 'datetime',
114+
});
96115
return {
97-
find: ids(await driver.find('logic_row', where === undefined ? {} : { where })),
116+
find: ids(await driver.find('logic_row', seamed === undefined ? {} : { where: seamed })),
98117
cube: ids((await service.query(cubeQuery(where))).rows),
99118
echo: whereOf((await service.generateSql(cubeQuery(where))).sql),
100119
};

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

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
*/
2020

2121
import { describe, it, expect, beforeEach } from 'vitest';
22-
import { parseFilterAST } from '@objectstack/spec/data';
22+
import { lowerFilterCondition, parseFilterAST } from '@objectstack/spec/data';
2323
import { InMemoryDriver } from './memory-driver.js';
2424

2525
const ids = (rows: any[]) => rows.map((r: any) => r.id).sort();
@@ -35,6 +35,20 @@ const TASK_SCHEMA = {
3535
},
3636
};
3737

38+
/**
39+
* [#20822 · ADR-0053 D-D1 items 5, 7 and 9, as amended] What a TYPED seam hands
40+
* this driver: the filter through the shared lowering, reading
41+
* {@link TASK_SCHEMA}'s declared `datetime` columns. The driver keeps no
42+
* whole-day copy of its own any more, so a bare-day upper bound in these
43+
* windows reaches it already lowered, as on every seam-fed path; the expected
44+
* rows are unchanged.
45+
*/
46+
const seamed = <T,>(where: T): T =>
47+
lowerFilterCondition(where, {
48+
isDatetimeColumn: (column) =>
49+
(TASK_SCHEMA.fields as Record<string, { type: string } | undefined>)[column]?.type === 'datetime',
50+
});
51+
3852
describe('InMemoryDriver Field.datetime storage (#4047)', () => {
3953
let driver: InMemoryDriver;
4054

@@ -79,7 +93,7 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => {
7993
it('a date window reaches rows written in BOTH forms', async () => {
8094
await seedMixed();
8195
const found = await driver.find('task', {
82-
where: { created_at: { $gte: '2026-04-29', $lte: '2026-07-28' } },
96+
where: seamed({ created_at: { $gte: '2026-04-29', $lte: '2026-07-28' } }),
8397
} as any);
8498
expect(ids(found)).toEqual(['d_midnight', 'd_yesterday', 's_evening', 's_morning']);
8599
});
@@ -106,25 +120,25 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => {
106120
expect(ids(lt)).toEqual(['d_yesterday', 's_old']);
107121

108122
const instant = await driver.find('task', {
109-
where: { created_at: { $lte: '2026-07-28T12:00:00.000Z' } },
123+
where: seamed({ created_at: { $lte: '2026-07-28T12:00:00.000Z' } }),
110124
} as any);
111125
expect(ids(instant)).toEqual(['d_midnight', 'd_yesterday', 's_morning', 's_old']);
112126
});
113127

114128
it('$between and the array spelling take the same coercion', async () => {
115129
await seedMixed();
116130
const between = await driver.find('task', {
117-
where: { created_at: { $between: ['2026-04-29', '2026-07-28'] } },
131+
where: seamed({ created_at: { $between: ['2026-04-29', '2026-07-28'] } }),
118132
} as any);
119133
expect(ids(between)).toEqual(['d_midnight', 'd_yesterday', 's_evening', 's_morning']);
120134

121135
// [#5158] The authored array form, lowered the declared way. The INFIX
122136
// join (`[condA, 'and', condB]`) has no lowering at all and is refused at
123137
// the door; the declared spelling of "both bounds" is the prefix group.
124138
const array = await driver.find('task', {
125-
where: parseFilterAST(
139+
where: seamed(parseFilterAST(
126140
['and', ['created_at', '>=', '2026-04-29'], ['created_at', '<=', '2026-07-28']],
127-
) as any,
141+
)) as any,
128142
} as any);
129143
expect(ids(array)).toEqual(['d_midnight', 'd_yesterday', 's_evening', 's_morning']);
130144
});
@@ -145,7 +159,7 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => {
145159
await seeded.syncSchema('task', TASK_SCHEMA);
146160

147161
const found = await seeded.find('task', {
148-
where: { created_at: { $gte: '2026-07-28', $lte: '2026-07-28' } },
162+
where: seamed({ created_at: { $gte: '2026-07-28', $lte: '2026-07-28' } }),
149163
} as any);
150164
expect(ids(found)).toEqual(['i_date', 'i_iso']);
151165
});
@@ -162,7 +176,7 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => {
162176
// …and the converged value is reachable by a date window, which is the
163177
// point of converging it.
164178
const found = await driver.find('task', {
165-
where: { due_at: { $gte: '2026-07-28', $lte: '2026-07-28' } },
179+
where: seamed({ due_at: { $gte: '2026-07-28', $lte: '2026-07-28' } }),
166180
} as any);
167181
expect(ids(found)).toEqual(['u1']);
168182
});
@@ -187,7 +201,7 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => {
187201
expect(onObj!.created_on).toBe('2026-07-28');
188202

189203
const found = await driver.find('task', {
190-
where: { created_on: { $gte: '2026-04-29', $lte: '2026-07-28' } },
204+
where: seamed({ created_on: { $gte: '2026-04-29', $lte: '2026-07-28' } }),
191205
} as any);
192206
expect(ids(found)).toEqual(['on_mid', 'on_obj']);
193207
});

0 commit comments

Comments
 (0)