Skip to content

feat(search)!: serve dates at their source precision, typed as ISO8601Date - #987

Open
ddeboer wants to merge 2 commits into
mainfrom
feat/serve-date-precision
Open

ddeboer wants to merge 2 commits into
mainfrom
feat/serve-date-precision

Conversation

@ddeboer

@ddeboer ddeboer commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

A date field now comes back at the precision its source gave, in one canonical form per precision:

Source literal Before After
1999 1999-01-01T00:00:00.000Z 1999
1999-05 1999-05-01T00:00:00.000Z 1999-05
1999-05-03+02:00 absent 1999-05-03
1999+02:00 1999-01-01T00:46:48.000Z 1999
1999-05-03T14:30:00+02:00 1999-05-03T12:30:00.000Z 1999-05-03T12:30:00Z
1999-05-03T14:30:00 (Amsterdam host) stored as 12:30Z 1999-05-03T14:30:00Z
  • Codec. isoToUnixSeconds returns { seconds, precision } and parses dates without Date’s string parser. A year, month or day ignores its timezone and is stored at its first second in UTC. A date-time without a timezone is read as UTC. 24:00:00 and offsets up to ±14:00 are accepted, as in XSD. unixSecondsToIso takes the precision to format at.
  • Storage. The precision goes in a stored, unindexed <field>_precision companion (PhysicalFields.precision), at top level and in nested objects. Sorting and filtering still read the seconds alone. validateSearchType reserves the companion’s name.
  • Seam. A date’s SearchValue is now a StoredDate. The Typesense adapter reconstructs it from the companion, and lookups fetch the companion along with the date. An index without the companion reports dateTime, so dates keep being served as date-times until a reindex.
  • Derive. A string is parsed like any literal. Seconds are truncated and stored as a date-time.

An ISO8601Date scalar

Every date on the GraphQL surface was a plain String. It is now an ISO8601Date, a custom scalar whose @specifiedBy URL points at a new Dates section in the docs. That section gives the four forms and states the agreement ISO 8601 asks for: years beyond four digits, reduced precision, year 0 as 1 BCE, citing XSD 1.1 Part 2.

  • The scalar covers date fields, DateExtent bounds and DateRange bounds. An unbinned date facet’s buckets get their own DateBucket (value and count), since ValueBucket.value stays String.
  • An unreadable bound is now rejected at coercion with BAD_USER_INPUT, as IRI does, instead of reaching validateQuery.
  • Serialised values are unchanged. The types change, so this is a separate breaking commit.
  • The name avoids Date: codegen commonly maps Date to a JS Date, which cannot hold a deep-time year, and graphql-scalars’ Date means a full date only.
  • Binned date facets still serve RangeBucket with Float bounds in Unix seconds, as before. That is left for a follow-up.

Deviation from the issue: facets stay date-times

The issue asks for a range facet’s extent and buckets to be formatted at the coarsest precision stored for the field. This PR leaves that out: they are served as date-times, and the UI picks the grain it draws at.

  • In mixed collections a single year-only record would make every facet year-grained, so the rule would almost always answer “year”, which the UI already knows.
  • Distinct seconds formatted at year precision collapse into duplicate bucket labels: 1999 and 1999-05-03 would both read 1999.
  • Knowing the coarsest precision means faceting on the companion, and that fails on any index built before this change.

So the companion is never faceted, and the mixed-precision facet test from “Done when” does not apply.

Coarse upper bounds (max: "1999" still means the first second of 1999) stay out of scope, as the issue says. The docs state the workaround.

Fix #973
Fix #980

- Keep each date’s precision (year, month, day or dateTime) in a stored,
  unindexed <field>_precision companion beside its Unix seconds
- Parse dates without Date’s string parser: a year, month or day ignores its
  timezone, a date-time without one is read as UTC, and 24:00:00 and offsets
  up to ±14:00 are accepted
- Serve each value in one canonical form per precision (1999, 1999-05,
  1999-05-03, 1999-05-03T12:30:00Z); serve a range facet’s extent and buckets
  as date-times
- Fetch the companion with a lookup’s fields, and reserve its name against
  declared fields

BREAKING CHANGE: GraphQL date fields come back at their source’s precision
rather than as a millisecond timestamp: 1999 instead of
1999-01-01T00:00:00.000Z, and date-times as 1999-05-03T12:30:00Z.
isoToUnixSeconds returns { seconds, precision } instead of a number,
unixSecondsToIso takes a required precision, and a date’s SearchValue is a
StoredDate instead of a number. Reindex to fill the companion; until then
dates are served as date-times.
- Type date fields, DateExtent bounds and DateRange bounds as
  ISO8601Date, specified by the dates section of the docs
- Serve an unbinned date facet's buckets as DateBucket, so a bucket
  value feeds straight into DateRange
- Reject an unreadable date bound at coercion with BAD_USER_INPUT
- Document the ISO 8601 agreement the scalar follows

BREAKING CHANGE: date fields, DateExtent, DateRange bounds and date
facet buckets are typed ISO8601Date instead of String, and date
buckets are DateBucket instead of ValueBucket. Declare variables as
ISO8601Date and select DateBucket where you selected ValueBucket.
@ddeboer ddeboer changed the title feat(search)!: serve a date at the precision its source gave feat(search)!: serve dates at their source precision, typed as ISO8601Date Nov 4, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Type GraphQL dates with a scalar that says what they hold Serve a date at the precision its source gave

1 participant