svelte-realtime 0.4.14 → 0.4.16

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
@@ -261,7 +261,7 @@ The `merge` option on `live.stream()` controls how live pub/sub events are appli
261
261
 
262
262
  ### crud (default)
263
263
 
264
- Handles `created`, `updated`, `deleted` events. The store maintains an array, keyed by `id` (configurable with `key`).
264
+ Handles `created`, `updated`, `deleted` events. The store maintains an array, keyed by `id` (configurable with `key`). Set `max` to cap the buffer size and drop the oldest items when exceeded (useful for live feeds with `prepend: true`).
265
265
 
266
266
  ```js
267
267
  // Server
@@ -361,7 +361,7 @@ Events: `update` (add/update by key), `remove` (remove by key), `set` (replace a
361
361
  | `merge` | `'crud'` | Merge strategy: `'crud'`, `'latest'`, `'set'`, `'presence'`, `'cursor'` |
362
362
  | `key` | `'id'` | Key field for `crud` mode |
363
363
  | `prepend` | `false` | Prepend new items instead of appending (`crud` mode) |
364
- | `max` | `50` | Max items to keep (`latest` mode) |
364
+ | `max` | `50` / `0` | Max items to keep. Defaults to 50 for `latest`, 0 (unlimited) for `crud`. Oldest items are dropped when exceeded |
365
365
  | `replay` | `false` | Enable seq-based replay for gap-free reconnection |
366
366
  | `onSubscribe` | -- | Callback `(ctx, topic)` fired when a client subscribes |
367
367
  | `onUnsubscribe` | -- | Callback `(ctx, topic)` fired when a client disconnects |
@@ -1222,6 +1222,40 @@ On the client, derived streams work like regular streams:
1222
1222
  <p>Orders: {$dashboardStats?.totalOrders}</p>
1223
1223
  ```
1224
1224
 
1225
+ ### Dynamic derived streams
1226
+
1227
+ When source topics depend on runtime arguments (e.g., an org ID, a room ID), pass a source factory function instead of a static array. The factory receives the same args the client passes at subscribe time:
1228
+
1229
+ ```js
1230
+ export const orgStats = live.derived(
1231
+ (orgId) => [`memberships:${orgId}`, `emails:${orgId}`, `audit:${orgId}`],
1232
+ async (ctx, orgId) => {
1233
+ const [members, emails, auditCount] = await Promise.all([
1234
+ db.query('SELECT count(*) FROM memberships WHERE org_id = $1', [orgId]),
1235
+ db.query('SELECT count(*) FROM emails WHERE org_id = $1', [orgId]),
1236
+ db.query('SELECT count(*) FROM audit_log WHERE org_id = $1', [orgId])
1237
+ ]);
1238
+ return { members, emails, auditCount };
1239
+ },
1240
+ { debounce: 100 }
1241
+ );
1242
+ ```
1243
+
1244
+ On the client, dynamic derived streams are called like functions:
1245
+
1246
+ ```svelte
1247
+ <script>
1248
+ import { orgStats } from '$live/dashboard';
1249
+ let { orgId } = $props();
1250
+ </script>
1251
+
1252
+ <p>Members: {$orgStats(orgId)?.members}</p>
1253
+ ```
1254
+
1255
+ Each unique set of args creates an independent instance with its own source subscriptions. Instances are created when the first subscriber connects and cleaned up when the last subscriber disconnects.
1256
+
1257
+ ### Activation
1258
+
1225
1259
  Call `_activateDerived(platform)` in your `open` hook to enable derived stream listeners:
1226
1260
 
