Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f97ddd8
fix: have single source of truth for typing events
isekovanic Oct 19, 2026
312e2e5
fix: shared throttling intervals for different collections
isekovanic Oct 19, 2026
c103da0
fix: exhaust event types properly
isekovanic Oct 19, 2026
79477d2
fix: remove * wildcard for ws event listeners
isekovanic Oct 19, 2026
e295664
fix: retry error propagation
isekovanic Oct 19, 2026
eef8868
fix: fail expired static tokens immediately
isekovanic Oct 19, 2026
dd1b8c7
fix: message updates not going through in channel when thread is not …
isekovanic Oct 19, 2026
45290f4
fix: message deletion for user outside of active window
isekovanic Oct 19, 2026
311c28a
fix: keep recover api working even if automatic recovery is disabled
isekovanic Oct 19, 2026
73a8775
fix: reminder list blanking between queries
isekovanic Oct 19, 2026
9cc208f
fix: lastQueryError flash
isekovanic Oct 19, 2026
e32c8d5
fix: merge messageSearchSort under created_at like v9
isekovanic Oct 19, 2026
8bc711c
chore: widen comment context
isekovanic Oct 19, 2026
be73434
refactor: use request objects for positional client and channel methods
isekovanic Oct 19, 2026
51fa769
refactor: drop redundant positional _request param
isekovanic Oct 19, 2026
4f8f95c
refactor: take a single object in PollComposer.updateFields
isekovanic Oct 19, 2026
202e690
fix: type member and ban query date filters as Date | string
isekovanic Oct 19, 2026
50e9fbe
docs: mark the fixed v10 follow-ups as solved
isekovanic Oct 19, 2026
425c0b1
fix: failing doc snippet test
isekovanic Oct 19, 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
143 changes: 70 additions & 73 deletions ai-docs/v10-breaking-changes.md

Large diffs are not rendered by default.

38 changes: 19 additions & 19 deletions ai-docs/v10-followups.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are

<a id="typing-stale"></a>

### Typing indicators go stale after the composer resets
### Typing indicators go stale after the composer resets [SOLVED f97ddd8b]

`bug` · confidence: high · pre-release: yes · composer / channel state

Expand All @@ -43,7 +43,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are

<a id="shared-markread-throttle"></a>

### One mark-read throttle shared by every channel and thread
### One mark-read throttle shared by every channel and thread [SOLVED 312e2e5c]

`bug` · confidence: medium · pre-release: yes · message delivery / threads

Expand All @@ -54,7 +54,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are

<a id="event-all-typing"></a>

### `on('all')` and undeclared custom events are typed `never`
### `on('all')` and undeclared custom events are typed `never` [SOLVED c103da00]

`bug` · confidence: high · pre-release: yes · types / events

Expand All @@ -65,7 +65,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are

<a id="event-star"></a>

### `client.on('*')` type-checks but never fires
### `client.on('*')` type-checks but never fires [SOLVED 79477d20]

`bug` · confidence: high · pre-release: yes · types / events

Expand All @@ -76,7 +76,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are

<a id="retry-error"></a>

### Exhausted retries hide the API error; 429 now holds callers for about 100 s
### Exhausted retries hide the API error; 429 now holds callers for about 100 s [SOLVED e295664a]

`bug` · confidence: high · pre-release: yes · client / HTTP

Expand All @@ -87,7 +87,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are

<a id="static-token-retries"></a>

### Static expired token is retried 10 times
### Static expired token is retried 10 times [SOLVED eef88681]

`bug` · confidence: high · pre-release: yes · client / HTTP

Expand All @@ -98,7 +98,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are

<a id="show-in-channel-edits"></a>

### Edits to `show_in_channel` replies are not applied to the channel list
### Edits to `show_in_channel` replies are not applied to the channel list [SOLVED dd1b8c7a]

`bug` · confidence: medium · pre-release: yes · channel

Expand All @@ -109,7 +109,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are

<a id="user-messages-deleted-window"></a>

### `user.messages.deleted` only processes the active window
### `user.messages.deleted` only processes the active window [SOLVED 45290f4e]

`bug` · confidence: medium · pre-release: yes · channel / pagination

Expand All @@ -120,7 +120,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are

<a id="recovery-disabled-no-event"></a>

### `connectionRecovery.enabled: false` also suppresses `connection.recovered`
### `connectionRecovery.enabled: false` also suppresses `connection.recovered` [SOLVED 311c28a2]

