Skip to content

feat: add instance configuration service - #1831

Open
MartinCupela wants to merge 16 commits into
release-v10from
feat/llc-instance-configuration
Open

feat: add instance configuration service#1831
MartinCupela wants to merge 16 commits into
release-v10from
feat/llc-instance-configuration

Conversation

@MartinCupela

@MartinCupela MartinCupela commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

What

release-v10 could already register a setup function per entity — but only a function, and only
against four hardcoded keys. Anything that was just a value meant writing a function to go and set it.

This PR makes configuration values first-class: they can be registered declaratively, the key space is
open, and every configurable object resolves its settings through one layered pipeline with the server's
say applied last.

The four ways to set configuration

Route Scope Use it for
Construction argumentnew StreamChat(key, { notifications }), new MessageComposer({ config }) one instance what you know at build time, for objects you build yourself
Declarativeclient.config.set({ channel: { … } }) per entity type the common case; reaches objects the SDK builds for you, including ones created later
Setup functionclient.config.setSetupFunction('messageComposer', fn) per entity type, but sees the instance behaviour values can't express — middleware, comparators, conditional exceptions
Imperativeinstance.updateConfig({ … }), and the setters that route through it one instance changes driven by app state after the fact

Plus one that isn't integrator's to set but participates: the server's configuration for the
channel — its type's settings, narrowed by that channel's own config_overrides where it has any.

Example of configuration API in use:

// declarative — applies to instances that exist and to any built later
client.config.set({
  channel: { messagePaginator: { pageSize: 50 }, typingEvents: { enabled: false } },
  messageComposer: { linkPreviews: { enabled: false } },
  client: { notifications: { durations: { error: 10_000 } } },
});

// setup function — a global default plus one conditional exception
client.config.setSetupFunction('messageComposer', ({ composer }) => {
  if (composer.threadId) composer.updateConfig({ text: { publishTypingEvents: false } });
});

client.config.reset() returns everything to its derived baseline.

The layers, and how they reconcile

For any one instance, later stages win:

# Stage Who set it Example
1a Class default the SDK, for every instance of the class every paginator starts at pageSize: 25
1b Instance default the SDK, for this particular object it built a channel's pinned list is wired differently from its main list, though both are MessagePaginators
2 Declarative you, per entity type client.config.set({ channel: { … } })
3 Construction argument you, for one instance new MessageComposer({ config })
4 Setup function you, per entity type, with the instance in hand client.config.setSetupFunction(key, fn)
5 Imperative you, for one instance, at any time instance.updateConfig({ … })
6 Server the server, per channel narrows only; can never be widened from the client

The one non-obvious part is why 1b and 3 are separate when both arrive through a constructor. It's who
passed the argument. When the SDK builds a paginator it fills in a page size, a throttle and a
cursor — if those counted as integrator's arguments they would outrank stage 2, and no client.config.set()
could ever change them. So the SDK's own values sit at 1b, below integrator's registration; only arguments integrators
actually passed sit at 3, above it.

Three properties of this that are load-bearing:

  • Re-resolved, not accumulated. Every layer is kept separately and the whole order replays whenever
    any of them changes. So applying the server's restrictions is idempotent, and stage 6 narrowing a field
    never destroys the request underneath it — if the server later relents, the request is honoured.
  • Server authority is last on every route. Declarative, setup function or updateConfig — all three
    have the server's restrictions re-asserted over the result. Nothing above can widen past them.
  • Booleans are ANDed at stage 6. Either side may switch a feature off; neither may widen. undefined
    from the server means "no opinion", not "no", so the request stands.

The consequence worth knowing: instance.config is the resulting answer. It should be read instead of the raw
server flag — channel.serverConfig?.uploads - which gives us only the server's half.

Why

Setting a page size used to require writing a function:

// before
client.instanceConfigurationService.setSetupFunctions({
  Channel: ({ channel }) => {
    channel.messagePaginator.updateConfig({ pageSize: 50 });
  },
});

// now
client.config.set({ channel: { messagePaginator: { pageSize: 50 } } });

