svelte-realtime 0.5.10 → 0.6.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2339,6 +2339,49 @@ Without a leader configured (the default), every worker fires every job. svelte-
2339
2339
 
2340
2340
  ---
2341
2341
 
2342
+ ## Feature flags
2343
+
2344
+ Use `live.flag()` to declare a server-controlled value that every client reads as a readable store. A flag is a thin wrapper over `live.stream`: it declares a `merge: 'set'` topic carrying the value, and `.set(value)` pushes a new value to every subscriber.
2345
+
2346
+ ```js
2347
+ // src/live/flags.js
2348
+ import { live } from 'svelte-realtime/server';
2349
+
2350
+ export const maintenance = live.flag('flag:maintenance', false);
2351
+ ```
2352
+
2353
+ Flip it from any handler - the `.set(value)` call publishes through the framework-owned platform, so the new value reaches every local subscriber and relays across the cluster when a bus is wired:
2354
+
2355
+ ```js
2356
+ export const toggleMaintenance = live(async (ctx, on) => {
2357
+ maintenance.set(on);
2358
+ });
2359
+ ```
2360
+
2361
+ On the client, the flag is a readable store carrying the current value:
2362
+
2363
+ ```svelte
2364
+ <script>
2365
+ import { maintenance } from '$live/flags';
2366
+ </script>
2367
+
2368
+ {#if $maintenance}
2369
+ <Banner>Down for maintenance</Banner>
2370
+ {/if}
2371
+ ```
2372
+
2373
+ `.set(value)` requires that the platform has been captured (`realtime().init`, `setCronPlatform`, or `_activateDerived` from your `hooks.ws.js` `init({ platform })` hook) - the same wiring cron and the top-level `publish()` helper need. Read the current value on the server with `.get()`.
2374
+
2375
+ Flags are cluster-consistent by default. A single-entry shared replay buffer is enabled automatically, so a `.set()` on any replica writes the cluster-shared buffer, and a client that connects fresh - to any replica, including one that never set the flag locally - is served the cluster-latest value on connect. Already-subscribed clients stay in sync wherever they connected, because `.set()` relays the update across the cluster. Pass a custom `replay` object (for example `{ replay: { size: 5 } }`) to size the buffer, or `{ replay: false }` to opt out - a single-process app loses nothing by opting out, since the locally cached value is authoritative in one process.
2376
+
2377
+ On every running replica an internal watcher keeps the cached value behind `.get()` fresh from boot. The watcher is installed when the registry module loads - the same moment `live.effect` watchers become active - so it does not wait for the flag module's first local import or subscribe: an inbound `set` relayed from any replica updates the cached value within a tick, and synchronous `.get()` reflects the cluster-latest value on any running instance. For a strict read on a replica that booted after the last `set` and has not yet received any inbound `set` - reading a flag the moment a replica comes up, before it has observed any traffic - use the asynchronous `getLatest()`, which reads the shared buffer directly:
2378
+
2379
+ ```js
2380
+ const on = await maintenance.getLatest();
2381
+ ```
2382
+
2383
+ ---
2384
+
2342
2385
  ## Derived streams
2343
2386
 
2344
2387
  Server-side computed streams that recompute when any source topic publishes.
@@ -3048,7 +3091,7 @@ With adapter 0.4.0+, the replay end marker sends `{ reqId }` (replay complete) o
3048
3091
 
3049
3092
  Once `platform.replay` is exposed (the standard install pattern is `platform.replay = createReplay(redisClient)` in your hooks), the framework auto-routes every publish to a replay-eligible topic through `platform.replay.publish` regardless of which seam the publisher sits on. `live.stream(topic, loader, { replay: true })` registers the topic at declaration time; static topics are registered up-front and dynamic topics are registered at first-subscribe time when they resolve.
3050
3093
 
3051
- This applies to every framework publish surface `ctx.publish` from RPC handlers, cron auto-publish (`live.cron('* * * * * *', topic, async (ctx) => result)` where `result !== undefined`), and `ctx.publish` from inside cron handlers without the user wiring anything beyond `replay: true`. Pre-fix, the user was responsible for wrapping the platform with a `wrapWithReplay` proxy at every seam (the docs showed it on `createMessage` only; cron was a separate `setCronPlatform(platform)` capture, and cron-published events silently bypassed the buffer because the wrap was missing there). The auto-routing makes that asymmetry impossible by construction.
3094
+ This applies to every framework publish surface - `ctx.publish` from RPC handlers, cron auto-publish (`live.cron('* * * * * *', topic, async (ctx) => result)` where `result !== undefined`), and `ctx.publish` from inside cron handlers - without the user wiring anything beyond `replay: true`. Pre-fix, the user was responsible for wrapping the platform with a `wrapWithReplay` proxy at every seam (the docs showed it on `createMessage` only; cron was a separate `setCronPlatform(platform)` capture, and cron-published events silently bypassed the buffer because the wrap was missing there). The auto-routing makes that asymmetry impossible by construction.
3052
3095
 
3053
3096
  If you need to keep your own platform-wrapping proxy (custom topic patterns, additional intercepts), set `[WRAPPED_FOR_REPLAY] = true` on the proxy:
3054
3097
 
@@ -3062,7 +3105,7 @@ function wrapWithReplay(p) {
3062
3105
  }
