Skip to content

Commit c16040d

Browse files
fix(objectql,formula): an unparseable date() input in a formula field is a reported fault, not a silent null (#28712)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XHQ6ZPS4brMoKPKLMETiXyc)_ Fixes #28660 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:公式字段里 `date()` / `datetime()` 拿到解析不了的文本(比如 `"grace"`)时,字段照旧读 null,但服务端日志现在会按「每个对象+字段一行」的既有公式故障告警(#22031 那条)说出来;源字段为空时仍然静默。 - **为什么改**:原来这种情况和「源字段为空」读出来一模一样,日志里一个字都没有,作者无从发现公式在部分记录上算不出值。 - **风险与回滚**:低。线上返回的字节不变(仍是 null),只多一行 warn;谓词(visibleWhen、校验规则、过滤)拿到的值完全不变。回滚即撤销本 PR。 - **先看**:`packages/objectql/src/engine.ts`(`applyFormulaPlan` 里新增的判断);`packages/formula/src/stdlib.ts`(`toDate` 给解析失败的文本打标记);`packages/objectql/src/engine-formula-fault-log.test.ts`(新增的一组用例)。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run src/engine-formula-fault-log.test.ts`,预期 11/11 通过(main 上新增的两条红:0 行告警)。 - **需要你做的**:无,席位落地。 ## What changes **The seam.** `date()` / `datetime()` keep answering the same invalid Date for text they cannot parse, and the evaluator keeps reporting it as `ok`. Predicates rely on that (a comparison over it stays `false`; the temporal-equality rewrite in `cel-engine.ts` wraps field operands in `date(...)` on that promise), so the value is not touched. What changes is that the one place that can tell "the text was not a date" from "there was no text" now says which, and the formula-field read path reports the first. - **`packages/formula/src/stdlib.ts` (`toDate` only, plus the two date-shift helpers).** Non-empty text that does not parse yields an invalid Date recorded in a module-level `WeakSet`. An empty source (`null`, `undefined`, empty or blank text) yields an unrecorded invalid Date, as before. `addDaysUtc` / `addMonthsUtc` return a recorded invalid Date as it is, so `addDays(date(record.first), 1)` carries the mark; for an invalid input the returned value was an invalid Date already, so nothing a caller can observe moves. A new export, `isUnparseableDate(value)`, reads the mark. - **`packages/objectql/src/engine.ts` (`applyFormulaPlan`).** A result that `isUnparseableDate` answers `true` for is turned into a `runtime` fault and goes down the existing path: the field reads `null` and the `onFault` sink fires, so `reportFormulaFault` writes its one `warn` per (object, field) per engine instance. No second warn path. The fault's message says `date()` / `datetime()` take ISO text, a timestamp or epoch milliseconds, and that an empty source reads null without a report. The record's text is not quoted, so no row data reaches the log. - **Formula fields only.** Predicates never call `isUnparseableDate`, and the value they see is the same object type and the same `NaN` instant as before. `visibleWhen`, validation rules, filters and the temporal-equality rewrite are untouched. - **Changeset** `.changeset/28660-formula-date-unparseable-fault.md`: `patch` on `@objectstack/formula` and `@objectstack/objectql`, with both declaration lines. The served value is unchanged. Over REST, the invalid Date serialized to `null` and the field now reads `null`. In process, `ObjectQL#find` hands back `null` where it handed back an invalid `Date`. That is the same `null` every other formula fault already reads. ## Tests - **Red first, at base `14049381`** (formula `dist` built from base, objectql at base): the new block in `packages/objectql/src/engine-formula-fault-log.test.ts` failed 2 of 11. Both reds were `expected [] to have a length of 1 but got +0`: zero warn lines of any kind for `f_date` on `find` / `findOne`, and zero on the write response. The three controls and the predicate control were already green on base. - **Green at `d5dea3be`**: the same file passes 11/11. Pinned: one line for `f_date` across two rows, a repeated `find` and a `findOne`; its meta names `fx_date`, `f_date`, kind `runtime`, and the error names `date()`; the served JSON carries `"f_date":null`; `addDays(date(record.first), 1)` reports on its own field; the write response reports where the fault is first met. - **Controls** (same file): ISO text `2026-03-04` reads `2026-03-04T00:00:00.000Z` (and `addDays` the next day) with no line; `null` and `''` sources serialize to `null` with no line; `date(record.first) == today()` over `"grace"` answers `{ ok: true, value: false }`. - **Formula unit pins**, new `packages/formula/src/date-unparseable-mark.test.ts` (6 tests): the mark on `date` and `datetime` over bad text, with the value still an `ok` invalid Date; the mark surviving `addDays` / `addMonths`; no mark for `null`, `''` or blank text, ISO text, epoch numbers, a hand-made invalid Date or non-Dates; `==` / `!=` against `today()` unchanged; `isoDate()` over it still refuses with "not a renderable timestamp", and `daysBetween()` still refuses. <details><summary>Ablations (one-off, from committed HEAD d5dea3b)</summary> - **Fault report removed.** `node scripts/ablation-replace.mjs` replaced `evaluated.ok && isUnparseableDate(evaluated.value)` with `evaluated.ok && false` in `packages/objectql/src/engine.ts`: anchor 1 → 0, blob `804ff9b4b490` → `03633c1d9473`. `engine-formula-fault-log.test.ts`: 2 failed, 9 passed (the two warn cases). Restored: blob equals HEAD `804ff9b4b490`, `git diff HEAD` empty. The objectql suite reads `engine.ts` from source, so no build sat between the mutation and the run. - **Mark removed.** In `packages/formula/src/stdlib.ts`, `unparseableDates.add(out);` was replaced with `void 0;`: anchor 1 → 0, blob `3e8646b058c1` → `ed9c5db3ee5e`. `date-unparseable-mark.test.ts` (source import): 2 failed, 4 passed (the two marking cases; the controls stay green, as they should). Restored: blob equals HEAD, `git diff HEAD` empty. </details> ## Acceptance notes - **Seam choice.** The order asked to measure whether the fault is reachable without changing what predicates see. It is, but not from the objectql side alone: an empty source (`date(null)`) and unparseable text both evaluate to an invalid Date, so a check on the result would also report every empty date source. The order's own control says that must stay silent. Hence the mark at `toDate`, read only by the formula-field path. Predicates keep today's answer because the value is unchanged and they never read the mark. - **What the mark does not follow.** cel-js's own timestamp arithmetic (`date(record.first) + duration('24h')`) builds a new Date, so the mark is lost and that field stays silent, as it is on `main` today. That is no regression, and no carrier. Lists and maps holding a marked Date are not inspected (only the field's top-level value); also no carrier. - **Non-ISO text that does parse.** `toDate` hands text to `new Date(...)`, which also accepts forms like `March 4, 2026`. That is unchanged and not marked. - **`EvalError.pos`.** #28679 fills `pos` from cel-js ranges. The fault built here has none, which matches that PR's "absent, never guessed" rule. No hunk of #28679 is touched. ## Verification All readings at head `afbf149e` (this branch merged with `origin/main` `a06feb76`, which carries #28679), after `pnpm install --frozen-lockfile` and a full `turbo run build` (117/117 tasks). <details><summary>Package tests and typecheck</summary> - `pnpm --filter @objectstack/formula test`: 62 files, 1584 tests passed. - `pnpm --filter @objectstack/objectql test`: 512 files, 9903 tests passed. - `pnpm --filter @objectstack/formula typecheck` and `pnpm --filter @objectstack/objectql typecheck`: exit 0, each `check:test-typecheck: OK` with its ledger held. - At the pre-merge head `d5dea3be` the same four were green too (formula 1569 tests, objectql 9903). </details> <details><summary>Derived gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands` at `afbf149e`: 63 commands (the same list as at `d5dea3be`). All 63 ran and exited 0. - `dispatch-gates --ran`: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN. - At `d5dea3be`, before the full build, `check:dual-build-cjs-loads` and `check:lean-entry-closure` answered PREREQUISITE NOT MET (exit 3), and `check-plugin-teardown-shape --self-test` exit 3 on the shallow clone. All three are green in the `afbf149e` run. - Not run locally, as the os-dev scope says: `test:repo`, the repo-wide lint, and the families outside the derivation. The PR's CI check-runs carry them. - No dogfood battery: the title has no `!`, and `Clause-②` has no `(narrowing)` arm. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5n9QhsJM6QFZQ4R1UJ)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent d62d973 commit c16040d

6 files changed

Lines changed: 256 additions & 5 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/formula': patch
3+
'@objectstack/objectql': patch
4+
---
5+
6+
fix(objectql,formula): a formula field whose `date()` / `datetime()` is handed text it cannot parse is a reported evaluation fault, not a silent null
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
A formula field such as `date(record.first)` on a row whose `first` holds text that is not a date (`"grace"`) read `null`, the same bytes an empty `first` gives, and nothing was logged. `date()` / `datetime()` answer an invalid Date for such text, the evaluator reports that as a success, and the invalid Date serializes to `null`, so the once-per-field formula fault line never fired.
12+
13+
- **The field now reports it.** The read path (`find`, `findOne` and a write's response) treats that invalid Date as an evaluation fault: the field reads `null` exactly as before, and the engine's logger gets the one `warn` line per object and field per engine instance that every other formula fault gets. The line says that `date()` / `datetime()` take ISO text, a timestamp or epoch milliseconds. The record's text is not quoted in the log. A date function over the bad value (`addDays(date(record.first), 1)`) reports the same way.
14+
- **An empty source is not a fault.** `null`, empty or blank text still reads `null` with no line.
15+
- **Predicates are unchanged.** `date()` still answers the same invalid Date, so `date(record.d) == today()` over non-date text is still `false`, and `visibleWhen`, validation rules and filters answer as before. `isoDate()` and `daysBetween()` over it still refuse at evaluation.
16+
- **`@objectstack/formula` gains `isUnparseableDate(value)`**: `true` for the invalid Date `date()` / `datetime()` (or `addDays` / `addMonths` over it) answered for non-empty text they could not parse, `false` for everything else, including an invalid Date from an empty source. It reads a weakly held mark and never changes what any function returns.
17+
- **What a read returns over the wire does not move.** In process, `ObjectQL#find` now hands back `null` for such a field where it handed back an invalid `Date`; serialized, both are `null`.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `isUnparseableDate` — the mark `date()` / `datetime()` put on the invalid
5+
* Date they answer for non-empty text they cannot parse.
6+
*
7+
* The VALUE is unchanged: still an invalid Date, still `ok: true`, so every
8+
* predicate answers exactly as before. What is added is a way for a
9+
* formula-field reader to tell "the text was not a date" from "there was no
10+
* text", which both serialize to `null`. The objectql read path reports the
11+
* first as a fault (`engine-formula-fault-log.test.ts`).
12+
*/
13+
14+
import { describe, it, expect } from 'vitest';
15+
16+
import { celEngine } from './cel-engine';
17+
import { isUnparseableDate } from './stdlib';
18+
19+
const ev = (source: string, record: Record<string, unknown>) =>
20+
celEngine.evaluate({ dialect: 'cel', source }, { record });
21+
22+
describe('isUnparseableDate', () => {
23+
it('marks date() / datetime() over text they cannot parse, and the value stays an ok invalid Date', () => {
24+
for (const fn of ['date', 'datetime']) {
25+
const r = ev(`${fn}(record.first)`, { first: 'grace' });
26+
expect(r.ok).toBe(true);
27+
const v = (r as { value: unknown }).value;
28+
expect(v).toBeInstanceOf(Date);
29+
expect(Number.isNaN((v as Date).getTime())).toBe(true);
30+
expect(isUnparseableDate(v)).toBe(true);
31+
}
32+
});
33+
34+
it('the mark survives addDays / addMonths over it', () => {
35+
for (const source of ['addDays(date(record.first), 1)', 'addMonths(date(record.first), 2)']) {
36+
const r = ev(source, { first: 'grace' });
37+
expect(r.ok).toBe(true);
38+
expect(isUnparseableDate((r as { value: unknown }).value)).toBe(true);
39+
}
40+
});
41+
42+
it('control: an EMPTY source is not marked (null, empty and blank text)', () => {
43+
for (const first of [null, '', ' ']) {
44+
const r = ev('date(record.first)', { first });
45+
expect(r.ok).toBe(true);
46+
const v = (r as { value: unknown }).value as Date;
47+
expect(Number.isNaN(v.getTime())).toBe(true);
48+
expect(isUnparseableDate(v)).toBe(false);
49+
}
50+
});
51+
52+
it('control: ISO text, epoch numbers and non-Dates are not marked', () => {
53+
const iso = ev('date(record.first)', { first: '2026-03-04' });
54+
expect(iso.ok && (iso.value as Date).toISOString()).toBe('2026-03-04T00:00:00.000Z');
55+
expect(isUnparseableDate((iso as { value: unknown }).value)).toBe(false);
56+
const epoch = ev('date(record.n)', { n: 5 });
57+
expect(isUnparseableDate((epoch as { value: unknown }).value)).toBe(false);
58+
for (const v of [null, undefined, 'grace', new Date(Number.NaN), 0]) {
59+
expect(isUnparseableDate(v)).toBe(false);
60+
}
61+
});
62+
63+
it('control: a predicate over date() on bad text answers as before — false, ok', () => {
64+
expect(ev('date(record.first) == today()', { first: 'grace' })).toEqual({ ok: true, value: false });
65+
expect(ev('date(record.first) != today()', { first: 'grace' })).toEqual({ ok: true, value: true });
66+
});
67+
68+
it('control: isoDate / daysBetween over it still refuse at evaluate', () => {
69+
const iso = ev('isoDate(date(record.first))', { first: 'grace' });
70+
expect(iso.ok).toBe(false);
71+
expect(!iso.ok && iso.error.message).toContain('not a renderable timestamp');
72+
const days = ev('daysBetween(date(record.first), today())', { first: 'grace' });
73+
expect(days.ok).toBe(false);
74+
});
75+
});

‎packages/formula/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export {
9292
MAIL_HTML_SUPPORTED_SHAPES,
9393
} from './mail-html-hole-placement';
9494
export type { MailHtmlHole, MailHtmlHolePlacement, MailHtmlHoleJudgeOptions } from './mail-html-hole-placement';
95-
export { registerStdLib, buildScope, registerPermissionPredicate, registerCapabilityPredicate } from './stdlib';
95+
export { registerStdLib, buildScope, registerPermissionPredicate, registerCapabilityPredicate, isUnparseableDate } from './stdlib';
9696
export type { PermissionBinding } from './stdlib';
9797
// objectui#4421 / batch #147 — the permission predicate's data door. `can` reads
9898
// `EvalContext.permissions` and nothing else, and this is how that map is built

‎packages/formula/src/stdlib.ts‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,48 @@ function startOfDayUtc(d: Date): Date {
7373
return out;
7474
}
7575

76-
/** Coerce a CEL value (Date | ISO string | epoch number) to a Date. */
76+
/**
77+
* The invalid Dates {@link toDate} minted from TEXT it could not parse — not
78+
* from an empty source. Read through {@link isUnparseableDate}.
79+
*
80+
* `date()` / `datetime()` answer an invalid Date for both, and that answer is
81+
* deliberate: a predicate compares it and stays `false` (`date(record.d) ==
82+
* today()` on a non-date string is simply not today), which is what the
83+
* temporal-equality rewrite in `cel-engine.ts` relies on. So the VALUE never
84+
* changes here. A formula FIELD, though, serializes an invalid Date to `null`,
85+
* the same bytes an empty source gives, and its reader must be able to tell
86+
* "the text was not a date" (a fault worth one warn line) from "there was no
87+
* text" (no fault). This side table is that distinction, held off the value.
88+
*
89+
* Weakly held, so it keeps nothing alive; it records a fact about an argument
90+
* and never changes what any function returns.
91+
*/
92+
const unparseableDates = new WeakSet<Date>();
93+
94+
/**
95+
* Coerce a CEL value (Date | ISO string | epoch number) to a Date.
96+
*
97+
* Non-empty text that does not parse yields an invalid Date marked in
98+
* {@link unparseableDates}; an empty source (`null`, `undefined`, blank text)
99+
* yields an unmarked invalid Date.
100+
*/
77101
function toDate(v: unknown): Date {
78102
if (v instanceof Date) return v;
79103
if (typeof v === 'number' || typeof v === 'bigint') return new Date(Number(v));
80-
return new Date(String(v));
104+
const out = new Date(String(v));
105+
if (typeof v === 'string' && v.trim() !== '' && Number.isNaN(out.getTime())) unparseableDates.add(out);
106+
return out;
107+
}
108+
109+
/**
110+
* True when `value` is the invalid Date `date()` / `datetime()` (or a stdlib
111+
* date function over one) answered for non-empty text it could not parse.
112+
* False for an invalid Date from an empty source, for every valid Date and for
113+
* anything that is not a Date. A formula-field reader reports a `true` as an
114+
* evaluation fault; a predicate never asks.
115+
*/
116+
export function isUnparseableDate(value: unknown): boolean {
117+
return value instanceof Date && unparseableDates.has(value);
81118
}
82119

83120
/** One UTC day in milliseconds. */
@@ -113,6 +150,8 @@ function isoTimestampText(fn: string, d: Date): string {
113150

114151
/** Add `n` days to a Date in UTC; returns a new Date. */
115152
function addDaysUtc(d: Date, n: number): Date {
153+
// Unparseable text stays the same invalid Date, so its mark survives.
154+
if (unparseableDates.has(d)) return d;
116155
const out = new Date(d.getTime());
117156
out.setUTCDate(out.getUTCDate() + n);
118157
return out;
@@ -125,6 +164,7 @@ function addDaysUtc(d: Date, n: number): Date {
125164
* "next service date = last + N months" rule to behave.
126165
*/
127166
function addMonthsUtc(d: Date, n: number): Date {
167+
if (unparseableDates.has(d)) return d;
128168
const out = new Date(d.getTime());
129169
const day = out.getUTCDate();
130170
out.setUTCDate(1); // avoid roll-over while shifting the month

‎packages/objectql/src/engine-formula-fault-log.test.ts‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
* (its #22019 block).
3131
*/
3232
import { beforeEach, describe, expect, it } from 'vitest';
33+
import { ExpressionEngine } from '@objectstack/formula';
3334
import { ObjectQL } from './engine.js';
3435

3536
type Rec = Record<string, unknown>;
@@ -204,3 +205,103 @@ describe('#22019 — a formula fault on the read path is said once per (object,
204205
expect(other.faultLines('score')).toHaveLength(1);
205206
});
206207
});
208+
209+
/**
210+
* An unparseable `date()` input is a fault, not a silent null.
211+
*
212+
* `date()` / `datetime()` coerce their argument with the stdlib's `toDate`,
213+
* which answers an INVALID Date for text it cannot parse, and the evaluator
214+
* reports that as `ok: true`. Predicates rely on that on purpose: a comparison
215+
* over an invalid Date stays `false`. A formula FIELD has no such use for it:
216+
* the invalid Date serializes to `null`, the same bytes an empty source gives,
217+
* and nothing was said. Pinned here:
218+
*
219+
* - the answer is still `null` — the bytes a read returns do not move;
220+
* - the field gets the one line per (object, field) any other fault gets, and
221+
* the line names `date()` and how to guard the operand;
222+
* - controls: ISO text still reads its instant with no line, an EMPTY source
223+
* logs nothing (a null operand is not a fault), and the same expression as a
224+
* predicate answers exactly as before.
225+
*/
226+
const FX_DATE = {
227+
name: 'fx_date',
228+
label: 'Date Formula Probe',
229+
fields: {
230+
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
231+
first: { name: 'first', label: 'First', type: 'text' as const },
232+
f_date: { name: 'f_date', label: 'F Date', type: 'formula' as const, expression: { dialect: 'cel', source: 'date(record.first)' } },
233+
f_next: { name: 'f_next', label: 'F Next', type: 'formula' as const, expression: { dialect: 'cel', source: 'addDays(date(record.first), 1)' } },
234+
},
235+
};
236+
237+
async function makeDateRig(rows: Rec[]) {
238+
const { logger, warns } = makeRecordingLogger();
239+
const engine = new ObjectQL({ logger });
240+
const { driver, storeFor } = makeStubDriver();
241+
engine.registerDriver(driver as never, true);
242+
await engine.init();
243+
engine.registry.registerObject(FX_DATE as never, 'test');
244+
for (const row of rows) storeFor('fx_date').set(String(row.id), row);
245+
const faultLines = (field?: string) =>
246+
warns.filter((w) => w.meta?.object === 'fx_date' && (field === undefined || w.meta?.field === field));
247+
return { engine, warns, faultLines };
248+
}
249+
250+
describe('an unparseable date() input in a formula field is a reported fault, and the answer stays null', () => {
251+
it('text date() cannot parse: the field reads null, and the fault is said once per field', async () => {
252+
const rig = await makeDateRig([{ id: 'r_g', first: 'grace' }, { id: 'r_h', first: 'hopper' }]);
253+
const rows = await rig.engine.find('fx_date', {} as never) as Rec[];
254+
await rig.engine.findOne('fx_date', { where: { id: 'r_g' } } as never);
255+
256+
const lines = rig.faultLines('f_date');
257+
expect(lines, JSON.stringify(rig.warns)).toHaveLength(1);
258+
// The served bytes are the ones the invalid Date serialized to before; the
259+
// in-process value is the `null` every other fault reads.
260+
expect(JSON.stringify(rows[0])).toContain('"f_date":null');
261+
expect(rows.map((r) => r.f_date)).toEqual([null, null]);
262+
expect(lines[0]!.message).toContain("formula field 'f_date' on 'fx_date'");
263+
expect(lines[0]!.meta).toMatchObject({ object: 'fx_date', field: 'f_date', kind: 'runtime' });
264+
expect(String(lines[0]!.meta?.error)).toContain('date()');
265+
// A date function over the invalid Date is the same fault, on its own field.
266+
expect(rows.map((r) => r.f_next)).toEqual([null, null]);
267+
expect(rig.faultLines('f_next')).toHaveLength(1);
268+
expect(rig.faultLines()).toHaveLength(2);
269+
});
270+
271+
it('the write response reads null for it and says it there', async () => {
272+
const rig = await makeDateRig([]);
273+
const created = await rig.engine.insert('fx_date', { first: 'grace' }) as Rec;
274+
275+
expect(rig.faultLines('f_date')).toHaveLength(1);
276+
expect(created.f_date).toBeNull();
277+
});
278+
279+
it('control: ISO text still reads its instant, with no line', async () => {
280+
const rig = await makeDateRig([{ id: 'r_iso', first: '2026-03-04' }]);
281+
const [row] = await rig.engine.find('fx_date', {} as never) as Rec[];
282+
283+
expect(row!.f_date).toBeInstanceOf(Date);
284+
expect((row!.f_date as Date).toISOString()).toBe('2026-03-04T00:00:00.000Z');
285+
expect((row!.f_next as Date).toISOString()).toBe('2026-03-05T00:00:00.000Z');
286+
expect(rig.faultLines()).toEqual([]);
287+
});
288+
289+
it('control: an EMPTY source is not a fault — nothing is logged for it', async () => {
290+
const rig = await makeDateRig([{ id: 'r_null', first: null }, { id: 'r_blank', first: '' }]);
291+
const rows = await rig.engine.find('fx_date', {} as never) as Rec[];
292+
293+
expect(rows).toHaveLength(2);
294+
// Read in-process as an invalid Date, served as `null`: the bytes this
295+
// card does not move for an empty source either.
296+
expect(JSON.parse(JSON.stringify(rows)).map((r: Rec) => r.f_date)).toEqual([null, null]);
297+
expect(rig.faultLines()).toEqual([]);
298+
});
299+
300+
it('control: the same date() as a predicate answers as before — false, no fault', () => {
301+
const r = ExpressionEngine.evaluate(
302+
{ dialect: 'cel', source: 'date(record.first) == today()' },
303+
{ record: { first: 'grace' } },
304+
);
305+
expect(r).toEqual({ ok: true, value: false });
306+
});
307+
});

‎packages/objectql/src/engine.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ import { applyProtection, pluralToSingular, ExternalWriteForbiddenError } from '
334334
import { SchemaRegistry, type ArtifactInstallScope } from './registry.js';
335335
import { expandSearchToFilter, normalizeSearch, SEARCHABLE_ENUM_TYPES, SEARCHABLE_TEXTUAL_TYPES } from './search-filter.js';
336336
import { stripSearchCompanion } from './search-companion.js';
337-
import { ExpressionEngine, analyzeRelationshipTraversals, type EvalError, type EvalOptionLabels, type EvalPermissions } from '@objectstack/formula';
337+
import { ExpressionEngine, analyzeRelationshipTraversals, isUnparseableDate, type EvalError, type EvalOptionLabels, type EvalPermissions } from '@objectstack/formula';
338338
import { declaredOptionLabels, readerOptionLabels } from './formula-option-labels.js';
339339
import type { Expression } from '@objectstack/spec';
340340
import {
@@ -3084,6 +3084,18 @@ function formulaHopRefusal(field: string, b: RelatedFieldBinding, named: Readonl
30843084
}
30853085
}
30863086

3087+
/**
3088+
* What a formula field reports when it evaluated to the invalid Date that
3089+
* `date()` / `datetime()` answer for text they cannot parse
3090+
* ({@link isUnparseableDate}). An EMPTY source is not this fault: it reads
3091+
* `null` and says nothing. The record's text is not quoted — a log line is no
3092+
* place for row data.
3093+
*/
3094+
const UNPARSEABLE_DATE_FAULT =
3095+
'date() / datetime() was handed text it cannot parse as a date, so the formula has no value on this record; '
3096+
+ 'they take ISO text (`2026-03-04`, `2026-03-04T09:30:00Z`), a timestamp or epoch milliseconds, and an empty '
3097+
+ 'source reads null without a report.';
3098+
30873099
function applyFormulaPlan(
30883100
plan: FormulaPlanEntry[],
30893101
records: any[],
@@ -3126,7 +3138,13 @@ function applyFormulaPlan(
31263138
scope = hop.record;
31273139
empty = hop.empty;
31283140
}
3129-
const r = ExpressionEngine.evaluate(fp.expression, { now, timezone, user, org, permissions, optionLabels, record: scope });
3141+
const evaluated = ExpressionEngine.evaluate(fp.expression, { now, timezone, user, org, permissions, optionLabels, record: scope });
3142+
// An invalid Date minted from text `date()` could not parse evaluates
3143+
// `ok` (a predicate relies on that) but serializes to the `null` an empty
3144+
// source gives: for a field it is a fault, said like any other.
3145+
const r = evaluated.ok && isUnparseableDate(evaluated.value)
3146+
? { ok: false as const, error: { kind: 'runtime' as const, message: UNPARSEABLE_DATE_FAULT } }
3147+
: evaluated;
31303148
if (r.ok) {
31313149
rec[fp.name] = roundFormulaValue(r.value, fp.scale);
31323150
continue;

0 commit comments

Comments
 (0)