1227
1261
  ```js
@@ -1916,7 +1950,7 @@ Import from `svelte-realtime/server`.
1916
1950
  | `live.binary(fn, options?)` | Mark a function as a binary RPC handler (`maxSize` limits payload, default 10MB) |
1917
1951
  | `live.validated(schema, fn)` | RPC with Zod/Valibot input validation |
1918
1952
  | `live.cron(schedule, topic, fn)` | Server-side scheduled function |
1919
- | `live.derived(sources, fn, options?)` | Server-side computed stream |
1953
+ | `live.derived(sources, fn, options?)` | Server-side computed stream (static or dynamic sources) |
1920
1954
  | `live.effect(sources, fn, options?)` | Server-side reactive side effect |
1921
1955
  | `live.aggregate(source, reducers, options)` | Real-time incremental aggregation |
1922
1956
  | `live.room(config)` | Collaborative room (data + presence + cursors + actions) |
package/client.d.ts CHANGED
@@ -1,6 +1,17 @@
1
1
  import type { Readable } from 'svelte/store';
2
2
  import type { WSEvent } from 'svelte-adapter-uws/client';
3
3
 
4
+ /**
5
+ * A store that always holds `undefined`. Use as a fallback for conditional
6
+ * streams so you don't need to import `readable` from `svelte/store`:
7
+ *
8
+ * ```svelte
9
+ * import { todos, empty } from '$live/todos';
10
+ * const items = $derived(user ? todos(orgId) : empty);
11
+ * ```
12
+ */
13
+ export const empty: Readable<undefined>;
14
+
4
15
  /**
5
16
  * Typed error for RPC failures.
6
17
  * Contains a `code` field for programmatic handling.
package/client.js CHANGED
@@ -1,6 +1,9 @@
1
1
  // @ts-check
2
2
  import { connect as _connect, on, status } from 'svelte-adapter-uws/client';
3
- import { writable } from 'svelte/store';
3
+ import { writable, readable } from 'svelte/store';
4
+
5
+ /** @type {import('svelte/store').Readable<undefined>} */
6
+ export const empty = readable(undefined);
4
7
 
5
8
  const _textEncoder = new TextEncoder();
6
9
 
@@ -499,7 +502,7 @@ function _createStream(path, options, dynamicArgs) {
499
502
  let merge = options?.merge || 'crud';
500
503
  let key = options?.key || 'id';
501
504
  let prepend = options?.prepend || false;
502
- let max = options?.max || 50;
505
+ let max = options?.max || (merge === 'latest' ? 50 : 0);
503
506
 
504
507
  /** @type {any} */
505
508
  let currentValue;
@@ -637,9 +640,18 @@ function _createStream(path, options, dynamicArgs) {
637
640
  currentValue.unshift(data);
638
641
  for (const [k, i] of _index) _index.set(k, i + 1);
639
642
  _index.set(data[key], 0);
643
+ if (max && currentValue.length > max) {
644
+ const removed = currentValue.splice(max);
645
+ for (const item of removed) _index.delete(item[key]);
646
+ }
640
647
  } else {
641
648
  _index.set(data[key], currentValue.length);
642
649
  currentValue.push(data);
650
+ if (max && currentValue.length > max) {
651
+ const removed = currentValue.splice(0, currentValue.length - max);
652
+ for (const item of removed) _index.delete(item[key]);
653
+ _rebuildIndex();
654
+ }
643
655
  }
644
656
  } else if (event === 'updated') {
645
657
  const idx = _index.get(data[key]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-realtime",
3
- "version": "0.4.14",
3
+ "version": "0.4.16",
4
4
  "description": "Realtime RPC and reactive subscriptions for SvelteKit, built on svelte-adapter-uws",
5
5
  "author": "Kevin Radziszewski",
6
6
  "license": "MIT",
package/server.d.ts CHANGED
@@ -67,8 +67,10 @@ export interface StreamOptions {
67
67
  prepend?: boolean;
68
68
 
69
69
  /**
70
- * Maximum items to keep (latest mode only).
71
- * @default 50
70
+ * Maximum items to keep. When the buffer exceeds this size, the oldest
71
+ * items are dropped. Works with `crud` and `latest` merge strategies.
72
+ *
73
+ * For `latest`, defaults to 50. For `crud`, defaults to 0 (unlimited).
72
74
  */
73
75
  max?: number;
74
76
 
@@ -475,17 +477,35 @@ export namespace live {
475
477
  /**
476
478
  * Create a server-side computed stream that recomputes when any source topic publishes.
477
479
  *
478
- * @param sources - Topic names to watch for changes
480
+ * Static form: pass an array of topic names to watch.
481
+ * Dynamic form: pass a function that receives runtime args and returns topic names.
482
+ *
483
+ * @param sources - Topic names to watch, or a factory function that receives runtime args
479
484
  * @param fn - Async function that computes the derived value
480
485
  * @param options - Merge mode and debounce settings
481
486
  *
482
487
  * @example
483
488
  * ```js
489
+ * // Static sources
484
490
  * export const summary = live.derived(['orders', 'inventory'], async () => {
485
491
  * return { totalOrders: await db.orders.count(), totalItems: await db.inventory.count() };
486
492
  * });
