99 * (`IDataDriver.temporalFilterValue` / `temporalFilterColumnSql`) bound to the
1010 * object, and applies them after the shared lowering to every value
1111 * comparison. Both of its consumers pass the context's pair. Absent members are
12- * identity.
12+ * identity. The draft preview, which has no driver, puts both sides of each
13+ * value comparison in the same storage form with `@objectstack/core`'s
14+ * `temporalStorageForm`, by the column's declared type.
1315 *
1416 * Measured at `d2f452b88`, before this change, with the comparand bound as
1517 * written: the read scope differed from `engine.find` on 7 of the 12
1618 * `datetime` cells below on SQLite and 9 of 12 on PostgreSQL 16 under a
1719 * non-UTC server, and on 0 of the 4 `date` cells. The native face, which runs
1820 * the read scope, differed on the same cells end to end; the ObjectQL face
19- * (the engine) on none.
21+ * (the engine) on none. The preview differed on 7 of the 12 `datetime` cells
22+ * and on 0 of the 4 `date` cells.
2023 *
2124 * The PostgreSQL cells run where `OS_TEST_POSTGRES_URL` is set, and are a
2225 * named skip otherwise. Each asserts its server is not on UTC, because a UTC
@@ -28,7 +31,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2831import { ObjectQL } from '@objectstack/objectql' ;
2932import { SqlDriver } from '@objectstack/driver-sql' ;
3033import { lowerFilterCondition , type Cube , type FilterCondition } from '@objectstack/spec/data' ;
31- import type { AnalyticsService } from '../analytics-service.js' ;
34+ import { DatasetSchema } from '@objectstack/spec/ui' ;
35+ import { AnalyticsService } from '../analytics-service.js' ;
3236import { AnalyticsServicePlugin } from '../plugin.js' ;
3337import { compileScopedFilterToSql , type ReadScopeCompileOptions } from '../read-scope-sql.js' ;
3438
@@ -120,6 +124,14 @@ const ROWS = [
120124 { id : 'r4' , signed_at : '2026-07-29T10:00:00.000Z' , due_on : '2026-07-29' , note : 'n' } ,
121125 { id : 'r5' , signed_at : null , due_on : null , note : null } ,
122126] ;
127+ /** The same instants and days as {@link ROWS}, drafted in other spellings an author can write. */
128+ const RESPELLED = [
129+ { id : 'r1' , signed_at : new Date ( Date . UTC ( 2026 , 6 , 27 , 10 ) ) , due_on : '2026-07-27T23:00:00Z' , note : '2026-07-27' } ,
130+ { id : 'r2' , signed_at : '2026-07-28T00:00:00Z' , due_on : '2026-07-28T00:00:00.000Z' , note : '2026-07-28' } ,
131+ { id : 'r3' , signed_at : '2026-07-28 10:00:00' , due_on : '2026-07-28' , note : '2026-07-28 late' } ,
132+ { id : 'r4' , signed_at : '2026-07-29T18:00:00+08:00' , due_on : '2026-07-29' , note : 'n' } ,
133+ { id : 'r5' , signed_at : null , due_on : null , note : null } ,
134+ ] ;
123135const CUBE = {
124136 name : 'os21505_cube' ,
125137 title : 'Coercion' ,
@@ -257,6 +269,41 @@ for (const cell of DB_CELLS) {
257269 expect ( echo . params ) . toContain ( driver . temporalFilterValue ( OBJECT , 'signed_at' , '2026-07-28' ) ) ;
258270 expect ( echo . params ) . not . toContain ( '2026-07-28' ) ;
259271 } ) ;
272+
273+ it ( 'the draft preview (queryDataset previewDrafts) answers the engine\'s rows over drafted rows in either spelling' , async ( ) => {
274+ // Window ends written shorter than the stored instant, beside the cells.
275+ const windows : Array < [ string , [ string , string ] ] > = [
276+ [ 'datetime window to a zone-naive minute' , [ '2026-07-28' , '2026-07-28T10:00' ] ] ,
277+ [ 'datetime window to a zone-naive second' , [ '2026-07-28' , '2026-07-28T10:00:00' ] ] ,
278+ ] ;
279+ for ( const [ label , [ start , end ] ] of windows ) {
280+ expect ( ids ( await engine . find ( OBJECT , { where : { signed_at : { $gte : start , $lte : end } } , fields : [ 'id' ] } as never ) ) , `${ label } : engine.find` ) . toBe ( 'r2,r3' ) ;
281+ }
282+ const dataset = DatasetSchema . parse ( {
283+ name : 'os21505_preview' ,
284+ label : 'Coercion preview' ,
285+ object : OBJECT ,
286+ dimensions : [ { name : 'id' , field : 'id' , type : 'string' } , { name : 'signed_at' , field : 'signed_at' , type : 'date' } ] ,
287+ measures : [ { name : 'row_count' , aggregate : 'count' } ] ,
288+ } ) ;
289+ for ( const [ spelling , drafted ] of [ [ 'canonical' , ROWS ] , [ 'respelled' , RESPELLED ] ] as const ) {
290+ // The live path is not wired, so an answer can only come from the preview.
291+ const svc = new AnalyticsService ( {
292+ sourceFieldMeta : ( object : string , field : string ) => ( object === OBJECT && DECLARED [ field ] ? { type : DECLARED [ field ] } : undefined ) ,
293+ queryCapabilities : ( ) => ( { nativeSql : false , objectqlAggregate : true , inMemory : false } ) ,
294+ executeAggregate : async ( ) => { throw new Error ( 'the live path ran: the preview did not answer' ) ; } ,
295+ draftRowsResolver : async ( object : string ) => ( object === OBJECT ? drafted . map ( ( r ) => ( { ...r } ) ) : null ) ,
296+ } as never ) ;
297+ const preview = async ( selection : Record < string , unknown > ) =>
298+ ids ( ( await svc . queryDataset ( dataset as never , { dimensions : [ 'id' ] , measures : [ 'row_count' ] , ...selection } as never , { tenantId : 'org_A' } as never , { previewDrafts : true } ) ) . rows ) ;
299+ for ( const [ label , where , expected ] of CELLS ) {
300+ expect ( await preview ( { runtimeFilter : where } ) , `${ spelling } rows, ${ label } : the preview` ) . toBe ( expected ) ;
301+ }
302+ for ( const [ label , dateRange ] of windows ) {
303+ expect ( await preview ( { timeDimensions : [ { dimension : 'signed_at' , dateRange } ] } ) , `${ spelling } rows, ${ label } : the preview` ) . toBe ( 'r2,r3' ) ;
304+ }
305+ }
306+ } ) ;
260307 } ,
261308 ) ;
262309}
0 commit comments