Four things were wrong with the old version:

  1. A function was the only way in. There was no way to register a plain value.
  2. Only four classes were supportedChannel, MessageComposer, StreamChat, Thread. Paginators,
    the notification manager, the reminder manager and the delivery reporter had no way in at all.
  3. No way to undo it. Nothing tracked where a value came from, so nothing could put an instance back
    to a known state.
  4. Server settings were not included. If the server disabled uploads, the code had to check that
    separately and combine it in multiple places. Most code didn't, so a UI would offer a feature the SDK then
    refused.

Where it lives

Two objects, and neither holds what the other holds:

InstanceConfigurationRegistry (client.config) ConfigController (per instance)
Holds what you asked for (stages 2 and 4) what one instance ended up with
Count one per client one per configurable instance
Knows defaults no yes, and freezes them

Every configurable class exposes the same shape: configState (a StateStore), config,
updateConfig(), initializeConfig().
The key space is open — your own class can register against a
custom key and get the same pipeline; ConfigController is exported for that.
Integrators' custom classes can use the same system. The names in client.config.set({ … })channel,
messageComposer, and so on — are not a fixed list. New keys can be added:

client.config.set({ myWidget: { pollIntervalMs: 10_000 } });

To make MyWidget pick that up, give it a ConfigController and subscribe it to the key with applyInstanceConfiguration:

import { applyInstanceConfiguration, ConfigController } from 'stream-chat';

type MyWidgetConfig = { enabled: boolean; pollIntervalMs: number };

class MyWidget {
  private readonly configController = new ConfigController<MyWidgetConfig>({
    defaults: { enabled: true, pollIntervalMs: 5000 },
  });

  constructor(client: StreamChat) {
    // subscribes this instance to the 'myWidget' key: applies whatever is
    // already registered, and re-applies on every later set() or reset()
    this.unsubscribe = applyInstanceConfiguration({
      args: { widget: this },
      config: client.config,
      key: 'myWidget',
      applyConfig: (config) => this.configController.initialize(config),
    });
  }

  get configState() {
    return this.configController.state;
  }
  get config() {
    return this.configController.value;
  }
  updateConfig(patch: Partial<MyWidgetConfig>) {
    this.configController.patch(patch);
  }
  dispose() {
    this.unsubscribe();
  }
}

It then gets everything the built-in entities get: the layer order, reset, and reactive configState.

⚠️ Breaking changes

Details in v9-to-v10-migration-guide-other.md and …-type-renames.md.

Two that produce no compile error

Uploads to storage outside Stream must now be declared. A custom doUploadRequest used to be taken
as proof of that, which was wrong — many still post to Stream. Without the flag, uploads are refused for
users lacking the upload-file capability, and the attachment button disappears:

attachments: { doUploadRequest: myUpload, customCdn: true }

Link previews now default to on. The old default of off overrode apps that had enabled enrichment
server-side. Turning them off is now explicit:

client.config.set({ messageComposer: { linkPreviews: { enabled: false } } });

Renames — all caught by the compiler

channel.getConfig()            channel.serverConfig         // a getter; drop the ()
client.configs                 client.channelServerConfigs  // same cid keys, clearer name
ChannelInstanceConfig          ChannelConfig
ThreadInstanceConfig           ThreadConfig
InstanceConfigurationService   InstanceConfigurationRegistry

serverConfig returns what getConfig() did. channel.config is a different thing: the resolved
value, with six server flags already combined with locally registered settings — that is the one to read
when checking whether a feature is on.

serverConfig answers for this channel, not for its type. Most of ChannelConfigWithInfo is a
channel-type setting, but a channel's own config_overrides narrow uploads, url_enrichment,
typing_events, replies, quotes, reactions, shared_locations, max_message_length, commands
and user_message_reminders for that channel alone — and this SDK is one of the things that can set them
(client.channel(type, id, { config_overrides }) sends them on query/watch; channel.update() and
updatePartial() reach the same state). So the cache behind the getter is keyed by cid: v9's
client.configs key space, under a name that says whose configuration it holds now that client.config
is the integrator's.

