Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
93fb57a
feat: update to API spec v238.16.2
szuperaz Sep 16, 2026
bf1d78d
feat!: turn off date transformers
szuperaz Sep 18, 2026
8775455
chore: enable type checks for test, fix tests
szuperaz Sep 18, 2026
84badb2
feat: add same helpers as stream-chat-js
szuperaz Sep 18, 2026
0fe110a
Merge branch 'main' into remove-date-transformers
oliverlaz Sep 23, 2026
7ec8b00
chore: use TimestampNS for date properties
oliverlaz Sep 23, 2026
1a09618
chore: regenerate models
oliverlaz Sep 24, 2026
e7b9d2e
Merge branch 'main' into remove-date-transformers
oliverlaz Oct 2, 2026
ef0dfd9
Merge branch 'main' into remove-date-transformers
oliverlaz Oct 5, 2026
dfbd048
chore: merge main (0.8.12) and regenerate the client from the current…
oliverlaz Oct 14, 2026
c153019
chore: add feeds, video and moderation snippets to runner
szuperaz Oct 19, 2026
7fe1373
feat: regenerate from latest api spec
szuperaz Oct 19, 2026
a91c3e5
Merge branch 'main' into remove-date-transformers
szuperaz Oct 19, 2026
e603a31
chore: update docs snippet runner after v9 snippets added back to docs
szuperaz Oct 16, 2026
35eceb9
chore: test docs snippet runner
szuperaz Oct 19, 2026
a68066f
Merge branch 'docs-snippets-feeds-video-moderation' into remove-date-…
szuperaz Oct 19, 2026
ebe7503
test: migrate feeds, video and moderation docs snippet tests to nanos…
szuperaz Oct 19, 2026
f27a213
Merge remote-tracking branch 'origin/main' into remove-date-transformers
szuperaz Oct 20, 2026
d491ccc
chore: add waitForFeedGroup helper
szuperaz Oct 20, 2026
25b6f44
chore: regenerate the client from the current chat spec
oliverlaz Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/docs-sync/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ To check what the node SDK can do, read `src/StreamClient.ts`, `src/StreamChatCl
### Writing the Node.js fence

- Mirror the `JavaScript` fence: same comments, structure and placeholder literals (`"user-1"`, `"messaging:general"`).
- Use the node SDK's shapes and types: `client.chat.*` / `client.feeds.*`, request objects (`filter_conditions`, `preferences: [...]`), `Date` where the types say `Date` (not `.toISOString()`), and `user_id` where the client code acts as the connected user (server-side `queryChannels` with `hidden` / `muted`, drafts, devices need it).
- Use the node SDK's shapes and types: `client.chat.*` / `client.feeds.*`, request objects (`filter_conditions`, `preferences: [...]`), `Date` where the request types say `Date` (not `.toISOString()`), the SDK time helpers (`nsToDate`, `nsToMs`) for response timestamps (unix-ns numbers, not `Date`s), and `user_id` where the client code acts as the connected user (server-side `queryChannels` with `hidden` / `muted`, drafts, devices need it).
- Don't write a `Node.js (stream-chat-js@9)` fence for it: list the group in the report so the v9 version can be added by hand.
- Put it before the group's `</Tabs>`. A fence that isn't in a `<Tabs>` block: wrap it and the new fence in `<Tabs>`.
- From `$DOCS_DIR`, run `npx prettier --write <page path>` and `node scripts/tab-lint.mjs <page path>` (it puts the tabs in the canonical order: client SDKs first, then server SDKs).
Expand Down
5 changes: 3 additions & 2 deletions __tests__/docs-snippets/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ New entries are added only after the user approves them (agents propose them in
- `helpers/call-types.ts`: `createCallType(client, cleanup)` creates a `uniqueId` call type, waits until it reads back, and registers it on `cleanup.callTypes`; `trackCallTypeCreates(client, cleanup)` does the same for fences that create the type themselves (spy, call before the region); `retryOnCallTypeMissing(fn)` retries calls into a new type (successful `getCallType` reads don't prove every node knows it: `getOrCreate` can still fail with code 16 "call type does not exist", wrap it in a spy inside regions). A call type with calls can't be deleted (code 4 "multiple calls of that type exist"): `Cleanup` hard-deletes `cleanup.calls` (waiting for the delete task) before `cleanup.callTypes`. `updateChannelType(client, name, patch)` (`helpers/channel-types.ts`) fills in the fields the request requires; `grantMessagingMembers` (`helpers/grants.ts`) adds `messaging` member grants and registers the restore. `helpers/server.ts` has repeated lookups: `sendServerMessage`, `getServerUser` / `getServerMember` / `getServerChannel` (throw unless exactly one result matches), `deletePollsCreatedBy`, `waitForAppSetting`, `waitForTeamIdSupport` (multi-tenancy toggles) `waitForReviewItem` (review queue item of an entity once its flags are there), `deleteFeedModerationTemplate` (the generated `v2DeleteTemplate()` can't send the name) and `saveAndRestoreEventHooks(client, cleanup)` (saves `event_hooks`, registers the restore, returns `{ original, restore }`; `waitForEventHooks`, `withoutTimestamps`). `helpers/feeds.ts`: `retryOnFeedGroupMissing`, `addActivityOnce`, `copyFeedGroup(client, cleanup, builtInId)` (a `uniqueId` group with a built-in group's settings, on `cleanup.feedGroups`), `notificationTypes` / `waitForNotificationTypes` (a user's built-in `notification` feed). `helpers/uploads.ts`: upload fixtures (`JPEG_BASE64` / `jpeg`, `MINIMAL_PDF`) and `ignoreMissingFile` (swallows the 404 of deleting a gone upload).
- One `it` per docs snippet (or per closely related group of snippets in the same tab group).
- Add basic `expect`s after each snippet to prove it did what the docs claim.
- **Timestamps**: server-sent dates on responses and events are unix-**nanosecond** numbers (`TimestampNS`), not `Date`s; request date fields still take a `Date`. Use the SDK's time helpers (`nsToMs` for arithmetic against `Date.now()`, `nsToDate` to pass one back in a request, `convertTimestampToDate` for optional ones), assert presence with `expect.any(Number)`, and compare two server timestamps directly. Never `new Date(t)` or `.getTime()` on a response field.

### Test app

Expand Down Expand Up @@ -134,7 +135,7 @@ New entries are added only after the user approves them (agents propose them in
- An app setting that changes API behavior (e.g. `enforce_unique_usernames`) can break later tests in the same file: restore it at the end of the test that changes it, and keep the `cleanup.add` restore as a fallback.
- Video calls: create them with `uniqueId` ids and push the cid to `cleanup.calls` (hard deleted, "not found" ignored). Call types: `cleanup.callTypes` (see `helpers/call-types.ts`).
- Feeds v3 works on the test app (feed groups `user`, `timeline`, `notification`, ...): create feeds with `uniqueId` ids (`client.feeds.feed(group, id).getOrCreate({ user_id })`) and push the fid (`group:id`) to `cleanup.feeds` (hard deleted, "not found" / "can't be found" ignored, so a snippet may delete it first). After a soft `deleteFeedUserData`, `getActivity` on the user's activity rejects with `metadata.responseCode: 404`.
- Feed groups are app-wide and eventually consistent: create them with a `uniqueId` id (hyphens are accepted) and push it to `cleanup.feedGroups` (deleted after `cleanup.feeds`). Calls into a new group can fail with code 16 "Feed group with ID ... not found" for a while (usually 1–2s): `retryOnFeedGroupMissing(client, ['getOrCreateFeed', 'follow', ...])` (`helpers/feeds.ts`) spies on the generated methods and retries them (`StreamFeed` methods go through `client.feeds`). `addActivity` into a new group can answer "not found" after storing the activity, so a blind retry adds it twice: in setup use `addActivityOnce(client, request)` (explicit id, retries only while `getActivity` doesn't find it). `updateFeedGroup` changes take a few seconds to reach reads: wait for several reads in a row to agree. An `updateFeedGroup` fence that leaves fields out clears them, including `aggregation` and `push_notification.push_types`. Feed group `ranking.type` accepts only `expression` and `interest` (code 4); recency is the default when `ranking` is omitted. `updateFeedGroup({ id })` with no other fields clears `custom` (updates replace every field). `deleteFeedGroup` without `hard_delete` soft-deletes (listed only with `include_soft_deleted: true`).
- Feed groups are app-wide and eventually consistent: create them with a `uniqueId` id (hyphens are accepted) and push it to `cleanup.feedGroups` (deleted after `cleanup.feeds`). Calls into a new group can fail with code 16 "Feed group with ID ... not found" for a while (usually 1–2s): `retryOnFeedGroupMissing(client, ['getOrCreateFeed', 'follow', ...])` (`helpers/feeds.ts`) spies on the generated methods and retries them (`StreamFeed` methods go through `client.feeds`). `addActivity` into a new group can answer "not found" after storing the activity, so a blind retry adds it twice: in setup use `addActivityOnce(client, request)` (explicit id, retries only while `getActivity` doesn't find it). A fence that calls `addActivity` into a group the setup created: `waitForFeedGroup(client, id)` first (several `getFeedGroup` reads in a row). `updateFeedGroup` changes take a few seconds to reach reads: wait for several reads in a row to agree. An `updateFeedGroup` fence that leaves fields out clears them, including `aggregation` and `push_notification.push_types`. Feed group `ranking.type` accepts only `expression` and `interest` (code 4); recency is the default when `ranking` is omitted. `updateFeedGroup({ id })` with no other fields clears `custom` (updates replace every field). `deleteFeedGroup` without `hard_delete` soft-deletes (listed only with `include_soft_deleted: true`).
- Feed views are app-wide and eventually consistent: `uniqueId` ids and `cleanup.add(() => client.feeds.deleteFeedView({ id }))` (ignore "not found"). `getFeedView` and `getOrCreate({ view })` answer code 16 "... not found" for 1–3s after a create (`retryOnFeedGroupMissing` / `isFeedGroupMissing` match it), `listFeedViews` lags ~4s, `ranking.type: "recency"` is rejected, `updateFeedView({ id })` replaces every field. To read a spy that `retryOnFeedGroupMissing` installed, call `vi.spyOn(client.feeds, 'method')` again (returns the existing mock); `vi.mocked(client.feeds.method)` fails `unbound-method`.
- `updateApp({ max_aggregated_activities_length })` returns 200 but `getApp` keeps 0 and the limit isn't applied (2026-10-16); values over 100 are rejected (code 4).
- Feed ranking: ranked results settle within ~1–3s; make test popularity the reverse of recency so a passing order proves the ranking. Popularity = reactions (one per type per user: use several types) + shares × 3. Ranking expressions are validated on create (unknown variables such as `externals`, fields without defaults, non-map arguments to map functions, `min_popularity` < 1 for the popular selector). `preference_score` is 0 until the user gives `activityFeedback`. A group created without `activity_selectors` defaults to `current_feed`. Map key order in responses (`ranking.functions`) isn't stable: sort before comparing. A duplicate `createFeedGroup` id (or a built-in id like `timeline`) fails with "already exists": a docs bug.
Expand Down Expand Up @@ -233,7 +234,7 @@ New entries are added only after the user approves them (agents propose them in
- A new channel type starts with empty `guest` grants (no `messaging` defaults). To test removing guest grants on a `uniqueId` type, first copy the guest grants from the built-in type (read only), then `waitForChannelTypePropagation()`; with create + update waits, give that `beforeAll` a 180s timeout.
- Uploads are checked by content as well as extension: a tiny CSV (`a,b`) is read as `text/plain` and fails "File type text/plain is not supported" under `allowed_mime_types: ["text/csv"]`. Use a few rows of real CSV.
- To prove `disable_auth_checks`, send a dev token (`<header>.<payload>.devtoken`, base64url JSON) to `POST /channels?api_key=...` with `stream-auth-type: jwt`: 201 while checks are off, 401 when on. The 401 message differs per API node ("development tokens are not allowed for this application" or "Token signature is invalid", the latter for over a minute on some nodes): accept both, and `retry` until the status matches.
- The same request proves a user token works or is revoked: `POST /channels?api_key=...` with `Authorization: <token>` and `stream-auth-type: jwt` returns 201 when accepted and 401 code 40 "This token has been revoked, please generate a new token" after a revocation (takes effect within seconds: `retry`). The SDK sets `iat` one second in the past by default, so a token made right after revoking with `new Date()` can still count as revoked: retry the fresh-token check, or give the tokens to revoke an `iat` well in the past. `revoke_tokens_issued_before` reads back as a `Date` that can be 1 ms off: compare with a ±1 ms tolerance.
- The same request proves a user token works or is revoked: `POST /channels?api_key=...` with `Authorization: <token>` and `stream-auth-type: jwt` returns 201 when accepted and 401 code 40 "This token has been revoked, please generate a new token" after a revocation (takes effect within seconds: `retry`). The SDK sets `iat` one second in the past by default, so a token made right after revoking with `new Date()` can still count as revoked: retry the fresh-token check, or give the tokens to revoke an `iat` well in the past. `revoke_tokens_issued_before` reads back (unix ns) up to 1 ms off the `Date` sent: compare `nsToMs(...)` with a ±1 ms tolerance.
- `file_upload_config` / `image_upload_config` come back with every list present (`[]`) and `size_limit: 0`: restore the saved original and `waitForAppSetting` (deep compare) for it.
- A new custom role isn't known to every API node right away: `updateUsersPartial({ set: { role } })` just after `createRole` can fail with code 4 `role "..." provided but not defined`. Spy on `client.updateUsersPartial` with a `mockImplementation` that calls the bound original through `retry(..., { retryIf: <message includes 'provided but not defined'> })`. `deleteRole` right after `createRole` fails the same way with `role "..." does not exist`: retry it like that on `'does not exist'`.
- `updateChannelType({ grants: null })` resets grants to the API defaults, which differ from a new type's initial grants (they add e.g. `create-attachment`, `send-poll`, `create-mention`): assert against the update response. New and built-in types start with `guest: []` and `anonymous: []`: to prove a removal snippet works, give those roles grants first.
Expand Down
3 changes: 2 additions & 1 deletion __tests__/docs-snippets/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createdAtFromId, DOCS_TEST_PREFIX } from './helpers/ids';
import {
describeLeftovers,
findLeftovers,
leftoverCreatedAt,
removeLeftovers,
} from './helpers/leaks';
import type { Leftover } from './helpers/leaks';
Expand All @@ -28,7 +29,7 @@ const createdAt = (leftover: Leftover) => {
// anything else falls back to the server timestamp.
const fromId = createdAtFromId(leftover.id.split(' ')[0]);
if (fromId) return fromId;
return leftover.createdAt ? new Date(leftover.createdAt) : undefined;
return leftoverCreatedAt(leftover);
};

const loadEnv = () => {
Expand Down
12 changes: 12 additions & 0 deletions __tests__/docs-snippets/helpers/feeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ export const retryOnFeedGroupMissing = (
}
};

/**
* Waits until a new feed group can be read back several times in a row, for setup
* before a region that calls `addActivity` into it (which can't be retried blindly).
*/
export const waitForFeedGroup = async (client: StreamClient, id: string) => {
for (let i = 0; i < 5; i++) {
await retry(() => client.feeds.getFeedGroup({ id }), {
retryIf: isFeedGroupMissing,
});
}
};

/**
* Adds an activity (setup only) into a feed whose group may be new. `addActivity` can
* answer "Feed group ... not found" after it already stored the activity, so a blind
Expand Down
29 changes: 23 additions & 6 deletions __tests__/docs-snippets/helpers/leaks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { StreamClient } from '../../../index';
import { nsToDate } from '../../../index';
import type { StreamClient, TimestampNS } from '../../../index';
import { DOCS_TEST_PREFIX } from './ids';
import { deleteMembershipLevel } from './feeds';
import {
Expand All @@ -8,12 +9,23 @@ import {
import { hardDeleteUsers } from './users';
import { retry, retryOnRateLimit, waitForTask } from './wait';

/** A leftover's `createdAt` as a `Date`, or `undefined` when it has none. */
export const leftoverCreatedAt = ({ createdAt }: Leftover) => {
if (createdAt === undefined) return undefined;
return typeof createdAt === 'number'
? nsToDate(createdAt)
: new Date(createdAt);
};

/** Something a docs test created and didn't remove. */
export type Leftover = {
kind: string;
id: string;
/** Used by the sweep to skip resources of runs that may still be in progress. */
createdAt?: Date | string;
/**
* Used by the sweep to skip resources of runs that may still be in progress. A unix-ns
* timestamp from a response, or an RFC3339 string; read it with `leftoverCreatedAt`.
*/
createdAt?: number | string;
remove: () => Promise<unknown>;
/**
* Removes many leftovers of this kind in one call (e.g. users in one `deleteUsers`).
Expand Down Expand Up @@ -112,7 +124,7 @@ const scanners: Record<string, Scanner> = {

async channels(client, { matches, ids: channelIds = [], createdSince }) {
const found = new Map<string, Leftover>();
const add = (channel: { cid: string; created_at: Date }) =>
const add = (channel: { cid: string; created_at: number }) =>
found.set(channel.cid, {
kind: 'channel',
id: channel.cid,
Expand Down Expand Up @@ -402,12 +414,17 @@ const scanners: Record<string, Scanner> = {
// without one it fails with code 4 "cannot specify sort and next/prev at the same
// time" (2026-10-16).
const scan = async (filter_conditions: Record<string, unknown>) => {
let after: Date | undefined;
let after: TimestampNS | undefined;
for (;;) {
const response = await retryOnRateLimit(() =>
client.video.queryCalls({
filter_conditions: after
? { $and: [filter_conditions, { created_at: { $gt: after } }] }
? {
$and: [
filter_conditions,
{ created_at: { $gt: nsToDate(after) } },
],
}
: filter_conditions,
sort: [{ field: 'created_at', direction: 1 }],
limit: 100,
Expand Down
6 changes: 3 additions & 3 deletions __tests__/docs-snippets/maintenance/sweep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createdAtFromId, DOCS_TEST_PREFIX } from '../helpers/ids';
import {
describeLeftovers,
findLeftovers,
leftoverCreatedAt,
removeLeftovers,
} from '../helpers/leaks';
import type { Leftover } from '../helpers/leaks';
Expand All @@ -20,9 +21,8 @@ const dryRun = process.env.DOCS_SWEEP_DRY_RUN === '1';
const minAgeMinutes = Number(process.env.DOCS_SWEEP_MIN_AGE ?? 30);

const isOldEnough = (leftover: Leftover) => {
const createdAt = leftover.createdAt
? new Date(leftover.createdAt)
: createdAtFromId(leftover.id.split(' ')[0]);
const createdAt =
leftoverCreatedAt(leftover) ?? createdAtFromId(leftover.id.split(' ')[0]);
// Unknown age: only sweep it when explicitly asked to ignore age.
if (!createdAt || Number.isNaN(createdAt.getTime()))
return minAgeMinutes === 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ describe(DOCS, () => {
return response;
});
expect(after.notification_status?.unread).toBe(0);
expect(after.notification_status?.last_read_at).toBeInstanceOf(Date);
expect(after.notification_status?.last_read_at).toEqual(expect.any(Number));
});

it('creates a poll, attaches it to an activity and votes', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,6 @@ describe(DOCS, () => {
});
return all.groups[feedGroupId];
});
expect(deleted?.deleted_at).toBeInstanceOf(Date);
expect(deleted?.deleted_at).toEqual(expect.any(Number));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -589,10 +589,10 @@ describe(DOCS, () => {
// #region snippet docs="activity-feeds/v3-latest/_default/03-feed_groups/06-notification_feeds.md" heading="Legacy: Client-side computation" tab="Node.js" index=1
// Deprecated — use group.is_read / group.is_seen instead
const isRead =
(lastReadAt && group.updated_at.getTime() < lastReadAt.getTime()) ||
(lastReadAt && group.updated_at < lastReadAt) ||
readActivities.includes(group.group);
const isSeen =
(lastSeenAt && group.updated_at.getTime() < lastSeenAt.getTime()) ||
(lastSeenAt && group.updated_at < lastSeenAt) ||
seenActivities.includes(group.group);
// #endregion snippet

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
copyFeedGroup,
isFeedGroupMissing,
retryOnFeedGroupMissing,
waitForFeedGroup,
} from '../../../../helpers/feeds';
import { uniqueId } from '../../../../helpers/ids';
import { retry } from '../../../../helpers/wait';
Expand Down Expand Up @@ -71,6 +72,8 @@ describe(DOCS, () => {
oneFid = await createFeed('user', oneId, oneId);
const stockGroupId = newFeedGroupId('stock');
await client.feeds.createFeedGroup({ id: stockGroupId });
// The fence adds an activity into this group, and addActivity can't be retried.
await waitForFeedGroup(client, stockGroupId);
appleFid = await createFeed(stockGroupId, uniqueId('apple'), jackId);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
import { nsToMs } from '../../../../../../index';
import { getServerClient } from '../../../../helpers/clients';
import { Cleanup } from '../../../../helpers/cleanup';
import { uniqueId } from '../../../../helpers/ids';
Expand Down Expand Up @@ -125,7 +126,7 @@ describe(DOCS, () => {
);
// The stored time can be 1 ms off the ISO string that was sent.
expect(
Math.abs((story.expires_at?.getTime() ?? 0) - tomorrow.getTime()),
Math.abs(nsToMs(story.expires_at ?? 0) - tomorrow.getTime()),
).toBeLessThanOrEqual(1);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ describe(DOCS, () => {
expect(firstPage.next).toBeTruthy();
expect(secondPage.feeds).toHaveLength(2);
expect([...fids(firstPage), ...fids(secondPage)].sort()).toEqual(expected);
const times = [...firstPage.feeds, ...secondPage.feeds].map((f) =>
f.created_at.getTime(),
const times = [...firstPage.feeds, ...secondPage.feeds].map(
(f) => f.created_at,
);
expect(times).toEqual([...times].sort((a, b) => b - a));
});
Expand Down
Loading
Loading