diff --git a/.changeset/20822-f3-route-then-delete.md b/.changeset/20822-f3-route-then-delete.md new file mode 100644 index 00000000000..8de990234ad --- /dev/null +++ b/.changeset/20822-f3-route-then-delete.md @@ -0,0 +1,13 @@ +--- +'@objectstack/metadata': patch +'@objectstack/objectql': patch +'@objectstack/driver-memory': patch +--- + +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) + +Clause-②: no + +- **`@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. +- **`@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. +- **`@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. diff --git a/packages/drivers/driver-memory/src/memory-analytics-20661-lte-whole-day-first.test.ts b/packages/drivers/driver-memory/src/memory-analytics-20661-lte-whole-day-first.test.ts index 7e230281f3e..4d4a47f3ab0 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-20661-lte-whole-day-first.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-20661-lte-whole-day-first.test.ts @@ -18,7 +18,7 @@ */ import { describe, it, expect } from 'vitest'; -import type { Cube, FilterCondition } from '@objectstack/spec/data'; +import { lowerFilterCondition, type Cube, type FilterCondition, type FilterLoweringOptions } from '@objectstack/spec/data'; import { InMemoryDriver } from './memory-driver.js'; import { MemoryAnalyticsService } from './memory-analytics.js'; @@ -44,13 +44,28 @@ const CUBE = { type Declaration = 'undeclared' | 'declared'; +const TASK_FIELDS: Record = { created_at: { type: 'datetime' }, made_on: { type: 'date' } }; + +/** + * [#20822 · ADR-0053 D-D1 items 5, 7 and 9, as amended] The `find()` reading is + * what the engine's `where` seam hands this driver, since the driver keeps no + * whole-day copy of its own: `declared` reads the declared field map (the + * typed seam, `datetime` columns only); `undeclared` has no field map to read, + * so the seam lowers type-blind (item 7) — the same reading the cube face's + * own door gives both. + */ +const SEAM: Record = { + declared: { isDatetimeColumn: (column) => TASK_FIELDS[column]?.type === 'datetime' }, + undeclared: {}, +}; + async function setup(declaration: Declaration, rows: ReadonlyArray> = CARD_ROWS) { const driver = new InMemoryDriver({}); await driver.connect(); if (declaration === 'declared') { await driver.syncSchema('task', { name: 'task', - fields: { created_at: { type: 'datetime' }, made_on: { type: 'date' } }, + fields: TASK_FIELDS, }); } for (const row of rows) await driver.create('task', { ...row }); @@ -72,7 +87,7 @@ const whereOf = (sql: string) => /WHERE (.*?)(?: GROUP BY|$)/.exec(sql)?.[1]; async function answer(declaration: Declaration, where: FilterCondition, rows?: ReadonlyArray>) { const { driver, service } = await setup(declaration, rows); return { - find: ids(await driver.find('task', { where })), + find: ids(await driver.find('task', { where: lowerFilterCondition(where, SEAM[declaration]) })), cube: ids((await service.query(cubeQuery(where))).rows), echo: whereOf((await service.generateSql(cubeQuery(where))).sql), }; @@ -143,7 +158,9 @@ describe('[#20661] the siblings on this face', () => { dimensions: ['id'], timeDimensions: [{ dimension: 'created_at', dateRange: ['2026-07-01', '2026-07-28'] }], } as any); - const found = ids(await driver.find('task', { where: { created_at: { $gte: '2026-07-01', $lte: '2026-07-28' } } })); + const found = ids(await driver.find('task', { + where: lowerFilterCondition({ created_at: { $gte: '2026-07-01', $lte: '2026-07-28' } }, SEAM.declared), + })); expect(ids(result.rows)).toEqual(found); expect(ids(result.rows)).toEqual(['r27', 'r28']); }); diff --git a/packages/drivers/driver-memory/src/memory-analytics-shared-lowering-door.test.ts b/packages/drivers/driver-memory/src/memory-analytics-shared-lowering-door.test.ts index b1a29d325a9..5a4bb3e040f 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-shared-lowering-door.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-shared-lowering-door.test.ts @@ -40,7 +40,13 @@ */ import { describe, it, expect } from 'vitest'; -import { FILTER_LOGIC_CASES, FILTER_LOGIC_ROWS, type Cube, type FilterCondition } from '@objectstack/spec/data'; +import { + FILTER_LOGIC_CASES, + FILTER_LOGIC_ROWS, + lowerFilterCondition, + type Cube, + type FilterCondition, +} from '@objectstack/spec/data'; import { InMemoryDriver } from './memory-driver.js'; import { MemoryAnalyticsService } from './memory-analytics.js'; @@ -78,10 +84,13 @@ const DAY_ROWS = [ { id: 'r29', created_at: '2026-07-29T10:00:00.000Z' }, ]; +/** The declared field map `logic_row` syncs — what the engine's typed seam reads. */ +const LOGIC_ROW_FIELDS: Record = { created_at: { type: 'datetime' } }; + async function setup(rows: ReadonlyArray) { const driver = new InMemoryDriver({}); await driver.connect(); - await driver.syncSchema('logic_row', { name: 'logic_row', fields: { created_at: { type: 'datetime' } } }); + await driver.syncSchema('logic_row', { name: 'logic_row', fields: LOGIC_ROW_FIELDS }); for (const row of rows) await driver.create('logic_row', { ...(row as Record) }); const service = new MemoryAnalyticsService({ driver, cubes: [LOGIC_CUBE] }); return { driver, service }; @@ -90,11 +99,21 @@ async function setup(rows: ReadonlyArray) { const cubeQuery = (where?: FilterCondition) => ({ cube: 'logic', measures: ['count'], dimensions: ['id'], ...(where === undefined ? {} : { where }) }) as any; -/** The live query path, the cube's rows and the echo's WHERE, for one `where`. */ +/** + * The live query path, the cube's rows and the echo's WHERE, for one `where`. + * + * [#20822 · ADR-0053 D-D1 items 5, 7 and 9, as amended] The live query path is + * the engine's `where` seam and then this driver, which keeps no whole-day copy + * of its own: so `find()` is handed what that TYPED seam hands it — the filter + * through the shared lowering, reading {@link LOGIC_ROW_FIELDS}. + */ async function answer(rows: ReadonlyArray, where?: FilterCondition) { const { driver, service } = await setup(rows); + const seamed = lowerFilterCondition(where, { + isDatetimeColumn: (column) => LOGIC_ROW_FIELDS[column]?.type === 'datetime', + }); return { - find: ids(await driver.find('logic_row', where === undefined ? {} : { where })), + find: ids(await driver.find('logic_row', seamed === undefined ? {} : { where: seamed })), cube: ids((await service.query(cubeQuery(where))).rows), echo: whereOf((await service.generateSql(cubeQuery(where))).sql), }; diff --git a/packages/drivers/driver-memory/src/memory-datetime-storage.test.ts b/packages/drivers/driver-memory/src/memory-datetime-storage.test.ts index 96e6693ca58..42163d35135 100644 --- a/packages/drivers/driver-memory/src/memory-datetime-storage.test.ts +++ b/packages/drivers/driver-memory/src/memory-datetime-storage.test.ts @@ -19,7 +19,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { parseFilterAST } from '@objectstack/spec/data'; +import { lowerFilterCondition, parseFilterAST } from '@objectstack/spec/data'; import { InMemoryDriver } from './memory-driver.js'; const ids = (rows: any[]) => rows.map((r: any) => r.id).sort(); @@ -35,6 +35,20 @@ const TASK_SCHEMA = { }, }; +/** + * [#20822 · ADR-0053 D-D1 items 5, 7 and 9, as amended] What a TYPED seam hands + * this driver: the filter through the shared lowering, reading + * {@link TASK_SCHEMA}'s declared `datetime` columns. The driver keeps no + * whole-day copy of its own any more, so a bare-day upper bound in these + * windows reaches it already lowered, as on every seam-fed path; the expected + * rows are unchanged. + */ +const seamed = (where: T): T => + lowerFilterCondition(where, { + isDatetimeColumn: (column) => + (TASK_SCHEMA.fields as Record)[column]?.type === 'datetime', + }); + describe('InMemoryDriver Field.datetime storage (#4047)', () => { let driver: InMemoryDriver; @@ -79,7 +93,7 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => { it('a date window reaches rows written in BOTH forms', async () => { await seedMixed(); const found = await driver.find('task', { - where: { created_at: { $gte: '2026-04-29', $lte: '2026-07-28' } }, + where: seamed({ created_at: { $gte: '2026-04-29', $lte: '2026-07-28' } }), } as any); expect(ids(found)).toEqual(['d_midnight', 'd_yesterday', 's_evening', 's_morning']); }); @@ -106,7 +120,7 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => { expect(ids(lt)).toEqual(['d_yesterday', 's_old']); const instant = await driver.find('task', { - where: { created_at: { $lte: '2026-07-28T12:00:00.000Z' } }, + where: seamed({ created_at: { $lte: '2026-07-28T12:00:00.000Z' } }), } as any); expect(ids(instant)).toEqual(['d_midnight', 'd_yesterday', 's_morning', 's_old']); }); @@ -114,7 +128,7 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => { it('$between and the array spelling take the same coercion', async () => { await seedMixed(); const between = await driver.find('task', { - where: { created_at: { $between: ['2026-04-29', '2026-07-28'] } }, + where: seamed({ created_at: { $between: ['2026-04-29', '2026-07-28'] } }), } as any); expect(ids(between)).toEqual(['d_midnight', 'd_yesterday', 's_evening', 's_morning']); @@ -122,9 +136,9 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => { // join (`[condA, 'and', condB]`) has no lowering at all and is refused at // the door; the declared spelling of "both bounds" is the prefix group. const array = await driver.find('task', { - where: parseFilterAST( + where: seamed(parseFilterAST( ['and', ['created_at', '>=', '2026-04-29'], ['created_at', '<=', '2026-07-28']], - ) as any, + )) as any, } as any); expect(ids(array)).toEqual(['d_midnight', 'd_yesterday', 's_evening', 's_morning']); }); @@ -145,7 +159,7 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => { await seeded.syncSchema('task', TASK_SCHEMA); const found = await seeded.find('task', { - where: { created_at: { $gte: '2026-07-28', $lte: '2026-07-28' } }, + where: seamed({ created_at: { $gte: '2026-07-28', $lte: '2026-07-28' } }), } as any); expect(ids(found)).toEqual(['i_date', 'i_iso']); }); @@ -162,7 +176,7 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => { // …and the converged value is reachable by a date window, which is the // point of converging it. const found = await driver.find('task', { - where: { due_at: { $gte: '2026-07-28', $lte: '2026-07-28' } }, + where: seamed({ due_at: { $gte: '2026-07-28', $lte: '2026-07-28' } }), } as any); expect(ids(found)).toEqual(['u1']); }); @@ -187,7 +201,7 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => { expect(onObj!.created_on).toBe('2026-07-28'); const found = await driver.find('task', { - where: { created_on: { $gte: '2026-04-29', $lte: '2026-07-28' } }, + where: seamed({ created_on: { $gte: '2026-04-29', $lte: '2026-07-28' } }), } as any); expect(ids(found)).toEqual(['on_mid', 'on_obj']); }); diff --git a/packages/drivers/driver-memory/src/memory-driver-20822-comparison-as-written.test.ts b/packages/drivers/driver-memory/src/memory-driver-20822-comparison-as-written.test.ts new file mode 100644 index 00000000000..e67f603e9a4 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-driver-20822-comparison-as-written.test.ts @@ -0,0 +1,165 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20822 · ADR-0053 D-D1 items 5, 7 and 9, as amended] This driver keeps no + * copy of the whole-day rule: it compiles the comparison it is handed. + * + * The rule — a bare `YYYY-MM-DD` upper bound means "through the whole of that + * day", and on the last supported day it bounds nothing — is applied once, by + * the shared lowering (`lowerFilterCondition`, `@objectstack/spec/data`), at the + * seams. This face carried its own copy at four sites (the `$lte` and + * `$between` arms of the FilterCondition translator, the `<=` and `between` + * arms of the AST-node translator) until #20822 deleted them. Two consequences, + * pinned here: + * + * - §A **Item 5 — a caller that passes no seam gets the comparison it wrote.** + * A direct `find()` with a bare-day `$lte` compares against midnight, as + * written; so does the AST-node spelling, which no seam emits. The one + * in-repo production caller that used to rely on the copy, + * `@objectstack/metadata`'s `DatabaseLoader.queryHistory` in driver mode, now + * lowers its own filter (its pin: `database-loader-20822-history-whole-day.test.ts`). + * One cell per deleted site, so restoring any one of them turns its cell red. + * - §B **Item 7 — what a typed seam leaves alone reaches this driver as + * written.** The engine's `where` seam reads the object's declared field + * map and rewrites a declared `datetime` column only. So on a REGISTERED + * object a bare-day `$lte` on a declared `text` column holding ISO text, or + * on a column the object does not declare, is now compared as written — the + * answer `SqlDriver` already gives (measured through the engine on + * `driver-sqlite-wasm`: the declared text column answers no row of the named + * day). The deleted copy applied the rule type-blind and answered the whole + * day on both. A declared `datetime` keeps the whole day (the seam lowers it) + * and a declared `date` does not move (`< next-day` orders exactly as + * `<= day` on calendar-day text). + * + * An object with NO field map is lowered type-blind by the engine's seam (item + * 7, `@objectstack/objectql`'s `engine-20822-no-field-map-type-blind-lowering.test.ts`), + * so it keeps the whole day on every column; §C pins that reading here. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { lowerFilterCondition, type FilterCondition } from '@objectstack/spec/data'; +import { InMemoryDriver } from './memory-driver.js'; + +const ids = (rows: ReadonlyArray>) => rows.map((r) => String(r.id)).sort(); + +/** The declared field map of the registered object §B stands for. */ +const EVENT_FIELDS: Record = { + at: { type: 'datetime' }, + stamp_text: { type: 'text' }, + on: { type: 'date' }, +}; + +/** The same instants in the declared datetime, the declared text and an undeclared column. */ +const ROWS = [ + { id: 'r_yesterday', at: '2026-07-27T14:00:00.000Z', stamp_text: '2026-07-27T14:00:00.000Z', extra: '2026-07-27T14:00:00.000Z', on: '2026-07-27' }, + { id: 'r_midnight', at: '2026-07-28T00:00:00.000Z', stamp_text: '2026-07-28T00:00:00.000Z', extra: '2026-07-28T00:00:00.000Z', on: '2026-07-28' }, + { id: 'r_evening', at: '2026-07-28T21:40:00.000Z', stamp_text: '2026-07-28T21:40:00.000Z', extra: '2026-07-28T21:40:00.000Z', on: '2026-07-28' }, + { id: 'r_next', at: '2026-07-29T00:00:00.000Z', stamp_text: '2026-07-29T00:00:00.000Z', extra: '2026-07-29T00:00:00.000Z', on: '2026-07-29' }, + { id: 'r_last', at: '9999-12-31T10:00:00.000Z', stamp_text: '9999-12-31T10:00:00.000Z', extra: '9999-12-31T10:00:00.000Z', on: '9999-12-31' }, +]; + +/** The whole of 2026-07-28 and everything before it. */ +const WHOLE_DAY = ['r_evening', 'r_midnight', 'r_yesterday']; +/** + * `<=` a bare day, as written, on the declared `datetime`: the comparand takes + * the column's storage form, the midnight instant, so the midnight row is in + * and the rest of the day is out. + */ +const THROUGH_MIDNIGHT = ['r_midnight', 'r_yesterday']; +/** + * `<=` a bare day, as written, on text (a declared `text` column, or a column + * with no declaration and so no storage form): every instant of the day sorts + * after the bare day's text, midnight included. + */ +const BEFORE_THE_DAY = ['r_yesterday']; +/** `<=` the last supported day's midnight, as written: every row but the one later that day. */ +const EVERY_ORDINARY_DAY = ['r_evening', 'r_midnight', 'r_next', 'r_yesterday']; + +describe('[#20822] InMemoryDriver compiles the comparison it is handed (ADR-0053 D-D1 items 5 and 7)', () => { + let driver: InMemoryDriver; + + beforeEach(async () => { + driver = new InMemoryDriver({}); + await driver.connect(); + await driver.syncSchema('event', { name: 'event', fields: EVENT_FIELDS }); + for (const row of ROWS) await driver.create('event', { ...row }); + }); + + const find = async (where: FilterCondition) => ids(await driver.find('event', { where })); + /** + * The AST-node spelling (`{ type: 'comparison', field, operator, value }`) is + * not a `FilterCondition` — no seam emits it — yet this driver still + * translates it for a direct caller; the cast names that bypass. + */ + const astNode = (operator: string, value: unknown) => + ({ type: 'comparison', field: 'at', operator, value }) as unknown as FilterCondition; + + describe('§A item 5: a direct call that passed no seam — one cell per deleted site', () => { + it('FilterCondition `$lte` a bare day: `<=` midnight, as written', async () => { + expect(await find({ at: { $lte: '2026-07-28' } })).toEqual(THROUGH_MIDNIGHT); + }); + + it('FilterCondition `$lte` the last supported day: `<=` its midnight, as written', async () => { + expect(await find({ at: { $lte: '9999-12-31' } })).toEqual(EVERY_ORDINARY_DAY); + }); + + it('FilterCondition `$between` with a bare-day max: both ends inclusive, as written', async () => { + expect(await find({ at: { $between: ['2026-07-27', '2026-07-28'] } })).toEqual(THROUGH_MIDNIGHT); + expect(await find({ at: { $between: ['2026-07-27', '9999-12-31'] } })).toEqual(EVERY_ORDINARY_DAY); + }); + + it('AST node `<=` a bare day, and the last supported day: as written', async () => { + expect(await find(astNode('<=', '2026-07-28'))).toEqual(THROUGH_MIDNIGHT); + expect(await find(astNode('<=', '9999-12-31'))).toEqual(EVERY_ORDINARY_DAY); + }); + + it('AST node `between` with a bare-day max, and with the last supported day: as written', async () => { + expect(await find(astNode('between', ['2026-07-27', '2026-07-28']))).toEqual(THROUGH_MIDNIGHT); + expect(await find(astNode('between', ['2026-07-27', '9999-12-31']))).toEqual(EVERY_ORDINARY_DAY); + }); + + it('the same filters through the shared lowering keep the whole day — the rule is the seam\'s', async () => { + const typed = (where: FilterCondition) => + lowerFilterCondition(where, { isDatetimeColumn: (column) => EVENT_FIELDS[column]?.type === 'datetime' }); + expect(await find(typed({ at: { $lte: '2026-07-28' } }))).toEqual(WHOLE_DAY); + expect(await find(typed({ at: { $between: ['2026-07-27', '2026-07-28'] } }))).toEqual(WHOLE_DAY); + expect(await find(typed({ at: { $lte: '9999-12-31' } }))).toEqual([...EVERY_ORDINARY_DAY, 'r_last'].sort()); + }); + }); + + describe('§B item 7: a REGISTERED object — the typed seam rewrites the declared datetime only', () => { + /** What the engine's `where` seam hands this driver for an object with {@link EVENT_FIELDS}. */ + const seamed = (where: FilterCondition) => + lowerFilterCondition(where, { + isDatetimeColumn: (column) => + Object.prototype.hasOwnProperty.call(EVENT_FIELDS, column) && EVENT_FIELDS[column]!.type === 'datetime', + }); + + it('the declared `datetime` keeps the whole named day', async () => { + expect(await find(seamed({ at: { $lte: '2026-07-28' } }))).toEqual(WHOLE_DAY); + }); + + it('a declared `text` column holding ISO text is compared as written (SqlDriver\'s answer; the copy answered the whole day)', async () => { + expect(await find(seamed({ stamp_text: { $lte: '2026-07-28' } }))).toEqual(BEFORE_THE_DAY); + expect(await find(seamed({ stamp_text: { $between: ['2026-07-27', '2026-07-28'] } }))).toEqual(BEFORE_THE_DAY); + }); + + it('a column the object does not declare is compared as written (the copy answered the whole day)', async () => { + expect(await find(seamed({ extra: { $lte: '2026-07-28' } }))).toEqual(BEFORE_THE_DAY); + }); + + it('control — a declared `date` does not move: calendar-day text orders `<= day` exactly as `< next-day`', async () => { + expect(await find(seamed({ on: { $lte: '2026-07-28' } }))).toEqual(['r_evening', 'r_midnight', 'r_yesterday']); + }); + }); + + describe('§C item 7: an object with NO field map — the seam lowers type-blind', () => { + const typeBlind = (where: FilterCondition) => lowerFilterCondition(where); + + it('every column keeps the whole named day', async () => { + expect(await find(typeBlind({ at: { $lte: '2026-07-28' } }))).toEqual(WHOLE_DAY); + expect(await find(typeBlind({ stamp_text: { $lte: '2026-07-28' } }))).toEqual(WHOLE_DAY); + expect(await find(typeBlind({ extra: { $between: ['2026-07-27', '2026-07-28'] } }))).toEqual(WHOLE_DAY); + }); + }); +}); diff --git a/packages/drivers/driver-memory/src/memory-driver-calendar-day-upper-bound.test.ts b/packages/drivers/driver-memory/src/memory-driver-calendar-day-upper-bound.test.ts index 12a5a148baa..41b31c96e35 100644 --- a/packages/drivers/driver-memory/src/memory-driver-calendar-day-upper-bound.test.ts +++ b/packages/drivers/driver-memory/src/memory-driver-calendar-day-upper-bound.test.ts @@ -17,13 +17,26 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { parseFilterAST, type FilterCondition } from '@objectstack/spec/data'; +import { lowerFilterCondition, parseFilterAST, type FilterCondition } from '@objectstack/spec/data'; import { InMemoryDriver } from './memory-driver.js'; import { MemoryAnalyticsService } from './memory-analytics.js'; import type { Cube } from '@objectstack/spec/data'; const ids = (rows: any[]) => rows.map((r: any) => r.id).sort(); +/** + * [#20822 · ADR-0053 D-D1 items 5, 7 and 9, as amended] Where the rule lives + * now: the shared lowering (`lowerFilterCondition`), applied once at the seams, + * never in this driver. The `find()` cases below hand the driver what a TYPED + * seam hands it for a `task` object that declares `created_at` a `datetime` + * (the engine's reading of a registered object's field map), and keep their + * expected rows. What this driver does with a filter that passed NO seam is + * pinned in `memory-driver-20822-comparison-as-written.test.ts`. + */ +const TASK_FIELDS: Record = { title: { type: 'text' }, created_at: { type: 'datetime' } }; +const seamed = (where: T): T => + lowerFilterCondition(where, { isDatetimeColumn: (column) => TASK_FIELDS[column]?.type === 'datetime' }); + describe('InMemoryDriver — bare-day $lte covers the whole day (#4042)', () => { let driver: InMemoryDriver; @@ -44,7 +57,7 @@ describe('InMemoryDriver — bare-day $lte covers the whole day (#4042)', () => it('keeps the whole final day — the dashboard default-config window', async () => { const found = await driver.find('task', { - where: { created_at: { $gte: '2026-04-29', $lte: '2026-07-28' } }, + where: seamed({ created_at: { $gte: '2026-04-29', $lte: '2026-07-28' } }), } as any); // Pre-fix: only [t_midnight, t_yesterday] — the 09:15 / 21:40 rows fell // past the midnight-anchored string prefix. @@ -53,37 +66,37 @@ describe('InMemoryDriver — bare-day $lte covers the whole day (#4042)', () => it('a full-ISO $lte keeps instant semantics — only the bare day is widened', async () => { const found = await driver.find('task', { - where: { created_at: { $lte: '2026-07-28T12:00:00.000Z' } }, + where: seamed({ created_at: { $lte: '2026-07-28T12:00:00.000Z' } }), } as any); expect(ids(found)).toEqual(['t_midnight', 't_morning', 't_old', 't_yesterday']); }); it('$gte / $gt / $lt keep their midnight anchoring', async () => { - const gte = await driver.find('task', { where: { created_at: { $gte: '2026-07-28' } } }); + const gte = await driver.find('task', { where: seamed({ created_at: { $gte: '2026-07-28' } }) }); expect(ids(gte)).toEqual(['t_evening', 't_midnight', 't_morning']); - const gt = await driver.find('task', { where: { created_at: { $gt: '2026-07-28' } } }); + const gt = await driver.find('task', { where: seamed({ created_at: { $gt: '2026-07-28' } }) }); expect(ids(gt)).toEqual(['t_evening', 't_midnight', 't_morning']); // string '…T00:00' > '2026-07-28' - const lt = await driver.find('task', { where: { created_at: { $lt: '2026-07-28' } } }); + const lt = await driver.find('task', { where: seamed({ created_at: { $lt: '2026-07-28' } }) }); expect(ids(lt)).toEqual(['t_old', 't_yesterday']); }); it('$between with a bare-day max covers the whole final day', async () => { const found = await driver.find('task', { - where: { created_at: { $between: ['2026-04-29', '2026-07-28'] } }, + where: seamed({ created_at: { $between: ['2026-04-29', '2026-07-28'] } }), } as any); expect(ids(found)).toEqual(['t_evening', 't_midnight', 't_morning', 't_yesterday']); }); it('stays correct inside an $or branch', async () => { const found = await driver.find('task', { - where: { + where: seamed({ $or: [ { created_at: { $gte: '2026-07-28', $lte: '2026-07-28' } }, // "today" preset { title: 't_old' }, ], - }, + }), } as any); expect(ids(found)).toEqual(['t_evening', 't_midnight', 't_morning', 't_old']); }); @@ -93,14 +106,14 @@ describe('InMemoryDriver — bare-day $lte covers the whole day (#4042)', () => // the driver no longer compiles it. Same authored filter, same rows. The // INFIX join has no lowering — the declared spelling is the prefix group. const lte = await driver.find('task', { - where: parseFilterAST( + where: seamed(parseFilterAST( ['and', ['created_at', '>=', '2026-04-29'], ['created_at', '<=', '2026-07-28']], - ) as any, + )) as any, } as any); expect(ids(lte)).toEqual(['t_evening', 't_midnight', 't_morning', 't_yesterday']); const between = await driver.find('task', { - where: parseFilterAST([['created_at', 'between', ['2026-04-29', '2026-07-28']]]) as any, + where: seamed(parseFilterAST([['created_at', 'between', ['2026-04-29', '2026-07-28']]])) as any, } as any); expect(ids(between)).toEqual(['t_evening', 't_midnight', 't_morning', 't_yesterday']); }); @@ -200,7 +213,7 @@ describe('[#20600] InMemoryDriver — a bare-day upper bound on the last support it('compiles no upper bound on 9999-12-31; 9999-12-30 is a bound; the lower-bound operators do not move', async () => { const got: Record = {}; - for (const [name, where] of CASES) got[name] = ids(await driver.find('task', { where: where() })); + for (const [name, where] of CASES) got[name] = ids(await driver.find('task', { where: seamed(where()) })); expect(got).toEqual(Object.fromEntries(CASES.map(([name, , want]) => [name, want]))); }); diff --git a/packages/drivers/driver-memory/src/memory-driver.ts b/packages/drivers/driver-memory/src/memory-driver.ts index a4c3db7e0e8..4df636dc892 100644 --- a/packages/drivers/driver-memory/src/memory-driver.ts +++ b/packages/drivers/driver-memory/src/memory-driver.ts @@ -14,7 +14,7 @@ import { hasDanglingLikeEscape, hasNulInLikePattern, likePatternToRegExp } from // the ruled 「is empty」 table, asked of the spec by the live query path. import { expandEmptyOperator, type ValueShapeFieldDef } from '@objectstack/spec/data'; import type { DriverQuery, IDataDriver } from '@objectstack/spec/contracts'; -import { Logger, createLogger, nextUtcCalendarDay, isUnboundedAbove, compensatedSum } from '@objectstack/core'; +import { Logger, createLogger, compensatedSum } from '@objectstack/core'; import { Query, Aggregator } from 'mingo'; import { assertSingleTenantPosture, @@ -111,15 +111,19 @@ interface LoweredWrite { * | lowered key | written by | * |---|---| * | `$eq` | `$eq`, `$null: true`, `$exists: false` | - * | `$ne` | `$ne`, `$null: false`, `$exists: true`, `$lte` (the LAST supported day, `9999-12-31` — #20600: no bound, a value) | + * | `$ne` | `$ne`, `$null: false`, `$exists: true` | * | `$gte` | `$gte`, `$between` | * | `$lte` | `$lte`, `$between` | - * | `$lt` | `$lt`, `$lte` (BARE CALENDAR DAY — #4042's half-open rewrite), `$between` (bare-day max) | * | `$regex` | the whole string family — promoted by `_multiRegex`, see below | * - * `$lte` → `$lt` is the member no card had named: a bare `YYYY-MM-DD` upper - * bound compiles half-open, so `{d: {$lte: '2026-07-28', $lt: '2026-07-02'}}` - * and its key-swapped twin answered `['1']` and `['1','2']` on this fixture. + * `$lte` → `$lt` WAS the member no card had named: while this face compiled a + * bare `YYYY-MM-DD` upper bound half-open itself, `{d: {$lte: '2026-07-28', + * $lt: '2026-07-02'}}` and its key-swapped twin answered `['1']` and + * `['1','2']` on this fixture. [#20822] That rewrite (and the last-supported-day + * `$lte` → `$ne`) now runs once, at the seams, in the shared lowering, which + * never clobbers an author's key either (a lowered key that is taken becomes + * its own conjunct); this face writes `$lte` as `$lte`, so `$lt` and `$ne` + * have one fewer writer here. * `$not` is written by `$notContains` and by NOTHING else — it is covered here * by construction rather than curatively, which is the point of ranging over * the vocabulary instead of over the three operators that had been noticed. @@ -1405,19 +1409,14 @@ export class InMemoryDriver implements IDataDriver { return { [field]: { $gte: store(value) } }; case '<': return { [field]: { $lt: store(value) } }; - case '<=': { - // A bare-day upper bound means "through that whole day" (#4042, the - // driver-sql twin is #3777): `<= 2026-07-28` on an ISO-timestamp value - // compiles half-open (`< 2026-07-29`), which is also order-equivalent - // to `<=` for plain `YYYY-MM-DD` date values — so no field-type lookup - // is needed, exactly the argument the preview evaluator uses. - // [#20600] On the last supported day there is no next day: every value - // is inside the bound, so what `<=` still asks is a value (`$ne: null`, - // the `is_not_null` arm below). - const nextDay = nextUtcCalendarDay(value); - if (isUnboundedAbove(nextDay)) return { [field]: { $ne: null } }; - return { [field]: nextDay != null ? { $lt: store(nextDay) } : { $lte: store(value) } }; - } + case '<=': + // [ADR-0053 D-D1 items 5 and 9, as amended — #20822] The comparison as + // written. The whole-day reading of a bare-day bound (#4042) is the + // shared lowering's, applied at the seams, and no seam emits this AST + // node form (the engine and the protocol hand a driver a + // FilterCondition), so it arrives here only from a direct driver call, + // which gets the comparison it wrote (item 5). + return { [field]: { $lte: store(value) } }; case 'in': return { [field]: { $in: store(value) } }; case 'nin': case 'not_in': case 'notin': case 'not in': @@ -1486,16 +1485,9 @@ export class InMemoryDriver implements IDataDriver { return this.emptyOperatorCondition(object, field, false, `filter.${field}.${operator}`); case 'between': if (Array.isArray(value) && value.length === 2) { - // Bare-day max → half-open, inheriting `<=`'s whole-day rule (#4042). - // [#20600] A max on the last supported day bounds nothing: the range - // keeps its minimum alone. - const nextDay = nextUtcCalendarDay(value[1]); - if (isUnboundedAbove(nextDay)) return { [field]: { $gte: store(value[0]) } }; - return { - [field]: nextDay != null - ? { $gte: store(value[0]), $lt: store(nextDay) } - : { $gte: store(value[0]), $lte: store(value[1]) }, - }; + // Both ends inclusive, as written: the whole-day reading of a + // bare-day max is the shared lowering's (see the `<=` arm above). + return { [field]: { $gte: store(value[0]), $lte: store(value[1]) } }; } // [#5328] One condition, one wording — the same refusal the // FilterCondition `$between` arm raises. They used to differ, which is @@ -1735,31 +1727,15 @@ export class InMemoryDriver implements IDataDriver { // range simply vanished, and no one was told. The shape gate refuses // it now; this throw is the totality floor. if (!Array.isArray(val) || val.length !== 2) throw malformedBetweenError(field, val, `${path}.$between`); + // Both ends inclusive, as written. [ADR-0053 D-D1 items 5 and 9, as + // amended — #20822] The whole-day reading of a bare-day max (#4042) + // and the last-supported-day rule (#20600) are the shared + // lowering's (`lowerFilterCondition`, `@objectstack/spec/data`), + // applied at the seams: a seam-fed filter arrives here with no + // `$between` left, and a direct driver call gets the comparison it + // wrote (item 5). put('$gte', store(val[0])); - // Bare-day max → half-open, inheriting `$lte`'s whole-day rule (#4042). - // [#20600] A max on the last supported day bounds nothing: the - // range keeps its minimum alone. - const betweenNextDay = nextUtcCalendarDay(val[1]); - if (isUnboundedAbove(betweenNextDay)) break; - if (betweenNextDay != null) put('$lt', store(betweenNextDay)); - else put('$lte', store(val[1])); - break; - } - case '$lte': { - // A bare-day upper bound means "through that whole day" (#4042; the - // driver-sql twin is #3777). Order-equivalent to `<=` for plain - // `YYYY-MM-DD` values, so it applies without a field-type lookup. - // [#13524] `$lt` here is a key an AUTHOR can also write — this arm is - // the member of the clobber class no card had named. See - // {@link assembleLoweredWrites}. - // [#20600] On the last supported day there is no next day: every - // value is inside the bound, so what `$lte` still asks is a value — - // `$ne: null`, the lowering `$null: false` takes below. Collected like - // every other write, so an author's own `$ne` survives beside it. - const nextDay = nextUtcCalendarDay(val); - if (isUnboundedAbove(nextDay)) put('$ne', null); - else if (nextDay != null) put('$lt', store(nextDay)); - else put('$lte', store(val)); + put('$lte', store(val[1])); break; } case '$null': @@ -1783,7 +1759,10 @@ export class InMemoryDriver implements IDataDriver { break; // Value comparisons take the field's storage form (#4047); the null / // existence predicates above are value-independent and must not. - case '$eq': case '$ne': case '$gt': case '$gte': case '$lt': + // [#20822] `$lte` is one of them, as written: its whole-day reading of + // a bare day is the shared lowering's, applied at the seams (see the + // `$between` arm above). + case '$eq': case '$ne': case '$gt': case '$gte': case '$lt': case '$lte': case '$in': case '$nin': put(op, store(val)); break; diff --git a/packages/drivers/driver-memory/src/memory-temporal-conformance.test.ts b/packages/drivers/driver-memory/src/memory-temporal-conformance.test.ts index 7495b6a09b1..8c48591515b 100644 --- a/packages/drivers/driver-memory/src/memory-temporal-conformance.test.ts +++ b/packages/drivers/driver-memory/src/memory-temporal-conformance.test.ts @@ -35,6 +35,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import { + lowerFilterCondition, TEMPORAL_CASES, TEMPORAL_NOW, TEMPORAL_ROWS, @@ -47,6 +48,23 @@ import { InMemoryDriver } from './memory-driver.js'; const resolveTokens = (filter: T): T => resolveFilterTokens(filter, { now: new Date(TEMPORAL_NOW) }); +/** The declared field maps this file syncs — what a typed seam reads. */ +const CONFORMANCE_FIELDS = { at: { type: 'datetime' }, on: { type: 'date' }, why: { type: 'string' } }; +const TIME_CONFORMANCE_FIELDS = { at: { type: 'time' }, why: { type: 'string' } }; + +/** + * [#20822 · ADR-0053 D-D1 items 5, 7 and 9, as amended] What a TYPED seam hands + * this driver: the case's filter through the shared lowering, reading the + * declared field map (`datetime` columns only). This driver keeps no + * whole-day copy of its own any more, so the whole-day cells are answered by + * the lowered filter, as on every seam-fed path; the expected rows are the + * shared table's, unchanged. Tokens resolve first, then lower (item 3). + */ +const lowered = (fields: Record, filter: T): T => + lowerFilterCondition(filter, { + isDatetimeColumn: (column) => Object.prototype.hasOwnProperty.call(fields, column) && fields[column]!.type === 'datetime', + }); + /** * The pre-#4047 storage forms, per the shared axis's seeding basis: `native` * → the raw platform-native instant (a JS `Date`, what an SDK caller or this @@ -88,7 +106,7 @@ describe('driver-memory — temporal conformance', () => { // without it the values would keep whatever form the writer produced. await driver.syncSchema('conformance', { name: 'conformance', - fields: { at: { type: 'datetime' }, on: { type: 'date' }, why: { type: 'string' } }, + fields: CONFORMANCE_FIELDS, }); for (const r of TEMPORAL_ROWS) { await driver.create('conformance', { @@ -103,14 +121,14 @@ describe('driver-memory — temporal conformance', () => { for (const c of TEMPORAL_CASES) { it(c.name, async () => { - const rows = await driver.find('conformance', { where: c.filter }); + const rows = await driver.find('conformance', { where: lowered(CONFORMANCE_FIELDS, c.filter) }); const got = (rows as any[]).map((r) => r.id).sort(); expect(got, c.note).toEqual([...c.expected].sort()); }); if (c.tokenFilter) { it(`${c.name} — via relative tokens`, async () => { - const rows = await driver.find('conformance', { where: resolveTokens(c.tokenFilter) }); + const rows = await driver.find('conformance', { where: lowered(CONFORMANCE_FIELDS, resolveTokens(c.tokenFilter)) }); const got = (rows as any[]).map((r) => r.id).sort(); expect(got, c.note).toEqual([...c.expected].sort()); }); @@ -126,7 +144,7 @@ describe('driver-memory — Field.time conformance', () => { await driver.connect(); await driver.syncSchema('time_conformance', { name: 'time_conformance', - fields: { at: { type: 'time' }, why: { type: 'string' } }, + fields: TIME_CONFORMANCE_FIELDS, }); for (const r of TEMPORAL_TIME_ROWS) { await driver.create('time_conformance', { @@ -141,7 +159,7 @@ describe('driver-memory — Field.time conformance', () => { for (const c of TEMPORAL_TIME_CASES) { it(c.name, async () => { - const rows = await driver.find('time_conformance', { where: c.filter }); + const rows = await driver.find('time_conformance', { where: lowered(TIME_CONFORMANCE_FIELDS, c.filter) }); const got = (rows as any[]).map((r) => r.id).sort(); expect(got, c.note).toEqual([...c.expected].sort()); }); @@ -159,7 +177,7 @@ describe('driver-memory — temporal conformance on rows that predate the schema // The retroactive pass runs here, on rows this driver never wrote. await driver.syncSchema('conformance', { name: 'conformance', - fields: { at: { type: 'datetime' }, on: { type: 'date' }, why: { type: 'string' } }, + fields: CONFORMANCE_FIELDS, }); }); @@ -179,7 +197,7 @@ describe('driver-memory — temporal conformance on rows that predate the schema // already swept above — a divergence here is a convergence bug by construction. for (const c of TEMPORAL_CASES) { it(c.name, async () => { - const rows = await driver.find('conformance', { where: c.filter }); + const rows = await driver.find('conformance', { where: lowered(CONFORMANCE_FIELDS, c.filter) }); const got = (rows as any[]).map((r) => r.id).sort(); expect(got, c.note).toEqual([...c.expected].sort()); }); @@ -194,7 +212,7 @@ describe('driver-memory — Field.time conformance on rows that predate the sche await driver.connect(); await driver.syncSchema('time_conformance', { name: 'time_conformance', - fields: { at: { type: 'time' }, why: { type: 'string' } }, + fields: TIME_CONFORMANCE_FIELDS, }); }); @@ -209,7 +227,7 @@ describe('driver-memory — Field.time conformance on rows that predate the sche for (const c of TEMPORAL_TIME_CASES) { it(c.name, async () => { - const rows = await driver.find('time_conformance', { where: c.filter }); + const rows = await driver.find('time_conformance', { where: lowered(TIME_CONFORMANCE_FIELDS, c.filter) }); const got = (rows as any[]).map((r) => r.id).sort(); expect(got, c.note).toEqual([...c.expected].sort()); }); diff --git a/packages/drivers/driver-memory/src/memory-temporal-storage-form.test.ts b/packages/drivers/driver-memory/src/memory-temporal-storage-form.test.ts index 1bdb23e5a64..757b33a8cac 100644 --- a/packages/drivers/driver-memory/src/memory-temporal-storage-form.test.ts +++ b/packages/drivers/driver-memory/src/memory-temporal-storage-form.test.ts @@ -22,7 +22,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import { temporalStorageForm } from '@objectstack/core'; -import type { FilterCondition } from '@objectstack/spec/data'; +import { lowerFilterCondition, type FilterCondition } from '@objectstack/spec/data'; import { coerceTemporalValue } from './memory-temporal.js'; import { InMemoryDriver } from './memory-driver.js'; @@ -95,6 +95,24 @@ const CARD_WHERE_TWINS: ReadonlyArray FilterCondition, n ['[#20480] the same instant as epoch milliseconds', () => ({ slot: { $gt: Date.parse('2026-02-01T11:00:00Z') } }), 3], ]; +/** The declared field map the twins run against — what a typed seam reads. */ +const LEDGER_FIELDS: Record = { + placed_on: { type: 'date' }, + opened_at: { type: 'datetime' }, + slot: { type: 'time' }, +}; + +/** + * [#20822 · ADR-0053 D-D1 items 5, 7 and 9, as amended] What a TYPED seam hands + * this driver: the twin through the shared lowering, reading + * {@link LEDGER_FIELDS}' `datetime` columns. Rows 3 and 4 (a bare day as a + * datetime's upper bound) are answered by the lowered filter, as on every + * seam-fed path — the engine's per-aggregation `filter` included — since this + * driver keeps no whole-day copy of its own; the counts are unchanged. + */ +const seamed = (where: T): T => + lowerFilterCondition(where, { isDatetimeColumn: (column) => LEDGER_FIELDS[column]?.type === 'datetime' }); + describe('[#20176] the where twins of the card\'s rows, on this driver', () => { let driver: InMemoryDriver; @@ -103,14 +121,14 @@ describe('[#20176] the where twins of the card\'s rows, on this driver', () => { await driver.connect(); await driver.syncSchema(OBJECT, { name: OBJECT, - fields: { placed_on: { type: 'date' }, opened_at: { type: 'datetime' }, slot: { type: 'time' } }, + fields: LEDGER_FIELDS, }); for (const row of ROWS) await driver.create(OBJECT, row); }); for (const [name, where, expected] of CARD_WHERE_TWINS) { it(`${name}: ${expected} of 6`, async () => { - const rows = await driver.find(OBJECT, { where: where() }); + const rows = await driver.find(OBJECT, { where: seamed(where()) }); expect(rows).toHaveLength(expected); }); } diff --git a/packages/metadata/src/loaders/database-loader-20822-history-whole-day.test.ts b/packages/metadata/src/loaders/database-loader-20822-history-whole-day.test.ts new file mode 100644 index 00000000000..37f41f83666 --- /dev/null +++ b/packages/metadata/src/loaders/database-loader-20822-history-whole-day.test.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20822 · ADR-0053 D-D1 items 5, 7 and 10, as amended] In driver mode + * `DatabaseLoader.queryHistory` is a seam: it lowers its own `where` with the + * shared lowering before any driver sees it, so `until: 'YYYY-MM-DD'` keeps + * meaning "through the whole of that day" on every driver. + * + * ## Why the loader, not the driver + * + * In driver mode (`MetadataManager.setDatabaseDriver`) the history filter goes + * straight to `IDataDriver.find` / `count` and passes no seam. Each driver's own + * whole-day copy is deleted by its step-4 card, and item 5 says a caller that + * reaches a face without a seam then "gets the comparison it wrote": `recorded_at + * <= 'YYYY-MM-DD'`, i.e. `<=` midnight, which drops every version recorded later + * that day. Measured on `@objectstack/driver-memory` with its copy deleted: + * `until` = today went from 2 rows to 0. Item 5's remedy is to make the caller a + * seam, so the loader runs `lowerFilterCondition` typed by the history object it + * syncs — `recorded_at` is `Field.datetime`, every other column lowers + * byte-identical (item 7). + * + * ## What is pinned, and on what + * + * - §A the rows: real SQLite (`driver-sqlite-wasm`, already a devDependency) + * in driver mode. `SqlDriver` still carries its own copy until its deletion + * card (#20822 group 2) lands, so §A is green with or without the loader's + * lowering today; it is the answer that card must keep. + * - §B the seam itself: what the loader hands the driver. This is the half + * that goes red when the loader stops lowering, whatever the driver does, + * so it holds for every driver — `driver-memory`, whose copy is gone, + * included. + * + * ⛔ No `@objectstack/driver-memory` here: a new test consumer of that package + * needs a maintainer ruling (`scripts/driver-memory-census.ledger.json`). §B pins + * the filter every driver receives, and `driver-memory`'s own suite pins how it + * answers that lowered filter and the unlowered one + * (`memory-driver-20822-comparison-as-written.test.ts`). + * + * The clock is faked for `Date` only, so the two versions are recorded at + * known instants on one known day and no midnight rollover can move a case. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { DatabaseLoader } from './database-loader.js'; + +const DAY = '2026-07-28'; +const NEXT_DAY = '2026-07-29'; +const MORNING = '2026-07-28T09:00:00.000Z'; +const EVENING = '2026-07-28T21:00:00.000Z'; + +describe('[#20822] queryHistory in driver mode lowers a bare-day bound itself (ADR-0053 D-D1 item 5)', () => { + let driver: SqliteWasmDriver; + let loader: DatabaseLoader; + + beforeEach(async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.connect(); + loader = new DatabaseLoader({ driver, trackHistory: true, cache: { enabled: false } }); + + vi.setSystemTime(new Date(MORNING)); + await loader.save('view', 'case_grid', { name: 'case_grid', label: 'one' }); + vi.setSystemTime(new Date(EVENING)); + await loader.save('view', 'case_grid', { name: 'case_grid', label: 'two' }); + }); + + afterEach(async () => { + vi.useRealTimers(); + await driver.disconnect(); + }); + + describe('§A the rows, on real SQLite', () => { + it('the fixture really holds two versions recorded after midnight of DAY', async () => { + const all = await loader.queryHistory('view', 'case_grid'); + expect(all.records.map((r) => r.recordedAt)).toEqual([EVENING, MORNING]); + expect(all.total).toBe(2); + }); + + it('until = DAY keeps both versions recorded on DAY', async () => { + const page = await loader.queryHistory('view', 'case_grid', { until: DAY }); + expect(page.records.map((r) => r.recordedAt)).toEqual([EVENING, MORNING]); + expect(page.total).toBe(2); + }); + + it('since = until = DAY is the whole of DAY', async () => { + const page = await loader.queryHistory('view', 'case_grid', { since: DAY, until: DAY }); + expect(page.records.map((r) => r.recordedAt)).toEqual([EVENING, MORNING]); + expect(page.total).toBe(2); + }); + + it('the bound still bounds: until = the day before keeps nothing', async () => { + const page = await loader.queryHistory('view', 'case_grid', { until: '2026-07-27' }); + expect(page.records).toEqual([]); + expect(page.total).toBe(0); + }); + + it('an instant until is kept as written, never widened', async () => { + const page = await loader.queryHistory('view', 'case_grid', { until: '2026-07-28T12:00:00.000Z' }); + expect(page.records.map((r) => r.recordedAt)).toEqual([MORNING]); + expect(page.total).toBe(1); + }); + }); + + describe('§B the filter the driver receives: the loader is the seam', () => { + it('until = DAY reaches find and count as recorded_at < the next day', async () => { + const find = vi.spyOn(driver, 'find'); + const count = vi.spyOn(driver, 'count'); + await loader.queryHistory('view', 'case_grid', { until: DAY }); + + const findWhere = find.mock.calls.find(([table]) => table === 'sys_metadata_history')?.[1]?.where; + const countWhere = count.mock.calls.find(([table]) => table === 'sys_metadata_history')?.[1]?.where; + expect(findWhere).toEqual({ type: 'view', name: 'case_grid', recorded_at: { $lt: NEXT_DAY } }); + expect(countWhere).toEqual(findWhere); + }); + + it('since = until = DAY keeps the midnight lower bound and widens only the upper', async () => { + const find = vi.spyOn(driver, 'find'); + await loader.queryHistory('view', 'case_grid', { since: DAY, until: DAY }); + + const where = find.mock.calls.find(([table]) => table === 'sys_metadata_history')?.[1]?.where; + expect(where).toEqual({ type: 'view', name: 'case_grid', recorded_at: { $gte: DAY, $lt: NEXT_DAY } }); + }); + + it('an instant until and the non-datetime columns reach the driver byte-identical', async () => { + const find = vi.spyOn(driver, 'find'); + await loader.queryHistory('view', 'case_grid', { + operationType: 'update', + until: '2026-07-28T12:00:00.000Z', + }); + + const where = find.mock.calls.find(([table]) => table === 'sys_metadata_history')?.[1]?.where; + expect(where).toEqual({ + type: 'view', + name: 'case_grid', + operation_type: 'update', + recorded_at: { $lte: '2026-07-28T12:00:00.000Z' }, + }); + }); + }); +}); diff --git a/packages/metadata/src/loaders/database-loader.ts b/packages/metadata/src/loaders/database-loader.ts index 5b5926c2d7e..e9a5e85cea6 100644 --- a/packages/metadata/src/loaders/database-loader.ts +++ b/packages/metadata/src/loaders/database-loader.ts @@ -21,6 +21,7 @@ import type { } from '@objectstack/spec/system'; import { SysMetadataObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; import { applyConversionsToStoredItem } from '@objectstack/spec'; +import { lowerFilterCondition, type FilterLoweringOptions } from '@objectstack/spec/data'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; import type { IDataDriver, IDataEngine, DriverQuery } from '@objectstack/spec/contracts'; import type { MetadataLoader, MetadataKeyedItem } from './loader-interface.js'; @@ -165,6 +166,22 @@ function recordedAtFallback(): string { return new Date(0).toISOString(); } +/** + * [ADR-0053 D-D1 items 5 and 7, as amended — #20822] The declared-type reader + * `queryHistory` lowers its driver-mode filter with: a column is `datetime` + * exactly when the history object this loader syncs + * ({@link DatabaseLoader.ensureHistorySchema}, `SysMetadataHistoryObject`) + * declares it `type: 'datetime'` — the test the engine seam and `SqlDriver` + * apply, so `recorded_at` takes the whole-day bound and every other column + * lowers byte-identical. + */ +const HISTORY_FILTER_LOWERING: FilterLoweringOptions = { + isDatetimeColumn: (column) => { + const fields = SysMetadataHistoryObject.fields as Record; + return Object.prototype.hasOwnProperty.call(fields, column) && fields[column]?.type === 'datetime'; + }, +}; + /** * Cache configuration for `DatabaseLoader`. * @@ -1259,12 +1276,23 @@ export class DatabaseLoader implements MetadataLoader { historyFilter.recorded_at = { $lte: options.until }; } } + // [ADR-0053 D-D1 items 5 and 7, as amended — #20822] In driver mode this + // filter goes straight to `IDataDriver.find` / `count`, and a driver is not + // a seam: it compiles the comparison it is handed, so a bare-day `until` + // would run as the `<=` midnight it was written as and drop every version + // recorded later that day. So in driver mode this call IS the seam and runs + // the shared lowering itself, typed by the history object it syncs + // ({@link HISTORY_FILTER_LOWERING}): `until: 'YYYY-MM-DD'` becomes + // `recorded_at < next day`, and an instant `until` is kept as written. In + // engine mode the engine's own `where` seam lowers it, typed by the + // registered history object, so this call does not lower a second time. + const where = this.engine ? historyFilter : lowerFilterCondition(historyFilter, HISTORY_FILTER_LOWERING); const limit = options?.limit ?? 50; const offset = options?.offset ?? 0; const historyRecords = await this._find(this.historyTableName, { - where: historyFilter, + where, orderBy: [ { field: 'recorded_at', order: 'desc' as const }, { field: 'version', order: 'desc' as const }, @@ -1275,7 +1303,7 @@ export class DatabaseLoader implements MetadataLoader { const hasMore = historyRecords.length > limit; const records = historyRecords.slice(0, limit); - const total = await this._count(this.historyTableName, { where: historyFilter }); + const total = await this._count(this.historyTableName, { where }); const includeMetadata = options?.includeMetadata !== false; const result = records.map((row: Record) => { diff --git a/packages/objectql/src/engine-20822-no-field-map-type-blind-lowering.test.ts b/packages/objectql/src/engine-20822-no-field-map-type-blind-lowering.test.ts new file mode 100644 index 00000000000..4df4ada196c --- /dev/null +++ b/packages/objectql/src/engine-20822-no-field-map-type-blind-lowering.test.ts @@ -0,0 +1,124 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20822 · ADR-0053 D-D1 item 7, as amended] The engine's `where` seam has no + * declared type to read for an object with no field map, and item 7 says what + * such a seam does: "A seam that cannot applies the rewrite type-blind." So a + * bare-day upper bound on ANY column of such an object leaves the engine + * already lowered — `$lte` a day becomes `$lt` the next day, and `$between` + * splits — on every verb that reads the object's declarations. + * + * Before #20822 this branch read "no field map" as "no datetime column" and + * left the rule to the driver's own copy. Item 5 retires those copies, so that + * reading would have dropped the whole-day bound on every such read: measured + * on `driver-memory` with its copy deleted, an unregistered object's `$lte` a + * day went from the whole day to midnight only. + * + * An object WITH a field map keeps the typed scope byte-identical (the control + * below; `engine-shared-filter-lowering-seam.test.ts` pins the rest of it). + * + * The witness is the recording driver's `where`, as in the seam's own pin. + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import type { EngineAggregateOptions } from '@objectstack/spec/data'; +import { ObjectQL } from './engine.js'; + +const UNREGISTERED = 'no_field_map_probe'; +const REGISTERED = 'typed_probe'; + +const TYPED_SCHEMA = { + name: REGISTERED, + label: 'Typed probe', + fields: { + id: { name: 'id', type: 'text' }, + note: { name: 'note', type: 'text' }, + closed_at: { name: 'closed_at', type: 'datetime' }, + due_on: { name: 'due_on', type: 'date' }, + }, +}; + +interface Seen { verb: string; ast: any } + +function makeRecordingDriver() { + const seen: Seen[] = []; + const driver: any = { + name: 'recording', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(_o: string, ast: any) { seen.push({ verb: 'find', ast }); return []; }, + async findOne(_o: string, ast: any) { seen.push({ verb: 'findOne', ast }); return null; }, + async count(_o: string, ast: any) { seen.push({ verb: 'count', ast }); return 0; }, + }; + return { driver, seen }; +} + +describe('[#20822] an object with no field map: the engine seam lowers type-blind (ADR-0053 D-D1 item 7)', () => { + let engine: ObjectQL; + let seen: Seen[]; + + beforeEach(async () => { + const rec = makeRecordingDriver(); + seen = rec.seen; + engine = new ObjectQL(); + engine.registerDriver(rec.driver, true); + await engine.init(); + engine.registry.registerObject(TYPED_SCHEMA as any, 'test'); + seen.length = 0; + }); + + const lastWhere = (verb: string) => [...seen].reverse().find((s) => s.verb === verb)?.ast?.where; + + it('the fixture: the probe object really has no registry entry', () => { + expect(engine.registry.getObject(UNREGISTERED)).toBeUndefined(); + expect(engine.registry.getObject(REGISTERED)).toBeDefined(); + }); + + it('find: a bare-day `$lte` on any column reaches the driver as `$lt` the next day', async () => { + await engine.find(UNREGISTERED, { where: { closed_at: { $lte: '2026-07-28' } } }); + expect(lastWhere('find')).toEqual({ closed_at: { $lt: '2026-07-29' } }); + // Type-blind means every column: no declaration says `note` is not a datetime. + await engine.find(UNREGISTERED, { where: { note: { $lte: '2026-07-28' } } }); + expect(lastWhere('find')).toEqual({ note: { $lt: '2026-07-29' } }); + }); + + it('find: `$between` with a bare-day maximum splits and widens its upper end', async () => { + await engine.find(UNREGISTERED, { where: { closed_at: { $between: ['2026-07-01', '2026-07-28'] } } }); + expect(lastWhere('find')).toEqual({ closed_at: { $gte: '2026-07-01', $lt: '2026-07-29' } }); + }); + + it('findOne and count take the same reading', async () => { + await engine.findOne(UNREGISTERED, { where: { closed_at: { $lte: '2026-07-28' } } }); + expect(lastWhere('findOne') ?? lastWhere('find')).toEqual({ closed_at: { $lt: '2026-07-29' } }); + await engine.count(UNREGISTERED, { where: { closed_at: { $lte: '2026-07-28' } } }); + expect(lastWhere('count')).toEqual({ closed_at: { $lt: '2026-07-29' } }); + }); + + it('aggregate: its `where` takes the same reading', async () => { + await engine.aggregate(UNREGISTERED, { + where: { closed_at: { $lte: '2026-07-28' } }, + groupBy: ['note'], + aggregations: [{ function: 'count', alias: 'n' }], + } as EngineAggregateOptions); + expect(lastWhere('find')).toEqual({ closed_at: { $lt: '2026-07-29' } }); + }); + + it('an instant, and `$gte` / `$lt` on a bare day, are never widened', async () => { + const where = { closed_at: { $lte: '2026-07-28T12:00:00.000Z' }, note: { $gte: '2026-07-01', $lt: '2026-07-28' } }; + await engine.find(UNREGISTERED, { where }); + expect(lastWhere('find')).toBe(where); + }); + + it('control — a field map keeps the typed scope: only the declared datetime is rewritten', async () => { + const where = { closed_at: { $lte: '2026-07-28' }, due_on: { $lte: '2026-07-28' }, note: { $lte: '2026-07-28' } }; + await engine.find(REGISTERED, { where }); + expect(lastWhere('find')).toEqual({ + closed_at: { $lt: '2026-07-29' }, + due_on: { $lte: '2026-07-28' }, + note: { $lte: '2026-07-28' }, + }); + }); + + it('the judge runs the same stage and gains no verdict from it', () => { + expect(engine.judgeFilter(UNREGISTERED, { closed_at: { $between: ['2026-07-01', '2026-07-28'] } })).toEqual({ ok: true }); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index cd1e9e39967..147983ea35e 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -1190,7 +1190,9 @@ function resolveWhereFilterTokens( * object's declarations, so the whole-day rule rewrites a declared * `datetime` column only ({@link declaredDatetimeLowering}) — the scope * `SqlDriver` holds, so a `date`, `time` or non-temporal column reaches every - * driver byte-identical to before (the amendment's item 7). + * driver byte-identical to before (the amendment's item 7). An object with no + * field map has no declarations to read, and there the rule applies + * type-blind (item 7's other half). * * Returns the input by reference when nothing resolved and nothing lowered. */ @@ -1207,12 +1209,21 @@ function resolveThenLowerWhere( * positions: a column is `datetime` exactly when the object's declared field * map says `type: 'datetime'` — the same test `SqlDriver` indexes its * `datetimeFields` by, so the columns this seam widens are a subset of the - * columns every face widens today. No field map (a registry-less host) reads - * no column as `datetime`: the whole-day rule is then left to the faces, as it - * was, rather than applied type-blind to columns no driver would widen. + * columns every face widens today. + * + * [#20822] No field map (an object the registry does not hold, a + * registry-less host) is a seam that cannot read the declared type, and item 7 + * says what such a seam does: "applies the rewrite type-blind", as the + * type-blind emitters do (sound on `Field.date` text, where `< next-day` + * orders exactly as `<= day`). So the reader is omitted and the whole-day + * rules apply to every column. This branch used to read "no field map" as "no + * datetime column" and leave the rule to the faces; item 5 retires those + * copies, and with them gone that reading would have dropped the whole-day + * bound on every such read. */ function declaredDatetimeLowering(schema: unknown): FilterLoweringOptions { const fields = (schema as { fields?: unknown } | undefined)?.fields; + if (fields === null || typeof fields !== 'object') return {}; return { isDatetimeColumn: (column) => fields !== null