From 0bd0e6d4f7b0561ac15bcb3d00e208597c6329ad Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 19:02:51 +0000 Subject: [PATCH 1/4] fix(metadata): queryHistory in driver mode lowers its own bare-day bound In driver mode DatabaseLoader.queryHistory hands its filter straight to IDataDriver.find / count, which passes no seam. ADR-0053 D-D1 item 5 (as amended) says such a caller "gets the comparison it wrote" once a face's whole-day copy is deleted, so until = 'YYYY-MM-DD' would run as <= midnight and drop every version recorded later that day (measured on driver-memory with its copy deleted: 2 rows to 0). The loader now runs the shared lowerFilterCondition itself in driver mode, typed by the history object it syncs (recorded_at is Field.datetime; every other column lowers byte-identical, item 7). Engine mode is left to the engine's own seam. Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- ...ase-loader-20822-history-whole-day.test.ts | 141 ++++++++++++++++++ .../metadata/src/loaders/database-loader.ts | 32 +++- 2 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 packages/metadata/src/loaders/database-loader-20822-history-whole-day.test.ts 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) => { From 5317b5aa22205ac1bc706356dc565e482cd4a433 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 19:02:51 +0000 Subject: [PATCH 2/4] fix(objectql): the where seam lowers type-blind for an object with no field map declaredDatetimeLowering read "no field map" as "no datetime column" and left the whole-day rule to each driver's own copy. ADR-0053 D-D1 item 5 (as amended) retires those copies, and item 7 says what a seam that cannot read the declared type does: "applies the rewrite type-blind". So the absent-map branch now omits the reader and the whole-day rules apply to every column of such an object. An object with a field map keeps the typed scope byte-identical. Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- ...2-no-field-map-type-blind-lowering.test.ts | 124 ++++++++++++++++++ packages/objectql/src/engine.ts | 19 ++- 2 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 packages/objectql/src/engine-20822-no-field-map-type-blind-lowering.test.ts 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 From e15606bae222670556ae385e4764ecab66a3608b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 19:02:51 +0000 Subject: [PATCH 3/4] 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 --- .changeset/20822-f3-route-then-delete.md | 13 ++ ...nalytics-20661-lte-whole-day-first.test.ts | 25 ++- ...ory-analytics-shared-lowering-door.test.ts | 27 ++- .../src/memory-datetime-storage.test.ts | 32 +++- ...driver-20822-comparison-as-written.test.ts | 165 ++++++++++++++++++ ...ry-driver-calendar-day-upper-bound.test.ts | 39 +++-- .../driver-memory/src/memory-driver.ts | 87 ++++----- .../src/memory-temporal-conformance.test.ts | 36 +++- .../src/memory-temporal-storage-form.test.ts | 24 ++- 9 files changed, 352 insertions(+), 96 deletions(-) create mode 100644 .changeset/20822-f3-route-then-delete.md create mode 100644 packages/drivers/driver-memory/src/memory-driver-20822-comparison-as-written.test.ts diff --git a/.changeset/20822-f3-route-then-delete.md b/.changeset/20822-f3-route-then-delete.md new file mode 100644 index 00000000000..056a9df0a28 --- /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). 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. 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); }); } From 86ccdc099e3464076c7a90d6d9ca177e05b857f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:06:08 +0000 Subject: [PATCH 4/4] docs(changeset): name the RLS no-guard path F3's deletion also narrows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The driver-memory bullet said every seam hands InMemoryDriver the lowered filter. That is false of a row-level security using filter, which the security middleware ANDs into the where after the engine's seam and which only the RLS compile seam lowers, for the columns its field guard types as datetime. With no guard (the object's fields unresolvable) a bare-day upper bound in such a policy now reaches the driver as written, where F3 used to widen it. The bullet now says so, and that it holds until #20822 group 2 makes that seam type-blind. Patch levels and Clause-② unchanged. Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- .changeset/20822-f3-route-then-delete.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/20822-f3-route-then-delete.md b/.changeset/20822-f3-route-then-delete.md index 056a9df0a28..8de990234ad 100644 --- a/.changeset/20822-f3-route-then-delete.md +++ b/.changeset/20822-f3-route-then-delete.md @@ -10,4 +10,4 @@ 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). 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. +- **`@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.