Skip to content

feat!: adopt stream-chat's branded TimestampNS - #3297

Open
oliverlaz wants to merge 2 commits into
release-v15from
feat/branded-timestamps
Open

oliverlaz wants to merge 2 commits into
release-v15from
feat/branded-timestamps

Conversation

@oliverlaz

@oliverlaz oliverlaz commented Sep 23, 2026

Copy link
Copy Markdown
Member

🎯 Goal

Adopt stream-chat's branded TimestampNS (GetStream/stream-chat-js#1884, REACT-1179). stream-chat now types every server-sent date as TimestampNS, a branded unix-nanosecond number. Its published types also make new Date(timestamp) a compile error, because a nanosecond value is out of Date's range and silently becomes an Invalid Date.

An audit of this SDK found no runtime unit bugs: every server timestamp that becomes a Date, gets formatted, or meets Date.now() already goes through convertTimestampToDate / nsToDate / nsToMs. The work is closing the type gaps so the brand, and with it the guard, reaches the public props and the tests.

Requires stream-chat@^10.0.0-rc.14, the first release with GetStream/stream-chat-js#1884 (TimestampNS, asTimestampNS, the DateConstructor guard); this PR bumps the dependency.

CI will still be red on three unrelated breakages that also landed in stream-chat-js between rc.12 and rc.14 (listed under Verification). They are not fixed here.

🛠 Implementation details

Public types (breaking)

These now take TimestampNS, so a millisecond number no longer compiles:

  • ProcessMessagesParams.lastRead (processMessages)
  • MessageList headerPosition / insertIntro. It already had to be in nanoseconds, but was typed number.
  • the VirtualizedMessageList lastReadDate render prop
  • ChannelFilesView.utils's internal normalizeTimestamp

Tests and fixtures

  • mock-builders/generator/time.ts convertDateToTimestamp returns TimestampNS, which makes the generated fixtures assignable (about 50 test errors fixed in one place).
  • The reminder, reaction and delivered-event builders brand their values.
  • Hand-written literals (epoch 0, NaN, now - msToNs(…)) go through asTimestampNS.
  • MessageList/__tests__/utils.test.ts passed a Date as lastRead through an untyped helper; it now passes nowNs(), and the helper is typed.
  • NotificationAnnouncer.test.tsx imported a type from the sibling ../stream-chat-js/src checkout; it now imports from stream-chat. That removes 17 errors from yarn types:tests.

Other

  • The Vite example's WebSocket event templates type their timestamps TimestampNS.
  • ai-docs/ai-migration-v14-v15.md now documents:
    • the brand and the Date guard;
    • what the guard does not catch: date libraries, ts ?? Date.now(), Math.max(…), and unit mix-ups;
    • asTimestampNS and the updated type table.
  • The same doc previously listed latestMessageDatesByChannels as retyped; it now points to the section that records its removal. i18n-v15-migration.md no longer destructures it from useChat.
  • A stale comment on ChatContextValue.mutes is fixed.

