feat: add instance configuration service - #1831
Conversation
… event cid fallback
… and delete the legacy manager
…manager # Conflicts: # src/client.ts
# 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
| /** | ||
| * Caches a channel type's server configuration. | ||
| * | ||
| * Keyed by **type**, not cid: every field in `ChannelConfigWithInfo` is a channel-*type* setting |
There was a problem hiding this comment.
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 } });
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?.(); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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)
| // Nested groups: naming `typingEvents.enabled` must not drop `readEvents`. | ||
| mergeSlice: 'deep', | ||
| applyAuthority: (requested) => ({ | ||
| ...(mergeServerRestrictions(requested, this.serverRestrictions) as ChannelConfig), |
There was a problem hiding this comment.
this config is not frozen, is that by design ?
| // 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 ?? [], |
There was a problem hiding this comment.
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 ?
|
|
||
| export class Thread extends WithSubscriptions { | ||
| public readonly configState = new StateStore<ThreadInstanceConfig>({}); | ||
| public readonly configState = new StateStore<ThreadConfig>({}); |
There was a problem hiding this comment.
should this not hold a SearchController instead of a custom StateStore ?
| // 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', () => { |
There was a problem hiding this comment.
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]); |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
What
release-v10could already register a setup function per entity — but only a function, and onlyagainst 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
new StreamChat(key, { notifications }),new MessageComposer({ config })client.config.set({ channel: { … } })client.config.setSetupFunction('messageComposer', fn)instance.updateConfig({ … }), and the setters that route through itPlus 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_overrideswhere it has any.Example of configuration API in use:
client.config.reset()returns everything to its derived baseline.The layers, and how they reconcile
For any one instance, later stages win:
pageSize: 25MessagePaginatorsclient.config.set({ channel: { … } })new MessageComposer({ config })client.config.setSetupFunction(key, fn)instance.updateConfig({ … })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:
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.
updateConfig— all threehave the server's restrictions re-asserted over the result. Nothing above can widen past them.
undefinedfrom the server means "no opinion", not "no", so the request stands.
The consequence worth knowing:
instance.configis the resulting answer. It should be read instead of the rawserver flag —
channel.serverConfig?.uploads- which gives us only the server's half.Why
Setting a page size used to require writing a function:
Four things were wrong with the old version:
Channel,MessageComposer,StreamChat,Thread. Paginators,the notification manager, the reminder manager and the delivery reporter had no way in at all.
to a known state.
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)Every configurable class exposes the same shape:
configState(aStateStore),config,updateConfig(),initializeConfig().The key space is open — your own class can register against a
custom key and get the same pipeline;
ConfigControlleris 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:To make
MyWidgetpick that up, give it aConfigControllerand subscribe it to the key withapplyInstanceConfiguration:It then gets everything the built-in entities get: the layer order, reset, and reactive
configState.Details in
v9-to-v10-migration-guide-other.mdand…-type-renames.md.Two that produce no compile error
Uploads to storage outside Stream must now be declared. A custom
doUploadRequestused to be takenas proof of that, which was wrong — many still post to Stream. Without the flag, uploads are refused for
users lacking the
upload-filecapability, and the attachment button disappears: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:
Renames — all caught by the compiler
serverConfigreturns whatgetConfig()did.channel.configis a different thing: the resolvedvalue, with six server flags already combined with locally registered settings — that is the one to read
when checking whether a feature is on.
serverConfiganswers for this channel, not for its type. Most ofChannelConfigWithInfois achannel-type setting, but a channel's own
config_overridesnarrowuploads,url_enrichment,typing_events,replies,quotes,reactions,shared_locations,max_message_length,commandsand
user_message_remindersfor that channel alone — and this SDK is one of the things that can set them(
client.channel(type, id, { config_overrides })sends them onquery/watch;channel.update()andupdatePartial()reach the same state). So the cache behind the getter is keyed by cid: v9'sclient.configskey space, under a name that says whose configuration it holds now thatclient.configis the integrator's.
It is
undefineduntil the channel has been queried or watched, as in v9. There is deliberately notype-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.configcovers thatwindow with its defaults, which is another reason to read it rather than the raw flag.
Note for tests:
serverConfigis a getter, sovi.spyOn(channel, 'getConfig')has no direct equivalent.A mocked query response has to carry a
cidmatching the channel under test, or its config lands under adifferent key.
Smaller things
MessageComposerConfiggainspolls;AttachmentManagerConfiggainsenabledandcustomCdn. Allhave defaults, so only code that types a variable as the complete config object is affected.
setInstanceConfigurationFunction,instanceConfigurationServiceandconfigsStore(all RC-line only).configsis renamed tochannelServerConfigs, not removed — thecid key space is unchanged, so a v9
client.configs[cid]lookup translates directly. Preferchannel.serverConfigover either.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 thesubscription that re-derives when the server's answer arrives after construction.
src/configuration/shape.tsis 644 lines of field descriptions, not logic — skim it.Docs
docs/instance-configuration.md— full reference: the stages, the registry/resolver split, serverauthority, custom keys, resetting.