|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#20550] A bare `YYYY-MM-DD` upper bound on a `datetime` field — `$lte`, or |
| 5 | + * a `$between` maximum — includes the whole of that day in the years |
| 6 | + * 0001..0099 exactly as it does in 2026, at the public door |
| 7 | + * `POST /api/v1/data/:object/query`, over a real `SqlDriver`. |
| 8 | + * |
| 9 | + * The widening (ADR-0053 D-D) compiles `$lte day` to `< nextUtcCalendarDay(day)` |
| 10 | + * midnight UTC, and falls back to `<= day` midnight when the helper answers |
| 11 | + * `null`. `@objectstack/spec`'s `nextUtcCalendarDay` built the date through |
| 12 | + * `Date.UTC`, which reads a year from 0 to 99 as 1900 + year, so the round trip |
| 13 | + * that proves a day real failed for every day of those years and the bound |
| 14 | + * stopped at the day's first instant. Measured through this door on the base, |
| 15 | + * the process in America/New_York, on the six rows below: |
| 16 | + * |
| 17 | + * | `where opened_at` | SQLite | PostgreSQL 16 | the whole day | |
| 18 | + * |:--|:--|:--|:--| |
| 19 | + * | `$lte '0050-01-01'` | `y49` | `y49` | `y49`, `y50`, `y50_last` | |
| 20 | + * | `$between ['0050-01-01', '0050-01-01']` | none | none | `y50`, `y50_last` | |
| 21 | + * | `$lte '2026-07-15'` (control) | the whole day | the whole day | `y49` … `y50_next`, `c26` | |
| 22 | + * | `$between ['2026-07-15', '2026-07-15']` (control) | `c26` | `c26` | `c26` | |
| 23 | + * |
| 24 | + * The next day's midnight (`y50_next`, `c26_next`) stays out in every cell: the |
| 25 | + * bound is half-open, never an inclusive `23:59:59.999`. |
| 26 | + * |
| 27 | + * ## The dialect axis of THIS file |
| 28 | + * |
| 29 | + * The SQLite cell always runs. The PostgreSQL cell runs where |
| 30 | + * `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise; no CI job |
| 31 | + * provisions it for this package. It owns one table, dropped before and after. |
| 32 | + * An in-memory cell is not here, for the reason |
| 33 | + * `data-temporal-write-real-day-iso.test.ts` gives: `@objectstack/driver-memory` |
| 34 | + * has no binding in this package. The helper's own pins, every year edge and |
| 35 | + * the impossible days included, are `packages/spec/src/data/calendar-day.test.ts`. |
| 36 | + */ |
| 37 | + |
| 38 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 39 | +import { ObjectQL } from '@objectstack/objectql'; |
| 40 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 41 | +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; |
| 42 | +import { RestServer } from './rest-server'; |
| 43 | + |
| 44 | +const OBJECT = 'rest_calendar_day_20550'; |
| 45 | +const HOST_ZONE = 'America/New_York'; |
| 46 | + |
| 47 | +const LEDGER = { |
| 48 | + name: OBJECT, |
| 49 | + label: 'Ledger 20550', |
| 50 | + fields: { |
| 51 | + memo: { name: 'memo', type: 'text' as const }, |
| 52 | + opened_at: { name: 'opened_at', type: 'datetime' as const }, |
| 53 | + }, |
| 54 | +}; |
| 55 | + |
| 56 | +/** id · the instant written through the door. */ |
| 57 | +const ROWS: ReadonlyArray<readonly [string, string]> = [ |
| 58 | + ['y49', '0049-12-31T10:00:00.000Z'], |
| 59 | + ['y50', '0050-01-01T10:00:00.000Z'], // the card's row |
| 60 | + ['y50_last', '0050-01-01T23:59:59.999Z'], // the day's last millisecond |
| 61 | + ['y50_next', '0050-01-02T00:00:00.000Z'], // the next day's first instant: out |
| 62 | + ['c26', '2026-07-15T14:00:00.000Z'], // the card's control row |
| 63 | + ['c26_next', '2026-07-16T00:00:00.000Z'], |
| 64 | +]; |
| 65 | + |
| 66 | +/** `where` on `opened_at` · the ids it answers, sorted. */ |
| 67 | +const QUERIES: ReadonlyArray<readonly [string, Record<string, unknown>, readonly string[]]> = [ |
| 68 | + ["$lte '0050-01-01'", { $lte: '0050-01-01' }, ['y49', 'y50', 'y50_last']], |
| 69 | + ["$between max '0050-01-01'", { $between: ['0050-01-01', '0050-01-01'] }, ['y50', 'y50_last']], |
| 70 | + ["$lte '2026-07-15' (control)", { $lte: '2026-07-15' }, ['c26', 'y49', 'y50', 'y50_last', 'y50_next']], |
| 71 | + ["$between max '2026-07-15' (control)", { $between: ['2026-07-15', '2026-07-15'] }, ['c26']], |
| 72 | +]; |
| 73 | + |
| 74 | +interface Cell { |
| 75 | + id: 'sqlite' | 'pg'; |
| 76 | + label: string; |
| 77 | + env: string | null; |
| 78 | + config: () => Record<string, unknown> | null; |
| 79 | +} |
| 80 | + |
| 81 | +const CELLS: readonly Cell[] = [ |
| 82 | + { id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) }, |
| 83 | + { |
| 84 | + id: 'pg', |
| 85 | + label: 'live postgres', |
| 86 | + env: 'OS_TEST_POSTGRES_URL', |
| 87 | + config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null), |
| 88 | + }, |
| 89 | +]; |
| 90 | + |
| 91 | +function createMockServer() { |
| 92 | + const noop = () => {}; |
| 93 | + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; |
| 94 | +} |
| 95 | + |
| 96 | +function makeRes() { |
| 97 | + const res: any = { |
| 98 | + write: () => true, end: () => {}, |
| 99 | + header: () => res, |
| 100 | + status: (code: number) => { res._status = code; return res; }, |
| 101 | + json: (body: any) => { res._json = body; return res; }, |
| 102 | + }; |
| 103 | + return res; |
| 104 | +} |
| 105 | + |
| 106 | +const originalTz = process.env.TZ; |
| 107 | + |
| 108 | +for (const cell of CELLS) { |
| 109 | + const config = cell.config(); |
| 110 | + describe.skipIf(!config)( |
| 111 | + `[#20550] a bare-day upper bound on a datetime includes the whole day in years 0001..0099, at the public door — ${cell.label}${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, |
| 112 | + () => { |
| 113 | + let engine: ObjectQL; |
| 114 | + let driver: any; |
| 115 | + let call: (method: string, path: string, params: Record<string, string>, body: unknown) => Promise<{ status: number; body: any }>; |
| 116 | + |
| 117 | + beforeAll(async () => { |
| 118 | + // A host whose zone is not UTC, so a host-zone reading of a bound would show. |
| 119 | + process.env.TZ = HOST_ZONE; |
| 120 | + expect(Intl.DateTimeFormat().resolvedOptions().timeZone, 'the host zone really changed').toBe(HOST_ZONE); |
| 121 | + |
| 122 | + driver = new SqlDriver(config as any); |
| 123 | + if (cell.id !== 'sqlite') await driver.execute(`drop table if exists ${OBJECT}`).catch(() => {}); |
| 124 | + engine = new ObjectQL(); |
| 125 | + engine.registerDriver(driver, true); |
| 126 | + await engine.init(); |
| 127 | + engine.registry.registerObject(LEDGER as any); |
| 128 | + await engine.syncSchemas(); |
| 129 | + |
| 130 | + const protocol = new ObjectStackProtocolImplementation(engine as any); |
| 131 | + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); |
| 132 | + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); |
| 133 | + rest.registerRoutes(); |
| 134 | + call = async (method, path, params, body) => { |
| 135 | + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path); |
| 136 | + expect(route, `${method} ${path}`).toBeDefined(); |
| 137 | + const res = makeRes(); |
| 138 | + await route!.handler({ params, body, query: {}, headers: {} } as any, res); |
| 139 | + return { status: res._status ?? 200, body: res._json }; |
| 140 | + }; |
| 141 | + |
| 142 | + for (const [id, openedAt] of ROWS) { |
| 143 | + const created = await call('POST', '/api/v1/data/:object', { object: OBJECT }, { id, memo: 'm', opened_at: openedAt }); |
| 144 | + expect(created.status, `create ${id} ${openedAt}: ${JSON.stringify(created.body)}`).toBe(201); |
| 145 | + } |
| 146 | + }); |
| 147 | + |
| 148 | + afterAll(async () => { |
| 149 | + if (cell.id !== 'sqlite') await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {}); |
| 150 | + try { await engine?.destroy(); } catch { /* noop */ } |
| 151 | + if (originalTz === undefined) delete process.env.TZ; |
| 152 | + else process.env.TZ = originalTz; |
| 153 | + }); |
| 154 | + |
| 155 | + const idsWhere = async (opened_at: Record<string, unknown>) => { |
| 156 | + const res = await call('POST', '/api/v1/data/:object/query', { object: OBJECT }, { where: { opened_at } }); |
| 157 | + expect(res.status, JSON.stringify(res.body)).toBe(200); |
| 158 | + return (res.body.records as Array<{ id: string }>).map((r) => r.id).sort(); |
| 159 | + }; |
| 160 | + |
| 161 | + it('every row reads back the instant it was written — the rows the bounds are measured against', async () => { |
| 162 | + const res = await call('POST', '/api/v1/data/:object/query', { object: OBJECT }, { where: { memo: 'm' } }); |
| 163 | + expect(res.status, JSON.stringify(res.body)).toBe(200); |
| 164 | + const stored = Object.fromEntries((res.body.records as Array<{ id: string; opened_at: string }>).map((r) => [r.id, r.opened_at])); |
| 165 | + expect(stored).toEqual(Object.fromEntries(ROWS)); |
| 166 | + }); |
| 167 | + |
| 168 | + it('$lte and the $between maximum on 0050-01-01 include that whole day, and not the next midnight — as the 2026 control does', async () => { |
| 169 | + // Every reading first, then one comparison, so a red run shows all four cells. |
| 170 | + const got: Record<string, string[]> = {}; |
| 171 | + for (const [name, bound] of QUERIES) got[name] = await idsWhere(bound); |
| 172 | + expect(got).toEqual(Object.fromEntries(QUERIES.map(([name, , want]) => [name, want]))); |
| 173 | + }); |
| 174 | + }, |
| 175 | + ); |
| 176 | +} |
0 commit comments