3063
3106
  ```
3064
3107
 
3065
- The framework defers entirely when this marker is present (no double-write to Redis). Without the marker, the framework's auto-routing runs alongside the user proxy's routing and will issue duplicate Redis writes explicit opt-out is required.
3108
+ The framework defers entirely when this marker is present (no double-write to Redis). Without the marker, the framework's auto-routing runs alongside the user proxy's routing and will issue duplicate Redis writes - explicit opt-out is required.
3066
3109
 
3067
3110
  If `replay: true` is declared but `platform.replay` is never set, dev-mode logs a one-time `console.warn` per topic on the first publish, with the install pointer for the replay extension. Production runs silently (no per-publish overhead) and the local broadcast still happens.
3068
3111
 
@@ -3401,6 +3444,8 @@ live.publishRateWarning(false);
3401
3444
 
3402
3445
  Production builds constant-fold the activation branch to dead code - zero overhead. The sampler runs once per platform on the first ctx-helpers cache miss; per-publish cost is unchanged. Topics already in `_topicCoalesce` or `_topicVolatile` are skipped (the user has already addressed them).
3403
3446
 
3447
+ The client emits the same hint from the receiving side: in development it measures each stream's inbound frame rate and logs one warning per topic when it crosses the threshold, suggesting `coalesceBy` / `volatile` and linking the same guide. Streams declared with `coalesceBy` are suppressed; silence it everywhere with `configure({ publishRateHint: false })`. Production builds strip the client hint entirely (`import.meta.env`-gated), leaving zero residue on the inbound dispatch path.
3448
+
3404
3449
  ### Dev-mode silent-topic warning
3405
3450
 
3406
3451
  In development, the framework arms a one-shot timer when a stream first subscribes to a topic. If no events arrive within `thresholdMs` (default `30000`), it logs a warning naming the topic and the common causes:
@@ -3677,6 +3722,7 @@ Import from `svelte-realtime/server`.
3677
3722
  | `live.upload(fn, options?)` | Streaming upload handler (chunked, abortable async-iterable; `maxSize` 100MB, `maxConcurrentPerSession` 4, `maxBufferedChunks` 64) |
3678
3723
  | `live.validated(schema, fn)` | RPC with [Standard Schema](https://standardschema.dev/) input validation (Zod, ArkType, Valibot, etc.) |
3679
3724
  | `live.cron(schedule, topic, fn)` | Server-side scheduled function |
3725
+ | `live.flag(topic, initialValue?, options?)` | Feature flag exposed as a readable stream with a server-side `.set(value)` |
3680
3726
  | `live.derived(sources, fn, options?)` | Server-side computed stream (static or dynamic sources) |
3681
3727
  | `live.effect(sources, fn, options?)` | Server-side reactive side effect |
3682
3728
  | `live.aggregate(source, reducers, options)` | Real-time incremental aggregation |
package/client.d.ts CHANGED
@@ -583,6 +583,15 @@ export function configure(config: {
583
583
  * @default 4_194_304 (4 MB)
584
584
  */
585
585
  volatileBackpressureBytes?: number;
586
+ /**
587
+ * Enable the dev-mode publish-rate hint: a one-shot console warning logged
588
+ * when an inbound stream's frame rate crosses the high-frequency threshold,
589
+ * suggesting `coalesceBy` / `volatile`. Set `false` to silence it.
590
+ * Production builds strip the hint entirely, so this flag only has an effect
591
+ * in development.
592
+ * @default true
593
+ */
594
+ publishRateHint?: boolean;
586
595
  /** Offline mutation queue configuration. */
587
596
  offline?: {
588
597
  /** Enable queuing RPCs when disconnected. */
package/client.js CHANGED
@@ -121,6 +121,32 @@ const _dedupMap = new Map();
121
121
  */
122
122
  const _dedupCoalesceWarned = new Set();
123
123
 
124
+ // - Dev-mode publish-rate hint (client half) -------------------------------
125
+ // Mirrors the server-side sampler in `svelte-realtime/server.js`, but the
126
+ // signal source differs. The server reads `platform.pressure.topPublishers`
127
+ // (rates the adapter already computes); the client has no such snapshot, so
128
+ // it measures inbound frame rate directly at the dispatch hook. A per-topic
129
+ // fixed window counts frames; when a window closes over threshold, one warn
130
+ // fires per topic per session with the SAME wording, threshold (200), and
131
+ // `svti.me/highfreq` link as the server. The whole feature is gated by the
132
+ // `import.meta.env`-folded `_IS_DEV` const so a production build strips it to
133
+ // dead code, leaving zero residue on the inbound dispatch hot path.
134
+
135
+ /** Inbound events/sec at which a topic is considered high-frequency. Matches the server sampler default. */
136
+ const _PUBLISH_RATE_HINT_THRESHOLD = 200;
137
+
138
+ /** Measurement window for the client frame-rate counter, in ms. Rate = frames-in-window / window-seconds. */
139
+ const _PUBLISH_RATE_HINT_WINDOW_MS = 1000;
140
+
141
+ /** Max distinct topics tracked in the warned dedup set. FIFO-evict on cap: dropping the oldest entry just lets that topic re-warn on its next over-threshold window. Mirrors the server `PUBLISH_RATE_WARN_DEDUP_MAX` eviction shape. */
142
+ const _PUBLISH_RATE_HINT_DEDUP_MAX = 1_000_000;
143
+
144
+ /** @type {Map<string, { start: number, count: number }>} Per-topic fixed-window frame counter. */
145
+ const _publishRateWindows = new Map();
146
+
147
+ /** @type {Set<string>} One-shot warned topics. FIFO-evicted at the dedup cap. */
148
+ const _publishRateHintWarned = new Set();
149
+
124
150
  /**
125
151
  * Dev-mode check, mirrored from the `process.env.NODE_ENV` pattern
126
152
  * used elsewhere in this file. Cached once at first call so the hot
@@ -227,13 +253,37 @@ const _healthStore = writable(/** @type {'healthy' | 'degraded'} */ ('healthy'))
227
253
  /** @type {(() => void) | null} */
228
254
  let _healthUnsub = null;
229
255
 
256
+ // Two independent inputs OR into the single health state: a server-pushed
257
+ // degraded/recovered event on the system topic, and the connection's local
258
+ // internal flow-control pressure (a queued/refused flow-controlled send).
259
+ // Tracked separately so neither input clobbers the other - health is degraded
260
+ // while EITHER is degraded, healthy only when BOTH are clear.
261
+ let _healthServerDegraded = false;
262
+ let _healthFlowDegraded = false;
263
+
264
+ function _recomputeHealth() {
265
+ _healthStore.set(_healthServerDegraded || _healthFlowDegraded ? 'degraded' : 'healthy');
266
+ }
267
+
230
268
  function _ensureHealthSubscription() {
231
269
  if (_healthUnsub) return;
232
- _healthUnsub = on(_HEALTH_TOPIC).subscribe((envelope) => {
270
+ const offTopic = on(_HEALTH_TOPIC).subscribe((envelope) => {
233
271
  if (!envelope) return;
234
- if (envelope.event === 'degraded') _healthStore.set('degraded');
235
- else if (envelope.event === 'recovered') _healthStore.set('healthy');
272
+ if (envelope.event === 'degraded') { _healthServerDegraded = true; _recomputeHealth(); }
273
+ else if (envelope.event === 'recovered') { _healthServerDegraded = false; _recomputeHealth(); }
236
274
  });
275
+ // Fold the connection's internal flow-control health in as a second,
276
+ // OR-ed input. A boolean is the only thing that crosses this accessor;
277
+ // no internal accounting value surfaces. Older adapter connections that
278
+ // predate the accessor simply do not contribute this input.
279
+ let offFlow = () => {};
280
+ try {
281
+ const conn = _connect();
282
+ if (conn && typeof conn._onLeaseDegraded === 'function') {
283
+ offFlow = conn._onLeaseDegraded((d) => { _healthFlowDegraded = !!d; _recomputeHealth(); });
284
+ }
285
+ } catch { /* connection not configured yet; flow health stays clear */ }
286
+ _healthUnsub = () => { offTopic(); offFlow(); };
237
287
  }
238
288
 
239
289
  /**
@@ -271,9 +321,18 @@ export function _resetHealth() {
271
321
  _healthUnsub();
272
322
  _healthUnsub = null;
273
323
  }
324
+ _healthServerDegraded = false;
325
+ _healthFlowDegraded = false;
274
326
  _healthStore.set('healthy');
275
327
  }
276
328
 
329
+ // Flow control is owned end to end by the adapter connection's send gate: it
330
+ // advertises the capability, paces its own flow-controlled sends against the
331
+ // server's window, and reports a single degraded boolean. The realtime layer
332
+ // consumes that boolean through conn._onLeaseDegraded in
333
+ // _ensureHealthSubscription above and ORs it into realtime.health. There is no
334
+ // realtime-owned mirror of the gate - a second copy would only drift.
335
+
277
336
  function _registerTopicErrorSetter(topic, setError) {
278
337
  let set = _streamErrorByTopic.get(topic);
279
338
  if (!set) { set = new Set(); _streamErrorByTopic.set(topic, set); }
@@ -494,6 +553,85 @@ function _warnCoalesceOnce(path) {
494
553
  );
495
554
  }
496
555
 
556
+ /**
557
+ * Count one inbound frame for a topic and, if its measured rate crosses the
558
+ * high-frequency threshold, emit a one-shot dev hint. Counterpart to the
559
+ * server-side sampler: same threshold (200), same `coalesceBy` / `volatile`
560
+ * suggestions, same `svti.me/highfreq` link. The server reads rates the
561
+ * adapter pre-computes; the client has none, so it counts frames over a fixed
562
+ * window and derives the rate locally.
563
+ *
564
+ * Suppressed when the stream was declared with `coalesceBy` - that is the user
565
+ * already choosing the latest-value-wins mitigation, so the hint would be
566
+ * noise. (There is no client-side `volatile` declaration to read; `volatile`
567
+ * is a per-call RPC concern, not a stream option, so only `coalesceBy`
568
+ * suppresses here.) Opt out entirely with `configure({ publishRateHint: false })`.
569
+ *
570
+ * The whole function is dead code in production: the only caller is gated by
571
+ * the `import.meta.env`-folded `_IS_DEV` const, and this body re-checks it so a
572
+ * direct call from a test still no-ops under a production build.
573
+ *
574
+ * @param {string} topic - the stream path (the identity available at dispatch)
575
+ * @param {any} options - the per-stream options object (read for `coalesceBy`)
576
+ */
577
+ function _maybeHintPublishRate(topic, options) {
578
+ if (!_IS_DEV) return;
579
+ if (_clientConfig.publishRateHint === false) return;
580
+ if (_publishRateHintWarned.has(topic)) return;
581
+ // A declared-coalesced stream already picked latest-value-wins, so the hint
582
+ // would be noise: skip the counting work entirely, mirroring the server which
583
+ // marks such topics handled up front.
584
+ if (options && options.coalesceBy) return;
585
+
586
+ const now = Date.now();
587
+ let win = _publishRateWindows.get(topic);
588
+ if (win === undefined) {
589
+ _publishRateWindows.set(topic, { start: now, count: 1 });
590
+ return;
591
+ }
592
+ win.count++;
593
+ const elapsed = now - win.start;
594
+ if (elapsed < _PUBLISH_RATE_HINT_WINDOW_MS) return;
595
+
596
+ // Window closed: derive events/sec and reset for the next window. A short
597
+ // final window (e.g. the stream unsubscribed mid-window) still scales to a
598
+ // per-second rate, so a genuine burst is not under-counted.
599
+ const rate = (win.count * 1000) / elapsed;
600
+ win.start = now;
601
+ win.count = 0;
602
+
603
+ if (rate < _PUBLISH_RATE_HINT_THRESHOLD) return;
604
+
605
+ if (_publishRateHintWarned.size >= _PUBLISH_RATE_HINT_DEDUP_MAX) {
606
+ const oldest = _publishRateHintWarned.values().next().value;
607
+ if (oldest !== undefined) _publishRateHintWarned.delete(oldest);
608
+ }
609
+ _publishRateHintWarned.add(topic);
610
+ // The window counter is never re-read once a topic has warned (the warned
611
+ // set short-circuits at the top), so drop it to keep the window map bounded
612
+ // by live unwarned topics, mirroring the server sampler's symmetry.
613
+ _publishRateWindows.delete(topic);
614
+ console.warn(
615
+ `[svelte-realtime] Topic '${topic}' is receiving ` +
616
+ `${Math.round(rate)} events/sec.\n` +
617
+ ` For high-frequency streams, consider one of:\n` +
618
+ ` live.stream(topic, loader, { coalesceBy: (data) => data.userId }) // latest-value-wins, queued per subscriber\n` +
619
+ ` live.stream(topic, loader, { volatile: true }) // drop on backpressure, best-effort\n` +
620
+ ` See: https://svti.me/highfreq`
621
+ );
622
+ }
623
+
624
+ /**
625
+ * Reset the dev-mode client publish-rate hint state. Tests only. Clears the
626
+ * one-shot warned set and the per-topic window counters so a previously seen
627
+ * topic can warn again.
628
+ * @internal
629
+ */
630
+ export function _resetClientPublishRateWarning() {
631
+ _publishRateHintWarned.clear();
632
+ _publishRateWindows.clear();
633
+ }
634
+
497
635
  /**
498
636
  * Build a dedup key from path and args, avoiding JSON.stringify for common cases.
499
637
  * @param {string} path
@@ -2424,6 +2562,7 @@ function _createStream(path, options, dynamicArgs, initialSchemaVersion) {
2424
2562
  }
2425
2563
 
2426
2564
  _devtoolsStreamEvent(path, envelope.event, envelope.data);
2565
+ if (_IS_DEV) _maybeHintPublishRate(path, options);
2427
2566
 
2428
2567
  if (_useRAF) {
2429
2568
  _activeBuf.push(envelope);
@@ -3530,7 +3669,7 @@ function _checkArgs(path, args) {
3530
3669
  * @typedef {{ path: string, args: any[], queuedAt: number, resolve: Function, reject: Function, idempotencyKey?: string, timeout?: number }} OfflineEntry
3531
3670
  */