It is undefined until the channel has been queried or watched, as in v9. There is deliberately no
type-level fallback — the only value available to seed one with is a sibling channel's effective config,
overrides included, which is the leak the cid keying exists to prevent. channel.config covers that
window with its defaults, which is another reason to read it rather than the raw flag.

Note for tests: serverConfig is a getter, so vi.spyOn(channel, 'getConfig') has no direct equivalent.
A mocked query response has to carry a cid matching the channel under test, or its config lands under a
different key.

Smaller things

  • MessageComposerConfig gains polls; AttachmentManagerConfig gains enabled and customCdn. All
    have defaults, so only code that types a variable as the complete config object is affected.
  • Removed from the client: setInstanceConfigurationFunction, instanceConfigurationService and
    configsStore (all RC-line only). configs is renamed to channelServerConfigs, not removed — the
    cid key space is unchanged, so a v9 client.configs[cid] lookup translates directly. Prefer
    channel.serverConfig over either.
  • Bug fix, no action required: setters no longer skip a write when the server is masking the field, which
    used to let a stale earlier request win once the server allowed it again.

Review guidance

  • src/configuration/ConfigController.ts — the layering itself (orderedLayers, resolve). Start here.
  • src/configuration/utils/serverAuthority.ts — stage 6: the AND rule and the upper-bound rule.
  • src/channel.ts — the most involved consumer: controller, server restrictions, and the
    subscription that re-derives when the server's answer arrives after construction.
  • src/configuration/shape.ts is 644 lines of field descriptions, not logic — skim it.

Docs

  • docs/instance-configuration.md — full reference: the stages, the registry/resolver split, server
    authority, custom keys, resetting.
  • Three v9→v10 migration guides updated, with a mechanical checklist.

# Conflicts:
#	CLAUDE.md
#	src/ChannelManager.ts
#	src/client.ts
#	src/pagination/paginators/ChannelPaginator.ts
#	test/unit/ChannelManager.test.ts
#	test/unit/pagination/paginators/ChannelPaginator.test.ts
#	v9-to-v10-migration-guide-methods.md
Channel-type flags were only readable from the raw server config, so any
consumer combining them with registered configuration had to do it itself —
and mostly didn't, offering features the client had already disabled.

They now resolve into the instance's own configuration, which becomes the
whole answer: uploads, polls and url_enrichment on MessageComposer;
typing_events, read_events, replies, user_message_reminders, delivery_events
and the command list on Channel. Channel gains a ConfigController to do it,
re-deriving when the server's answer arrives after construction.

Also fixes setters skipping a write when the server was masking the field,
which let a stale earlier request win once the server relented.

