Skip to content

Commit e15606b

Browse files
committed
refactor(driver-memory): delete the query path's own whole-day copy (F3)
InMemoryDriver's four copies of the bare-day upper-bound rule are deleted: the $lte and $between arms of the FilterCondition translator and the <= and between arms of the AST-node translator. Every seam hands the driver the lowered filter (ADR-0053 D-D1 items 5 and 9, as amended), so it compiles the comparison it is handed. Its direct-call suites now hand it what a typed seam hands it (lowerFilterCondition with the declared-datetime reader) and keep their expected rows. New pins: one cell per deleted site for a direct call that passed no seam, and the item-7 convergence on a registered object's text and undeclared columns (SqlDriver's answer), declared in the changeset. Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5317b5a commit e15606b

9 files changed

Lines changed: 352 additions & 96 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). Every seam hands it the lowered filter, so a declared `datetime` column keeps the whole named day, and a declared `date` column answers as before. 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. 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)