3532
3671
 
3533
- /** @type {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, timeout?: number, resumeGraceMs?: number, volatileBackpressureBytes?: number, upload?: { frameSize?: number, chunkSize?: number, highWaterMark?: number, lowWaterMark?: number }, offline?: { queue?: boolean, maxQueue?: number, maxAge?: number, replay?: 'sequential' | 'batch' | ((queue: OfflineEntry[]) => OfflineEntry[]), beforeReplay?: (call: { path: string, args: any[], queuedAt: number }) => boolean, onReplayError?: (call: { path: string, args: any[], queuedAt: number }, error: any) => void } }} */
3672
+ /** @type {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, timeout?: number, resumeGraceMs?: number, volatileBackpressureBytes?: number, publishRateHint?: boolean, upload?: { frameSize?: number, chunkSize?: number, highWaterMark?: number, lowWaterMark?: number }, offline?: { queue?: boolean, maxQueue?: number, maxAge?: number, replay?: 'sequential' | 'batch' | ((queue: OfflineEntry[]) => OfflineEntry[]), beforeReplay?: (call: { path: string, args: any[], queuedAt: number }) => boolean, onReplayError?: (call: { path: string, args: any[], queuedAt: number }, error: any) => void } }} */
3534
3673
  let _clientConfig = {};
