Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/20822-f3-route-then-delete.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -44,13 +44,28 @@ const CUBE = {

type Declaration = 'undeclared' | 'declared';

const TASK_FIELDS: Record<string, { type: string }> = { 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<Declaration, FilterLoweringOptions> = {
declared: { isDatetimeColumn: (column) => TASK_FIELDS[column]?.type === 'datetime' },
undeclared: {},
};

async function setup(declaration: Declaration, rows: ReadonlyArray<Record<string, unknown>> = 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 });
Expand All @@ -72,7 +87,7 @@ const whereOf = (sql: string) => /WHERE (.*?)(?: GROUP BY|$)/.exec(sql)?.[1];
async function answer(declaration: Declaration, where: FilterCondition, rows?: ReadonlyArray<Record<string, unknown>>) {
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),
};
Expand Down Expand Up @@ -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']);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string, { type: string }> = { created_at: { type: 'datetime' } };

async function setup(rows: ReadonlyArray<object>) {
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<string, unknown>) });
const service = new MemoryAnalyticsService({ driver, cubes: [LOGIC_CUBE] });
return { driver, service };
Expand All @@ -90,11 +99,21 @@ async function setup(rows: ReadonlyArray<object>) {
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<object>, 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),
};
Expand Down
32 changes: 23 additions & 9 deletions packages/drivers/driver-memory/src/memory-datetime-storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 = <T,>(where: T): T =>
lowerFilterCondition(where, {
isDatetimeColumn: (column) =>
(TASK_SCHEMA.fields as Record<string, { type: string } | undefined>)[column]?.type === 'datetime',
});

describe('InMemoryDriver Field.datetime storage (#4047)', () => {
let driver: InMemoryDriver;

Expand Down Expand Up @@ -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']);
});
Expand All @@ -106,25 +120,25 @@ 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']);
});

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']);

// [#5158] The authored array form, lowered the declared way. The INFIX
// 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']);
});
Expand All @@ -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']);
});
Expand All @@ -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']);
});
Expand All @@ -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']);
});
Expand Down
Loading
Loading