`bug` · confidence: high · pre-release: yes · connection recovery

Expand All @@ -131,7 +131,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are

<a id="reminder-paginator-flash"></a>

### Reminder list blanks on every "load more"
### Reminder list blanks on every "load more" [SOLVED 73a87752]

`bug` · confidence: high · pre-release: yes · pagination / reminders

Expand All @@ -142,7 +142,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are

<a id="last-query-error-flash"></a>

### `lastQueryError` flashes on every failed retry attempt
### `lastQueryError` flashes on every failed retry attempt [SOLVED 9cc208fd]

`bug` · confidence: high · pre-release: yes · pagination / channel list

Expand All @@ -153,7 +153,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are

<a id="message-search-sort"></a>

### `messageSearchSort` cannot change the primary order
### `messageSearchSort` cannot change the primary order [SOLVED e32c8d5e]

`bug` · confidence: high · pre-release: yes · search

Expand All @@ -173,7 +173,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are

<a id="query-date-filters-ns"></a>

### Request date filters typed as `TimestampNS`
### Request date filters typed as `TimestampNS` [SOLVED 202e6903]

`bug` · confidence: medium · pre-release: yes · types / generated models

Expand All @@ -184,7 +184,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are

<a id="pin-message"></a>

### Positional `pinMessage` / `unpinMessage` wrappers
### Positional `pinMessage` / `unpinMessage` wrappers [SOLVED be73434c]

`simplification` · confidence: low · pre-release: yes · client

Expand All @@ -194,7 +194,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are

<a id="placeholder-request"></a>

### Placeholder `_request?: Record<string, never>` parameters
### Placeholder `_request?: Record<string, never>` parameters [SOLVED 51fa7695]

`simplification` · confidence: high · pre-release: yes · channel / generated API

Expand All @@ -214,7 +214,7 @@ Ordered by severity. The breaking dead-code removals and leftover decisions are

<a id="poll-update-fields"></a>

### `PollComposer.updateFields` "change with the next major" FIXME
### `PollComposer.updateFields` "change with the next major" FIXME [SOLVED 4f8f95cf]

`simplification` · confidence: high · pre-release: yes · composer

Expand Down Expand Up @@ -1022,7 +1022,7 @@ Only places that survive the guide cleanup: JSDoc and code comments, README, CHA
- **Where / What:**
- `src/offline-support/offline_support_api.ts:285`: `getChannelsForQuery` `@returns` says `Omit<ChannelStateResponseFields, 'duration'>[] | null`; it returns `DBGetChannelsForQueryResult | null` (`{ channels, predefinedFilter? }`).
- `src/offline-support/types.ts:189`: `DBGetChannelsForQueryType.options` is commented "Optional filters for channels."; it is the full `QueryChannelsRequest` used as the cache key.
- `src/offline-support/types.ts:31, 155, 167`, `src/moderation.ts:54`: bulk-rename artifacts ("MessageRequest to which the reaction is applied", "MessageRequest ID to be flagged", ...) where "message" is meant.
- `src/offline-support/types.ts:31, 155, 167`, `src/moderation.ts:54`: bulk-rename artifacts ("MessageRequest to which the reaction is applied", "MessageRequest ID to be flagged", ...) where "message" is meant. (`src/moderation.ts:54` [SOLVED be73434c]; the `offline-support/types.ts` ones remain)
- **Suggested change:** Fix the `@returns` and describe `predefinedFilter`; reword the options comment; revert the artifacts to "message" and grep `MessageRequest ID\|MessageRequest to\|MessageRequest associated` across `src`.

