diff --git a/.changeset/calendar-day-primitive-to-spec.md b/.changeset/calendar-day-primitive-to-spec.md new file mode 100644 index 0000000000..65a2b0f57d --- /dev/null +++ b/.changeset/calendar-day-primitive-to-spec.md @@ -0,0 +1,32 @@ +--- +"@objectstack/spec": minor +"@objectstack/formula": patch +"@objectstack/core": patch +--- + +fix(formula,spec,core): the RLS write-side `check` evaluator honours calendar-day upper bounds (ADR-0053 D-D) + +`@objectstack/formula`'s `matchesFilterCondition` — the evaluator behind RLS +write-side `check` policies (ADR-0058 D4) — compared a bare `YYYY-MM-DD` `$lte` +bound literally. On a `datetime` post-image that meant a policy of the shape +`{ signed_on: { $lte: '{today}' } }` **denied every write made after 00:00**: +the write-side twin of the read-side data loss #3777 fixed, and the last of the +platform's filter backends that disagreed about what a bare day means as a +bound. + +`$lte` and a `$between` max now evaluate half-open against the next calendar +day, matching the SQL compiler, the memory and mongo drivers, and the analytics +preview evaluator. Unchanged, per the same semantics table: full-ISO bounds keep +exact-instant semantics, `$gte`/`$gt`/`$lt` keep their midnight anchoring, and a +plain `YYYY-MM-DD` value compares identically (string ordering makes the two +forms equivalent). The evaluator stays fail-closed on a null bound. + +**Where the rule now lives.** `nextUtcCalendarDay` moved from +`@objectstack/core` to `@objectstack/spec/data` — beside `date-macros.zod.ts`, +whose vocabulary it interprets. `formula` cannot depend on `core`, and a second +copy of the rule is exactly the divergence #3777 catalogued; `spec` is the one +package all six consumers already depend on, so this adds no dependency edge. + +No import changes are required: `@objectstack/core` re-exports the symbol, so +existing `import { nextUtcCalendarDay } from '@objectstack/core'` keeps working. +New code should prefer `@objectstack/spec/data`. diff --git a/content/docs/data-modeling/queries.mdx b/content/docs/data-modeling/queries.mdx index 1a10a54d1e..7f550d9e57 100644 --- a/content/docs/data-modeling/queries.mdx +++ b/content/docs/data-modeling/queries.mdx @@ -83,7 +83,7 @@ disagree about shape: | Field type | Canonical comparand | Semantics | |:---|:---|:---| | `date` | `YYYY-MM-DD` | Timezone-naive calendar day. A `Date` collapses to its UTC day; a longer ISO string is truncated to its leading date. | -| `datetime` | `YYYY-MM-DDTHH:MM:SS.sssZ` | A UTC instant. A `Date`, an epoch-millisecond number, a bare `YYYY-MM-DD` (→ midnight UTC) and a zone-naive `YYYY-MM-DD HH:MM:SS` (→ read as UTC) all fold to this one form. | +| `datetime` | `YYYY-MM-DDTHH:MM:SS.sssZ` | A UTC instant. A `Date`, an epoch-millisecond number, a bare `YYYY-MM-DD` (→ midnight UTC, but see the whole-day rule below) and a zone-naive `YYYY-MM-DD HH:MM:SS` (→ read as UTC) all fold to this one form. | | `time` | `HH:MM:SS`, with `.fff` **only** when the milliseconds are non-zero | Timezone-naive wall clock. `'14:30'` and `'14:30:00'` canonicalise identically, so they are the same filter. | ```typescript @@ -97,6 +97,32 @@ disagree about shape: } ``` +### A bare calendar day as an UPPER bound means the whole day + +Canonicalisation settles a comparand's *shape*; which **instant** a bare +`YYYY-MM-DD` denotes additionally depends on the side of the comparison it sits on: + +| Operator | A bare `YYYY-MM-DD` means | +|:---|:---| +| `$gte` / `$gt` / `$lt` | that day's `00:00:00.000` | +| `$lte`, and the max of a `$between` | the **whole** day — compiled half-open, as `< next day` | + +So `{ created_at: { $gte: '2026-01-01', $lte: '2026-03-31' } }` includes +everything recorded *on* March 31st, not just the instant it began. Without +that rule a dashboard window ending "today" silently dropped every row created +after midnight (#3777). Pass a full ISO timestamp when you want an exact +instant — only the day-granular *string* carries whole-day intent: + +```typescript +{ created_at: { $lte: '2026-03-31' } } // through all of March 31st +{ created_at: { $lte: '2026-03-31T12:00:00.000Z' } } // up to noon exactly +``` + +On a `date` field the two forms are equivalent (`< next day` and `<= day` order +identically over `YYYY-MM-DD` text), so the rule needs no special handling +there. It applies uniformly across the SQL drivers, the in-memory and MongoDB +drivers, analytics windows, and the RLS write-side `check` evaluator. + Canonicalisation runs on **every SQL dialect**, not just SQLite — a zone-naive string bound into a Postgres `timestamptz` would otherwise be read in the *server's* timezone. MySQL diff --git a/docs/adr/0053-date-and-datetime-semantics.md b/docs/adr/0053-date-and-datetime-semantics.md index 20248eeccc..e1d29991da 100644 --- a/docs/adr/0053-date-and-datetime-semantics.md +++ b/docs/adr/0053-date-and-datetime-semantics.md @@ -782,8 +782,36 @@ cover lives in `mongodb-datetime-storage.test.ts` (against a real MongoDB via `mongodb-memory-server`) and `memory-datetime-storage.test.ts`. Still open under D-A3: the relative-token × live-driver × timezone combinations. -One evaluator remains unaligned on both the D-D and D-E rules: +~~One evaluator remains unaligned on both the D-D and D-E rules: `@objectstack/formula`'s `matchesFilterCondition` (the RLS write-side `check` path), which cannot depend on `@objectstack/core` for the shared primitive. Deciding its home — a private copy, as `formula` already keeps for `today()`, -or lowering the utility into `spec`/`types` — is the remaining piece. +or lowering the utility into `spec`/`types` — is the remaining piece.~~ +Closed 2026-07-30 — see D-D2 below. + +### D-D2 — The primitive lives in `spec`, beside the vocabulary it interprets + +`nextUtcCalendarDay` moved from `@objectstack/core` to +`@objectstack/spec/data` (`calendar-day.ts`), next to `date-macros.zod.ts`. +The two are halves of one contract: the macros define which bare days an author +can *name*, D-D defines what such a day *denotes* as a bound. That is protocol, +not business logic — the same species as the pure helpers `spec/data` already +owns (`parseDateMacroParam`, `canonicalAstOperator`, `parseAutonumberFormat`), +so Prime Directive #2 is satisfied. + +Chosen over `@objectstack/types` because **it adds no dependency edge**: every +consumer already depends on `spec`, whereas `core` does not depend on `types` +and would have needed a new one. `core` re-exports the symbol, so the published +`@objectstack/core` surface — which the drivers and analytics strategies import +from — is unchanged. + +Rejected: a private copy in `formula` (the precedent it would cite, `formula`'s +own `today()`, is debt rather than a pattern). Six backends now share one +definition: the SQL compiler, the memory and mongo drivers, the analytics +raw-SQL strategy, the dataset preview evaluator, and `matchesFilterCondition`. + +The write-side gap this closed was not cosmetic. A `check` policy of the shape +`{ signed_on: { $lte: '{today}' } }` evaluated against a `datetime` post-image +**denied every write made after 00:00** — the write-side twin of the read-side +loss #3777 fixed, on the one surface where the failure direction is a rejected +write rather than a missing row. diff --git a/packages/core/src/utils/datetime.test.ts b/packages/core/src/utils/datetime.test.ts index 248a456ee1..1dd4d22e3f 100644 --- a/packages/core/src/utils/datetime.test.ts +++ b/packages/core/src/utils/datetime.test.ts @@ -61,28 +61,14 @@ describe('zonedDateStartToUtcMs — round-trips to the day boundary in the zone' } }); -describe('nextUtcCalendarDay — the exclusive upper bound of a bare calendar day (#3777)', () => { - it('advances one day, rolling month, year and leap boundaries', () => { +describe('nextUtcCalendarDay — re-exported from @objectstack/spec (ADR-0053 D-D)', () => { + it('is still reachable from this package, with the same semantics', () => { + // The rule itself now lives in spec (six backends share it, and + // `@objectstack/formula` cannot depend on core) — its thorough coverage is + // `packages/spec/src/data/calendar-day.test.ts`. What this asserts is the + // re-export the drivers and analytics strategies import from HERE, so + // dropping it would break them loudly rather than at their call sites. expect(nextUtcCalendarDay('2026-07-28')).toBe('2026-07-29'); - expect(nextUtcCalendarDay('2026-07-31')).toBe('2026-08-01'); - expect(nextUtcCalendarDay('2026-12-31')).toBe('2027-01-01'); - expect(nextUtcCalendarDay('2024-02-28')).toBe('2024-02-29'); // leap year - expect(nextUtcCalendarDay('2025-02-28')).toBe('2025-03-01'); - expect(nextUtcCalendarDay(' 2026-07-28 ')).toBe('2026-07-29'); // trimmed - }); - - it('returns null for anything that is not a valid bare calendar day', () => { - // Instants keep instant semantics — never widened. expect(nextUtcCalendarDay('2026-07-28T12:00:00Z')).toBeNull(); - expect(nextUtcCalendarDay(new Date('2026-07-28T00:00:00Z'))).toBeNull(); - // Impossible days are rejected, not rolled into an invented bound. - expect(nextUtcCalendarDay('2026-02-30')).toBeNull(); - expect(nextUtcCalendarDay('2026-13-01')).toBeNull(); - // Non-strings / junk. - expect(nextUtcCalendarDay(1753660800000)).toBeNull(); - expect(nextUtcCalendarDay(null)).toBeNull(); - expect(nextUtcCalendarDay(undefined)).toBeNull(); - expect(nextUtcCalendarDay('7/28/2026')).toBeNull(); - expect(nextUtcCalendarDay('2026-7-28')).toBeNull(); // not zero-padded → not the canonical shape }); }); diff --git a/packages/core/src/utils/datetime.ts b/packages/core/src/utils/datetime.ts index 1043641a25..c1ef541393 100644 --- a/packages/core/src/utils/datetime.ts +++ b/packages/core/src/utils/datetime.ts @@ -103,37 +103,16 @@ export function zonedDateStartToUtcMs(ymd: string, tz?: string): number { } /** - * The calendar day after a bare `YYYY-MM-DD` string — the exclusive upper - * bound of that day, for compiling "through day X" into the half-open - * `[X, X+1)` a `datetime` column needs (#3777). + * Calendar-day bound semantics (ADR-0053 D-D) now live in `@objectstack/spec`, + * beside the date-macro vocabulary they give meaning to — the fifth consumer + * (`@objectstack/formula`'s RLS write-side `check` evaluator) cannot depend on + * this package, and a second copy of the rule is exactly the divergence #3777 + * catalogued. * - * A bare calendar day used as an upper bound (`$lte`, a `dateRange` end, a - * `{current_month_end}` token) always carries whole-day intent — the author - * means "including everything that happened on X", never "up to the stroke of - * midnight that begins X". On a `date` column `<= X` already says that; on a - * `datetime` column it silently drops every instant after 00:00. The correct - * translation is the half-open pair `>= start AND < nextUtcCalendarDay(end)` - * — the same shape the analytics drill ranges already emit — rather than an - * inclusive `23:59:59.999` constant, which re-opens the gap at whatever - * precision the dialect stores beyond milliseconds. - * - * `< nextUtcCalendarDay(X)` is also *equivalent* to `<= X` for a `date` column - * (plain `YYYY-MM-DD` text ordering), so emitters that cannot see the column - * type (raw-SQL strategies, the dataset preview evaluator) can apply it - * unconditionally to a bare-day bound and be right on both column types. - * - * Returns `null` for anything that is not a valid bare calendar day — full - * ISO timestamps keep instant semantics and must NOT be widened, and an - * impossible day (`2026-02-30`) is rejected the same way - * {@link bucketKeyToCalendarRange} rejects it, so a caller falls back to the - * untranslated comparand instead of inventing a bound. + * Re-exported here so the published `@objectstack/core` surface is unchanged + * for the drivers and analytics strategies that already import it from here. */ -export function nextUtcCalendarDay(value: unknown): string | null { - if (typeof value !== 'string') return null; - const day = value.trim(); - if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return null; - return bucketKeyToCalendarRange(day, 'day')?.end ?? null; -} +export { nextUtcCalendarDay } from '@objectstack/spec/data'; /** * Granularity of a canonical date-bucket key. Mirrors `@objectstack/spec`'s diff --git a/packages/formula/src/matches-filter.test.ts b/packages/formula/src/matches-filter.test.ts index a9853abc90..8c4ad203b0 100644 --- a/packages/formula/src/matches-filter.test.ts +++ b/packages/formula/src/matches-filter.test.ts @@ -108,3 +108,55 @@ describe('matchesFilterCondition — FAIL CLOSED', () => { expect(m(rec, 'nope' as never)).toBe(false); }); }); + +describe('matchesFilterCondition — calendar-day upper bounds (ADR-0053 D-D, #3777)', () => { + // A `check` policy of this shape on a datetime post-image used to DENY every + // write made after 00:00 of the bound day — the write-side twin of the + // read-side data loss #3777 fixed. The four other filter backends already + // share this rule; this evaluator was the last one that disagreed. + const at = (created_at: string) => ({ created_at }); + + it('a bare-day $lte admits the whole day', () => { + expect(m(at('2026-07-28T00:00:00.000Z'), { created_at: { $lte: '2026-07-28' } })).toBe(true); + expect(m(at('2026-07-28T09:15:00.000Z'), { created_at: { $lte: '2026-07-28' } })).toBe(true); + expect(m(at('2026-07-28T23:59:59.999Z'), { created_at: { $lte: '2026-07-28' } })).toBe(true); + }); + + it('…and stops at the next day', () => { + expect(m(at('2026-07-29T00:00:00.000Z'), { created_at: { $lte: '2026-07-28' } })).toBe(false); + }); + + it('a plain calendar-day value is unchanged (string ordering is equivalent)', () => { + expect(m({ signed_on: '2026-07-28' }, { signed_on: { $lte: '2026-07-28' } })).toBe(true); + expect(m({ signed_on: '2026-07-29' }, { signed_on: { $lte: '2026-07-28' } })).toBe(false); + }); + + it('a full-ISO bound keeps exact-instant semantics — never widened', () => { + expect(m(at('2026-07-28T09:15:00.000Z'), { created_at: { $lte: '2026-07-28T12:00:00.000Z' } })).toBe(true); + expect(m(at('2026-07-28T21:40:00.000Z'), { created_at: { $lte: '2026-07-28T12:00:00.000Z' } })).toBe(false); + }); + + it('$gte / $gt / $lt keep their midnight anchoring', () => { + expect(m(at('2026-07-28T09:15:00.000Z'), { created_at: { $gte: '2026-07-28' } })).toBe(true); + expect(m(at('2026-07-27T23:59:59.999Z'), { created_at: { $gte: '2026-07-28' } })).toBe(false); + expect(m(at('2026-07-28T09:15:00.000Z'), { created_at: { $lt: '2026-07-28' } })).toBe(false); + }); + + it('$between inherits the rule on its max, and still bounds the min', () => { + expect(m(at('2026-07-28T21:40:00.000Z'), { created_at: { $between: ['2026-04-29', '2026-07-28'] } })).toBe(true); + expect(m(at('2026-07-29T00:00:00.000Z'), { created_at: { $between: ['2026-04-29', '2026-07-28'] } })).toBe(false); + expect(m(at('2026-04-28T23:00:00.000Z'), { created_at: { $between: ['2026-04-29', '2026-07-28'] } })).toBe(false); + }); + + it('an impossible day is not rolled over — the bound stays as written', () => { + // `2026-02-30` is rejected by the primitive, so the comparison falls back + // to the literal `<=` rather than silently querying March 2nd. + expect(m({ signed_on: '2026-02-30' }, { signed_on: { $lte: '2026-02-30' } })).toBe(true); + expect(m({ signed_on: '2026-03-01' }, { signed_on: { $lte: '2026-02-30' } })).toBe(false); + }); + + it('stays fail-closed on a null bound', () => { + expect(m(at('2026-07-28T09:15:00.000Z'), { created_at: { $lte: null } as never })).toBe(false); + expect(m(at('2026-07-28T09:15:00.000Z'), { created_at: { $between: ['2026-04-29', null] } as never })).toBe(false); + }); +}); diff --git a/packages/formula/src/matches-filter.ts b/packages/formula/src/matches-filter.ts index b51760f9b4..19851c2afc 100644 --- a/packages/formula/src/matches-filter.ts +++ b/packages/formula/src/matches-filter.ts @@ -17,6 +17,7 @@ */ import type { FilterCondition } from '@objectstack/spec/data'; +import { nextUtcCalendarDay } from '@objectstack/spec/data'; /** True iff `record` satisfies `filter`. A null/empty filter matches everything. */ export function matchesFilterCondition(record: Record, filter: FilterCondition | null | undefined): boolean { @@ -72,12 +73,12 @@ function evalOp(actual: unknown, op: string, raw: unknown, record: Record (v as never); case '$gte': return actual != null && v != null && (actual as never) >= (v as never); case '$lt': return actual != null && v != null && (actual as never) < (v as never); - case '$lte': return actual != null && v != null && (actual as never) <= (v as never); + case '$lte': return actual != null && v != null && lteBound(actual, v); case '$in': return Array.isArray(v) && v.some((x) => looseEq(actual, x)); case '$nin': return Array.isArray(v) && !v.some((x) => looseEq(actual, x)); case '$between': - return Array.isArray(v) && v.length === 2 && actual != null - && (actual as never) >= (v[0] as never) && (actual as never) <= (v[1] as never); + return Array.isArray(v) && v.length === 2 && actual != null && v[0] != null && v[1] != null + && (actual as never) >= (v[0] as never) && lteBound(actual, v[1]); case '$contains': return typeof actual === 'string' && typeof v === 'string' && actual.includes(v); case '$notContains': return !(typeof actual === 'string' && typeof v === 'string' && actual.includes(v)); case '$startsWith': return typeof actual === 'string' && typeof v === 'string' && actual.startsWith(v); @@ -88,6 +89,31 @@ function evalOp(actual: unknown, op: string, raw: unknown, record: Record): unknown { if (raw && typeof raw === 'object' && !Array.isArray(raw) && '$field' in (raw as Record)) { diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index c6b6f0c95c..958458f09d 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -582,6 +582,7 @@ "isTitleEligible (function)", "isUniqueDeclared (function)", "missingFieldValues (function)", + "nextUtcCalendarDay (function)", "objectForm (const)", "objectTitleCompleteness (function)", "parseAutonumberFormat (function)", diff --git a/packages/spec/src/data/calendar-day.test.ts b/packages/spec/src/data/calendar-day.test.ts new file mode 100644 index 0000000000..b36013cbf7 --- /dev/null +++ b/packages/spec/src/data/calendar-day.test.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The calendar-day bound primitive (ADR-0053 D-D). It lives in spec because six + * backends now share it — the SQL compiler, the memory and mongo drivers, the + * analytics raw-SQL strategy, the dataset preview evaluator, and `formula`'s + * RLS write-side `check` evaluator. Its refusals are as load-bearing as its + * answers: every `null` below is a case some backend must NOT widen. + */ + +import { describe, expect, it } from 'vitest'; +import { nextUtcCalendarDay } from './calendar-day'; + +describe('nextUtcCalendarDay', () => { + it('advances one calendar day', () => { + expect(nextUtcCalendarDay('2026-07-28')).toBe('2026-07-29'); + expect(nextUtcCalendarDay(' 2026-07-28 ')).toBe('2026-07-29'); // trimmed + }); + + it('rolls month, year and leap boundaries', () => { + expect(nextUtcCalendarDay('2026-07-31')).toBe('2026-08-01'); + expect(nextUtcCalendarDay('2026-12-31')).toBe('2027-01-01'); + expect(nextUtcCalendarDay('2024-02-28')).toBe('2024-02-29'); // leap year + expect(nextUtcCalendarDay('2024-02-29')).toBe('2024-03-01'); + expect(nextUtcCalendarDay('2025-02-28')).toBe('2025-03-01'); // non-leap + }); + + it('refuses instants — they keep exact-instant semantics', () => { + expect(nextUtcCalendarDay('2026-07-28T12:00:00Z')).toBeNull(); + expect(nextUtcCalendarDay('2026-07-28T00:00:00.000Z')).toBeNull(); + expect(nextUtcCalendarDay(new Date('2026-07-28T00:00:00Z'))).toBeNull(); + expect(nextUtcCalendarDay(1753660800000)).toBeNull(); + }); + + it('refuses impossible days rather than rolling them over', () => { + // `Date.UTC(2026, 1, 30)` is March 2nd — returning that would query a date + // the author never wrote, so the round-trip check rejects it. + expect(nextUtcCalendarDay('2026-02-30')).toBeNull(); + expect(nextUtcCalendarDay('2025-02-29')).toBeNull(); // not a leap year + expect(nextUtcCalendarDay('2026-13-01')).toBeNull(); + expect(nextUtcCalendarDay('2026-00-10')).toBeNull(); + expect(nextUtcCalendarDay('2026-07-32')).toBeNull(); + }); + + it('refuses anything that is not the canonical bare-day shape', () => { + expect(nextUtcCalendarDay('2026-7-28')).toBeNull(); // not zero-padded + expect(nextUtcCalendarDay('7/28/2026')).toBeNull(); + expect(nextUtcCalendarDay('2026-07')).toBeNull(); + expect(nextUtcCalendarDay('')).toBeNull(); + expect(nextUtcCalendarDay(null)).toBeNull(); + expect(nextUtcCalendarDay(undefined)).toBeNull(); + expect(nextUtcCalendarDay({})).toBeNull(); + }); + + it('the result is itself a valid bare day — the rule composes', () => { + // Chaining is not a use case, but a returned value that the primitive + // would reject would mean the two halves disagree about the shape. + const next = nextUtcCalendarDay('2026-12-31')!; + expect(nextUtcCalendarDay(next)).toBe('2027-01-02'); + }); +}); diff --git a/packages/spec/src/data/calendar-day.ts b/packages/spec/src/data/calendar-day.ts new file mode 100644 index 0000000000..fe0ad6083d --- /dev/null +++ b/packages/spec/src/data/calendar-day.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Calendar-day bound semantics — what a bare `YYYY-MM-DD` MEANS on each side of + * a comparison (ADR-0053 D-D, #3777). + * + * This lives beside `date-macros.zod.ts` because it is the semantic companion of + * that vocabulary: the macros define which bare days an author can *name* + * (`{today}`, `{current_month_end}`, …), and this defines what such a day + * *denotes* when it lands on one side of a filter operator. Both halves are + * protocol, so both belong to the one contract every producer and consumer + * shares — the alternative is each backend re-deriving the rule, which is how + * the four divergent implementations #3777 catalogued came about. + * + * The rule, per field type and operator: + * + * | Operator | A bare `YYYY-MM-DD` on a `datetime` column means | + * |---|---| + * | `$gte` / `$gt` / `$lt` | that day's `00:00:00.000` — already correct as written | + * | `$lte`, a `$between` max, a `dateRange` end | the WHOLE day → compile `< nextUtcCalendarDay(day)` | + * + * Half-open, never an inclusive `23:59:59.999`: the latter re-opens the gap at + * whatever sub-millisecond precision a dialect keeps (Postgres stores + * microseconds), and `[gte, lt)` is the shape the analytics drill ranges (#1752) + * already emit. On a `date` column `< nextDay` is order-equivalent to + * `<= day` under plain `YYYY-MM-DD` text ordering, which is what lets emitters + * that cannot see the column type apply it unconditionally. + */ + +/** + * The calendar day after a bare `YYYY-MM-DD` string — the exclusive upper bound + * of that day. + * + * Returns `null` for anything that is not a valid bare calendar day. That + * refusal is load-bearing in two directions: + * - a full ISO timestamp or a `Date` keeps **instant** semantics and must not + * be widened, so callers get `null` and compile their original bound; + * - an impossible day (`2026-02-30`, `2026-13-01`) is rejected rather than + * rolled over, so a caller falls back to the untranslated comparand instead + * of silently querying a date the author never wrote. + */ +export function nextUtcCalendarDay(value: unknown): string | null { + if (typeof value !== 'string') return null; + const day = value.trim(); + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day); + if (!m) return null; + const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])]; + const start = new Date(Date.UTC(y, mo - 1, d)); + // Reject a shape-valid but impossible day: `Date.UTC` rolls 2026-02-30 into + // March, so the round-trip is what proves the input was a real calendar day. + if (fmtUtcDay(start) !== day) return null; + return fmtUtcDay(new Date(Date.UTC(y, mo - 1, d + 1))); +} + +/** `YYYY-MM-DD` of an instant's UTC calendar day. */ +function fmtUtcDay(dt: Date): string { + return ( + `${String(dt.getUTCFullYear()).padStart(4, '0')}-` + + `${String(dt.getUTCMonth() + 1).padStart(2, '0')}-` + + `${String(dt.getUTCDate()).padStart(2, '0')}` + ); +} diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index 44103c05dc..c3d69afeb8 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -7,6 +7,7 @@ export * from './filter.zod'; // against, so they cannot drift apart again (#3774). export * from './filter-logic-conformance'; export * from './date-macros.zod'; +export * from './calendar-day'; // Session-scoped filter placeholders ({current_user_id} / {current_org_id}) — // the sibling vocabulary to date macros. Presentation scope only; RLS is the // enforcement boundary. See context-tokens.zod.ts.