Skip to content

Commit 85bfc99

Browse files
committed
fix(objectql): a CEL defaultValue stores the declared type's contract shape, not a raw Date (#7373)
`applyFieldDefaults` produces a default three ways and only two honoured the stored-value contract: the `NOW()` token routes through `resolveNowDefault`, a literal is checked against `valueSchemaFor(def, 'stored')` at author time (#7127) — and the expression envelope's result was assigned verbatim. The temporal stdlib returns a JS `Date` (ADR-0053 D1: `today()`/`daysFromNow(n)`/ `daysAgo(n)` are UTC-midnight of the reference-tz calendar day, `now()` the raw instant), so `{ dialect: 'cel', source: 'daysFromNow(7)' }` on a `datetime` put a `Date` OBJECT in the column while `valueSchemaFor` names an ISO-8601 STRING. `validateRecord` accepts a `Date` on `date`/`datetime` by explicit decision, so nothing refused the write and the divergence was silent — while `os migrate value-shapes` walks stored values against that same schema and reports such a row as a violation by the platform's own scan. The expression branch now routes a `Date` result through the SAME per-type table the `NOW()` token uses, rather than growing a second copy of the contract: `datetime` → `YYYY-MM-DDTHH:MM:SS.sssZ`, `date` → `YYYY-MM-DD`, `time` → `HH:MM:SS[.fff]`. Storage on SQL and MongoDB is byte-identical to before: `SqlDriver.formatInput` already coerced a `Date` through `canonicalUtcDatetime`/`toDateOnly`, and mongodb's `storageDatetimeValue`/`storageDateValue` do the same. What changes is the memory driver, which applies its temporal canon to filter comparands only (`coerceTemporalValue`) and stored writes as handed. Same declaration, different stored shape per datasource — the split #4597 / #4560 closed for the `NOW()` token, reappearing on the CEL branch and closed the same way, engine-side. Normalization rather than refusal: refusing a `Date` here would make the rule depend on WHO wrote the value — `validateRecord` accepts one from any caller, temporal types are not in ADR-0104's strict value-shape block, and the documented envelope (#7244) stores correctly on SQL today. Non-`Date` results pass through untouched, as does an `Invalid Date` (the totality the driver canons keep). No day can shift: the ADR-0053 `Date` is UTC-midnight OF the reference-tz day and is read back with UTC getters. Pins in `engine-cel-default-temporal-shape.test.ts` assert the stored value against `valueSchemaFor` itself, and assert the TYPE as well as the text — `JSON.stringify` renders a `Date` as its ISO string, which is what made the original defect read as correct. Reverse-verified: the four subject pins fail against the pre-fix assignment; the four controls (`NOW()` token, literals, non-date CEL results, caller-supplied values) pass in both states. Refs #7373, #7244, ADR-0053, ADR-0104. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012tjfhdVGuYU9KH7SNbor56
1 parent b61afc1 commit 85bfc99

3 files changed

Lines changed: 388 additions & 6 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a CEL `defaultValue` stores the declared type's contract shape instead of a raw `Date` (#7373)
6+
7+
`applyFieldDefaults` produces a default three ways, and only two of them
8+
honoured the stored-value contract. The `NOW()` token routes through
9+
`resolveNowDefault`, which emits the form the declared type stores; a literal is
10+
checked against `valueSchemaFor(def, 'stored')` at author time (#7127); the
11+
expression envelope's result was assigned **verbatim**. The temporal stdlib
12+
returns a JS `Date` — ADR-0053 D1 fixes `today()` / `daysFromNow(n)` /
13+
`daysAgo(n)` as UTC-midnight of the reference-tz calendar day, and `now()` as
14+
the raw instant — so `{ dialect: 'cel', source: 'daysFromNow(7)' }` on a
15+
`datetime` put a `Date` **object** in the column while `valueSchemaFor` names an
16+
ISO-8601 **string**. Nothing refused the write (`validateRecord` accepts a
17+
`Date` on `date`/`datetime` by explicit decision), so the divergence was silent
18+
— and `os migrate value-shapes`, which walks stored values against that same
19+
schema, reports such a row as a violation by the platform's own scan.
20+
21+
The expression branch now routes a `Date` result through the same per-type table
22+
the `NOW()` token uses: `datetime` stores `YYYY-MM-DDTHH:MM:SS.sssZ`, `date`
23+
stores `YYYY-MM-DD`, `time` stores `HH:MM:SS[.fff]`. One table, both branches —
24+
not a second copy of the contract.
25+
26+
**Storage on SQL and MongoDB is byte-identical to before.** Handed a `Date`,
27+
`SqlDriver.formatInput` already coerced it through `canonicalUtcDatetime`
28+
(`toISOString()`) and `toDateOnly`, and mongodb's `storageDatetimeValue` /
29+
`storageDateValue` do the same, so those backends already stored exactly what
30+
the engine now produces. What changes is the memory driver, which applies its
31+
temporal canon to filter comparands only (`coerceTemporalValue`) and stored
32+
writes as handed: it kept the `Date` object. Same declaration, different stored
33+
shape per datasource — the split #4597 / #4560 closed for the `NOW()` token,
34+
reappearing on the CEL branch and now closed the same way, engine-side, so one
35+
answer serves every driver.
36+
37+
Normalization rather than refusal, because refusing a `Date` here would make the
38+
rule depend on who wrote the value: `validateRecord` accepts one from any
39+
caller, temporal types are not in ADR-0104's strict value-shape block, and the
40+
documented envelope (#7244) stores correctly on SQL today. Non-`Date` results
41+
pass through untouched — a CEL default's result type is otherwise a runtime
42+
concern — as does an `Invalid Date`, keeping the totality the driver canons
43+
have. No calendar day can shift: ADR-0053 D1's `Date` is UTC-midnight *of* the
44+
reference-tz day and is read back with UTC getters, the same `getUTC*` the ADR
45+
names for the driver filter path.
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #7373 — a CEL `defaultValue` stores the DECLARED TYPE's contract shape, not
5+
* the raw `Date` the temporal stdlib returns.
6+
*
7+
* `applyFieldDefaults` produces a default three ways, and only two of them
8+
* honoured the stored-value contract: the `NOW()` token routes through
9+
* `resolveNowDefault`, a literal is checked against `valueSchemaFor(def,
10+
* 'stored')` at author time (#7127) — and the expression envelope's result was
11+
* assigned verbatim. ADR-0053 D1 makes `today()` / `daysFromNow(n)` /
12+
* `daysAgo(n)` return a **JS `Date`** (UTC-midnight of the reference-tz
13+
* calendar day) and `now()` the raw instant, so
14+
* `{ dialect: 'cel', source: 'daysFromNow(7)' }` on a `datetime` put a `Date`
15+
* OBJECT in the column while `valueSchemaFor` names an ISO-8601 string. The
16+
* platform's own `os migrate value-shapes` scan reports such a row as a
17+
* violation.
18+
*
19+
* The assertions below check the stored value against `valueSchemaFor` itself
20+
* rather than against a hand-copied string shape: the contract is what was
21+
* violated, so the contract — not a restatement of it — is what pins the fix.
22+
* `JSON.stringify` renders a `Date` as its ISO string, which is exactly why
23+
* the original defect read as correct; every pin here therefore asserts the
24+
* TYPE as well as the text.
25+
*
26+
* The driver is a store-as-handed stub, which is the memory driver's observable
27+
* behaviour (it applies its temporal canon to filter comparands only) and the
28+
* one backend where the defect is visible: `SqlDriver.formatInput` coerces a
29+
* `Date` through `canonicalUtcDatetime`/`toDateOnly` at the wire and mongodb's
30+
* `storageDatetimeValue`/`storageDateValue` do the same, so those two already
31+
* stored the shape this change now produces engine-side for everyone.
32+
*/
33+
34+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
35+
import { valueSchemaFor } from '@objectstack/spec/data';
36+
import { ObjectQL } from './engine.js';
37+
38+
const cel = (source: string) => ({ dialect: 'cel' as const, source });
39+
40+
/**
41+
* One object carrying every branch that matters side by side, so a control and
42+
* its subject are defaulted by the SAME insert and cannot drift apart through
43+
* two differently-configured rigs.
44+
*/
45+
const DEFAULTED = {
46+
name: 'cel_default_probe',
47+
label: 'CEL Default Probe',
48+
fields: {
49+
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
50+
51+
// ── subjects: CEL defaults whose result is a `Date` ──────────────
52+
dt_now: { name: 'dt_now', label: 'dt now', type: 'datetime' as const, defaultValue: cel('now()') },
53+
dt_days: { name: 'dt_days', label: 'dt days', type: 'datetime' as const, defaultValue: cel('daysFromNow(7)') },
54+
dt_today: { name: 'dt_today', label: 'dt today', type: 'datetime' as const, defaultValue: cel('today()') },
55+
d_today: { name: 'd_today', label: 'd today', type: 'date' as const, defaultValue: cel('today()') },
56+
d_days: { name: 'd_days', label: 'd days', type: 'date' as const, defaultValue: cel('daysAgo(3)') },
57+
t_now: { name: 't_now', label: 't now', type: 'time' as const, defaultValue: cel('now()') },
58+
59+
// ── controls: the `NOW()` token, byte-identical before and after ──
60+
tok_dt: { name: 'tok_dt', label: 'tok dt', type: 'datetime' as const, defaultValue: 'NOW()' },
61+
tok_d: { name: 'tok_d', label: 'tok d', type: 'date' as const, defaultValue: 'NOW()' },
62+
tok_t: { name: 'tok_t', label: 'tok t', type: 'time' as const, defaultValue: 'NOW()' },
63+
64+
// ── controls: literals, untouched by this path ────────────────────
65+
lit_txt: { name: 'lit_txt', label: 'lit txt', type: 'text' as const, defaultValue: 'plain' },
66+
lit_dt: {
67+
name: 'lit_dt', label: 'lit dt', type: 'datetime' as const,
68+
defaultValue: '2020-01-02T03:04:05.678Z',
69+
},
70+
71+
// ── controls: CEL results that are NOT dates, passed through ──────
72+
cel_str: { name: 'cel_str', label: 'cel str', type: 'text' as const, defaultValue: cel("'hello'") },
73+
cel_num: { name: 'cel_num', label: 'cel num', type: 'number' as const, defaultValue: cel('1 + 2') },
74+
cel_bool: { name: 'cel_bool', label: 'cel bool', type: 'boolean' as const, defaultValue: cel('true') },
75+
},
76+
};
77+
78+
/**
79+
* A driver that stores exactly what the engine hands it. Deliberately WITHOUT
80+
* the temporal coercion the SQL/mongodb drivers apply on write: those repair a
81+
* `Date` at the wire, which is precisely what hid this defect on SQL-backed
82+
* stores. Storing as-handed is what makes the engine's own output observable.
83+
*/
84+
function makeStoreAsHandedDriver() {
85+
const rows = new Map<string, Record<string, unknown>>();
86+
let nextId = 0;
87+
const driver = {
88+
name: 'memory',
89+
version: '0.0.0',
90+
supports: {},
91+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
92+
async execute() { return null; },
93+
async find() { return Array.from(rows.values()).map((r) => ({ ...r })); },
94+
async findOne() { for (const r of rows.values()) return { ...r }; return null; },
95+
async create(_object: string, data: Record<string, unknown>) {
96+
nextId += 1;
97+
const id = (data.id as string) ?? `r_${nextId}`;
98+
const row = { ...data, id };
99+
rows.set(id, row);
100+
return { ...row };
101+
},
102+
async update(_object: string, id: string, data: Record<string, unknown>) {
103+
const cur = rows.get(id);
104+
if (!cur) return null;
105+
const next = { ...cur, ...data, id };
106+
rows.set(id, next);
107+
return { ...next };
108+
},
109+
async updateMany() { return 0; },
110+
async upsert(object: string, data: Record<string, unknown>) { return this.create(object, data); },
111+
async delete(_object: string, id: string) { return rows.delete(id); },
112+
async count() { return rows.size; },
113+
async bulkCreate(object: string, batch: Record<string, unknown>[]) {
114+
const out: Record<string, unknown>[] = [];
115+
for (const r of batch) out.push(await this.create(object, r));
116+
return out;
117+
},
118+
async bulkUpdate() { return []; }, async bulkDelete() {},
119+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
120+
async commit() {}, async rollback() {},
121+
};
122+
return { driver, rows };
123+
}
124+
125+
async function makeEngine() {
126+
const engine = new ObjectQL();
127+
const rig = makeStoreAsHandedDriver();
128+
engine.registerDriver(rig.driver as never, true);
129+
await engine.init();
130+
engine.registry.registerObject(DEFAULTED as never);
131+
return { engine, ...rig };
132+
}
133+
134+
/** The stored row after one defaults-only insert. */
135+
async function insertDefaulted(
136+
context?: Record<string, unknown>,
137+
): Promise<Record<string, unknown>> {
138+
const { engine, rows } = await makeEngine();
139+
await (engine as unknown as {
140+
insert(o: string, d: unknown, opts?: unknown): Promise<unknown>;
141+
}).insert('cel_default_probe', {}, context ? { context } : undefined);
142+
return Array.from(rows.values())[0];
143+
}
144+
145+
/** Assert a stored value satisfies its field's own ADR-0104 stored contract. */
146+
function expectStoredShape(value: unknown, type: string): void {
147+
const parsed = valueSchemaFor({ type }, 'stored').safeParse(value);
148+
expect(
149+
parsed.success ? null : `${type}: ${parsed.error.issues[0]?.message} (got ${Object.prototype.toString.call(value)})`,
150+
).toBeNull();
151+
}
152+
153+
// A fixed instant whose UTC calendar day and its Los_Angeles calendar day are
154+
// DIFFERENT days: 2026-08-10T05:00Z is 2026-08-09 22:00 in America/Los_Angeles.
155+
// Every reference-tz assertion below turns on that gap.
156+
const PINNED_NOW = new Date('2026-08-10T05:00:00.000Z');
157+
158+
describe('#7373 — a CEL `defaultValue` stores the declared type\'s contract shape', () => {
159+
beforeEach(() => {
160+
vi.useFakeTimers({ toFake: ['Date'] });
161+
vi.setSystemTime(PINNED_NOW);
162+
});
163+
afterEach(() => { vi.useRealTimers(); });
164+
165+
it('stores an ISO-8601 STRING on `datetime`, never a `Date` object', async () => {
166+
const row = await insertDefaulted();
167+
168+
for (const field of ['dt_now', 'dt_days', 'dt_today'] as const) {
169+
// The defect precisely: the value was a `Date`, which JSON.stringify
170+
// renders as the right text — so assert the type, not just the text.
171+
expect(row[field]).not.toBeInstanceOf(Date);
172+
expect(typeof row[field]).toBe('string');
173+
expectStoredShape(row[field], 'datetime');
174+
}
175+
176+
// …and the instants themselves are the ones CEL computed.
177+
expect(row.dt_now).toBe('2026-08-10T05:00:00.000Z');
178+
expect(row.dt_days).toBe('2026-08-17T00:00:00.000Z'); // UTC-midnight calendar day + 7
179+
});
180+
181+
it('stores `YYYY-MM-DD` on `date`, never a `Date` object', async () => {
182+
const row = await insertDefaulted();
183+
184+
for (const field of ['d_today', 'd_days'] as const) {
185+
expect(row[field]).not.toBeInstanceOf(Date);
186+
expect(typeof row[field]).toBe('string');
187+
expectStoredShape(row[field], 'date');
188+
}
189+
190+
expect(row.d_today).toBe('2026-08-10');
191+
expect(row.d_days).toBe('2026-08-07');
192+
});
193+
194+
it('stores a wall clock on `time`, never a `Date` object', async () => {
195+
const row = await insertDefaulted();
196+
expect(row.t_now).not.toBeInstanceOf(Date);
197+
expectStoredShape(row.t_now, 'time');
198+
expect(row.t_now).toBe('05:00:00');
199+
});
200+
201+
/**
202+
* The day-shift guard. ADR-0053 D1 fixes `today()` as UTC-midnight OF the
203+
* reference-tz calendar day, so the serialization must read the parts back
204+
* with UTC getters — the same `getUTC*` the ADR names for the driver filter
205+
* path. Reading them in LOCAL time is the move that shifts a day, and this
206+
* is the case that would catch it: at the pinned instant the UTC day is the
207+
* 10th while the Los_Angeles day is the 9th.
208+
*/
209+
it('keeps the REFERENCE-TZ calendar day on `date` — no off-by-one', async () => {
210+
const row = await insertDefaulted({ isSystem: true, timezone: 'America/Los_Angeles' });
211+
212+
expect(row.d_today).toBe('2026-08-09'); // the LA day, not the UTC 10th
213+
expectStoredShape(row.d_today, 'date');
214+
215+
// The same reference day, one week out, on a `datetime`: still the LA day
216+
// at UTC-midnight, so the calendar arithmetic and the serialization agree.
217+
expect(row.dt_days).toBe('2026-08-16T00:00:00.000Z');
218+
});
219+
220+
it('leaves the `NOW()` token byte-identical (control)', async () => {
221+
const row = await insertDefaulted();
222+
223+
// Exactly `resolveNowDefault`'s table — unchanged by this fix, which is the
224+
// point: the CEL branch now shares that table rather than owning a copy.
225+
expect(row.tok_dt).toBe('2026-08-10T05:00:00.000Z');
226+
expect(row.tok_d).toBe('2026-08-10');
227+
expect(row.tok_t).toBe('05:00:00');
228+
for (const [f, t] of [['tok_dt', 'datetime'], ['tok_d', 'date'], ['tok_t', 'time']] as const) {
229+
expect(row[f]).not.toBeInstanceOf(Date);
230+
expectStoredShape(row[f], t);
231+
}
232+
});
233+
234+
it('leaves LITERAL defaults untouched (control)', async () => {
235+
const row = await insertDefaulted();
236+
expect(row.lit_txt).toBe('plain');
237+
expect(row.lit_dt).toBe('2020-01-02T03:04:05.678Z');
238+
});
239+
240+
it('passes NON-date CEL results through unchanged (control)', async () => {
241+
const row = await insertDefaulted();
242+
243+
// Normalization is scoped to temporal FORM; a CEL default's result type is
244+
// otherwise a runtime concern and must not be rewritten.
245+
expect(row.cel_str).toBe('hello');
246+
expect(row.cel_num).toBe(3);
247+
expect(row.cel_bool).toBe(true);
248+
});
249+
250+
it('does not touch a value the caller supplied explicitly', async () => {
251+
const { engine, rows } = await makeEngine();
252+
const explicit = new Date('2001-02-03T04:05:06.007Z');
253+
await (engine as unknown as {
254+
insert(o: string, d: unknown): Promise<unknown>;
255+
}).insert('cel_default_probe', { dt_now: explicit });
256+
const row = Array.from(rows.values())[0];
257+
258+
// Defaults apply only to an omitted/null slot. A caller-supplied `Date` is
259+
// the drivers' business (SQL/mongodb coerce it at the wire), not this
260+
// path's — narrowing the change to what the issue measured.
261+
expect(row.dt_now).toBe(explicit);
262+
});
263+
});

0 commit comments

Comments
 (0)