Skip to content

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

Merged
szuperaz merged 3 commits into
V10from
feat/branded-timestamps
Sep 24, 2026
Merged

szuperaz merged 3 commits into
V10from
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, the offline store and the example apps found no runtime unit bugs: every server timestamp that becomes a Date or meets the local clock already goes through convertTimestampToDate / nsToDate / nsToMs / nowNs. The work is making values from SQLite, fixtures and example code assignable to the branded types.

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 two 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

Offline DB

  • SQLite rows hold plain integers, and the read mappers now brand them on the way out: mapStorableToTimestamp returns TimestampNS | undefined.
  • The new mapStorableToRequiredTimestamp replaces the 17 mapStorableToTimestamp(x) ?? 0 fallbacks across the 10 read mappers, with unchanged behaviour: an absent column still reads back as the epoch.
  • Half of those mappers compiled before only because they spread JSON.parse(extraData), which is any; all of them are updated.
  • Row types in schema.ts stay number, since they are the storage boundary.

Public API (breaking)

  • findInMessagesByDate(messages, targetTimestamp) takes a TimestampNS (its stale JSDoc is fixed too).
  • getChannelUnreadState returns last_read as TimestampNS, falling back to asTimestampNS(0).
  • useIsChannelMuted uses core's ChannelMuteStatus instead of a hand-copied shape.
  • deleteMessagesForChannel's truncated_at is a TimestampNS.

Tests and fixtures

  • mock-builders/generator/time.ts convertDateToTimestamp returns TimestampNS.
  • Truncation, epoch and NaN literals go through asTimestampNS.
  • The offline reaction-group fixtures used ISO strings for first_reaction_at / last_reaction_at, which is the wrong unit; they now use nowNs().

Example apps

  • SampleApp: DraftsList's date and the WebSocket event templates' created_at are TimestampNS.
  • ExpoMessaging's map screen brands the end_at it reads from the route.

Cleanup

  • Removed the write-only lastReadRef in Channel.
  • Removed the unused, unexported src/utils/date.ts (secondsUntil).

Docs: ai-docs/ai-migration-v9-to-v10.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, the updated signatures, and how the offline mappers brand rows.

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)

  • cd package && yarn typecheck (source, tests and mock builders): no new errors. The two remaining errors predate this PR and also appear on plain V10 against the same build:
  • yarn test:unit: 2000 tests passing (203 suites).
  • The native and Expo wrappers typecheck clean. The SampleApp and ExpoMessaging error counts (15 and 5) are unchanged from their baseline.
  • I ran SampleApp on the iOS simulator:
    • channel list, message list (system message, date separators, times, receipts), threads and drafts all render correct dates;
    • a cold start with offline support hydrates correctly, and the SQLite tables hold integer nanoseconds;
    • no JS errors or date warnings in the console.

🎨 UI Changes

None. Types, offline-mapper typing, tests and docs only; rendering is unchanged.

🧪 Testing

cd package && yarn typecheck && yarn test:unit. For end-to-end checks, run SampleApp against a stream-chat build that includes GetStream/stream-chat-js#1884.

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

- offline DB read mappers brand stored integers: `mapStorableToTimestamp` returns
  `TimestampNS`, and the new `mapStorableToRequiredTimestamp` replaces the `?? 0` fallbacks
- `findInMessagesByDate`, `getChannelUnreadState`, `deleteMessagesForChannel` and
  `useIsChannelMuted` (now core's `ChannelMuteStatus`) carry `TimestampNS`
- mock builders mint branded timestamps; tests brand epoch/derived literals, and the
  reaction-group fixtures use nanoseconds instead of ISO strings
- SampleApp / ExpoMessaging timestamp props and event templates carry `TimestampNS`
- remove the write-only `lastReadRef` in `Channel` and the unused `utils/date.ts`
- migration doc describes the brand, the Date guard and its gaps

BREAKING CHANGE: `findInMessagesByDate` takes a `TimestampNS`, and `getChannelUnreadState` /
`useIsChannelMuted` return `TimestampNS` timestamps.
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)
oliverlaz and others added 2 commits September 24, 2026 12:58
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
@Stream-SDK-Bot

Copy link
Copy Markdown
Contributor

SDK Size

title develop branch diff status
js_bundle_size 2031 KB 2034 KB +3540 B 🔴

@szuperaz
szuperaz merged commit 23e7fdc into V10 Sep 24, 2026
3 of 4 checks passed
@szuperaz
szuperaz deleted the feat/branded-timestamps branch September 24, 2026 12:53
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