3535
3674
 
3536
3675
  /** @type {boolean} */
@@ -3561,7 +3700,12 @@ let _replayingQueue = false;
3561
3700
  * volatile traffic, lower it on mobile-constrained targets where the OS
3562
3701
  * send buffer is tighter.
3563
3702
  *
3564
- * @param {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, timeout?: number, resumeGraceMs?: number, volatileBackpressureBytes?: number, offline?: { queue?: boolean, maxQueue?: number, maxAge?: number, replay?: 'sequential' | 'batch' | ((queue: OfflineEntry[]) => OfflineEntry[]), beforeReplay?: (call: { path: string, args: any[], queuedAt: number }) => boolean, onReplayError?: (call: { path: string, args: any[], queuedAt: number }, error: any) => void } }} config
3703
+ * `publishRateHint` (default enabled in dev) controls the one-shot console
3704
+ * hint logged when an inbound stream's frame rate crosses the high-frequency
3705
+ * threshold. Set `false` to silence it. Production builds strip the hint
3706
+ * regardless, so this only matters in development.
3707
+ *
3708
+ * @param {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, timeout?: number, resumeGraceMs?: number, volatileBackpressureBytes?: number, publishRateHint?: boolean, offline?: { queue?: boolean, maxQueue?: number, maxAge?: number, replay?: 'sequential' | 'batch' | ((queue: OfflineEntry[]) => OfflineEntry[]), beforeReplay?: (call: { path: string, args: any[], queuedAt: number }) => boolean, onReplayError?: (call: { path: string, args: any[], queuedAt: number }, error: any) => void } }} config
3565
3709
  */