<a id="doc-paginator-jsdoc"></a>
Expand Down Expand Up @@ -1146,9 +1146,9 @@ Only places that survive the guide cleanup: JSDoc and code comments, README, CHA
| `test/unit/MessageComposer/messageComposer.test.ts:1846, 1879` | "as not delivered" and "as not failed" have identical bodies (only the ms offset differs) and neither sets that status. Confidence medium. | Make each set the status it names. |
| `src/messageComposer/attachmentManager.ts:667` | New throw "The upload succeeded but the response carried no file URL" has no test. | Add one. |
| `src/search/MessageSearchSource.ts:259, 288` | Both abort guards are untested since v9's "skips the channel hydration request when the query was aborted" was deleted. | Restore the test. |
| `test/unit/search/MessageSearchSource.test.ts:272-276, 324-328` | Pins the contradictory sort from [messageSearchSort](#message-search-sort). | Update with the fix. |
| `test/unit/search/MessageSearchSource.test.ts:272-276, 324-328` | Pins the contradictory sort from [messageSearchSort](#message-search-sort). [SOLVED e32c8d5e] | Update with the fix. |
| `test/unit/signing.test.js:33-36` | "round-trips ... non-Latin-1 ids" only asserts no throw. | Assert `UserFromToken(DevToken(id)) === id` for non-ASCII ids. |
| `src/channel.ts:2680` | No test covers `show_in_channel` reply edits ([item](#show-in-channel-edits)). | Add one. |
| `src/channel.ts:2680` | No test covers `show_in_channel` reply edits ([item](#show-in-channel-edits)). [SOLVED dd1b8c7a] | Add one. |
| `src/thread_manager.ts:472-475` | No test covers unregister then register of the manager with loaded threads ([item](#thread-manager-reregister)). | Add one. |
| `test/unit/messageDelivery/MessageReceiptsTracker.test.ts` (`createChannelMock`) | Mock provides only the deprecated `userID`, blocking the `userId` switch. | Provide `userId`. |

Expand Down
39 changes: 38 additions & 1 deletion scripts/apply-custom-data-types.mts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,23 @@ const INTERFACE_CLOSE_RE = /^\}\s*$/;
const FILTER_OPEN_RE = /^\s*(\w+)\??:\s*Filters<\{\s*$/;
const CUSTOM_ENTRY_OPEN_RE = /^\s*custom:\s*\{\s*$/;
const CUSTOM_ENTRY_TYPE_RE = /^(\s*)type:\s*Record<string,\s*any>;\s*$/;
// The spec's custom-event placeholder, a member of every WS event union. No event is ever dispatched
// with type `'*'` (a custom event arrives under its own name, typed through `CustomEventTypes`), so
// the member is dropped instead of letting `'*'` into `Event['type']`, where `client.on('*', ...)`
// would type-check and never fire.
const WILDCARD_EVENT_MEMBER_RE = /^\s*\|\s*\(\{ type: '\*' \} & CustomEvent\)\s*$/;
// Request payloads whose `created_at_*` filters come out as response timestamps (`TimestampNS`):
// the spec declares them `date-time` strings, like every other request date, but they are only
// reachable through the JSON `payload` query param. The API rejects a nanosecond number there
// ("Time.UnmarshalJSON: input is not a JSON string") and takes RFC3339, so they are typed the way
// every other request date is: a `Date` (serialized as RFC3339) or an RFC3339 string.
const REQUEST_DATE_FILTER_INTERFACES = new Set([
'QueryBannedUsersPayload',
'QueryFutureChannelBansPayload',
'QueryMembersPayload',
]);
const REQUEST_DATE_FILTER_RE =
/^(\s*)(created_at_(?:after|before)(?:_or_equal)?)(\??):\s*TimestampNS;\s*$/;

const lines = source.split('\n');
const rewritten: string[] = [];
Expand All @@ -213,6 +230,8 @@ let currentInterfaceHadMappedCustom = false;
let rewrittenCustomCount = 0;
let rewrittenChannelCustomCount = 0;
let rewrittenFilterCustomCount = 0;
let droppedWildcardEventMemberCount = 0;
let retypedRequestDateFilterCount = 0;
const skippedInterfaces = new Set<string>();
const skippedFilterKeys = new Set<string>();

Expand All @@ -236,6 +255,11 @@ const countBraces = (line: string) => {
};

for (const line of lines) {
if (WILDCARD_EVENT_MEMBER_RE.test(line)) {
droppedWildcardEventMemberCount += 1;
continue;
}

const openMatch = line.match(INTERFACE_OPEN_RE);
if (openMatch) {
currentInterface = openMatch[1];
Expand Down Expand Up @@ -310,6 +334,17 @@ for (const line of lines) {
continue;
}

const requestDateFilterMatch =
currentInterface && REQUEST_DATE_FILTER_INTERFACES.has(currentInterface)
? line.match(REQUEST_DATE_FILTER_RE)
: null;
if (requestDateFilterMatch) {
const [, indent, field, optional] = requestDateFilterMatch;
rewritten.push(`${indent}${field}${optional}: Date | string;`);
retypedRequestDateFilterCount += 1;
continue;
}

const channelCustomMatch = line.match(CHANNEL_CUSTOM_FIELD_RE);
if (channelCustomMatch) {
const [, indent, optional] = channelCustomMatch;
Expand Down Expand Up @@ -357,7 +392,9 @@ for (const key of [...skippedFilterKeys].sort()) {
console.log(
`Rewrote ${rewrittenCustomCount} custom + ${rewrittenChannelCustomCount} channel_custom` +
` + ${rewrittenFilterCustomCount} filter custom fields; ` +
`skipped ${skippedInterfaces.size} interfaces and ${skippedFilterKeys.size} filter blocks.`,
`skipped ${skippedInterfaces.size} interfaces and ${skippedFilterKeys.size} filter blocks; ` +
`dropped ${droppedWildcardEventMemberCount} '*' event union members; ` +
`retyped ${retypedRequestDateFilterCount} request date filters.`,
);

/**
Expand Down
30 changes: 30 additions & 0 deletions scripts/drop-placeholder-request-params.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';

/**
* Removes the `_request?: Record<string, never>` parameter the generator emits on body-less
* endpoints (`getApp`, `getPoll`, `listDevices`, ...). It is never read and only holds a position,
* so callers had to pass `undefined` before they could reach `requestOptions`. Without it,
* `requestOptions` directly follows the path params, or is the first argument.
*
* Usage: node scripts/drop-placeholder-request-params.mts <path-to-api-file>...
*/
const PLACEHOLDER_PARAM_RE = /^\s*_request\?: Record<string, never>,\s*$/;

const paths = process.argv.slice(2);
if (paths.length === 0) {
console.error(
'Usage: node scripts/drop-placeholder-request-params.mts <path-to-api-file>...',
);
process.exit(1);
}

for (const path of paths) {
const absolutePath = resolve(process.cwd(), path);
const lines = readFileSync(absolutePath, 'utf8').split('\n');
const kept = lines.filter((line) => !PLACEHOLDER_PARAM_RE.test(line));
writeFileSync(absolutePath, kept.join('\n'));
console.log(
`${path}: dropped ${lines.length - kept.length} placeholder request params.`,
);
}
2 changes: 2 additions & 0 deletions scripts/generate-client.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ yarn exec prettier --write $OUTPUT_DIR/models/index.ts

node ./scripts/apply-custom-data-types.mts -i $OUTPUT_DIR/models/index.ts

node ./scripts/drop-placeholder-request-params.mts $OUTPUT_DIR/chat/ChatApi.ts $OUTPUT_DIR/chat/ChannelApi.ts $OUTPUT_DIR/moderation/ModerationApi.ts

yarn lint-fix
4 changes: 2 additions & 2 deletions src/EventHandlerPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import type { Unsubscribe } from '@stream-io/state-store';
* Flat routing view of an event: every field any event can carry, all optional, on one object.
*
* Handlers route without narrowing, which `Event` does not allow — it is a discriminated union whose
* `{ type: '*' } & CustomEvent` member has none of the channel fields, and it admits bare custom-event
* strings (hence the `unknown` hop in `processOne`). Derived from that union, so a new generated field
* members carry different fields (a custom event has none of the channel fields), and it admits bare
* custom-event strings (hence the `unknown` hop in `processOne`). Derived from that union, so a new generated field
* is routable without widening anything by hand.
*/
type EventObject = Extract<Event, object>;
Expand Down
15 changes: 7 additions & 8 deletions src/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,11 @@ export class ApiClient {
'x-client-request-id'
] as string;

// Only a token provider can hand out a fresh token. A static token reloads to the same expired
// value, so its request fails immediately.
const canRefreshExpiredToken = (error: unknown) =>
isTokenExpiredError(error) && !this.client.tokenManager.isStatic();

try {
const response = await runWithRetry(
async () => {
Expand All @@ -249,7 +254,7 @@ export class ApiClient {
try {
requestResponse = await this.client.axiosInstance.request<T>(config);
} catch (error) {
if (isTokenExpiredError(error)) {
if (canRefreshExpiredToken(error)) {
logger
.withExtraTags('_doRequest')
.debug(
Expand All @@ -267,13 +272,7 @@ export class ApiClient {
{
delayBetweenRetries: (attemptNumber) => retryInterval(attemptNumber + 1),
retryAttempts: 10,
isRetryable: (error) => {
if (!isAxiosError(error)) return false;

if (error.status === 429 || isTokenExpiredError(error)) return true;

return false;
},
isRetryable: canRefreshExpiredToken,
},
)();

Expand Down
Loading
Loading