iii-stream and pubsub use case to primitives every SDK already ships:
worker-owned trigger types, explicit storage with queries, channels, and the durable queue. The
sections below also list why both are deprecated and what stays supported.
Why they are deprecated
iii-stream bundles three concerns behind one API: a generic key/value store, a change
notification, and a dedicated WebSocket transport. Every stream::set writes to storage even when
the caller only wanted to notify someone, stored items have no owner that can bound or clean them
up, and consumers depend on the stream envelope instead of a domain contract. Authorization is handled
by a separate auth_function that only covers the dedicated WebSocket.
The pubsub worker is a global topic bus. It drops the namespace and metadata of a binding,
delivers fire-and-forget with silent drops, and never evaluates condition_function_id.
The replacements already exist in Node, Browser, Python, Rust and Go: function and trigger-type
registration, trigger bindings, channels, the state and database workers, and the queue
worker. They put each concern with the worker that owns the data.
Do not replace
stream::set with state::set mechanically. That keeps the storage cost of
writing every event and loses the notification. Pick the recipe below that matches what the data
is for.Timeline and support window
What the warnings look like:
- Engine: a
warnlog with targetiii::deprecationthe first time a caller uses a deprecated function, binds a deprecated trigger type, or connects to the dedicated Streams WebSocket, then at most once every 10 minutes per entry point and caller. Function calls and bindings are keyed by the calling worker; WebSocket connections are keyed by client IP. Startingiii-streamand its WebSocket listener warns once per process. The log names the entry point, the calling worker when known and this guide. It never contains payloads, stream, group or item ids, headers or query strings. Direct calls such astrigger({ function_id: "stream::set" })from any SDK version are covered. Wheniii-streamis not running, astream::*call still fails withfunction_not_found(code unchanged), and the message now explains how to enableiii-streamand links this guide. - Node and Browser SDKs:
@deprecatedJSDoc oncreateStreamandIStream, so editors strike them through. No runtime change. - Python SDK: calling
create_streamor subclassingIStreamemits aFutureWarningpointing at your call site. Nothing is raised and behavior is unchanged. - Rust SDK:
#[deprecated]on the legacy stream helpers,IStream,create_stream, theIIITrigger::Stream,IIITrigger::StreamJoinandIIITrigger::StreamLeavevariants, and the pubsub itemsSubscribeTriggerConfigandIIITrigger::Subscribe. - pubsub worker: the worker logs a warning once at startup; on
publish, at most once every 10 minutes per caller; and onsubscribetrigger registration, at most once every 10 minutes for the whole worker (the subscriber is not identified). Traffic on topicstream.events, used by the engine’s owniii-streambridge, never warns.
What is deprecated and what is not
Deprecated withiii-stream:
- The
iii-streamengine worker. - Functions
stream::set,stream::get,stream::delete,stream::list,stream::list_groups,stream::list_all,stream::sendandstream::update. - Trigger types
stream,stream:joinandstream:leave. - The dedicated Streams WebSocket listener (default
127.0.0.1:3112) and its clients. - The
auth_functionsetting ofiii-stream. - SDK helpers: Node and Browser
createStreamandIStream; Pythoncreate_streamandIStream; the Rust legacy stream provider and helpers; models that only exist for the legacy Streams contract.
pubsub (the registry worker pubsub, formerly iii-pubsub):
- Function
publish, trigger typesubscribeand thepubsubconfiguration entry. - Rust SDK
SubscribeTriggerConfigandIIITrigger::Subscribe.
- Function, trigger-type and trigger registration APIs in every SDK.
- Channels, channel readers and writers, and
StreamChannelRef. StreamRequestandStreamResponseused for request/response streaming.UpdateOp,MergePath,UpdateOpError, and the RustStreamSetResult,StreamUpdateResultandStreamDeleteResulttypes. Thestateworker uses them.- The
auth_function_idofiii-worker-managerandrbac-proxy. That is connection authorization for RBAC listeners, unrelated to theiii-streamauth_function. See Access Control. - The
queueworker, includingiii::durable::publishanddurable:subscriber. - Provider token streaming in LLM workers, which already uses channels.
Choose a replacement
A trigger is a notification mechanism, not a database or durable event log. If history or replay is
required, define and retain the source of truth in the worker that owns the data. Do not build
another generic, unbounded event store. Prefer small notification payloads plus a query for large
datasets, or bounded record snapshots with a revision.
Find your current usage:
Runnable TypeScript versions of the three recipes live in the repository under
sdk/packages/node/iii-example/src/streams-migration/:
owned-trigger-type.ts: ephemeral notification through a worker-owned trigger type.stored-records.ts: storage, get and paginated list, change trigger, initial read and recovery.channel.ts: bounded per-operation content over a channel.
Recipe 1: Ephemeral notifications
Use this when consumers need to react to something that happened and nobody needs to read it back later: a lifecycle change, a status flip, a “work is done” signal. The worker that produces the event declares a trigger type and keeps its own table of bindings. Consumers bind a function to it the same way they bind tohttp or cron. See
Declaring a Trigger Type and
Dispatch events to bound functions
for the full API in every SDK.
Producer:
Recipe 2: Stored records with live updates
Use this when consumers need the current data and also want to know when it changes: a dashboard, a list view, a sync job. Storage and notification are two separate things owned by the same domain worker.- Store records in storage the domain worker owns (
state,database, or your own store), with a revision number per record. - Expose read functions:
orders::getand a paginatedorders::list(cursor plus limit). - After the write is committed, emit a small change notification through a worker-owned trigger
type (
orders::changedwith{ order_id, revision }), built as in Recipe 1. - Consumers bind first, then do an initial read, then apply notifications. They repeat the read to recover after a reconnect.
state::compare-and-set or your
database’s transaction so revisions stay monotonic.
Consumer:
Recipe 3: Continuous content for one operation
Use this for content produced by one running operation and read by one reader: progress output, tokens, frames, a file. A channel has an explicit lifetime and backpressure, and nothing is stored. The reader creates the channel and passes the writer ref to the function that produces the content:- One channel per operation and reader. For fan-out to many consumers, use Recipe 1 or 2 instead.
- End the writer when the operation finishes or fails. When the reader leaves, the engine closes the writer. See Backpressure and lifecycle.
- Bound the content: cap size or duration, and never queue an unbounded backlog for a slow reader.
- For bidirectional traffic, create two channels.
Provider and consumer obligations
iii-stream hid these responsibilities. With an owned trigger type they belong to your code.
The provider (the worker that owns the trigger type):
- Owns registration and unregistration. Keep a binding table keyed by trigger id; add in
registerTrigger, remove inunregisterTrigger. When a consumer disconnects, the engine removes its bindings and the provider receives the unregister callback. - Validates filters. Reject unknown or malformed
configby throwing fromregisterTrigger. Never let a filter widen what the caller is allowed to see. - Preserves the target namespace and metadata. Pass
binding.namespaceandbinding.metadataon everytrigger()so the event reaches the consumer’s namespace. - Bounds fan-out. Cap bindings per type and per consumer, use timeouts, and never spawn an unbounded task per event or keep an unbounded queue for a slow consumer.
- Emits after commit. For stored data, notify only after the mutation is durable. Never report success while dropping data or events.
- Handles duplicates. Make handlers idempotent; delivery can repeat after retries or reconnects.
- Tracks revisions or cursors. Ignore notifications older than what it already applied.
- Recovers after reconnect. Re-run the initial read (Recipe 2) because notifications sent while it was away are not replayed.
- Cleans up. Unregister bindings it no longer needs and close channels it opened.
Authorization and tenant isolation
A transport change alone does not reproduce the old policies. Moving a client from the dedicated Streams WebSocket to Browser SDK subscriptions withtype: "stream" changes the connection, not the
dependency on iii-stream, and it keeps the deprecated trigger types.
For tenant isolation, give each tenant or browser session its own namespace through the RBAC
listener’s
namespaces grant, and have the provider deliver only events that belong to
binding.namespace. Do not trust a tenant id taken from the consumer’s config; derive it from the
namespace or check it against it.
The
auth_function_id of iii-worker-manager and rbac-proxy is not deprecated. Only the
auth_function of iii-stream and the stream:join / stream:leave trigger types are.A note on stream::send
stream::send already avoids the key/value write that stream::set does, but it still runs through
iii-stream and is deprecated with it. Changing stream::set to stream::send is not the final
migration, and it changes the event envelope consumers receive. Move straight to Recipe 1 or
Recipe 3.
Migrate from pubsub
Things to check while migrating:
- Payload envelope.
pubsubanddurable:subscriberdeliver the publisheddataas-is. An owned trigger type defines its own payload, so update consumer handlers. - Namespaces.
pubsubignored namespaces: topics were global and subscriber functions resolved in thedefaultnamespace. Owned trigger types and queue subscribers respect the binding namespace, so a consumer in another namespace must bind there. - Silent drops. A
publishsent withTriggerAction.Void()when nopubsubworker is running is dropped without an error to the caller. The replacements make delivery explicit; do not rely on that behavior. - Order of removal. The
iii-streambridgeadapter usespublishandsubscribeinternally. If you run it, keeppubsubuntiliii-streamis gone.
Existing stream data
During the support window, data stored byiii-stream stays readable through stream::get,
stream::list and stream::list_groups. To keep it, copy what you still need into storage your
domain worker owns while iii-stream is running, and map stream, group and item ids to your own
keys. Do not delete the iii-stream store files or Redis keys until your consumers run without
iii-stream.
A documented export path, the id mapping and a rollback plan are part of the removal phase and will
be published before storage access is removed in an upcoming release.
Worked examples in the tutorials
The Linkly tutorial and the real-time todo how-to no longer useiii-stream or pubsub. They use
worker-owned trigger types and the queue worker:
- Ch. 5: Stream live clicks is a worked example of Recipe 2. The
click-streamerworker owns theclick-streamer::clicktrigger type and announces each click after its row is committed, andlink::click_summarygives a late listener the current total. - Ch. 7: Bring in the browser binds that trigger type from the
Browser SDK through
rbac-proxy, reads the total first, and reads it again after a reconnect. - Ch. 4: Make it durable fans
link.createdandlink.updatedout on durable topics withiii::durable::publishanddurable:subscriber. - Build a real-time todo app outlines Recipe 2 for a browser
app: stored todos,
todos::getandtodos::list, and an ownedtodos::changedtrigger type.
Checklist
- List every call to
stream::*,publish, and every binding ofstream,stream:join,stream:leaveandsubscribe, including calls whose failures your code ignores. - Pick a recipe per use case and name the worker that owns each piece of data.
- Move authorization to the RBAC listener and the provider’s
registerTriggervalidation. - Migrate producers and their consumers together. If you must publish to both paths for a while, define event identity, which consumer reads which path, and a deadline.
- Copy any stream data you still need into domain storage.
- Run your consumers with
iii-streamandpubsubabsent fromworker-compose.yamland confirm no deprecation warnings remain in the engine log.