493
+ *
494
+ * // Dynamic sources (parameterized)
495
+ * export const stats = live.derived(
496
+ * (orgId) => [`memberships:${orgId}`, `emails:${orgId}`],
497
+ * async (ctx, orgId) => {
498
+ * return { members: await countMembers(orgId), emails: await countEmails(orgId) };
499
+ * },
500
+ * { debounce: 100 }
501
+ * );
487
502
  * ```
488
503
  */
504
+ function derived<T extends (...args: any[]) => any>(
505
+ sources: (...args: any[]) => string[],
506
+ fn: T,
507
+ options?: { merge?: string; debounce?: number }
508
+ ): T;
489
509
  function derived<T extends () => any>(
490
510
  sources: string[],
491
511
  fn: T,
package/server.js CHANGED
@@ -108,6 +108,14 @@ function _copyStreamMeta(target, source) {
108
108
  if (source.__streamVersion !== undefined) target.__streamVersion = source.__streamVersion;
109
109
  if (source.__streamMigrate) target.__streamMigrate = source.__streamMigrate;
110
110
  if (source.__isChannel) target.__isChannel = source.__isChannel;
111
+ if (source.__isDerived) target.__isDerived = source.__isDerived;
112
+ if (source.__derivedDynamic) {
113
+ target.__derivedDynamic = source.__derivedDynamic;
114
+ target.__derivedSourceFactory = source.__derivedSourceFactory;
115
+ target.__derivedTopicArgs = source.__derivedTopicArgs;
116
+ target.__derivedDebounce = source.__derivedDebounce;
117
+ }
118
+ if (source.__derivedSources) target.__derivedSources = source.__derivedSources;
111
119
  if (source.__isGated) {
112
120
  target.__isGated = true;
113
121
  target.__gatePredicate = source.__gatePredicate;
@@ -720,28 +728,69 @@ const derivedRegistry = new Map();
720
728
  /**
721
729
  * Create a server-side computed stream that recomputes when any source topic publishes.
722
730
  *
723
- * @param {string[]} sources - Topic names to watch
731
+ * Static form: sources is a string[] of topic names.
732
+ * Dynamic form: sources is a function (...args) => string[] that resolves topics at subscribe time.
733
+ *
734
+ * @param {string[] | Function} sources - Topic names to watch, or a factory that receives runtime args
724
735
  * @param {Function} fn - Async function that computes the derived value
725
736
  * @param {{ merge?: string, debounce?: number }} [options]
726
737
  * @returns {Function}
727
738
  */
728
739
  live.derived = function derived(sources, fn, options) {
729
- const topic = /** @type {any} */ (fn).__derivedTopic || ('__derived:' + (_derivedIdCounter++));
740
+ const baseTopic = /** @type {any} */ (fn).__derivedTopic || ('__derived:' + (_derivedIdCounter++));
730
741
  const merge = options?.merge || 'set';
731
742
  const debounce = options?.debounce || 0;
743
+ const dynamic = typeof sources === 'function';
732
744
 
733
745
  /** @type {any} */ (fn).__isDerived = true;
734
746
  /** @type {any} */ (fn).__isStream = true;
735
747
  /** @type {any} */ (fn).__isLive = true;
736
- /** @type {any} */ (fn).__streamTopic = topic;
737
748
  /** @type {any} */ (fn).__streamOptions = { merge, key: 'id' };
738
- /** @type {any} */ (fn).__derivedSources = sources;
739
749
  /** @type {any} */ (fn).__derivedDebounce = debounce;
750
+
751
+ if (dynamic) {
752
+ /** @type {any} */ (fn).__derivedDynamic = true;
753
+ /** @type {any} */ (fn).__derivedSourceFactory = sources;
754
+ /** @type {Map<string, any[]>} */
755
+ const topicArgs = new Map();
756
+ const topicFn = (...args) => {
757
+ const t = baseTopic + '\x00' + args.map(a => String(a).replace(/\x00/g, '')).join('\x00');
758
+ topicArgs.set(t, args);
759
+ if (topicArgs.size > 10000) {
760
+ const iter = topicArgs.keys();
761
+ topicArgs.delete(iter.next().value);
762
+ }
763
+ return t;
764
+ };
765
+ /** @type {any} */ (topicFn).__topicUsesCtx = false;
766
+ /** @type {any} */ (fn).__streamTopic = topicFn;
767
+ /** @type {any} */ (fn).__derivedTopicArgs = topicArgs;
768
+
769
+ /** @type {any} */ (fn).__onSubscribe = function (_ctx, resolvedTopic) {
770
+ _activateDynamicDerived(fn, resolvedTopic);
771
+ };
772
+ /** @type {any} */ (fn).__onUnsubscribe = function (_ctx, resolvedTopic) {
773
+ _deactivateDynamicDerived(fn, resolvedTopic);
774
+ };
775
+ } else {
776
+ /** @type {any} */ (fn).__streamTopic = baseTopic;
777
+ /** @type {any} */ (fn).__derivedSources = sources;
778
+ }
779
+
740
780
  return fn;
741
781
  };
742
782
 
743
783
  let _derivedIdCounter = 0;
744
784
 
785
+ /** @type {boolean} Whether any dynamic derived streams have been registered */
786
+ let _hasDynamicDerived = false;
787
+
788
+ /** @type {Map<Function, object>} O(1) lookup from fn reference to dynamic derived registry entry */
789
+ const _dynamicDerivedByFn = new Map();
790
+
791
+ /** @type {import('svelte-adapter-uws').Platform | null} Captured platform for dynamic derived recomputation */
792
+ let _derivedPlatform = null;
793
+
745
794
  /** @type {Map<string, { sources: string[], fn: Function, debounce: number, timer: ReturnType<typeof setTimeout> | null }>} */
746
795
  const effectRegistry = new Map();
747
796
 
@@ -1316,6 +1365,20 @@ export function __registerDerived(path, fn) {
1316
1365
  _lazyQueue.push({ type: 'derived', path, loader: fn });
1317
1366
  return;
1318
1367
  }
1368
+
1369
+ if (/** @type {any} */ (fn).__derivedDynamic) {
1370
+ const sourceFactory = /** @type {any} */ (fn).__derivedSourceFactory;
1371
+ const debounce = /** @type {any} */ (fn).__derivedDebounce || 0;
1372
+ const entry = {
1373
+ sources: null, sourceFactory, fn, topic: /** @type {any} */ (fn).__streamTopic,
1374
+ debounce, timer: null, dynamic: true, instances: new Map()
1375
+ };
1376
+ derivedRegistry.set(path, entry);
1377
+ _dynamicDerivedByFn.set(fn, entry);
1378
+ _hasDynamicDerived = true;
1379
+ return;
1380
+ }
1381
+
1319
1382
  const sources = /** @type {any} */ (fn).__derivedSources;
1320
1383
  const topic = /** @type {any} */ (fn).__streamTopic;
1321
1384
  const debounce = /** @type {any} */ (fn).__derivedDebounce || 0;
@@ -1339,14 +1402,19 @@ export function __registerDerived(path, fn) {
1339
1402
  const _activatedPlatforms = new WeakSet();
1340
1403
 
1341
1404
  export function _activateDerived(platform) {
1405
+ _derivedPlatform = platform;
1342
1406
  if (_activatedPlatforms.has(platform)) return;
1343
1407
 
1344
1408
  // Only wrap platform.publish if there are actual reactive registrations
1345
- if (_derivedBySource.size === 0 && _effectBySource.size === 0 && _aggregateBySource.size === 0) {
1409
+ if (_derivedBySource.size === 0 && _effectBySource.size === 0 && _aggregateBySource.size === 0 && !_hasDynamicDerived) {
1346
1410
  return;
1347
1411
  }
1348
1412
 
1349
1413
  _activatedPlatforms.add(platform);
1414
+ _wrapPlatformPublish(platform);
1415
+ }
1416
+
1417
+ function _wrapPlatformPublish(platform) {
1350
1418
 
1351
1419
  const originalPublish = platform.publish.bind(platform);
1352
1420
 
@@ -1426,20 +1494,121 @@ export function _activateDerived(platform) {
1426
1494
 
1427
1495
  /**
1428
1496
  * Recompute a derived stream and publish the result.
1429
- * @param {{ fn: Function, topic: string }} entry
1497
+ * For dynamic instances, entry.args holds the runtime args and a ctx is built from the platform.
1498
+ * @param {{ fn: Function, topic: string, args?: any[] }} entry
1430
1499
  * @param {import('svelte-adapter-uws').Platform} platform
1431
1500
  */
1432
1501
  async function _recomputeDerived(entry, platform) {
1433
1502
  try {
1434
- const result = await entry.fn();
1503
+ let result;
1504
+ if (entry.args) {
1505
+ const _h = _getCtxHelpers(platform);
1506
+ const ctx = _buildCtx(null, null, platform, _h, null);
1507
+ result = await entry.fn(ctx, ...entry.args);
1508
+ } else {
1509
+ result = await entry.fn();
1510
+ }
1435
1511
  platform.publish(entry.topic, 'set', result);
1436
1512
  } catch (err) {
1437
- if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
1513
+ if (_serverErrorHandler) {
1514
+ try { _serverErrorHandler('derived', err); } catch {}
1515
+ } else if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
1438
1516
  console.error(`[svelte-realtime] Derived stream '${entry.topic}' error:`, err);
1439
1517
  }
1440
1518
  }
1441
1519
  }
1442
1520
 
1521
+ /**
1522
+ * Activate a dynamic derived instance for a resolved topic.
1523
+ * Wires the instance's resolved sources into _derivedBySource so publishes trigger recomputation.
1524
+ * @param {Function} fn - The derived compute function
1525
+ * @param {string} resolvedTopic - The resolved output topic (e.g. '__derived:5:org_123')
1526
+ */
1527
+ function _activateDynamicDerived(fn, resolvedTopic) {
1528
+ const entry = _dynamicDerivedByFn.get(fn);
1529
+ if (!entry) return;
1530
+
1531
+ const existing = entry.instances.get(resolvedTopic);
1532
+ if (existing) {
1533
+ existing.refCount++;
1534
+ return;
1535
+ }
1536
+
1537
+ // Late activation: if _activateDerived returned early before dynamic entries existed,
1538
+ // wrap platform.publish now that we have something to watch.
1539
+ if (_derivedPlatform && !_activatedPlatforms.has(_derivedPlatform)) {
1540
+ _activatedPlatforms.add(_derivedPlatform);
1541
+ _wrapPlatformPublish(_derivedPlatform);
1542
+ }
1543
+
1544
+ const topicArgs = /** @type {any} */ (fn).__derivedTopicArgs;
1545
+ const args = topicArgs && topicArgs.get(resolvedTopic);
1546
+ if (!args) return;
1547
+
1548
+ const resolvedSources = entry.sourceFactory(...args);
1549
+ if (!Array.isArray(resolvedSources) || resolvedSources.length === 0) {
1550
+ if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
1551
+ console.warn(`[svelte-realtime] Dynamic derived sourceFactory returned empty sources for topic '${resolvedTopic}'`);
1552
+ }
1553
+ return;
1554
+ }
1555
+
1556
+ const instance = {
1557
+ fn: entry.fn,
1558
+ args,
1559
+ topic: resolvedTopic,
1560
+ resolvedSources,
1561
+ debounce: entry.debounce,
1562
+ timer: null,
1563
+ refCount: 1
1564
+ };
1565
+
1566
+ entry.instances.set(resolvedTopic, instance);
1567
+
1568
+ for (const src of resolvedSources) {
1569
+ let set = _derivedBySource.get(src);
1570
+ if (!set) { set = new Set(); _derivedBySource.set(src, set); }
1571
+ set.add(instance);
1572
+ _watchedTopics.add(src);
1573
+ }
1574
+ }
1575
+
1576
+ /**
1577
+ * Deactivate a dynamic derived instance when the last subscriber disconnects.
1578
+ * Removes the instance from _derivedBySource and cleans up.
1579
+ * @param {Function} fn - The derived compute function
1580
+ * @param {string} resolvedTopic - The resolved output topic
1581
+ */
1582
+ function _deactivateDynamicDerived(fn, resolvedTopic) {
1583
+ const entry = _dynamicDerivedByFn.get(fn);
1584
+ if (!entry) return;
1585
+
1586
+ const instance = entry.instances.get(resolvedTopic);
1587
+ if (!instance) return;
1588
+
1589
+ instance.refCount--;
1590
+ if (instance.refCount > 0) return;
1591
+
1592
+ if (instance.timer) clearTimeout(instance.timer);
1593
+
1594
+ for (const src of instance.resolvedSources) {
1595
+ const set = _derivedBySource.get(src);
1596
+ if (set) {
1597
+ set.delete(instance);
1598
+ if (set.size === 0) {
1599
+ _derivedBySource.delete(src);
1600
+ if (!_effectBySource.has(src) && !_aggregateBySource.has(src)) {
1601
+ _watchedTopics.delete(src);
1602
+ }
1603
+ }
1604
+ }
1605
+ }
1606
+
1607
+ entry.instances.delete(resolvedTopic);
1608
+ const topicArgs = /** @type {any} */ (fn).__derivedTopicArgs;
1609
+ if (topicArgs) topicArgs.delete(resolvedTopic);
1610
+ }
1611
+
1443
1612
  /**
1444
1613
  * Fire an effect handler. Errors are caught and routed to the error handler.
1445
1614
  * @param {{ fn: Function }} entry
@@ -1606,7 +1775,12 @@ export function _prepareHmr() {
1606
1775
  };
1607
1776
 
1608
1777
  // Clear debounce timers
1609
- for (const e of derivedRegistry.values()) { if (e.timer) clearTimeout(e.timer); }
1778
+ for (const e of derivedRegistry.values()) {
1779
+ if (e.timer) clearTimeout(e.timer);
1780
+ if (e.instances) {
1781
+ for (const inst of e.instances.values()) { if (inst.timer) clearTimeout(inst.timer); }
1782
+ }
1783
+ }
1610
1784
  for (const e of effectRegistry.values()) { if (e.timer) clearTimeout(e.timer); }
1611
1785
  for (const e of aggregateRegistry.values()) { if (e.timer) clearTimeout(e.timer); }
1612
1786
 
@@ -1636,6 +1810,8 @@ export function _prepareHmr() {
1636
1810
  _aggregateByTopic.clear();
1637
1811
  _watchedTopics.clear();
1638
1812
  _streamsWithUnsubscribe.clear();
1813
+ _hasDynamicDerived = false;
1814
+ _dynamicDerivedByFn.clear();
1639
1815
 
1640
1816
  return snap;
1641
1817
  }
@@ -1662,11 +1838,27 @@ export function _restoreHmr(snap) {
1662
1838
  for (const [k, v] of snap.derived) {
1663
1839
  v.timer = null;
1664
1840
  derivedRegistry.set(k, v);
1665
- for (const src of v.sources) {
1666
- let set = _derivedBySource.get(src);
1667
- if (!set) { set = new Set(); _derivedBySource.set(src, set); }
1668
- set.add(v);
1669
- _watchedTopics.add(src);
1841
+ if (v.dynamic) {
1842
+ _hasDynamicDerived = true;
1843
+ _dynamicDerivedByFn.set(v.fn, v);
1844
+ if (v.instances) {
1845
+ for (const inst of v.instances.values()) {
1846
+ inst.timer = null;
1847
+ for (const src of inst.resolvedSources) {
1848
+ let set = _derivedBySource.get(src);
1849
+ if (!set) { set = new Set(); _derivedBySource.set(src, set); }
1850
+ set.add(inst);
1851
+ _watchedTopics.add(src);
1852
+ }
1853
+ }
1854
+ }
1855
+ } else {
1856
+ for (const src of v.sources) {
1857
+ let set = _derivedBySource.get(src);
1858
+ if (!set) { set = new Set(); _derivedBySource.set(src, set); }
1859
+ set.add(v);
1860
+ _watchedTopics.add(src);
1861
+ }
1670
1862
  }
1671
1863
  }
1672
1864
 
package/vite.js CHANGED
@@ -12,6 +12,7 @@ const DYNAMIC_STREAM_RE = /export\s+const\s+(\w+)\s*=\s*live\.stream\s*\(\s*(?:\
12
12
  const CRON_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.cron\s*\(/g;
13
13
  const BINARY_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.binary\s*\(/g;
14
14
  const DERIVED_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.derived\s*\(/g;
15
+ const DYNAMIC_DERIVED_RE = /export\s+const\s+(\w+)\s*=\s*live\.derived\s*\(\s*(?:\([^)]*\)|[a-zA-Z_$][\w$]*)\s*=>/g;
15
16
  const ROOM_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.room\s*\(/g;
16
17
  const WEBHOOK_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.webhook\s*\(/g;
17
18
  const CHANNEL_EXPORT_RE = /export\s+const\s+(\w+)\s*=\s*live\.channel\s*\(/g;
@@ -518,8 +519,8 @@ function _generateSsrStubs(filePath, modulePath) {
518
519
  const dynamicNames = new Set();
519
520
  let match;
520
521
 
521
- // Detect dynamic (function-returning) streams and channels
522
- for (const re of [DYNAMIC_STREAM_RE, DYNAMIC_CHANNEL_RE]) {
522
+ // Detect dynamic (function-returning) streams, channels, and derived
523
+ for (const re of [DYNAMIC_STREAM_RE, DYNAMIC_CHANNEL_RE, DYNAMIC_DERIVED_RE]) {
523
524
  re.lastIndex = 0;
524
525
  while ((match = re.exec(source)) !== null) {
525
526
  dynamicNames.add(match[1]);
@@ -683,8 +684,12 @@ function _generateClientStubs(filePath, modulePath, dir) {
683
684
  if (!exportedNames.has(name)) {
684
685
  exportedNames.add(name);
685
686
  imports.add('__stream');
686
- // Derived streams use 'set' merge by default
687
- lines.push(`export const ${name} = __stream('${modulePath}/${name}', ${JSON.stringify({ merge: 'set', key: 'id' })});`);
687
+ const isDynamic = _isDynamicExport(source, name, 'live\\.derived');
688
+ if (isDynamic) {
689
+ lines.push(`export const ${name} = __stream('${modulePath}/${name}', ${JSON.stringify({ merge: 'set', key: 'id' })}, true);`);
690
+ } else {
691
+ lines.push(`export const ${name} = __stream('${modulePath}/${name}', ${JSON.stringify({ merge: 'set', key: 'id' })});`);
692
+ }
688
693
  }
689
694
  }
690
695
 
@@ -785,7 +790,9 @@ function _generateClientStubs(filePath, modulePath, dir) {
785
790
  ? `import { ${[...imports].join(', ')} } from 'svelte-realtime/client';\n`
786
791
  : '';
787
792
 
788
- return importLine + lines.join('\n') + '\n';
793
+ const reexport = `export { empty } from 'svelte-realtime/client';\n`;
794
+
795
+ return importLine + reexport + lines.join('\n') + '\n';
789
796
  }
790
797
 
791
798
  /**
@@ -1643,14 +1650,25 @@ function _generateTypeDeclarations(liveDir, dir) {
1643
1650
  }
1644
1651
  }
1645
1652
 
1646
- // Detect live.derived() exports (read-only stream)
1653
+ // Detect live.derived() exports (read-only stream, static or dynamic)
1647
1654
  DERIVED_EXPORT_RE.lastIndex = 0;
1648
1655
  while ((match = DERIVED_EXPORT_RE.exec(source)) !== null) {
1649
1656
  const name = match[1];
1650
1657
  handledNames.add(name);
1651
1658
  if (!exports.some(e => e.includes(`export const ${name}:`))) {
1652
1659
  needsStreamStore = true;
1653
- exports.push(` export const ${name}: StreamStore<any> & { load(platform: any, options?: { args?: any[]; user?: any }): Promise<any> };`);
1660
+ const isDynamic = _isDynamicExport(source, name, 'live\\.derived');
1661
+ const loadSig = `{ load(platform: any, options?: { args?: any[]; user?: any }): Promise<any> }`;
1662
+ if (isDynamic) {
1663
+ if (isTS) {
1664
+ const factoryParams = _extractDynamicFactoryParams(source, name, 'live\\.derived');
1665
+ exports.push(` export const ${name}: (${factoryParams} => StreamStore<any>) & ${loadSig};`);
1666
+ } else {
1667
+ exports.push(` export const ${name}: ((...args: any[]) => StreamStore<any>) & ${loadSig};`);
1668
+ }
1669
+ } else {
1670
+ exports.push(` export const ${name}: StreamStore<any> & ${loadSig};`);
1671
+ }
1654
1672
  }
1655
1673
  }
1656
1674
 
@@ -1712,10 +1730,12 @@ function _generateTypeDeclarations(liveDir, dir) {
1712
1730
  if (needsRpcError) clientImports.push('RpcError');
1713
1731
  declarations.push(` import type { ${clientImports.join(', ')} } from 'svelte-realtime/client';`);
1714
1732
  }
1733
+ declarations.push(` import type { Readable } from 'svelte/store';`);
1715
1734
  if (needsStreamStore || needsRpcError) {
1716
1735
  declarations.push('');
1717
1736
  }
1718
1737
  declarations.push(...exports);
1738
+ declarations.push(` export const empty: Readable<undefined>;`);
1719
1739
  declarations.push('}');
1720
1740
  declarations.push('');
1721
1741
  }