BREAKING CHANGE: `channel.getConfig()` is removed; use the `channel.serverConfig`
getter, or `channel.config` for the six flags that now have a resolved
counterpart. `ChannelInstanceConfig` -> `ChannelConfig`, `ThreadInstanceConfig`
-> `ThreadConfig`, `InstanceConfigurationService` ->
`InstanceConfigurationRegistry`. `MessageComposerConfig` gains required `polls`;
`AttachmentManagerConfig` gains required `enabled` and `customCdn`.
`linkPreviews.enabled` now defaults to `true`. See
v9-to-v10-migration-guide-other.md.
# Conflicts:
#	src/channel.ts
#	src/client.ts
#	src/thread.ts
#	v9-to-v10-migration-guide-other.md
#	v9-to-v10-migration-guide-type-renames.md
Comment thread src/client.ts
/**
* Caches a channel type's server configuration.
*
* Keyed by **type**, not cid: every field in `ChannelConfigWithInfo` is a channel-*type* setting

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm isn't ChannelConfigOverrides a per channel feature ? I think this is how it works serverside at least.

If this is indeed the case then I think that 2 different channels can override the config for a certain type and leak into each other, for example:

const a = client.channel('messaging', 'a');
const b = client.channel('messaging', 'b');

// channel A server reports replies disabled (a channel-level override)
client._addChannelConfig({ type: 'messaging', config: { name: 'messaging', replies: false } });

// channel B of the smae type, no override
client._addChannelConfig({ type: 'messaging', config: { name: 'messaging', replies: true } });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In other words many channels of the same type (if containing overrides, mandated by the server for example) can override the global typed config wrongly (depending on when they're consumed and addChannelConfig is called

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right I did not realize it was possible to override the channel config on individual channel level. Fixing.

const released = super.unregisterSubscriptions();
// Ref-counted: only the last caller actually tears down, and the configuration subscription is not
// one of the ref-counted ones — it was registered by the constructor, so it is released here.
this.unsubscribeConfiguration?.();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unregistration is indeed refcounted, but that also means that something like:

manager.registerSubscriptions();
manager.registerSubscriptions();
manager.unregisterSubscriptions();

is unintentionally going to tear down the subscription entirely here. Can we wither make sure that this is registered again whenever we register subscriptions (if we are willing to accept potential config changes if done later) ? because this seems a bit scary

if (!this.options.retainPatches) {
// A plain spread, deliberately: an explicit `undefined` has to be able to clear a field, which is
// how a paginator's state throttle is switched off.
this.write({ ...this.value, ...owned } as TConfig);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A patch() doesn't seem to respect applyAuthority, is this intentional ? If I understand it correctly applyAuthority should always have the last say, so maybe something like:

// ...rest of the function
const patched = { ...this.value, ...owned } as TConfig;
const { applyAuthority } = this.options;
this.write(applyAuthority ? applyAuthority(patched) : patched);

I'm assuming we don't want to this.resolve() here because of its merging implications (for which we already provide a patch)

Comment thread src/channel.ts
// Nested groups: naming `typingEvents.enabled` must not drop `readEvents`.
mergeSlice: 'deep',
applyAuthority: (requested) => ({
...(mergeServerRestrictions(requested, this.serverRestrictions) as ChannelConfig),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this config is not frozen, is that by design ?

Comment thread src/channel.ts
// Assigned rather than merged: `mergeServerRestrictions` treats an array as an interior node
// and hands it to the deep merge, which would combine the two lists. The server owns this one
// outright, so it replaces.
availableCommands: this.serverConfig?.commands ?? [],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since this references whatever value commands has in serverConfig, it also means that if serverConfig updates this will be updated too, is that what we are looking for ?

Comment thread src/thread.ts

export class Thread extends WithSubscriptions {
public readonly configState = new StateStore<ThreadInstanceConfig>({});
public readonly configState = new StateStore<ThreadConfig>({});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this not hold a SearchController instead of a custom StateStore ?

Comment thread src/CooldownTimer.ts
// the guard above filters exactly that case out. Unguarded on purpose: `refresh()` already no-ops
// unless one of its inputs actually moved.
this.addUnsubscribeFunction(
this.channel.on('capabilities.changed', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't the this.cooldownTimer.refresh() within channel.ts enough for this ? why do we need it here as well ?

const copy: Record<string | symbol, unknown> = {};
for (const key of Reflect.ownKeys(value)) {
if (!Object.prototype.propertyIsEnumerable.call(value, key)) continue;
copy[key] = copyConfigPatch((value as Record<string | symbol, unknown>)[key]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would very strongly recommend that we also track cycles in here. The recursion here is unbound and since integrators can add their own keys as far as I understand this can easily overflow the stack if they aren't careful.

And even if not for that, I would anyway still add it because it'll protect us from making a mistake in the future as well (so it should behave something like cancel the cycle but also print a big fat warning that a topological cycle has been detected.

* the same field.
*
* Keys are **open**: the four built-ins are typed for autocomplete, but any string works, so a
* downstream SDK or an integrator can register a key for a class of its own. Stores are therefore

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this ? It actually complicates the tree unnecessarily I believe. Integrators should not be able to set anything outside of the standard interface.

Regarding the downstream SDK, if there is a need for something to be set in the client's configuration service and meanwhile it has nothing to do with the client I think it would be pointless. Then that configuration belongs elsewhere probably, unless I'm missing some usecase here that I can't see

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.

2 participants