Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 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
6ffd040
chore: merge main (0.8.14) and regenerate the client from the current…
oliverlaz Oct 27, 2026
4c7ff45
feat: update to latest API spec
szuperaz Oct 29, 2026
f0520a1
chore: docs sync
szuperaz Oct 29, 2026
e7cc754
chore: test fixes
szuperaz Oct 29, 2026
bc0b1e7
chore: add ts-expect-error to reported type issues
szuperaz Oct 29, 2026
d0d76cf
chore: rename stream-chat-js reference to stream-chat
szuperaz Oct 30, 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
64 changes: 47 additions & 17 deletions .claude/skills/docs-sync/SKILL.md

Large diffs are not rendered by default.

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
8 changes: 4 additions & 4 deletions __tests__/docs-snippets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ these sidebars in `getstream.io/content/docs/_sidebars/` (`SIDEBAR_FILES` in `sy
- platform: `[platform][default].json`

Only fences labelled `Node.js` are in scope (a fence label, or the `label` of the enclosing `<tabs-item>` on the video pages) (docs fences must always have a label): they are fixed when they are
wrong. `JavaScript`, `React` and `React Native` fences (client-side code), the stream-chat-js@9 fences (`JavaScript (stream-chat-js@9)`,
`Node.js (stream-chat-js@9)`) and `Node.js` fences inside a `<disclosure label="... (no SDK)">` block (reference
wrong. `JavaScript`, `React` and `React Native` fences (client-side code), the stream-chat@9 fences (`JavaScript (stream-chat@9)`,
`Node.js (stream-chat@9)`) and `Node.js` fences inside a `<disclosure label="... (no SDK)">` block (reference
implementations without the SDK) are out of scope and are neither tested nor changed.

```sh
Expand Down Expand Up @@ -91,8 +91,8 @@ await channel.getOrCreate({ data: { created_by_id: creatorId } });
- `index` is 1-based among fences with the same heading text and tab (default 1).
- **COPY**: `name="literal"` pairs, applied to the region before comparing (identifier `name` becomes the docs literal). Don't use COPY variables as object keys or shorthand properties.
- **Comparison**: both sides are formatted with the same prettier options, so quote style and line wrapping don't matter, but any code or comment change does.
- **Coverage** (checked first): every `Node.js` fence (`js`/`javascript`/`ts`/`typescript`) on the pages of the tested sidebars (see the top of this file; `$fragment` entries are resolved from `_sidebars/fragments/`), whether or not the page has tests yet, must have a region (inside `it.skip` if it can't run). `JavaScript` / `React` / `React Native` / stream-chat-js@9 fences and comment-only fences are ignored.
- **Labels**: every JS fence on those pages must be labelled `JavaScript`, `React`, `React Native`, `Node.js`, `JavaScript (stream-chat-js@9)` or `Node.js (stream-chat-js@9)`; unlabelled or otherwise labelled JS fences fail.
- **Coverage** (checked first): every `Node.js` fence (`js`/`javascript`/`ts`/`typescript`) on the pages of the tested sidebars (see the top of this file; `$fragment` entries are resolved from `_sidebars/fragments/`), whether or not the page has tests yet, must have a region (inside `it.skip` if it can't run). `JavaScript` / `React` / `React Native` / stream-chat@9 fences and comment-only fences are ignored.
- **Labels**: every JS fence on those pages must be labelled `JavaScript`, `React`, `React Native`, `Node.js`, `JavaScript (stream-chat@9)` or `Node.js (stream-chat@9)`; unlabelled or otherwise labelled JS fences fail.
- **Excluded pages and fences**: `EXCLUDED_PAGES` in `sync/parse.ts` lists sidebar pages that are deliberately not validated, with a reason. Both checks skip them. `EXCLUDED_FENCES` does the same for single `Node.js` fences (keyed by fence id) that hold another SDK's code, e.g. Feeds v2 `getstream` client code on the moderation Feeds v2 quick start: not ours to test or update.
- **`DOCS_SYNC_WRITE=<page path> yarn test:docs:sync`** writes the regions of that page into the docs fences (docs prettier style). Use it to apply a fix to the docs. `DOCS_SYNC_WRITE=1` writes all pages.
- **`DOCS_DIR`** points at another docs checkout (default: `../getstream.io/content/docs` next to this repo). The check is skipped if the directory doesn't exist.
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
37 changes: 30 additions & 7 deletions __tests__/docs-snippets/helpers/leaks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { StreamClient } from '../../../index';
import { nsToDate } from '../../../index';
import type {
PushProviderName,
StreamClient,
TimestampNS,
} from '../../../index';
import { DOCS_TEST_PREFIX } from './ids';
import { deleteMembershipLevel } from './feeds';
import {
Expand All @@ -8,12 +13,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 +128,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 @@ -337,7 +353,9 @@ const scanners: Record<string, Scanner> = {
createdAt: provider.created_at,
remove: () =>
client.deletePushProvider({
type: provider.type,
// The spec types `PushProviderResponse.type` as a plain string, but the
// delete endpoint takes a `PushProviderName`: the listed type is one of them.
type: provider.type as PushProviderName,
name: provider.name,
}),
}));
Expand Down Expand Up @@ -402,12 +420,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));
});
});
Loading
Loading