Verification (first against a local build of GetStream/stream-chat-js#1884, re-run against the published stream-chat@10.0.0-rc.14 with the same results)

🎨 UI Changes

None. Types, tests and docs only.

stream-chat now types every server-sent date as `TimestampNS` and makes `new Date(timestamp)`
a compile error (GetStream/stream-chat-js#1884).

- `processMessages` `lastRead`, `MessageList` `headerPosition` / `insertIntro` and the
  `VirtualizedMessageList` `lastReadDate` render prop are typed `TimestampNS`
- mock builders mint branded timestamps (`convertDateToTimestamp` returns `TimestampNS`)
- tests brand epoch/derived literals with `asTimestampNS`; a test that passed a `Date` as
  `lastRead` now passes a wire timestamp, and a test no longer imports the sibling
  `stream-chat-js/src` checkout
- the Vite example's WebSocket event templates carry `TimestampNS`
- migration docs describe the brand, the Date guard and its gaps, and drop the stale
  `latestMessageDatesByChannels` references

BREAKING CHANGE: `processMessages`' `lastRead`, `MessageList`'s `headerPosition` and the
`lastReadDate` render prop are typed `TimestampNS`; a plain millisecond `number` no longer
compiles.
@coderabbitai

coderabbitai Bot commented Sep 23, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: fa1a0442-813c-4a4a-9612-c99ceb21bc70

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

oliverlaz added a commit to GetStream/stream-chat-js that referenced this pull request Sep 24, 2026
…guard (#1884)

## CLA

- [ ] I have signed the [Stream
CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform)
(required).
- [x] Code changes are tested

## Description of the changes, What, Why and How?

Follow-up to GetStream/chat#17356
([REACT-1179](https://linear.app/stream/issue/REACT-1179)), which taught
the TypeScript generator to type every server-sent date as
`TimestampNS`, a branded unix-nanosecond `number`, and to emit
`models/timestamp-guard.d.ts`, which makes `new Date(timestamp)` a
compile error. This PR regenerates the client with it and closes the
gaps the brand exposed.

**Why:** a nanosecond timestamp is out of `Date`'s range, so the natural
`new Date(message.created_at)` silently yields an Invalid Date that
throws later from `.toISOString()` or renders "Invalid Date". Until now
nothing flagged it at compile time.

### Time helpers (`src/utils/time.ts`)

- `nowNs`, `msToNs` and `dateToNs` return `TimestampNS`.
- `nsToDate`, `nsToRfc3339` and `convertTimestampToDate` require a
`TimestampNS`, so `nsToDate(Date.now())` no longer compiles instead of
returning a 1970 date.
- `nsToMs` keeps taking a plain `number`, because it also converts
durations (the difference of two timestamps).
- New `asTimestampNS(n)` brands a number that is already in nanoseconds
(DB rows, fixtures, the epoch `asTimestampNS(0)`). It is exported from
the root.

### Hand-written types carry the brand

Plain `number` would drop the brand, so `new
Date(channel.state.read[id].last_read)` would still compile. These are
now `TimestampNS`:

- read state (`last_read`, `last_delivered_at`), mute status,
`lastRead()` / `countUnread()`
- thread state, poll `lastActivityAt`, reminder state and `timeLeftMs`
- the receipts tracker and delivery reporter
- the paginators (`lastMessageAt`, unread snapshot, `truncate`,
deletion, `findItemByTimestamp`)
- the `LocalEvent` / `ConnectedEvent` timestamps, the cooldown timer,
the composer audit clock, and offline `truncated_at`

### Shipping the guard to consumers

The client is regenerated from GetStream/chat master, which since
GetStream/chat#17460 emits the guard as `models/timestamp-guard.ts`, a
module `tsc` compiles into `dist/types` like any other file. No copy
step is needed. Emitting it is not enough on its own, though: a global
augmentation only loads for a consumer if the entry point's type graph
imports it. So:

- `src/index.ts` re-exports the guard type-only (`export type {} from
'./gen/models/timestamp-guard'`). Declaration emit keeps it, and esbuild
erases it, so there is no runtime import of an empty module. Without
this line the guard is still emitted but never loads for a consumer; I
checked, and the dist type check below then fails.
- `test/types/timestamps.ts` holds compile-time assertions, run twice:
  - against `src` by `yarn types`, which **this PR adds to PR CI**;
- against the built `dist`, resolved through `exports` exactly as a
consumer would, at the end of `yarn build`. A release whose published
types lost the guard now fails.

### Other timestamp fixes found while auditing

- **`pinMessage`**: a `number` there means seconds, so passing
`message.pinned_at` compiled and threw at runtime. Both parameters now
reject a `TimestampNS`.
- **Offline read state**: `handleRead` persisted `received_at` (the
local clock at receipt) as `last_read`. For a mark-unread that is "now",
which is past the messages just marked unread, so a cold start from
SQLite lost the unread boundary. It now writes what `Channel` writes:
`last_read_at` for a mark-unread, `created_at` otherwise.
- **`rate_limit_reset`**: `new Date('1700000000')` is an Invalid Date.
The header is now read as unix seconds.
- The dev burst simulator and several stale test fixtures used ISO
strings or `Date`s; they now use nanoseconds.

### Unrelated items in the regen

These come from the current spec, not from the timestamp work:

- `translateMessage` now returns `TranslateMessageResponse`.
- `updatePoll` forwards `team`.
- `appeal` forwards `channel_cid`.

The generator's compact-interface change (#17356) also makes the
`src/gen/models/index.ts` diff mostly whitespace.

### Verification

- `yarn types` (src, scripts, type assertions), `yarn lint`, `yarn test`
(3967 passing) and `yarn build` (including the dist type check) all pass
after rebasing on `release-v10`.
- I checked that the assertions aren't hollow: against a stale build,
the `pinMessage` assertions fail with "Unused `@ts-expect-error`".
- I ran stream-chat-react's Vite example and stream-chat-react-native's
SampleApp (iOS simulator) against this build. The channel list, message
list, date separators, threads, drafts and search all render correct
dates. The RN offline database holds integer nanoseconds and hydrates
correctly on a cold start.

Related PRs:
- GetStream/stream-chat-react#3297 and
GetStream/stream-chat-react-native#3822 adopt this. Both need a
stream-chat release that includes it.
- GetStream/chat#17425 lists `asTimestampNS` in the generator template;
this PR's generated file already carries the same line.

## Changelog

- **BREAKING:** server-sent timestamps are typed `TimestampNS` (a
branded `number`). Constructing a response-shaped object needs `nowNs()`
/ `msToNs()` / `dateToNs()` / `asTimestampNS()`, and `new
Date(timestamp)` no longer compiles.
- **BREAKING:** `nsToDate`, `nsToRfc3339` and `convertTimestampToDate`
require a `TimestampNS`; `nowNs`, `msToNs` and `dateToNs` return one.
- **BREAKING:** `client.pinMessage` rejects a server timestamp where a
number means a seconds offset.
- New `asTimestampNS` helper.
- Fix: offline read state persists the server read timestamp instead of
the local receipt time.
- Fix: `rate_limit_reset` is parsed from unix seconds instead of
producing an Invalid Date.
github-actions Bot pushed a commit to GetStream/stream-chat-js that referenced this pull request Sep 24, 2026
## [10.0.0-rc.14](v10.0.0-rc.13...v10.0.0-rc.14) (2026-09-24)

### ⚠ BREAKING CHANGES

* brand server-sent timestamps as TimestampNS and ship the Date guard (#1884)

### Features

* brand server-sent timestamps as TimestampNS and ship the Date guard ([#1884](#1884)) ([e51198a](e51198a)), closes [GetStream/chat#17356](https://github.com/GetStream/chat/issues/17356) [GetStream/chat#17460](https://github.com/GetStream/chat/issues/17460) [#17356](https://github.com/GetStream/stream-chat-js/issues/17356) [GetStream/stream-chat-react#3297](GetStream/stream-chat-react#3297) [GetStream/stream-chat-react-native#3822](GetStream/stream-chat-react-native#3822) [GetStream/chat#17425](https://github.com/GetStream/chat/issues/17425)
rc.14 is the first release with TimestampNS, asTimestampNS and the
published Date guard, which this SDK now imports.

Refs: GetStream/stream-chat-js#1884

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.

3 participants