3566
3710
  export function configure(config) {
3567
3711
  _clientConfig = config;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "svelte-realtime",
3
- "version": "0.5.10",
3
+ "version": "0.6.0-next.2",
4
4
  "publishConfig": {
5
- "tag": "latest"
5
+ "tag": "next"
6
6
  },
7
7
  "description": "Realtime RPC and reactive subscriptions for SvelteKit, built on svelte-adapter-uws",
8
8
  "author": "Kevin Radziszewski",
package/server.d.ts CHANGED
@@ -1659,6 +1659,57 @@ export namespace live {
1659
1659
  fn: T
1660
1660
  ): T;
1661
1661
 
1662
+ /**
1663
+ * Declare a server-side feature flag exposed as a readable stream.
1664
+ *
1665
+ * A flag is a thin wrapper over `live.stream`: it declares a
1666
+ * `merge: 'set'` topic carrying the flag value, and any `.set(value)`
1667
+ * pushes the new value to every subscriber. On the client,
1668
+ * `$live/<module>` exposes the export as a readable store carrying the
1669
+ * current value.
1670
+ *
1671
+ * Flags are cluster-consistent by default: a single-entry shared replay
1672
+ * buffer is enabled, so `.set()` writes the cluster-shared buffer and a
1673
+ * subscriber that connects fresh - to any replica, including one that
1674
+ * never set the flag locally - is served the cluster-latest value.
1675
+ * Already-subscribed clients stay in sync across the cluster as `.set()`
1676
+ * relays the update. Pass a custom `replay` object to size the buffer, or
1677
+ * `replay: false` to opt out (single-process apps lose nothing, since the
1678
+ * locally cached value is authoritative in one process).
1679
+ *
1680
+ * `.set(value)` publishes through the framework-owned platform (the same
1681
+ * path as the top-level `publish()` helper), so the new value reaches
1682
+ * every local subscriber and relays across the cluster when a bus is
1683
+ * wired. Call it from any server context after the platform has been
1684
+ * captured. `.get()` reads the current value on the server synchronously;
1685
+ * an internal watcher - installed when the registry module loads, the same
1686
+ * lifecycle that activates `live.effect` watchers - keeps it fresh from boot
1687
+ * within a tick of any inbound `set` on a running replica, without waiting
1688
+ * for the flag module's first local import. `getLatest()` reads the
1689
+ * cluster-latest value asynchronously from the shared buffer for the strict
1690
+ * read on a replica that booted after the last `set` and has not yet
1691
+ * received any inbound `set`.
1692
+ *
1693
+ * @param topic - Topic carrying the flag value
1694
+ * @param initialValue - Value served to subscribers before the first `.set`
1695
+ * @param options - Optional `replay` to size the shared buffer, or `replay: false` to opt out
1696
+ *
1697
+ * @example
1698
+ * ```js
1699
+ * // src/live/flags.js
1700
+ * export const maintenance = live.flag('flag:maintenance', false);
1701
+ *
1702
+ * export const toggleMaintenance = live(async (ctx, on) => {
1703
+ * maintenance.set(on);
1704
+ * });
1705
+ * ```
1706
+ */
1707
+ function flag<V = any>(
1708
+ topic: string,
1709
+ initialValue?: V,
1710
+ options?: { replay?: boolean | { size?: number } }
1711
+ ): Function & { set(value: V): any; get(): V; getLatest(): Promise<V> };
1712
+
1662
1713
  /**
1663
1714
  * Create a real-time incremental aggregation over a source topic.
1664
1715
  *
@@ -2495,6 +2546,15 @@ export function __registerEffect(path: string, fn: Function): void;
2495
2546
  */
2496
2547
  export function __registerAggregate(path: string, fn: Function): void;
2497
2548
 
2549
+ /**
2550
+ * Install a flag's refresh watcher eagerly at registry-module load, keyed by
2551
+ * topic, so a server-side `.get()` reflects cluster-latest sets from boot
2552
+ * without waiting for the flag module's first local import. Called by the
2553
+ * Vite-generated registry module.
2554
+ * @internal
2555
+ */
2556
+ export function __registerFlag(topic: string, initialValue?: any): void;
2557
+
2498
2558
  /**
2499
2559
  * Register room actions lazily. Called by the Vite-generated registry module.
2500
2560
  * @internal
package/server.js CHANGED
@@ -3720,6 +3720,139 @@ live.cron = function cron(schedule, topic, fn) {
3720
3720
  return fn;
3721
3721
  };
3722
3722
 
3723
+ /**
3724
+ * Declare a server-side feature flag exposed as a readable stream.
3725
+ *
3726
+ * A flag is a thin wrapper over `live.stream`: it declares a `merge: 'set'`
3727
+ * topic carrying the flag value, and any `.set(value)` pushes the new value
3728
+ * to every subscriber. On the client, `$live/<module>` exposes the export as
3729
+ * a readable store carrying the current value.
3730
+ *
3731
+ * Flags are cluster-consistent by default: a single-entry shared replay
3732
+ * buffer is enabled, so `.set()` writes the cluster-shared buffer and a
3733
+ * subscriber that connects fresh - to any replica, including one that never
3734
+ * set the flag locally - is served the cluster-latest value. Already-
3735
+ * subscribed clients stay in sync across the cluster as `.set()` relays the
3736
+ * update. Pass a custom `replay` object to size the buffer, or
3737
+ * `replay: false` to opt out (single-process apps lose nothing, since the
3738
+ * locally cached value is authoritative in one process).
3739
+ *
3740
+ * On every running replica an internal watcher keeps the cached value fresh
3741
+ * from boot. The watcher is installed when the registry module loads (the
3742
+ * same moment `live.effect` watchers become active), so it does not wait for
3743
+ * the flag module's first local import or subscribe: an inbound `set` relayed
3744
+ * from any replica updates the cached value within a tick, and the synchronous
3745
+ * `.get()` reflects the cluster-latest value on any running instance. For a
3746
+ * strict read on a replica that booted AFTER the last `set` and has not yet
3747
+ * received any inbound `set` (the watcher only catches post-boot sets), use the
3748
+ * asynchronous `getLatest()`, which reads the shared buffer directly.
3749
+ *
3750
+ * The `.set(value)` method publishes through the framework-owned platform
3751
+ * (the same path as the top-level `publish()` helper), so the new value
3752
+ * reaches every local subscriber and relays across the cluster when a bus
3753
+ * is wired. Call it from any server context after the platform has been
3754
+ * captured (RPC handler, cron tick, effect, an admin `+server.js` route).
3755
+ *
3756
+ * @param {string} topic - Topic carrying the flag value
3757
+ * @param {any} [initialValue] - Value served to subscribers before the first `.set`
3758
+ * @param {{ replay?: boolean | { size?: number } }} [options]
3759
+ * @returns {Function & { set(value: any): any, get(): any, getLatest(): Promise<any> }}
3760
+ *
3761
+ * @example
3762
+ * ```js
3763
+ * // src/live/flags.js
3764
+ * import { live } from 'svelte-realtime/server';
3765
+ * export const maintenance = live.flag('flag:maintenance', false);
3766
+ *
3767
+ * // Flip it from any handler:
3768
+ * export const toggleMaintenance = live(async (ctx, on) => {
3769
+ * maintenance.set(on);
3770
+ * });
3771
+ * ```
3772
+ *
3773
+ * ```svelte
3774
+ * <script>
3775
+ * import { maintenance } from '$live/flags';
3776
+ * </script>
3777
+ * {#if $maintenance}<Banner />{/if}
3778
+ * ```
3779
+ */
3780
+ live.flag = function flag(topic, initialValue, options) {
3781
+ if (typeof topic !== 'string' || topic.length === 0) {
3782
+ throw new Error('[svelte-realtime] live.flag topic must be a non-empty string');
3783
+ }
3784
+ // The flag's value lives in a per-topic cell shared with the eager
3785
+ // registry-load watcher (installed by `__registerFlag`). Binding to the
3786
+ // cell instead of a private closure variable decouples the value from this
3787
+ // module's import: a `set` that arrives before the module is first imported
3788
+ // is captured into the cell by the eager watcher, so the first `.get()`
3789
+ // after import reads the cluster-latest value rather than a stale init.
3790
+ const cell = _flagCell(topic, initialValue);
3791
+ const initFn = async function flagInit() { return cell.value; };
3792
+ // Replay is ON by default with a single-entry buffer so the flag's
3793
+ // topic is replay-eligible at declaration: `.set() -> publish() ->
3794
+ // _maybeReplayPublish` writes the cluster-shared buffer, and a fresh
3795
+ // subscriber (or a just-booted replica) is served the cluster-latest
3796
+ // value through the seeding branch in `_executeStreamRpc`. Pass a
3797
+ // custom `replay` object to override the buffer size, or `replay: false`
3798
+ // to opt out (single-process apps lose nothing - the cached value is
3799
+ // authoritative in one process).
3800
+ const streamOpts = { merge: 'set' };
3801
+ if (options && options.replay === false) {
3802
+ // opt out: leave replay unset
3803
+ } else if (options && options.replay) {
3804
+ /** @type {any} */ (streamOpts).replay = options.replay;
3805
+ } else {
3806
+ /** @type {any} */ (streamOpts).replay = { size: 1 };
3807
+ }
3808
+ const stream = live.stream(topic, initFn, streamOpts);
3809
+ /** @type {any} */ (stream).__isFlag = true;
3810
+ /**
3811
+ * Read the flag's current value on the server (synchronous). On a running
3812
+ * replica this stays fresh from boot within a tick of any inbound `set` via
3813
+ * the per-topic watcher installed eagerly at registry load (see
3814
+ * `__registerFlag`). For a strict read on a replica that booted after the
3815
+ * last `set` and has not yet received any inbound `set`, use `getLatest()`.
3816
+ */
3817
+ /** @type {any} */ (stream).get = function get() { return cell.value; };
3818
+ /**
3819
+ * Read the cluster-latest flag value (asynchronous). Reads the shared
3820
+ * replay buffer when one is wired and non-empty; otherwise falls back to
3821
+ * the locally cached value. Serves the strict read-after-cold-boot
3822
+ * case where a replica may not yet have observed the cluster-latest set.
3823
+ */
3824
+ /** @type {any} */ (stream).getLatest = async function getLatest() {
3825
+ // Resolve the captured platform the same way `.set()` does (via the
3826
+ // top-level `publish()` helper), so `getLatest()` reads the shared
3827
+ // buffer whether the platform was captured by `_activateDerived` or
3828
+ // `setCronPlatform`.
3829
+ const platform = getPlatform();
3830
+ const replay = platform && /** @type {any} */ (platform).replay;
3831
+ if (replay && typeof replay.since === 'function') {
3832
+ try {
3833
+ const buffered = await replay.since(topic, 0);
3834
+ if (Array.isArray(buffered) && buffered.length > 0) {
3835
+ const last = buffered[buffered.length - 1];
3836
+ if (last && 'data' in last) return last.data;
3837
+ }
3838
+ } catch {}
3839
+ }
3840
+ return cell.value;
3841
+ };
3842
+ /** Publish a new flag value to every subscriber. */
3843
+ /** @type {any} */ (stream).set = function set(value) {
3844
+ cell.value = value;
3845
+ return publish(topic, 'set', value);
3846
+ };
3847
+ // Ensure the per-topic refresh watcher is installed. This is idempotent
3848
+ // with the eager `__registerFlag` install the registry module emits, and
3849
+ // covers the cases where a flag module is imported without a generated
3850
+ // registry (the dev-mode direct-load fallback, or a flag declared inline
3851
+ // in tests).
3852
+ _installFlagWatcher(topic);
3853
+ return /** @type {any} */ (stream);
3854
+ };
3855
+
3723
3856
  /** @type {Map<string, { sources: string[], fn: Function, topic: string, debounce: number, timer: ReturnType<typeof setTimeout> | null }>} */
3724
3857
  const derivedRegistry = new Map();
3725
3858
 
@@ -3860,6 +3993,95 @@ export function __registerEffect(path, fn) {
3860
3993
  _maybeLateActivate();
3861
3994
  }
3862
3995
 
3996
+ /**
3997
+ * Per-topic value cells for `live.flag`. The cell holds the flag's current
3998
+ * value and is shared between the flag export's accessors (`get`/`set`/the
3999
+ * loader) and the eager refresh watcher installed at registry load. Keying on
4000
+ * the topic (rather than the export path) lets the watcher install before the
4001
+ * flag module is imported - the value the watcher captures from inbound sets is
4002
+ * exactly the value the flag's `.get()` reads once the module is imported.
4003
+ * @type {Map<string, { value: any }>}
4004
+ */
4005
+ const _flagCells = new Map();
4006
+
4007
+ /**
4008
+ * Per-topic refresh watcher entries, so the install is idempotent across the
4009
+ * eager registry call and the flag module's own import.
4010
+ * @type {Map<string, { sources: string[], fn: Function, debounce: number, timer: ReturnType<typeof setTimeout> | null }>}
4011
+ */
4012
+ const _flagWatchers = new Map();
4013
+
4014
+ /**
4015
+ * Get (or create) the value cell for a flag topic. A cell created by the eager
4016
+ * watcher path may not have a meaningful seed yet; the first caller that knows
4017
+ * the declared `initialValue` (the watcher install or the flag body, whichever
4018
+ * runs first) seeds it. Inbound sets always overwrite the seed.
4019
+ * @param {string} topic
4020
+ * @param {any} [initialValue]
4021
+ * @returns {{ value: any }}
4022
+ */
4023
+ function _flagCell(topic, initialValue) {
4024
+ let cell = _flagCells.get(topic);
4025
+ if (!cell) {
4026
+ cell = { value: initialValue };
4027
+ _flagCells.set(topic, cell);
4028
+ } else if (cell.value === undefined && initialValue !== undefined) {
4029
+ // Adopt the declared initial value when the cell was created without
4030
+ // one (e.g. the eager watcher had no static initialValue to pass).
4031
+ cell.value = initialValue;
4032
+ }
4033
+ return cell;
4034
+ }
4035
+
4036
+ /**
4037
+ * Install the per-topic flag refresh watcher into the effect index. Idempotent:
4038
+ * one watcher per topic, regardless of how many times this is called. The
4039
+ * watcher updates the topic's value cell on every inbound `set`, so a sync
4040
+ * `.get()` reflects the cluster-latest value from boot on every running replica.
4041
+ *
4042
+ * The bus inbound relay reaches `derivedPublishLocal -> fireWatchers(topic,
4043
+ * event, data)`, so a `set` originating on any replica updates the cell here
4044
+ * (the self-set echo on the origin is idempotent). `_maybeLateActivate()`
4045
+ * installs the publish wrap even when the flag is declared before
4046
+ * `_activateDerived`.
4047
+ * @param {string} topic
4048
+ */
4049
+ function _installFlagWatcher(topic) {
4050
+ if (_flagWatchers.has(topic)) return;
4051
+ const cell = _flagCell(topic);
4052
+ const watcher = {
4053
+ sources: [topic],
4054
+ fn: function flagWatcher(event, data) { if (event === 'set') cell.value = data; },
4055
+ debounce: 0,
4056
+ timer: null
4057
+ };
4058
+ _flagWatchers.set(topic, watcher);
4059
+ let watcherSet = _effectBySource.get(topic);
4060
+ if (!watcherSet) { watcherSet = new Set(); _effectBySource.set(topic, watcherSet); }
4061
+ watcherSet.add(watcher);
4062
+ _watchedTopics.add(topic);
4063
+ _maybeLateActivate();
4064
+ }
4065
+
4066
+ /**
4067
+ * Register a flag's refresh watcher eagerly. Called by the Vite-generated
4068
+ * registry module at registry-module load (the same lifecycle that activates
4069
+ * `live.effect` watchers), so the watcher is live from server boot on every
4070
+ * replica WITHOUT waiting for the flag module's first local import or subscribe.
4071
+ *
4072
+ * Carries the static topic and (when statically analyzable) the declared
4073
+ * `initialValue` so the cell is seeded before any inbound `set`. The flag's
4074
+ * `__register` stream entry is emitted alongside this and remains lazy; only the
4075
+ * watcher install is hoisted to boot.
4076
+ * @param {string} topic
4077
+ * @param {any} [initialValue]
4078
+ */
4079
+ export function __registerFlag(topic, initialValue) {
4080
+ if (typeof topic !== 'string' || topic.length === 0) return;
4081
+ _flagCell(topic, initialValue);
4082
+ _installFlagWatcher(topic);
4083
+ }
4084
+
3863
4085
  /** @type {Map<string, { source: string, reducers: any, topic: string, state: any, snapshot: Function | null, debounce: number, timer: ReturnType<typeof setTimeout> | null }>} */
3864
4086
  const aggregateRegistry = new Map();
3865
4087
  /** @type {Map<string, any>} Topic-keyed lookup for aggregates */
@@ -6140,6 +6362,11 @@ export function _prepareHmr() {
6140
6362
  _aggregateBySource.clear();
6141
6363
  _aggregateByTopic.clear();
6142
6364
  _watchedTopics.clear();
6365
+ // Drop the flag watcher index so `__registerFlag` reinstalls watchers on
6366
+ // the regenerated registry load. The value cells persist across HMR - the
6367
+ // flag's cluster-latest value should not reset when an unrelated module is
6368
+ // edited - and the reinstalled watcher rebinds to the surviving cell.
6369
+ _flagWatchers.clear();
6143
6370
  _streamsWithUnsubscribe.clear();
6144
6371
  _hasDynamicDerived = false;
6145
6372
  _hasLazyReactive = false;
@@ -7002,6 +7229,27 @@ async function _executeStreamRpc(ws, platform, fn, ctx, args, msg, subscribedRef
7002
7229
  } catch {}
7003
7230
  }
7004
7231
 
7232
+ // Flag fresh-subscribe seeding (cluster-latest on cold connect). A
7233
+ // fresh subscribe omits `seq`, so the seq-gated block above is skipped
7234
+ // and the loader would otherwise return this replica's locally-cached
7235
+ // value. For a flag backed by shared replay, read the whole buffer
7236
+ // (size:1 => one `set` envelope) and serve it through the same
7237
+ // `replay: true` array response the seq-gated block uses, so a fresh
7238
+ // connect to a replica that never set the flag locally still gets the
7239
+ // cluster-latest value. Gated strictly on `__isFlag` so non-flag replay
7240
+ // streams (crud/latest, whose loaders intentionally hit the DB on a
7241
+ // fresh subscribe) keep loader-only fresh-subscribe behavior. Empty
7242
+ // buffer (no `.set()` anywhere yet) falls through to the loader.
7243
+ if (replayOpts && platform.replay && typeof clientSeq === 'undefined' && /** @type {any} */ (fn).__isFlag) {
7244
+ try {
7245
+ const missed = await platform.replay.since(topic, 0);
7246
+ if (Array.isArray(missed) && missed.length > 0) {
7247
+ const currentSeq = await platform.replay.seq(topic);
7248
+ return { id, ok: true, data: missed, topic, merge: streamOpts.merge, key: streamOpts.key, prepend: streamOpts.prepend, max: streamOpts.max, seq: currentSeq, replay: true };
7249
+ }
7250
+ } catch {}
7251
+ }
7252
+
7005
7253
  // Seq-delta (user-provided bridge for older-than-buffer reconnects)
7006
7254
  if (deltaOpts && typeof deltaOpts.fromSeq === 'function' && typeof clientSeq === 'number') {
7007
7255
  try {
package/vite.js CHANGED
@@ -21,6 +21,10 @@ const DYNAMIC_CHANNEL_RE = /export\s+const\s+(\w+)\s*=\s*live\.channel\s*\(\s*(?
21
21
  const RATE_LIMIT_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.rateLimit\s*\(/g;
22
22
  const EFFECT_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.effect\s*\(/g;
23
23
  const AGGREGATE_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.aggregate\s*\(/g;
24
+ // `live.flag(topic, initialValue)` declares a `merge: 'set'` stream carrying
25
+ // the flag value, so the client treats it exactly like a static stream: emit
26
+ // a `__stream(..., { merge: 'set' })` stub and register it as a plain stream.
27
+ const FLAG_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.flag\s*\(/g;
24
28
  // `live.lock(...)` and `live.idempotent(...)` wrap an inner handler. From the
25
29
  // client's perspective they're plain RPCs (the lock / idempotency runs
26
30
  // server-side inside the wrapper), so the codegen treats them identically
@@ -1272,6 +1276,18 @@ function _generateClientStubs(filePath, modulePath, dir) {
1272
1276
  }
1273
1277
  }
1274
1278
 
1279
+ // Detect live.flag() exports (readable set-merge stream on the client)
1280
+ FLAG_EXPORT_RE.lastIndex = 0;
1281
+ while ((match = FLAG_EXPORT_RE.exec(source)) !== null) {
1282
+ const name = match[1];
1283
+ if (!/^\w+$/.test(name)) continue;
1284
+ if (!exportedNames.has(name)) {
1285
+ exportedNames.add(name);
1286
+ imports.add('__stream');
1287
+ lines.push(`export const ${name} = __stream(${safeModulePath(name)}, ${JSON.stringify({ merge: 'set' })});`);
1288
+ }
1289
+ }
1290
+
1275
1291
  // Dev warnings for non-live exports
1276
1292
  const allExportRe = /export\s+(?:const|function|let|var|class)\s+(\w+)/g;
1277
1293
  allExportRe.lastIndex = 0;
@@ -1684,6 +1700,65 @@ function _extractStreamOptions(source, name) {
1684
1700
  return opts;
1685
1701
  }
1686
1702
 
1703
+ /**
1704
+ * Extract the static declaration of a `live.flag(topic, initialValue)` export:
1705
+ * the required topic string literal, and the second argument's source text when
1706
+ * it is present. The topic is needed so the registry can install the flag's
1707
+ * refresh watcher eagerly (keyed by topic, before the flag module is imported).
1708
+ * The `initialArg` is the raw source of the second argument and is emitted only
1709
+ * when it is a statically safe literal (a string, number, boolean, or null) so
1710
+ * the value cell can be seeded at registry load; anything else is left for the
1711
+ * flag body to seed on first import. Returns null when the topic is not a plain
1712
+ * string literal (dynamic-topic flags are not supported by the codegen).
1713
+ * @param {string} source
1714
+ * @param {string} name
1715
+ * @returns {{ topic: string, initialArg: string | null } | null}
1716
+ */
1717
+ function _extractFlagDecl(source, name) {
1718
+ const openPattern = new RegExp(
1719
+ `export\\s+const\\s+${name}\\s*=\\s*live\\.flag\\s*\\(`
1720
+ );
1721
+ const openMatch = openPattern.exec(source);
1722
+ if (!openMatch) return null;
1723
+ let i = openMatch.index + openMatch[0].length;
1724
+ while (i < source.length && /\s/.test(source[i])) i++;
1725
+ if (source[i] !== '\'' && source[i] !== '"' && source[i] !== '`') return null;
1726
+ const topicLit = _readStringLiteral(source, i);
1727
+ if (!topicLit) return null;
1728
+ const topic = topicLit.value;
1729
+ i = topicLit.end + 1;
1730
+ while (i < source.length && /\s/.test(source[i])) i++;
1731
+ if (source[i] !== ',') return { topic, initialArg: null };
1732
+ i++;
1733
+ while (i < source.length && /\s/.test(source[i])) i++;
1734
+ // Read the second argument up to the next top-level `,` or the closing `)`.
1735
+ let depth = 0;
1736
+ const argStart = i;
1737
+ while (i < source.length) {
1738
+ const skipped = _skipNonCode(source, i);
1739
+ if (skipped >= 0) { i = skipped + 1; continue; }
1740
+ const c = source[i];
1741
+ if (c === '(' || c === '[' || c === '{') depth++;
1742
+ else if (c === ')' || c === ']' || c === '}') { if (depth === 0) break; depth--; }
1743
+ else if (c === ',' && depth === 0) break;
1744
+ i++;
1745
+ }
1746
+ const rawArg = source.slice(argStart, i).trim();
1747
+ if (rawArg === '') return { topic, initialArg: null };
1748
+ // Only forward statically safe literals so the generated registry never
1749
+ // evaluates user expressions. String literals are re-quoted via the parsed
1750
+ // value; primitives pass through verbatim.
1751
+ if (rawArg[0] === '\'' || rawArg[0] === '"' || rawArg[0] === '`') {
1752
+ const lit = _readStringLiteral(rawArg, 0);
1753
+ if (lit && lit.end === rawArg.length - 1) return { topic, initialArg: JSON.stringify(lit.value) };
1754
+ return { topic, initialArg: null };
1755
+ }
1756
+ if (rawArg === 'true' || rawArg === 'false' || rawArg === 'null' || /^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(rawArg)) {
1757
+ return { topic, initialArg: rawArg };
1758
+ }
1759
+ return { topic, initialArg: null };
1760
+ }
1761
+
1687
1762
  /**
1688
1763
  * Parse option properties from an object body string into an opts object.
1689
1764
  * Shared by stream, channel, and room option extraction.
@@ -1851,7 +1926,7 @@ function _generateRegistry(liveDir, dir, topicsRegistry) {
1851
1926
 
1852
1927
  const files = _findLiveFiles(liveDir);
1853
1928
  const lines = [
1854
- `import { __register, __registerGuard, __registerCron, __registerDerived, __registerEffect, __registerAggregate, __registerRoomActions } from 'svelte-realtime/server';`,
1929
+ `import { __register, __registerGuard, __registerCron, __registerDerived, __registerEffect, __registerAggregate, __registerRoomActions, __registerFlag } from 'svelte-realtime/server';`,
1855
1930
  `const __L = fn => (fn.__lazy = true, fn);\n`
1856
1931
  ];
1857
1932
 
@@ -2061,6 +2136,27 @@ function _generateRegistry(liveDir, dir, topicsRegistry) {
2061
2136
  }
2062
2137
  }
2063
2138
 
2139
+ // Register live.flag() exports. The stream registration stays lazy (the
2140
+ // flag module is imported on first subscribe), but the refresh watcher
2141
+ // is installed eagerly via __registerFlag at registry-module load - the
2142
+ // same lifecycle that activates live.effect watchers - so a server-side
2143
+ // .get() reflects cluster-latest sets from boot without waiting for the
2144
+ // flag module's first local import.
2145
+ FLAG_EXPORT_RE.lastIndex = 0;
2146
+ while ((match = FLAG_EXPORT_RE.exec(source)) !== null) {
2147
+ const name = match[1];
2148
+ if (!/^\w+$/.test(name)) continue;
2149
+ if (!registered.has(name)) {
2150
+ registered.add(name);
2151
+ lines.push(`__register(${JSON.stringify(rel + '/' + name)}, ${_lazy(name)});`);
2152
+ const decl = _extractFlagDecl(source, name);
2153
+ if (decl) {
2154
+ const initArg = decl.initialArg === null ? '' : `, ${decl.initialArg}`;
2155
+ lines.push(`__registerFlag(${JSON.stringify(decl.topic)}${initArg});`);
2156
+ }
2157
+ }
2158
+ }
2159
+
2064
2160
  // Warn about exports with non-path-safe names (pass registered to avoid
2065
2161
  // double-warning for names already handled above)
2066
2162
  _warnUnsafeExports(source, `${dir}/${rel}`, registered);
@@ -2318,6 +2414,17 @@ function _generateTypeDeclarations(liveDir, dir) {
2318
2414
  }
2319
2415
  }
2320
2416
 
2417
+ // Detect live.flag() exports (readable set-merge stream)
2418
+ FLAG_EXPORT_RE.lastIndex = 0;
2419
+ while ((match = FLAG_EXPORT_RE.exec(source)) !== null) {
2420
+ const name = match[1];
2421
+ handledNames.add(name);
2422
+ if (!exports.some(e => e.includes(`export const ${name}:`))) {
2423
+ needsStreamStore = true;
2424
+ exports.push(` export const ${name}: StreamStore<any> & { load(platform: any, options?: { args?: any[]; user?: any }): Promise<any> };`);
2425
+ }
2426
+ }
2427
+
2321
2428
  // Detect live.binary() exports
2322
2429
  BINARY_EXPORT_RE.lastIndex = 0;
2323
2430
  while ((match = BINARY_EXPORT_RE.exec(source)) !== null) {