Skip to content

Commit e4c2dc8

Browse files
os-zhuangclaude
andauthored
fix(formula,service-analytics): 类型盲后端把 Date 与线上文本比出错——storage-form 轴指过去当场测出两个真缺陷 (#4191) (#4223)
* test(driver-memory): pin D-E3 schema-arrival convergence with the storage-form axis (#4191) D-E3 promises this driver converges the rows ALREADY in a table when the schema arrives — the population initialData fixtures and persistence restores create, both landing before any schema is declared. Nothing asserted it: every existing sweep seeds through create(), which is the one path that cannot produce a pre-convention form. Two describes seed the raw pre-#4047 forms via initialData (native → a JS Date, wire → zone-naive text; the time twin → an epoch-day Date and a full timestamp), let syncSchema run its retroactive pass, then run the shared tables. Each carries a premise assertion on the converged values so the sweep cannot pass vacuously. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWHvwFgUU2U8M9nbV1g1iQ * fix(formula,service-analytics,spec,core): order a Date against wire text on the type-blind filter backends (#4191) Pointing the storage-form axis at the two backends that have no storage found the same defect in both, and neither needed a legacy fixture to reach it — the cross-type pairing arrives from real callers: - the RLS `check` post-image is the caller's RAW write payload (`{ ...opCtx.data }`, before any driver formatInput), so an SDK write of `new Date()` lands in matchesFilterCondition as a Date against wire-text comparands; the mirror pairing arrives too, since a CEL `today()` lowers to a Date; - a preview row from a mongo-backed dataset arrives as a BSON Date (D-E2), against the same comparands. JS relational operators cannot order that pair: they coerce with hint number, so the Date becomes its epoch and the string becomes NaN. 10 of the 16 shared cases diverged on each backend. Fail-closed made formula's half a DENIED write — the write-side twin of #4047, the direction D-D2 recorded for the bare-day upper bound. The preview's half was worse: falling back to String(value) puts 'Mon Jul 27 2026 …' after every '2026-…' comparand, so windows both lost rows and gained them, and a drafted chart's numbers changed at publish. utcInstantMs joins nextUtcCalendarDay in spec/data/calendar-day.ts (core re-exports it) — one definition for both, the D-D2 discipline. It accepts only unambiguous spellings, so the answer never depends on the process zone, and returns null for a bare wall clock, which denotes no instant: the Field.time table and the date column stay out of the sweep by the same scope rule this matrix already applies to $gt-on-datetime. Deliberately narrow: the lift runs only when one operand is a Date and both read as instants, so every comparison that worked before is byte-identical and the security posture never loosens beyond what the operands justify. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWHvwFgUU2U8M9nbV1g1iQ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a329cca commit e4c2dc8

10 files changed

Lines changed: 375 additions & 9 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': minor
4+
'@objectstack/formula': patch
5+
'@objectstack/service-analytics': patch
6+
---
7+
8+
Order temporal operands correctly when one side is a JS `Date` on the two
9+
type-blind filter backends (ADR-0053 D-A3 / #4191).
10+
11+
`utcInstantMs` joins `nextUtcCalendarDay` in `@objectstack/spec/data`
12+
(re-exported from `@objectstack/core`): it reads the UTC instant a temporal
13+
operand denotes, accepting only unambiguous spellings — a `Date`, epoch ms, a
14+
bare `YYYY-MM-DD`, and an ISO timestamp with or without an explicit zone (a
15+
zone-naive one being UTC, per D-B2) — and returning `null` for everything
16+
else, notably a bare wall clock, which denotes no instant.
17+
18+
Both type-blind evaluators now use it to compare a `Date` against wire text,
19+
which JS relational operators cannot do: `<` and friends coerce with hint
20+
`number`, so the `Date` becomes its epoch and the string becomes `NaN`.
21+
22+
- `formula`'s `matchesFilterCondition` (the RLS write-side `check`) dropped
23+
every `Date`-valued row in 10 of the 16 shared conformance cases. The
24+
post-image is the caller's raw write payload, so an SDK write of
25+
`new Date()` hit this directly, and fail-closed turned it into a **denied
26+
write**.
27+
- `service-analytics`' preview evaluator diverged on the same 10 cases in
28+
BOTH directions, because `String(new Date())` sorts after every `'2026-…'`
29+
comparand — a drafted chart both lost rows and gained ones, then changed
30+
its numbers at publish. Rows from a mongo-backed dataset arrive as BSON
31+
`Date`s, so this was reachable in normal use.
32+
33+
Comparisons that did not involve a `Date` are unchanged.

packages/core/src/utils/datetime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export function zonedDateStartToUtcMs(ymd: string, tz?: string): number {
112112
* Re-exported here so the published `@objectstack/core` surface is unchanged
113113
* for the drivers and analytics strategies that already import it from here.
114114
*/
115-
export { nextUtcCalendarDay } from '@objectstack/spec/data';
115+
export { nextUtcCalendarDay, utcInstantMs } from '@objectstack/spec/data';
116116

117117
/**
118118
* Granularity of a canonical date-bucket key. Mirrors `@objectstack/spec`'s

packages/formula/src/matches-filter-temporal-conformance.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,52 @@ describe('matchesFilterCondition — temporal conformance', () => {
3333
}
3434
});
3535

36+
/**
37+
* The storage-form axis, as it exists on a type-blind surface (#4191).
38+
*
39+
* The drivers' version of this axis injects rows below the write path. This
40+
* evaluator has no storage to inject into — but it has the same defect for the
41+
* same reason, and the population is not synthetic: `plugin-security` builds
42+
* the RLS `check` post-image as `{ ...opCtx.data }`, the caller's RAW write
43+
* payload, BEFORE any driver `formatInput` converges it. So an SDK write of
44+
* `new Date()` is exactly what this evaluator is handed, and the shared
45+
* `writerForm` tag names precisely that population.
46+
*
47+
* Measured before the fix: 10 of the 16 shared cases dropped every
48+
* `Date`-valued row, because JS relational operators coerce a `Date`/ISO-string
49+
* pair to `epoch`/`NaN`. Fail-closed turns that into a **denied write** — the
50+
* write-side twin of #4047, on the surface where the failure direction is a
51+
* rejected write rather than a missing row (the same asymmetry D-D2 recorded
52+
* for the bare-day upper bound).
53+
*
54+
* `on` stays text: a `Field.date` payload is a calendar day, and the shared
55+
* expectations for the `date` column are calendar-day text semantics. A `Date`
56+
* there would be asserting a different question (what a native `date` write
57+
* denotes), which belongs with the drivers that own a `date` storage form.
58+
*/
59+
describe('matchesFilterCondition — temporal conformance on a native-writer post-image', () => {
60+
const nativeRows = TEMPORAL_ROWS.map((r) => ({
61+
...r,
62+
at: r.writerForm === 'native' ? new Date(r.at) : r.at,
63+
}));
64+
65+
for (const c of TEMPORAL_CASES) {
66+
it(c.name, () => {
67+
const got = nativeRows.filter((r) => matchesFilterCondition(r as any, c.filter)).map((r) => r.id);
68+
expect(got, c.note).toEqual(c.expected);
69+
});
70+
}
71+
72+
it('the mirror pairing too: a CEL-lowered Date comparand against wire-form records', () => {
73+
// `today()` lowers to a `Date` at UTC midnight (ADR-0053 D1), so a compiled
74+
// `check` hands this evaluator the OTHER cross-type pairing. It broke
75+
// identically and must answer identically.
76+
const bound = new Date('2026-07-28T00:00:00.000Z');
77+
const got = TEMPORAL_ROWS.filter((r) => matchesFilterCondition(r, { at: { $gte: bound } } as any)).map((r) => r.id);
78+
expect(got).toEqual(['c_open', 'd_mid', 'e_late', 'f_next', 'g_eom']);
79+
});
80+
});
81+
3682
/**
3783
* Why the token axis is absent here.
3884
*

packages/formula/src/matches-filter.ts

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
*/
1818

1919
import type { FilterCondition } from '@objectstack/spec/data';
20-
import { nextUtcCalendarDay } from '@objectstack/spec/data';
20+
import { nextUtcCalendarDay, utcInstantMs } from '@objectstack/spec/data';
2121

2222
/** True iff `record` satisfies `filter`. A null/empty filter matches everything. */
2323
export function matchesFilterCondition(record: Record<string, unknown>, filter: FilterCondition | null | undefined): boolean {
@@ -70,15 +70,15 @@ function evalOp(actual: unknown, op: string, raw: unknown, record: Record<string
7070
switch (op) {
7171
case '$eq': return v === null ? actual == null : looseEq(actual, v);
7272
case '$ne': return v === null ? actual != null : !looseEq(actual, v);
73-
case '$gt': return actual != null && v != null && (actual as never) > (v as never);
74-
case '$gte': return actual != null && v != null && (actual as never) >= (v as never);
75-
case '$lt': return actual != null && v != null && (actual as never) < (v as never);
73+
case '$gt': return actual != null && v != null && order(actual, v, (a, b) => a > b);
74+
case '$gte': return actual != null && v != null && order(actual, v, (a, b) => a >= b);
75+
case '$lt': return actual != null && v != null && order(actual, v, (a, b) => a < b);
7676
case '$lte': return actual != null && v != null && lteBound(actual, v);
7777
case '$in': return Array.isArray(v) && v.some((x) => looseEq(actual, x));
7878
case '$nin': return Array.isArray(v) && !v.some((x) => looseEq(actual, x));
7979
case '$between':
8080
return Array.isArray(v) && v.length === 2 && actual != null && v[0] != null && v[1] != null
81-
&& (actual as never) >= (v[0] as never) && lteBound(actual, v[1]);
81+
&& order(actual, v[0], (a, b) => a >= b) && lteBound(actual, v[1]);
8282
case '$contains': return typeof actual === 'string' && typeof v === 'string' && actual.includes(v);
8383
case '$notContains': return !(typeof actual === 'string' && typeof v === 'string' && actual.includes(v));
8484
case '$startsWith': return typeof actual === 'string' && typeof v === 'string' && actual.startsWith(v);
@@ -110,8 +110,43 @@ function evalOp(actual: unknown, op: string, raw: unknown, record: Record<string
110110
function lteBound(actual: unknown, bound: unknown): boolean {
111111
if (bound == null) return false;
112112
const nextDay = nextUtcCalendarDay(bound);
113-
if (nextDay != null) return (actual as never) < (nextDay as never);
114-
return (actual as never) <= (bound as never);
113+
if (nextDay != null) return order(actual, nextDay, (a, b) => a < b);
114+
return order(actual, bound, (a, b) => a <= b);
115+
}
116+
117+
/**
118+
* Apply an ordering comparison, lifting the pair to instants when exactly one
119+
* side is a JS `Date` — the cross-type case JS relational operators answer
120+
* `false` to unconditionally (they coerce with hint `number`, so the `Date`
121+
* becomes its epoch and the ISO string becomes `NaN`).
122+
*
123+
* This is not a hypothetical pairing on this surface: the RLS `check`
124+
* post-image is the caller's RAW write payload (`{ ...opCtx.data }` in
125+
* `plugin-security`, built before any driver `formatInput` converges it), so an
126+
* SDK write of `new Date()` lands here as a `Date` while the policy's comparand
127+
* is the platform's wire text — and the mirror pairing arrives too, because a
128+
* CEL `today()` lowers to a `Date` against a record holding canonical text.
129+
* Measured against the shared matrix, 10 of 16 cases dropped every
130+
* `Date`-valued row; fail-closed makes that a **denied write**, the write-side
131+
* twin of #4047's missing rows and the same failure direction D-D2 recorded
132+
* for the bare-day upper bound.
133+
*
134+
* Deliberately narrow. The lift triggers only when one operand is a `Date`
135+
* AND {@link utcInstantMs} can read both as instants, so every comparison that
136+
* worked before is byte-identical: string-vs-string keeps ISO lexicographic
137+
* ordering, number-vs-number stays numeric, and a `Field.time` wall clock —
138+
* which denotes no instant — is left alone rather than being given an invented
139+
* calendar day. Anything that cannot be lifted falls through to the original
140+
* comparison, so the security posture never becomes more permissive than the
141+
* operands actually justify.
142+
*/
143+
function order(actual: unknown, bound: unknown, cmp: (a: never, b: never) => boolean): boolean {
144+
if (actual instanceof Date || bound instanceof Date) {
145+
const a = utcInstantMs(actual);
146+
const b = utcInstantMs(bound);
147+
if (a !== null && b !== null) return cmp(a as never, b as never);
148+
}
149+
return cmp(actual as never, bound as never);
115150
}
116151

117152
/** Resolve a `{ $field: 'path' }` reference against the record; else passthrough. */

packages/plugins/driver-memory/src/memory-temporal-conformance.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,23 @@
1414
* the sweep proves the converged storage answers every shared case. Cases
1515
* carrying a token spelling also run through `resolveFilterTokens` at the
1616
* pinned `TEMPORAL_NOW` — the D-A3 "token → row results" axis (#4081).
17+
*
18+
* The last two describes add the **storage-form axis** (#4191): rows that
19+
* entered the table BEFORE any schema was declared, in the raw pre-#4047
20+
* forms, and were therefore never touched by the write path. That is not a
21+
* hypothetical population here — it is precisely what D-E3 says this driver
22+
* has to converge:
23+
*
24+
* > `driver-memory` converges the rows already in a table when the schema
25+
* > arrives, which is what catches `initialData` fixtures and
26+
* > persistence-adapter restores (both land before any schema is declared).
27+
*
28+
* `initialData` is the named case and the one seeded here: `connect()` pushes
29+
* the fixtures straight into the table, `syncSchema` then runs the retroactive
30+
* pass. The claim under test is that the pass leaves the table answering the
31+
* SAME row-id sets as the canonical sweep — the in-memory analogue of the
32+
* `backfillCanonicalDatetimes` sweeps in `driver-sql`, and the half of D-E3
33+
* that had no coverage at all.
1734
*/
1835

1936
import { describe, it, expect, beforeAll } from 'vitest';
@@ -30,6 +47,37 @@ import { InMemoryDriver } from './memory-driver.js';
3047
const resolveTokens = <T,>(filter: T): T =>
3148
resolveFilterTokens(filter, { now: new Date(TEMPORAL_NOW) });
3249

50+
/**
51+
* The pre-#4047 storage forms, per the shared axis's seeding basis: `native`
52+
* → the raw platform-native instant (a JS `Date`, what an SDK caller or this
53+
* driver's own timestamp defaults produced), `wire` → raw text the write path
54+
* never rewrote (the zone-naive `YYYY-MM-DD HH:MM:SS` a `CURRENT_TIMESTAMP`
55+
* default or a restored snapshot carries). Neither is this driver's canon —
56+
* canonical UTC ISO text for `datetime`, bare-day text for `date` (D-E2) — so
57+
* the fixture is genuinely un-converged on both halves rather than only one.
58+
*/
59+
const legacyRows = () =>
60+
TEMPORAL_ROWS.map((r) => ({
61+
id: r.id,
62+
at: r.writerForm === 'native' ? new Date(r.at) : r.at.replace('T', ' ').replace('Z', ''),
63+
on: r.writerForm === 'native' ? new Date(r.on) : r.on,
64+
why: r.why,
65+
}));
66+
67+
/**
68+
* The `Field.time` twin: `native` → a `Date` whose UTC time-of-day is the wall
69+
* clock (`a_midnight` becomes the epoch instant itself — the row whose
70+
* cross-type comparison hid it from every text bound), `wire` → a full
71+
* timestamp still carrying a calendar day, pinned to the fixture's boundary
72+
* day so every consumer of this axis seeds the same bytes.
73+
*/
74+
const legacyTimeRows = () =>
75+
TEMPORAL_TIME_ROWS.map((r) => ({
76+
id: r.id,
77+
at: r.writerForm === 'native' ? new Date(`1970-01-01T${r.at}Z`) : `2026-07-28T${r.at}Z`,
78+
why: r.why,
79+
}));
80+
3381
describe('driver-memory — temporal conformance', () => {
3482
let driver: InMemoryDriver;
3583

@@ -99,3 +147,71 @@ describe('driver-memory — Field.time conformance', () => {
99147
});
100148
}
101149
});
150+
151+
describe('driver-memory — temporal conformance on rows that predate the schema (D-E3)', () => {
152+
let driver: InMemoryDriver;
153+
154+
beforeAll(async () => {
155+
// `initialData` lands during connect(), BEFORE any schema is declared, so
156+
// nothing coerced these values — the population D-E3 promises to converge.
157+
driver = new InMemoryDriver({ initialData: { conformance: legacyRows() } });
158+
await driver.connect();
159+
// The retroactive pass runs here, on rows this driver never wrote.
160+
await driver.syncSchema('conformance', {
161+
name: 'conformance',
162+
fields: { at: { type: 'datetime' }, on: { type: 'date' }, why: { type: 'string' } },
163+
});
164+
});
165+
166+
it('converged every pre-schema row to the storage canon (the premise, so the sweep cannot pass vacuously)', async () => {
167+
const rows = await driver.find('conformance', {} as any);
168+
expect(rows).toHaveLength(TEMPORAL_ROWS.length);
169+
for (const row of rows as any[]) {
170+
const expected = TEMPORAL_ROWS.find((r) => r.id === row.id)!;
171+
// Canonical UTC ISO text for `datetime`, bare-day text for `date`
172+
// (D-E2) — no `Date` object and no zone-naive spelling survives.
173+
expect(row.at, `${row.id}.at`).toBe(expected.at);
174+
expect(row.on, `${row.id}.on`).toBe(expected.on);
175+
}
176+
});
177+
178+
// Literal spellings only: the token axis is orthogonal to storage form and
179+
// already swept above — a divergence here is a convergence bug by construction.
180+
for (const c of TEMPORAL_CASES) {
181+
it(c.name, async () => {
182+
const rows = await driver.find('conformance', { where: c.filter } as any);
183+
const got = (rows as any[]).map((r) => r.id).sort();
184+
expect(got, c.note).toEqual([...c.expected].sort());
185+
});
186+
}
187+
});
188+
189+
describe('driver-memory — Field.time conformance on rows that predate the schema (D-E3)', () => {
190+
let driver: InMemoryDriver;
191+
192+
beforeAll(async () => {
193+
driver = new InMemoryDriver({ initialData: { time_conformance: legacyTimeRows() } });
194+
await driver.connect();
195+
await driver.syncSchema('time_conformance', {
196+
name: 'time_conformance',
197+
fields: { at: { type: 'time' }, why: { type: 'string' } },
198+
});
199+
});
200+
201+
it('converged every pre-schema wall clock to the storage canon (the premise)', async () => {
202+
const rows = await driver.find('time_conformance', {} as any);
203+
expect(rows).toHaveLength(TEMPORAL_TIME_ROWS.length);
204+
for (const row of rows as any[]) {
205+
const expected = TEMPORAL_TIME_ROWS.find((r) => r.id === row.id)!;
206+
expect(row.at, `${row.id}.at`).toBe(expected.at);
207+
}
208+
});
209+
210+
for (const c of TEMPORAL_TIME_CASES) {
211+
it(c.name, async () => {
212+
const rows = await driver.find('time_conformance', { where: c.filter } as any);
213+
const got = (rows as any[]).map((r) => r.id).sort();
214+
expect(got, c.note).toEqual([...c.expected].sort());
215+
});
216+
}
217+
});

packages/services/service-analytics/src/__tests__/preview-temporal-conformance.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,41 @@ import { evaluateAnalyticsQueryOverRows, matchesWhere } from '../preview-evaluat
3232
const resolveTokens = <T,>(filter: T): T =>
3333
resolveFilterTokens(filter, { now: new Date(TEMPORAL_NOW) });
3434

35+
/**
36+
* The storage-form axis on a type-blind surface (#4191).
37+
*
38+
* There is no storage to inject into here, but the same cross-type pairing
39+
* arrives all the same, and not synthetically: `Field.datetime`'s storage form
40+
* is a BSON `Date` on `driver-mongodb` (D-E2), so rows fetched from a
41+
* mongo-backed dataset reach this evaluator as `Date` objects while the
42+
* comparands stay wire text. The shared `writerForm` tag names exactly that
43+
* population.
44+
*
45+
* Measured before the fix: 10 of the 16 shared cases diverged — and in BOTH
46+
* directions, which is what makes this surface's variant nastier than the
47+
* drivers'. `String(new Date())` is `'Mon Jul 27 2026 …'`, which sorts after
48+
* every `'2026-…'` comparand, so a window dropped rows that belong in it and
49+
* admitted rows that do not. A drafted chart therefore showed numbers that
50+
* changed at publish — precisely the continuity this evaluator exists to
51+
* provide.
52+
*/
53+
const nativeRows = TEMPORAL_ROWS.map((r) => ({
54+
...r,
55+
at: r.writerForm === 'native' ? new Date(r.at) : r.at,
56+
}));
57+
3558
describe('preview-evaluator — temporal conformance', () => {
3659
for (const c of TEMPORAL_CASES) {
3760
it(c.name, () => {
3861
const got = TEMPORAL_ROWS.filter((r) => matchesWhere(r as any, c.filter as any)).map((r) => r.id);
3962
expect(got, c.note).toEqual(c.expected);
4063
});
4164

65+
it(`${c.name} — on a native-writer (BSON Date) row population`, () => {
66+
const got = nativeRows.filter((r) => matchesWhere(r as any, c.filter as any)).map((r) => r.id);
67+
expect(got, c.note).toEqual(c.expected);
68+
});
69+
4270
// The D-A3 token axis (#4081): the same case spelled in relative tokens,
4371
// resolved at the pinned instant, must reach the same rows.
4472
if (c.tokenFilter) {

packages/services/service-analytics/src/preview-evaluator.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,42 @@
1717
// Anything beyond (joins via `include`, raw SQL) falls back to the caller's
1818
// normal execution path — the preview simply doesn't claim it.
1919

20-
import { calendarPartsInTzOrUtc, nextUtcCalendarDay } from '@objectstack/core';
20+
import { calendarPartsInTzOrUtc, nextUtcCalendarDay, utcInstantMs } from '@objectstack/core';
2121
import type { AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts';
2222
import type { Cube } from '@objectstack/spec/data';
2323

2424
type Row = Record<string, unknown>;
2525

2626
// ── Filters (the unified Query DSL subset) ──────────────────────────────────
2727

28+
/**
29+
* Order two operands the way every other filter backend orders them.
30+
*
31+
* The `Date` arm is load-bearing rather than defensive: `String(new Date())`
32+
* is `'Mon Jul 27 2026 …'`, which under the plain string ordering below sorts
33+
* AFTER every `'2026-…'` comparand — so a preview row carrying an instant both
34+
* disappeared from windows it belongs in and appeared in ones it does not.
35+
* Measured against the shared matrix, 10 of 16 cases diverged, and unlike the
36+
* cross-type silence on the drivers this direction ADDS rows: a drafted chart
37+
* showed numbers no published chart would.
38+
*
39+
* The population is real. `Field.datetime`'s storage form is a BSON `Date` on
40+
* `driver-mongodb` (ADR-0053 D-E2), so rows fetched from a mongo-backed dataset
41+
* arrive here as `Date` objects, while the comparands are wire text.
42+
* {@link utcInstantMs} is the same primitive `formula`'s write-side evaluator
43+
* uses for the same pairing, so the two type-blind surfaces cannot drift.
44+
*
45+
* Deliberately narrow: the lift runs only when one side is a `Date` and both
46+
* read as instants, so string-vs-string keeps ISO lexicographic ordering and a
47+
* `Field.time` wall clock — which denotes no instant — is left untouched.
48+
*/
2849
function compare(a: unknown, b: unknown): number {
2950
if (typeof a === 'number' && typeof b === 'number') return a - b;
51+
if (a instanceof Date || b instanceof Date) {
52+
const ai = utcInstantMs(a);
53+
const bi = utcInstantMs(b);
54+
if (ai !== null && bi !== null) return ai - bi;
55+
}
3056
return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
3157
}
3258

packages/spec/api-surface.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,7 @@
636636
"sequenceWidth (function)",
637637
"stripLegacyApiMethods (function)",
638638
"suggestFieldType (function)",
639+
"utcInstantMs (function)",
639640
"valueSchemaFor (function)"
640641
],
641642
"./system": [

0 commit comments

Comments
 (0)