svelte-realtime 0.4.23 → 0.5.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 +986 -38
- package/cli-utils.js +1 -1
- package/cli.js +2 -2
- package/client.d.ts +428 -1
- package/client.js +1166 -112
- package/devtools.js +157 -2
- package/hooks.d.ts +61 -0
- package/hooks.js +70 -0
- package/package.json +17 -4
- package/server.d.ts +987 -52
- package/server.js +2950 -405
- package/shared/assert.js +83 -0
- package/shared/merge.js +54 -0
- package/test.d.ts +123 -1
- package/test.js +185 -2
- package/vite.js +544 -77
package/README.md
CHANGED
|
@@ -185,6 +185,7 @@ The `ctx` object passed to every server function contains:
|
|
|
185
185
|
| `ctx.platform` | The adapter platform API |
|
|
186
186
|
| `ctx.publish` | Shorthand for `platform.publish()` |
|
|
187
187
|
| `ctx.cursor` | Cursor from a `loadMore()` call, or `null` |
|
|
188
|
+
| `ctx.requestId` | Correlation id from `platform.requestId` (per WS connection or per HTTP request); honors `X-Request-ID` |
|
|
188
189
|
| `ctx.throttle` | `(topic, event, data, ms)` -- publish at most once per `ms` ms |
|
|
189
190
|
| `ctx.debounce` | `(topic, event, data, ms)` -- publish after `ms` ms of silence |
|
|
190
191
|
| `ctx.signal` | `(userId, event, data)` -- point-to-point message |
|
|
@@ -199,9 +200,12 @@ Note: `ctx.user` may contain adapter-injected properties (`__subscriptions`, `re
|
|
|
199
200
|
**Core**
|
|
200
201
|
- [Getting started](#getting-started)
|
|
201
202
|
- [Merge strategies](#merge-strategies)
|
|
203
|
+
- [Connection state stores](#connection-state-stores)
|
|
204
|
+
- [Svelte 5 store helpers](#svelte-5-store-helpers)
|
|
202
205
|
- [Error handling](#error-handling)
|
|
203
206
|
- [Per-module auth](#per-module-auth)
|
|
204
207
|
- [Dynamic topics](#dynamic-topics)
|
|
208
|
+
- [Topic registry](#topic-registry)
|
|
205
209
|
- [Schema validation](#schema-validation)
|
|
206
210
|
- [Channels](#channels)
|
|
207
211
|
- [SSR hydration](#ssr-hydration)
|
|
@@ -212,8 +216,10 @@ Note: `ctx.user` may contain adapter-injected properties (`__subscriptions`, `re
|
|
|
212
216
|
- [Stream pagination](#stream-pagination)
|
|
213
217
|
- [Undo and redo](#undo-and-redo)
|
|
214
218
|
- [Request deduplication](#request-deduplication)
|
|
219
|
+
- [Idempotency keys](#idempotency-keys)
|
|
215
220
|
- [Offline queue](#offline-queue)
|
|
216
221
|
- [Connection hooks](#connection-hooks)
|
|
222
|
+
- [SvelteKit transport](#sveltekit-transport)
|
|
217
223
|
- [Combine stores](#combine-stores)
|
|
218
224
|
|
|
219
225
|
**Server features**
|
|
@@ -222,6 +228,10 @@ Note: `ctx.user` may contain adapter-injected properties (`__subscriptions`, `re
|
|
|
222
228
|
- [Stream lifecycle hooks](#stream-lifecycle-hooks)
|
|
223
229
|
- [Access control](#access-control)
|
|
224
230
|
- [Rate limiting](#rate-limiting)
|
|
231
|
+
- [Load shedding](#load-shedding)
|
|
232
|
+
- [Concurrency control](#concurrency-control)
|
|
233
|
+
- [Server-initiated push](#server-initiated-push)
|
|
234
|
+
- [Request correlation](#request-correlation)
|
|
225
235
|
- [Cron scheduling](#cron-scheduling)
|
|
226
236
|
- [Derived streams](#derived-streams)
|
|
227
237
|
- [Effects](#effects)
|
|
@@ -361,8 +371,16 @@ Events: `update` (add/update by key), `remove` (remove by key), `set` (replace a
|
|
|
361
371
|
| `prepend` | `false` | Prepend new items instead of appending (`crud` mode) |
|
|
362
372
|
| `max` | `50` / `0` | Max items to keep. Defaults to 50 for `latest`, 0 (unlimited) for `crud`. Oldest items are dropped when exceeded |
|
|
363
373
|
| `replay` | `false` | Enable seq-based replay for gap-free reconnection |
|
|
374
|
+
| `args` | -- | Standard Schema (Zod / ArkType / Valibot) for stream arguments. Validated before topic resolution -- prevents topic injection via malformed dynamic-topic args |
|
|
375
|
+
| `transform` | -- | `(data) => projection` applied to BOTH initial-load data (per-item for arrays) AND every live publish for this topic. Ship a wide row from the database, emit a narrow shape on the wire |
|
|
376
|
+
| `coalesceBy` | -- | `(data) => key` extractor. Publishes fan out via per-socket `sendCoalesced`; the latest value for each `(topic, key)` pair wins. For high-frequency latest-value streams (prices, cursors, presence). Cannot combine with `volatile` |
|
|
377
|
+
| `volatile` | `false` | Mark messages fire-and-forget. Disables seq stamping for this topic so reconnects with `lastSeenSeq` won't try to backfill. Wire-level drop-on-backpressure is the adapter's default. For typing indicators, telemetry pings, cursors |
|
|
378
|
+
| `staleAfterMs` | -- | Per-topic staleness watchdog. If no events arrive for N ms, the loader re-runs and the result broadcasts as a `refreshed` event. Useful for streams whose source can quietly stop emitting. See [Stream lifecycle hooks](#stream-lifecycle-hooks) |
|
|
379
|
+
| `invalidateOn` | -- | String or array of glob-style topic patterns (e.g. `'todos:*'`). When `ctx.publish` hits a matching topic, the stream's loader reruns and the result broadcasts as a `refreshed` event. See [Stream lifecycle hooks](#stream-lifecycle-hooks) |
|
|
380
|
+
| `onError` | -- | `(err, ctx, topic)` per-stream observer. Fires on loader throws (subscribe / stale-reload / `.load()` SSR). Errors thrown inside are silently swallowed |
|
|
381
|
+
| `classOfService` | -- | Names a class registered via `live.admission()`. New subscribes are shed under matching pressure. See [Load shedding](#load-shedding) |
|
|
364
382
|
| `onSubscribe` | -- | Callback `(ctx, topic)` fired when a client subscribes |
|
|
365
|
-
| `onUnsubscribe` | -- | Callback `(ctx, topic)` fired when a client disconnects |
|
|
383
|
+
| `onUnsubscribe` | -- | Callback `(ctx, topic, remainingSubscribers)` fired when a client disconnects. `remainingSubscribers` counts OTHER WebSockets still on the topic -- use it to tear down upstream feeds at zero |
|
|
366
384
|
| `filter` / `access` | -- | Per-connection publish filter (see [Access control](#access-control)) |
|
|
367
385
|
| `delta` | -- | Delta sync config (see [Delta sync and replay](#delta-sync-and-replay)) |
|
|
368
386
|
| `version` | -- | Schema version (see [Schema evolution](#schema-evolution)) |
|
|
@@ -372,6 +390,113 @@ Events: `update` (add/update by key), `remove` (remove by key), `set` (replace a
|
|
|
372
390
|
|
|
373
391
|
When the WebSocket reconnects, streams automatically refetch initial data and resubscribe. The store keeps showing stale data during the refetch -- it does not reset to `undefined`.
|
|
374
392
|
|
|
393
|
+
**Mid-flight RPCs reject with `DISCONNECTED`.** If the WS drops while an RPC or `mutate` is awaiting a response, the corresponding promise rejects with `RpcError('DISCONNECTED')`. Callers that wrap the call in `mutate(asyncOp, change)` get auto-rollback for free: the optimistic-queue entry settles as failed, removing the placeholder from the displayed state. Concurrent in-flight mutates each roll back independently -- if A and B are both pending when the WS drops, both promises reject and both placeholders are removed in one display recompute.
|
|
394
|
+
|
|
395
|
+
**Catch-up via initial fetch on resubscribe.** Once the WS reconnects, every active stream re-runs its loader and broadcasts the result through the same merge strategy used on first subscribe. Pub/sub events that fired during the disconnect window arrive as part of the fresh fetch (they're materialized in the loader's data source). For tighter "no-frame-loss" guarantees use the `delta` configuration (see [Delta sync and replay](#delta-sync-and-replay)), which fills small gaps via the per-topic seq-numbered replay buffer and falls back to delta sync for larger gaps.
|
|
396
|
+
|
|
397
|
+
**Triggering reconnect.** The next RPC call after the WS drops triggers the adapter's reconnect logic. Most apps don't need to do anything special -- the user clicks something, the RPC fires, the adapter reconnects, the call lands. If you want to display a reconnecting banner, watch the `status` store from `svelte-adapter-uws/client` for the `'reconnecting'` -> `'open'` transition.
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
## Connection state stores
|
|
402
|
+
|
|
403
|
+
Four reactive stores re-export from `svelte-realtime/client` for rendering connection state without app-side WebSocket plumbing.
|
|
404
|
+
|
|
405
|
+
```svelte
|
|
406
|
+
<script>
|
|
407
|
+
import { status, failure, quiescent, health } from 'svelte-realtime/client';
|
|
408
|
+
</script>
|
|
409
|
+
|
|
410
|
+
{#if $health === 'degraded'}
|
|
411
|
+
<Banner severity="warn">Real-time updates paused, reconnecting...</Banner>
|
|
412
|
+
{:else if $failure?.class === 'TERMINAL'}
|
|
413
|
+
<Banner severity="error">Session expired. <a href="/login">Sign in again</a></Banner>
|
|
414
|
+
{:else if $failure?.class === 'THROTTLE'}
|
|
415
|
+
<Banner severity="warn">Server is busy. Reconnecting more slowly...</Banner>
|
|
416
|
+
{:else if $status === 'disconnected'}
|
|
417
|
+
<Banner severity="info">Reconnecting...</Banner>
|
|
418
|
+
{/if}
|
|
419
|
+
|
|
420
|
+
{#if !$quiescent}
|
|
421
|
+
<Spinner />
|
|
422
|
+
{/if}
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
| Store | Type | Behavior |
|
|
426
|
+
|---|---|---|
|
|
427
|
+
| `status` | `'connecting' \| 'open' \| 'suspended' \| 'disconnected' \| 'failed'` | Connection state machine. `suspended` = tab in background; `failed` = terminal (auth denied or `close()` called) |
|
|
428
|
+
| `failure` | `{ kind, class, code, reason } \| null` | Cause of the most recent non-open transition. `class` is `TERMINAL` (auth) / `EXHAUSTED` (max retries) / `THROTTLE` (4429) / `RETRY` / `AUTH` (HTTP preflight). Cleared on next `'open'`. Not set on intentional `close()` |
|
|
429
|
+
| `quiescent` | `Readable<boolean>` | `true` when every active stream has settled (initial load + all reconnects). Continuous signal -- a `false -> true` transition after a reconnect cycle marks "everything caught up" |
|
|
430
|
+
| `health` | `'healthy' \| 'degraded'` | System-wide health, sourced from `degraded` / `recovered` events on the `__realtime` topic. Stays `'healthy'` until something publishes -- typically the extensions package's pub/sub bus circuit breaker |
|
|
431
|
+
|
|
432
|
+
`failure` and `quiescent` are pure additions; apps that don't use them pay nothing. `health` lazily subscribes to `__realtime` only on first read; never reading it = no subscription.
|
|
433
|
+
|
|
434
|
+
Apps that need richer health detail (reason strings, timestamps) can listen to the topic directly:
|
|
435
|
+
|
|
436
|
+
```js
|
|
437
|
+
import { on } from 'svelte-adapter-uws/client';
|
|
438
|
+
on('__realtime').subscribe((envelope) => { /* full payload */ });
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
## Svelte 5 store helpers
|
|
444
|
+
|
|
445
|
+
Generated `$live/*` stream stores work out of the box as Svelte 4 `Readable<T>` values via the `$store` auto-subscribe syntax. For Svelte 5 apps, two methods are exposed alongside the existing `subscribe` interface so component code stays terse without reaching for `$derived.by(() => $store ?? [])` boilerplate.
|
|
446
|
+
|
|
447
|
+
### `store.rune()` -- Svelte 5 reactive object
|
|
448
|
+
|
|
449
|
+
Returns an object with a single `current` getter, backed by Svelte's `fromStore` from `svelte/store`. Reading `current` inside an effect or component subscribes via Svelte's `createSubscriber` for fine-grained reactivity; reading it outside an effect synchronously returns the latest value.
|
|
450
|
+
|
|
451
|
+
```svelte
|
|
452
|
+
<script>
|
|
453
|
+
import { todos } from '$live/todos';
|
|
454
|
+
const items = todos.rune();
|
|
455
|
+
</script>
|
|
456
|
+
|
|
457
|
+
<p>{items.current?.length ?? 0} items</p>
|
|
458
|
+
{#each items.current ?? [] as todo}
|
|
459
|
+
<li>{todo.title}</li>
|
|
460
|
+
{/each}
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
`rune()` requires Svelte 5 (the `fromStore` export is not available in Svelte 4) and throws a descriptive error if called against an older runtime. Apps still on Svelte 4 use the `$store` auto-subscribe syntax instead.
|
|
464
|
+
|
|
465
|
+
### `store.map(fn)` -- per-item projection
|
|
466
|
+
|
|
467
|
+
Returns a mapped store with the same `{ subscribe, rune, map }` shape as the source. Idiomatic alternative to `$derived.by(() => ($stream ?? []).map(...))` and avoids the `$derived(() => ...)` footgun where storing a function reference instead of its return value silently breaks rendering.
|
|
468
|
+
|
|
469
|
+
```svelte
|
|
470
|
+
<script>
|
|
471
|
+
import { todos } from '$live/todos';
|
|
472
|
+
const titles = todos.map(t => t.title);
|
|
473
|
+
// Or compose with rune() for Svelte 5:
|
|
474
|
+
const titlesRune = todos.map(t => t.title).rune();
|
|
475
|
+
</script>
|
|
476
|
+
|
|
477
|
+
{#each $titles as title}<li>{title}</li>{/each}
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
Semantics match the documented `($stream ?? []).map(fn)` pattern: a `null` or `undefined` source emits `[]`; an array source emits `source.map(fn)`; a non-array source (set-merge stream, paginated wrapper) emits `[]` after a dev-mode `console.warn` pointing at the merge-strategy docs. Subscriptions are lazy: the source is only subscribed while at least one mapped consumer is active. Chains via further `.map()` calls preserve the same shape.
|
|
481
|
+
|
|
482
|
+
### `empty` -- bundled placeholder store
|
|
483
|
+
|
|
484
|
+
Every generated `$live/<name>.js` re-exports an `empty` store that holds `undefined`. Use it as the fallback for conditional streams without importing `readable` from `svelte/store`:
|
|
485
|
+
|
|
486
|
+
```svelte
|
|
487
|
+
<script>
|
|
488
|
+
import { todos, empty } from '$live/todos';
|
|
489
|
+
let { user, orgId } = $props();
|
|
490
|
+
const items = $derived(user ? todos(orgId) : empty);
|
|
491
|
+
</script>
|
|
492
|
+
|
|
493
|
+
{#each $items ?? [] as todo}
|
|
494
|
+
<li>{todo.title}</li>
|
|
495
|
+
{/each}
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Auto-imported alongside the stream itself; nothing extra to wire.
|
|
499
|
+
|
|
375
500
|
---
|
|
376
501
|
|
|
377
502
|
## Error handling
|
|
@@ -554,6 +679,8 @@ export const _guard = guard(
|
|
|
554
679
|
|
|
555
680
|
Use a function instead of a string as the first argument to `live.stream()` for per-entity streams. The client-side stub becomes a factory function -- call it with arguments to get a cached store for that entity.
|
|
556
681
|
|
|
682
|
+
> **The first argument's shape decides the client export.** If the first argument is a string, the export is a Svelte store and you read it as `$messages`. If the first argument is a function, the export is a factory that returns a store, and you must call it first: `const messages = roomMessages(data.roomId); ... $messages`. Forgetting the call gives `Svelte error: store_invalid_shape -- 'roomMessages' is not a store with a 'subscribe' method` during SSR. If the topic does not depend on arguments, prefer the string form.
|
|
683
|
+
|
|
557
684
|
```js
|
|
558
685
|
// src/live/rooms.js
|
|
559
686
|
import { live } from 'svelte-realtime/server';
|
|
@@ -590,6 +717,44 @@ Same arguments return the same cached store instance. The cache is cleaned up wh
|
|
|
590
717
|
|
|
591
718
|
---
|
|
592
719
|
|
|
720
|
+
## Topic registry
|
|
721
|
+
|
|
722
|
+
Centralize topic strings so the SQL trigger and the stream definition reference one source of truth. `defineTopics(map)` validates the map at boot and exposes `__patterns` for tooling.
|
|
723
|
+
|
|
724
|
+
```js
|
|
725
|
+
// src/lib/topics.js
|
|
726
|
+
import { defineTopics } from 'svelte-realtime/server';
|
|
727
|
+
|
|
728
|
+
export const TOPICS = defineTopics({
|
|
729
|
+
audit: (orgId) => `audit:${orgId}`,
|
|
730
|
+
security: (orgId) => `security:${orgId}`,
|
|
731
|
+
systemNotices: 'system:notices'
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// Stream definitions reference the registry
|
|
735
|
+
live.stream((ctx, orgId) => TOPICS.audit(orgId), loadAudit, { merge: 'crud' });
|
|
736
|
+
|
|
737
|
+
// Tooling reads __patterns to derive shapes
|
|
738
|
+
TOPICS.__patterns;
|
|
739
|
+
// => { audit: 'audit:{arg0}', security: 'security:{arg0}', systemNotices: 'system:notices' }
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
Map values can be strings (static topics) or `(...args) => string` functions (dynamic topics). The helper validates non-empty strings and rejects reserved names (`__patterns`, `__definedTopics`). Pattern derivation calls each function with sentinel placeholders (`{arg0}`, `{arg1}`, ...) and falls back to `'<dynamic>'` if the function throws on placeholders or returns a non-string.
|
|
743
|
+
|
|
744
|
+
### Build-time registry check
|
|
745
|
+
|
|
746
|
+
When the Vite plugin sees a `defineTopics({...})` call anywhere under `src/`, it builds a registry of patterns and validates string-literal topics passed to `live.stream(...)` and `live.channel(...)` against it. A literal that does not match any registered pattern triggers a one-shot warning per `(file, topic)` pair:
|
|
747
|
+
|
|
748
|
+
```
|
|
749
|
+
[svelte-realtime] src/live/feed.js: live.stream topic 'mistyped-topic' is not in
|
|
750
|
+
your TOPICS registry. Either add it to defineTopics({...}) or call TOPICS.<name>(...)
|
|
751
|
+
instead of passing a string literal.
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
The check covers static-string patterns (`feed: 'feed:notices'`) and arrow-return template literals (`audit: (orgId) => \`audit:${orgId}\``); template interpolations match `.+` so `'audit:org-123'` and `'audit:any-id'` both pass against the `audit` pattern. Function references and other dynamic value shapes are silently skipped at parse time -- the warning only fires for literal topics under a confidently parsed registry. If your project does not call `defineTopics` at all, the check is disabled.
|
|
755
|
+
|
|
756
|
+
---
|
|
757
|
+
|
|
593
758
|
## Schema validation
|
|
594
759
|
|
|
595
760
|
Use `live.validated(schema, fn)` to validate the first argument against a schema before the function runs. Any [Standard Schema](https://standardschema.dev/)-compatible validator is supported, including Zod, ArkType, Valibot, and others.
|
|
@@ -627,6 +792,22 @@ export const addTodo = live.validated(CreateTodo, async (ctx, input) => {
|
|
|
627
792
|
|
|
628
793
|
On the client, validated exports work like regular `live()` calls. Validation errors are thrown as `RpcError` with `code: 'VALIDATION'` and an `issues` array.
|
|
629
794
|
|
|
795
|
+
### Validating stream arguments
|
|
796
|
+
|
|
797
|
+
Stream arguments validate via the `args` option on `live.stream()`. Validation runs BEFORE topic resolution, so a malformed argument can never reach a dynamic topic function.
|
|
798
|
+
|
|
799
|
+
```js
|
|
800
|
+
import { z } from 'zod';
|
|
801
|
+
|
|
802
|
+
export const auditFeed = live.stream(
|
|
803
|
+
(ctx, orgId) => `audit:${orgId}`,
|
|
804
|
+
async (ctx, orgId) => loadAudit(orgId),
|
|
805
|
+
{ args: z.tuple([z.string().uuid()]) }
|
|
806
|
+
);
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
Validation failures reject the subscribe RPC with `{ code: 'VALIDATION', issues }`. Both the WebSocket and `.load()` SSR paths apply the schema. Coerced values from the schema (e.g. Zod transforms) flow through to the loader and the topic function.
|
|
810
|
+
|
|
630
811
|
---
|
|
631
812
|
|
|
632
813
|
## Channels
|
|
@@ -659,6 +840,18 @@ export const cursors = live.channel(
|
|
|
659
840
|
);
|
|
660
841
|
```
|
|
661
842
|
|
|
843
|
+
For high-frequency streams where a missed frame is acceptable (typing indicators, telemetry pings, raw cursor positions you don't need replayed on reconnect), set `volatile: true` on the stream:
|
|
844
|
+
|
|
845
|
+
```js
|
|
846
|
+
export const cursors = live.stream(
|
|
847
|
+
(ctx, roomId) => `room:${roomId}:cursors`,
|
|
848
|
+
loader,
|
|
849
|
+
{ merge: 'cursor', volatile: true }
|
|
850
|
+
);
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
Two effects: per-event seq stamping is skipped for the topic (so reconnect with `lastSeenSeq` won't try to backfill), and the option declares intent at the call site. Wire-level "drop on backpressure" is the adapter's default behavior already -- uWS auto-skips a subscriber whose outbound buffer is over `maxBackpressure` (default 64 KB). Cannot combine with `coalesceBy` (queue vs drop -- different intents) or `replay` (volatile messages aren't buffered for resume).
|
|
854
|
+
|
|
662
855
|
---
|
|
663
856
|
|
|
664
857
|
## SSR hydration
|
|
@@ -796,6 +989,73 @@ Apply changes to a stream store instantly, then roll back if the server call fai
|
|
|
796
989
|
| `latest` | any event name | Appends data to the ring buffer. |
|
|
797
990
|
| `set` | any event name | Replaces the entire value. |
|
|
798
991
|
|
|
992
|
+
### Auto-rollback with `store.mutate()`
|
|
993
|
+
|
|
994
|
+
`store.mutate(asyncOp, optimisticChange)` wraps the apply-await-rollback pattern. Applies the optimistic change synchronously, awaits the RPC, and on rejection rolls back and re-throws.
|
|
995
|
+
|
|
996
|
+
```js
|
|
997
|
+
// Event-based: server's confirming event reconciles the placeholder by key
|
|
998
|
+
const todo = await todos.mutate(
|
|
999
|
+
() => addTodo(text),
|
|
1000
|
+
{ event: 'created', data: { id: tempId(), text } }
|
|
1001
|
+
);
|
|
1002
|
+
|
|
1003
|
+
// Free-form mutator: arbitrary local change, no merge-strategy assumptions
|
|
1004
|
+
await todos.mutate(
|
|
1005
|
+
() => removeTodo(id),
|
|
1006
|
+
(current) => current.filter((t) => t.id !== id)
|
|
1007
|
+
);
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
Returns the result of `asyncOp` on success. The free-form mutator receives a shallow copy: top-level array shape changes (push, pop, filter, splice) roll back cleanly; in-place item field mutations (`draft[0].name = 'x'`) do NOT, because the draft and the prior items share item references. Replace whole items instead: `draft[i] = { ...draft[i], name: 'x' }`.
|
|
1011
|
+
|
|
1012
|
+
**Concurrent mutates roll back independently.** Pending mutations are tracked in an in-flight queue and the displayed value is recomputed by replaying that queue against the un-overlaid server state after every server event and every settle. If `mutate` A and `mutate` B are both in flight and both fail, the displayed state returns to the latest server state with no phantom traces of either A or B. Server events with a key matching a queue entry's optimistic key absorb the entry, so the typical "client generates UUID, server confirms with same id" flow does not flicker.
|
|
1013
|
+
|
|
1014
|
+
### RPC-bound shorthand: `rpc.createOptimistic()`
|
|
1015
|
+
|
|
1016
|
+
Every generated RPC stub also exposes a `.createOptimistic(store, callArgs, optimisticChange)` method. It threads `callArgs` into the optimistic-change callback so the call site doesn't have to capture them in a closure:
|
|
1017
|
+
|
|
1018
|
+
```js
|
|
1019
|
+
import { sendMessage, messages } from '$live/chat';
|
|
1020
|
+
|
|
1021
|
+
await sendMessage.createOptimistic(
|
|
1022
|
+
messages,
|
|
1023
|
+
['Hello!'],
|
|
1024
|
+
(current, args) => [...current, { id: tempId(), text: args[0] }]
|
|
1025
|
+
);
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
`callArgs` is always passed as an array (so multi-argument RPCs work the same as single-argument ones; pass `[arg]` for the single-arg case). The third argument accepts the same two shapes as `store.mutate()`: a `(current, args) => newValue` function or a `{ event, data }` object. Equivalent to:
|
|
1029
|
+
|
|
1030
|
+
```js
|
|
1031
|
+
store.mutate(() => rpc(...callArgs), wrappedChange);
|
|
1032
|
+
```
|
|
1033
|
+
|
|
1034
|
+
so behavior on success/rollback/server-confirmation is identical to `store.mutate()`. The shorthand is purely syntactic; reach for `store.mutate()` directly when the asyncOp isn't an RPC (third-party API call, multi-step flow, etc.).
|
|
1035
|
+
|
|
1036
|
+
**Curried form** -- bind once, call many times. Pass two arguments instead of three (`store, change`) and `createOptimistic` returns a callable bound to that store + change:
|
|
1037
|
+
|
|
1038
|
+
```js
|
|
1039
|
+
const optimisticSend = sendMessage.createOptimistic(
|
|
1040
|
+
messages,
|
|
1041
|
+
(current, args) => [...current, { id: tempId(), text: args[0] }]
|
|
1042
|
+
);
|
|
1043
|
+
await optimisticSend('Hello!');
|
|
1044
|
+
await optimisticSend('There!');
|
|
1045
|
+
```
|
|
1046
|
+
|
|
1047
|
+
**Stream-side spelling** -- the same flow can be expressed from the stream's perspective via `store.createOptimistic(rpc, callArgs, change)`:
|
|
1048
|
+
|
|
1049
|
+
```js
|
|
1050
|
+
await messages.createOptimistic(
|
|
1051
|
+
sendMessage,
|
|
1052
|
+
['Hello!'],
|
|
1053
|
+
(current, args) => [...current, { id: tempId(), text: args[0] }]
|
|
1054
|
+
);
|
|
1055
|
+
```
|
|
1056
|
+
|
|
1057
|
+
Identical semantics to the RPC-side spelling; pick whichever reads more naturally for your call site (stream-focused code prefers `store.createOptimistic`; RPC-focused code prefers `rpc.createOptimistic`).
|
|
1058
|
+
|
|
799
1059
|
---
|
|
800
1060
|
|
|
801
1061
|
## Stream pagination
|
|
@@ -881,6 +1141,52 @@ const result = await getUser.fresh(userId); // always sends a new request
|
|
|
881
1141
|
|
|
882
1142
|
---
|
|
883
1143
|
|
|
1144
|
+
## Idempotency keys
|
|
1145
|
+
|
|
1146
|
+
Microtask deduplication only collapses calls within the same tick. For durable safety against retries that span reconnects, tab reloads, or offline replay, wrap the handler with `live.idempotent(config, fn)`. Identical calls (by key) return the cached result without re-running the handler.
|
|
1147
|
+
|
|
1148
|
+
```js
|
|
1149
|
+
// Server: server-derived key (Stripe-style)
|
|
1150
|
+
import { live } from 'svelte-realtime/server';
|
|
1151
|
+
|
|
1152
|
+
export const createOrder = live.idempotent(
|
|
1153
|
+
{ keyFrom: (ctx, input) => `order:${ctx.user.id}:${input.clientOrderId}`, ttl: 48 * 3600 },
|
|
1154
|
+
live.validated(OrderSchema, async (ctx, input) => {
|
|
1155
|
+
return db.orders.insert({ userId: ctx.user.id, ...input });
|
|
1156
|
+
})
|
|
1157
|
+
);
|
|
1158
|
+
```
|
|
1159
|
+
|
|
1160
|
+
```js
|
|
1161
|
+
// Client: envelope-supplied key (uuid per intent)
|
|
1162
|
+
import { createOrder } from '$live/orders';
|
|
1163
|
+
|
|
1164
|
+
const intentId = crypto.randomUUID();
|
|
1165
|
+
const order = await createOrder.with({ idempotencyKey: intentId })(payload);
|
|
1166
|
+
```
|
|
1167
|
+
|
|
1168
|
+
Resolution: `keyFrom(ctx, ...args)` if defined, otherwise the client envelope's `idempotencyKey`, otherwise the wrapper is a no-op. Only successful results cache; throwing handlers abort the slot so the next caller re-runs. Default store is in-process and bounded; for multi-instance deployments pass `store: createIdempotencyStore(redis)` from `svelte-adapter-uws-extensions`.
|
|
1169
|
+
|
|
1170
|
+
| Option | Default | Description |
|
|
1171
|
+
|---|---|---|
|
|
1172
|
+
| `keyFrom` | -- | `(ctx, ...args) => string \| null \| undefined`. `null`/`undefined` falls back to the envelope key |
|
|
1173
|
+
| `store` | in-process | Any object exposing `acquire(key, ttlSec)` matching the extensions store contract |
|
|
1174
|
+
| `ttl` | `172800` (48h) | TTL in seconds. `0` skips the cache write (concurrent waiters re-run after the first finishes) |
|
|
1175
|
+
|
|
1176
|
+
`__rpc().with({ ... })` composes options on the same surface:
|
|
1177
|
+
|
|
1178
|
+
```js
|
|
1179
|
+
// Compose idempotency + per-call timeout
|
|
1180
|
+
await createOrder.with({ idempotencyKey: id, timeout: 60_000 })(payload);
|
|
1181
|
+
```
|
|
1182
|
+
|
|
1183
|
+
| `.with({})` option | Description |
|
|
1184
|
+
|---|---|
|
|
1185
|
+
| `idempotencyKey` | Carried in the envelope for the server's `live.idempotent` wrapper |
|
|
1186
|
+
| `timeout` | Per-call timeout in ms; overrides the global `configure({ timeout })` default. Sleep-detect threshold scales with the override |
|
|
1187
|
+
|
|
1188
|
+
---
|
|
1189
|
+
|
|
884
1190
|
## Offline queue
|
|
885
1191
|
|
|
886
1192
|
Queue RPC calls when the WebSocket is disconnected and replay them on reconnect.
|
|
@@ -1035,6 +1341,36 @@ The client coalesces concurrent connects into a single in-flight preflight, trea
|
|
|
1035
1341
|
|
|
1036
1342
|
---
|
|
1037
1343
|
|
|
1344
|
+
## SvelteKit transport
|
|
1345
|
+
|
|
1346
|
+
`realtimeTransport()` from `svelte-realtime/hooks` is a SvelteKit transport-hook preset that auto-registers `RpcError` and `LiveError` serialization across the SSR / client boundary. Without it, typed errors thrown from `+page.server.js` `load()` arrive at `+error.svelte` as plain `Error` instances and lose their `code` field.
|
|
1347
|
+
|
|
1348
|
+
```js
|
|
1349
|
+
// src/hooks.js (NOT hooks.server.js -- the shared hook is required)
|
|
1350
|
+
import { realtimeTransport } from 'svelte-realtime/hooks';
|
|
1351
|
+
|
|
1352
|
+
export const transport = realtimeTransport();
|
|
1353
|
+
```
|
|
1354
|
+
|
|
1355
|
+
Compose with app-defined types via the optional `extras` parameter (user entries appear after defaults so they win on key conflict):
|
|
1356
|
+
|
|
1357
|
+
```js
|
|
1358
|
+
// src/hooks.js
|
|
1359
|
+
import { realtimeTransport } from 'svelte-realtime/hooks';
|
|
1360
|
+
import { Vector } from '$lib/geometry';
|
|
1361
|
+
|
|
1362
|
+
export const transport = realtimeTransport({
|
|
1363
|
+
Vector: {
|
|
1364
|
+
encode: (v) => v instanceof Vector && [v.x, v.y],
|
|
1365
|
+
decode: ([x, y]) => new Vector(x, y)
|
|
1366
|
+
}
|
|
1367
|
+
});
|
|
1368
|
+
```
|
|
1369
|
+
|
|
1370
|
+
Wire from `src/hooks.js`, NOT `hooks.server.js`. SvelteKit's transport primitive needs both encode (server-side) and decode (client-side hydration) visible at build time -- the wrong file silently half-works (encode runs but decode never reaches the client). `RpcError`'s optional `issues` field (carried by `live.validated()` failures) survives the round-trip. Validation runs at registration: malformed extras throw immediately so misconfiguration fails fast at app boot.
|
|
1371
|
+
|
|
1372
|
+
---
|
|
1373
|
+
|
|
1038
1374
|
## Combine stores
|
|
1039
1375
|
|
|
1040
1376
|
Compose multiple stream stores into a single derived store. When any source updates, the combining function re-runs.
|
|
@@ -1115,8 +1451,9 @@ export const presence = live.stream('room:lobby', async (ctx) => {
|
|
|
1115
1451
|
onSubscribe(ctx, topic) {
|
|
1116
1452
|
ctx.publish(topic, 'join', { key: ctx.user.id, name: ctx.user.name });
|
|
1117
1453
|
},
|
|
1118
|
-
onUnsubscribe(ctx, topic) {
|
|
1454
|
+
onUnsubscribe(ctx, topic, remainingSubscribers) {
|
|
1119
1455
|
ctx.publish(topic, 'leave', { key: ctx.user.id });
|
|
1456
|
+
if (remainingSubscribers === 0) stopUpstreamFeed(topic);
|
|
1120
1457
|
}
|
|
1121
1458
|
});
|
|
1122
1459
|
```
|
|
@@ -1129,11 +1466,64 @@ export { message, close, unsubscribe } from 'svelte-realtime/server';
|
|
|
1129
1466
|
|
|
1130
1467
|
`onUnsubscribe` fires for both static and dynamic topics. For dynamic topics, the server tracks which stream produced each subscription and fires the correct hook. The `unsubscribe` hook fires as soon as the client drops a topic; `close` only fires for topics still active at disconnect time. There is no double-firing.
|
|
1131
1468
|
|
|
1469
|
+
The third argument `remainingSubscribers` counts OTHER WebSockets still holding a realtime-stream subscription to the topic after the current one drops. Use it to tear down upstream feeds (CDC connections, polling loops, external pub/sub follows) when the count reaches zero. Existing 2-argument `(ctx, topic) => ...` handlers continue to work; the third arg is silently ignored.
|
|
1470
|
+
|
|
1471
|
+
### Staleness watchdog and per-stream `onError`
|
|
1472
|
+
|
|
1473
|
+
Streams whose underlying source can quietly stop emitting (CDC drops, polling stalls, upstream cache evicts the key) declare `staleAfterMs` to arm a per-topic watchdog. Every `ctx.publish` to the topic resets the timer; if no events arrive for the configured duration, the realtime layer re-runs the loader and broadcasts the result as a `refreshed` event. The client merges `refreshed` as a full-state replacement across every merge strategy.
|
|
1474
|
+
|
|
1475
|
+
```js
|
|
1476
|
+
export const auditFeed = live.stream(
|
|
1477
|
+
(ctx, orgId) => `audit:${orgId}`,
|
|
1478
|
+
async (ctx, orgId) => loadAudit(orgId),
|
|
1479
|
+
{
|
|
1480
|
+
merge: 'crud',
|
|
1481
|
+
key: 'id',
|
|
1482
|
+
staleAfterMs: 30_000,
|
|
1483
|
+
onError: (err, ctx, topic) => log.warn({ err, topic }, 'audit stream error')
|
|
1484
|
+
}
|
|
1485
|
+
);
|
|
1486
|
+
```
|
|
1487
|
+
|
|
1488
|
+
Watchdog state is per-topic. Multiple subscribers share one timer; the timer arms on the first subscribe and clears when the last subscriber leaves. The reload uses the first subscriber's `ctx` and `args`, which is correct for shared topics since the loader's output is identical regardless of which subscriber's ctx triggers it.
|
|
1489
|
+
|
|
1490
|
+
`onError(err, ctx, topic)` is observer-only. It fires on loader throws across three paths -- the initial subscribe, the staleness-driven reload, and the `.load()` SSR path. Errors thrown inside `onError` are silently swallowed so a buggy logger never breaks the original error path. Apps that want a topic-scoped degraded signal can publish a system event from inside the handler:
|
|
1491
|
+
|
|
1492
|
+
```js
|
|
1493
|
+
onError: (err, ctx, topic) => {
|
|
1494
|
+
ctx.publish(`__system:${topic}`, 'degraded', { reason: err.message });
|
|
1495
|
+
}
|
|
1496
|
+
```
|
|
1497
|
+
|
|
1498
|
+
### Topic-driven invalidation
|
|
1499
|
+
|
|
1500
|
+
For mutations whose effects don't fit the merge-strategy model cleanly (bulk operations, server-side recomputation, cascading writes), declare an `invalidateOn` pattern so any matching `ctx.publish` triggers a loader rerun:
|
|
1501
|
+
|
|
1502
|
+
```js
|
|
1503
|
+
export const todos = live.stream('todos', loadTodos, {
|
|
1504
|
+
merge: 'crud',
|
|
1505
|
+
invalidateOn: 'todos:*'
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
// Anywhere in your live functions:
|
|
1509
|
+
ctx.publish('todos:bulk-imported', 'created', { count: 42 });
|
|
1510
|
+
// -> matches 'todos:*', the todos loader reruns, the result is broadcast
|
|
1511
|
+
// as a 'refreshed' event, and every subscriber gets the new state.
|
|
1512
|
+
```
|
|
1513
|
+
|
|
1514
|
+
`invalidateOn` accepts a single string or an array of strings. `*` is a wildcard that matches any sequence of one or more characters; other regex specials are escaped. Multiple patterns are OR-ed (any match triggers the reload).
|
|
1515
|
+
|
|
1516
|
+
The reload reuses the staleness machinery: it captures the first subscriber's `ctx` + args, applies any configured init `transform`, and publishes a `refreshed` event to the stream's own topic. The client merges `refreshed` as a full-state replacement.
|
|
1517
|
+
|
|
1518
|
+
`refreshed` events are themselves excluded from the invalidation check, so a stream whose own topic happens to match its `invalidateOn` pattern (e.g., `'todos*'` matching topic `'todos'`) won't loop. Concurrent triggers while a reload is in flight are deduped via a per-watcher `reloading` flag.
|
|
1519
|
+
|
|
1520
|
+
Errors thrown by the loader during an `invalidateOn` reload route through the same `onError(err, ctx, topic)` observer as staleness-driven reloads.
|
|
1521
|
+
|
|
1132
1522
|
---
|
|
1133
1523
|
|
|
1134
1524
|
## Access control
|
|
1135
1525
|
|
|
1136
|
-
Use the `filter` / `access` option on `live.stream()` to control who can subscribe. The predicate receives `ctx` and is checked once at subscription time. If it returns `false`, the subscription is denied with `{ ok: false, code: 'FORBIDDEN', error: 'Access denied' }` and no data is sent. For per-event filtering, use `
|
|
1526
|
+
Use the `filter` / `access` option on `live.stream()` to control who can subscribe. The predicate receives `ctx` and is checked once at subscription time. If it returns `false`, the subscription is denied with `{ ok: false, code: 'FORBIDDEN', error: 'Access denied' }` and no data is sent. For per-event projection or filtering on a live stream, use the `transform` option on `live.stream({ transform })`.
|
|
1137
1527
|
|
|
1138
1528
|
```js
|
|
1139
1529
|
import { live } from 'svelte-realtime/server';
|
|
@@ -1174,9 +1564,80 @@ export const myOrders = live.stream(
|
|
|
1174
1564
|
| `live.access.owner(field?)` | Subscription allowed if `ctx.user[field]` is present (default: `'id'`) |
|
|
1175
1565
|
| `live.access.team()` | Subscription allowed if `ctx.user.teamId` is present |
|
|
1176
1566
|
| `live.access.role(map)` | Role-based: `{ admin: true, viewer: (ctx) => ... }` |
|
|
1567
|
+
| `live.access.org(opts?)` | Subscription allowed if `args[0]` matches `ctx.user.organization_id`. Configurable via `{ from, orgField }` |
|
|
1568
|
+
| `live.access.user(opts?)` | Subscription allowed if `args[0]` matches `ctx.user.user_id`. Configurable via `{ from, userField }` |
|
|
1177
1569
|
| `live.access.any(...predicates)` | OR: any predicate returning true allows the subscription |
|
|
1178
1570
|
| `live.access.all(...predicates)` | AND: all predicates must return true |
|
|
1179
1571
|
|
|
1572
|
+
`live.access.org` and `live.access.user` follow the SQL `[table]_id` convention. Override the field for non-default user shapes:
|
|
1573
|
+
|
|
1574
|
+
```js
|
|
1575
|
+
// Stream that takes an orgId arg and verifies the caller belongs to that org
|
|
1576
|
+
export const auditFeed = live.stream(
|
|
1577
|
+
(ctx, orgId) => `audit:${orgId}`,
|
|
1578
|
+
async (ctx, orgId) => loadAudit(orgId),
|
|
1579
|
+
{ access: live.access.org() }
|
|
1580
|
+
);
|
|
1581
|
+
|
|
1582
|
+
// Custom user-shape (e.g. SvelteKit locals.user with camelCase)
|
|
1583
|
+
access: live.access.org({ orgField: 'organizationId' })
|
|
1584
|
+
```
|
|
1585
|
+
|
|
1586
|
+
### Authentication shorthand on guards
|
|
1587
|
+
|
|
1588
|
+
`guard()` accepts an options object alongside the existing variadic-function shape. `{ authenticated: true }` rejects calls with no `ctx.user` as `UNAUTHENTICATED`:
|
|
1589
|
+
|
|
1590
|
+
```js
|
|
1591
|
+
// src/live/_guard.js
|
|
1592
|
+
import { guard } from 'svelte-realtime/server';
|
|
1593
|
+
|
|
1594
|
+
// Bare authenticated check
|
|
1595
|
+
export const _guard = guard({ authenticated: true });
|
|
1596
|
+
|
|
1597
|
+
// Compose with custom predicates
|
|
1598
|
+
export const _guard = guard({ authenticated: true }, (ctx) => ctx.user.role === 'admin');
|
|
1599
|
+
```
|
|
1600
|
+
|
|
1601
|
+
Bare `Error` throws from any guard auto-classify: thrown errors against an anonymous user produce `LiveError('UNAUTHENTICATED')`; thrown errors with a user produce `LiveError('FORBIDDEN')`. Original errors travel on `.cause` for server-side logging. Throw `LiveError(code, message)` explicitly to control the wire-visible code and message verbatim.
|
|
1602
|
+
|
|
1603
|
+
### `live.scoped(predicate, fn)`
|
|
1604
|
+
|
|
1605
|
+
Wrap an RPC handler with a per-call access predicate that throws `UNAUTHENTICATED` (no user) or `FORBIDDEN` (predicate rejects). Composes with `live.validated`, `live.rateLimit`, etc.
|
|
1606
|
+
|
|
1607
|
+
```js
|
|
1608
|
+
export const editOrgSettings = live.scoped(
|
|
1609
|
+
live.access.org(),
|
|
1610
|
+
live.validated(SettingsSchema, async (ctx, orgId, patch) => {
|
|
1611
|
+
return db.orgs.update(orgId, patch);
|
|
1612
|
+
})
|
|
1613
|
+
);
|
|
1614
|
+
```
|
|
1615
|
+
|
|
1616
|
+
For streams, use the `access` option directly. `live.scoped` is the RPC equivalent.
|
|
1617
|
+
|
|
1618
|
+
### Subscribe-denial codes on stream stores
|
|
1619
|
+
|
|
1620
|
+
When a server-side subscribe denial fires (guard rejection, access predicate, rate limit, invalid topic), the typed reason flows through the stream store's `error` slot as an `RpcError` with the canonical code:
|
|
1621
|
+
|
|
1622
|
+
```svelte
|
|
1623
|
+
<script>
|
|
1624
|
+
import { auditFeed } from '$live/audit';
|
|
1625
|
+
const err = auditFeed.error;
|
|
1626
|
+
</script>
|
|
1627
|
+
|
|
1628
|
+
{#if $err?.code === 'UNAUTHENTICATED'}
|
|
1629
|
+
<p>Please sign in to view audit history.</p>
|
|
1630
|
+
{:else if $err?.code === 'FORBIDDEN'}
|
|
1631
|
+
<p>You don't have access to this organization's audit log.</p>
|
|
1632
|
+
{:else if $err?.code === 'RATE_LIMITED'}
|
|
1633
|
+
<p>Too many requests. Please wait a moment.</p>
|
|
1634
|
+
{:else if $err}
|
|
1635
|
+
<p>Audit feed unavailable: {$err.message}</p>
|
|
1636
|
+
{/if}
|
|
1637
|
+
```
|
|
1638
|
+
|
|
1639
|
+
Custom denial reasons returned from a server-side `subscribe` hook (e.g. `'KYC_PENDING'`) flow through verbatim as the `code` field. The same denial fans out to every stream subscribed to the topic.
|
|
1640
|
+
|
|
1180
1641
|
---
|
|
1181
1642
|
|
|
1182
1643
|
## Rate limiting
|
|
@@ -1193,6 +1654,26 @@ export const sendMessage = live.rateLimit({ points: 5, window: 10000 }, async (c
|
|
|
1193
1654
|
});
|
|
1194
1655
|
```
|
|
1195
1656
|
|
|
1657
|
+
### Registry-level rate limiting
|
|
1658
|
+
|
|
1659
|
+
For the common case of "a default for everyone, with a few path overrides and a few exemptions" you can configure the registry once at startup with `live.rateLimits()`:
|
|
1660
|
+
|
|
1661
|
+
```js
|
|
1662
|
+
// hooks.ws.js or any startup module
|
|
1663
|
+
import { live } from 'svelte-realtime/server';
|
|
1664
|
+
|
|
1665
|
+
live.rateLimits({
|
|
1666
|
+
default: { points: 200, window: 10_000 },
|
|
1667
|
+
overrides: {
|
|
1668
|
+
'chat/sendMessage': { points: 50, window: 10_000 },
|
|
1669
|
+
'orders/create': { points: 5, window: 60_000 }
|
|
1670
|
+
},
|
|
1671
|
+
exempt: ['presence/moveCursor', 'cursor/move']
|
|
1672
|
+
});
|
|
1673
|
+
```
|
|
1674
|
+
|
|
1675
|
+
Resolution order per call: `exempt` -> per-handler `live.rateLimit(...)` wrapping (explicit wins over central) -> `overrides[path]` -> `default` -> none. Stream subscribes are not rate-limited by this primitive. Pass `null` to clear the registry.
|
|
1676
|
+
|
|
1196
1677
|
### Global rate limiting with Redis
|
|
1197
1678
|
|
|
1198
1679
|
Use the `beforeExecute` hook with the rate limit extension for global per-connection throttling:
|
|
@@ -1215,54 +1696,236 @@ export const message = createMessage({
|
|
|
1215
1696
|
|
|
1216
1697
|
---
|
|
1217
1698
|
|
|
1218
|
-
##
|
|
1699
|
+
## Load shedding
|
|
1219
1700
|
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
`live.metrics(registry)` is a one-time setup call. The top of `src/hooks.ws.{js,ts}` is a natural place, since it loads once when the server boots. Pair it with the `createMetrics()` registry from `svelte-adapter-uws-extensions/prometheus`:
|
|
1701
|
+
Under sustained pressure, drop low-priority traffic before it reaches the handler. `live.admission(config)` registers named pressure rules; `ctx.shed(className)` checks them; the `classOfService` stream option gates new subscribes automatically.
|
|
1223
1702
|
|
|
1224
1703
|
```js
|
|
1225
|
-
// src/hooks.ws.js
|
|
1226
1704
|
import { live } from 'svelte-realtime/server';
|
|
1227
|
-
import { createMetrics } from 'svelte-adapter-uws-extensions/prometheus';
|
|
1228
1705
|
|
|
1229
|
-
|
|
1706
|
+
// Register classes once at startup
|
|
1707
|
+
live.admission({
|
|
1708
|
+
classes: {
|
|
1709
|
+
background: ['PUBLISH_RATE', 'SUBSCRIBERS', 'MEMORY'], // shed under any pressure
|
|
1710
|
+
nonCritical: ['MEMORY'], // shed only on memory pressure
|
|
1711
|
+
realtime: (snapshot) => snapshot.publishRate > 8000 // custom predicate
|
|
1712
|
+
}
|
|
1713
|
+
});
|
|
1230
1714
|
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1715
|
+
// Stream gating: new subscribes shed under matching pressure
|
|
1716
|
+
export const browseList = live.stream('browse:list', loader, {
|
|
1717
|
+
merge: 'crud',
|
|
1718
|
+
classOfService: 'background'
|
|
1235
1719
|
});
|
|
1236
1720
|
|
|
1237
|
-
|
|
1721
|
+
// RPC gating: per-call decision
|
|
1722
|
+
export const expensiveSearch = live(async (ctx, query) => {
|
|
1723
|
+
if (ctx.shed('background')) {
|
|
1724
|
+
throw new LiveError('OVERLOADED', 'Server is busy, try again shortly');
|
|
1725
|
+
}
|
|
1726
|
+
return search(query);
|
|
1727
|
+
});
|
|
1728
|
+
```
|
|
1729
|
+
|
|
1730
|
+
`platform.pressure.reason` is a precedence-ordered enum (`MEMORY > PUBLISH_RATE > SUBSCRIBERS > NONE`); class rules using a string-array match if the active reason is in the array. Predicate rules receive the full pressure snapshot. Existing subscribers are unaffected -- shedding applies to NEW subscribes and RPC calls only.
|
|
1731
|
+
|
|
1732
|
+
`live.admission()` validates rules at registration; unknown reasons throw with a `[svelte-realtime]`-prefixed error so typos fail fast at boot. Pass `null` to clear.
|
|
1733
|
+
|
|
1734
|
+
---
|
|
1735
|
+
|
|
1736
|
+
## Concurrency control
|
|
1737
|
+
|
|
1738
|
+
`live.lock(keyOrConfig, fn)` serializes concurrent RPC calls that resolve to the same key. Composes with `live.validated`, `live.idempotent`, `live.rateLimit`, etc.
|
|
1739
|
+
|
|
1740
|
+
```js
|
|
1741
|
+
// Per-org leaderboard recompute: one in-flight per org, others wait for the result
|
|
1742
|
+
export const recomputeLeaderboard = live.lock(
|
|
1743
|
+
(ctx) => `leaderboard:${ctx.user.organization_id}`,
|
|
1744
|
+
async (ctx) => {
|
|
1745
|
+
const rows = await db.expensive.recompute(ctx.user.organization_id);
|
|
1746
|
+
ctx.publish(`org:${ctx.user.organization_id}:leaderboard`, 'set', rows);
|
|
1747
|
+
return rows;
|
|
1748
|
+
}
|
|
1749
|
+
);
|
|
1750
|
+
|
|
1751
|
+
// Static key (single global section)
|
|
1752
|
+
export const rebuildSearchIndex = live.lock('search-index-rebuild', async (ctx) => {
|
|
1753
|
+
return rebuildIndex();
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
// Distributed lock via the extensions package
|
|
1757
|
+
import { createDistributedLock } from 'svelte-adapter-uws-extensions/redis/lock';
|
|
1758
|
+
const distributedLock = createDistributedLock(redis);
|
|
1759
|
+
|
|
1760
|
+
export const settleInvoice = live.lock(
|
|
1761
|
+
{ key: (ctx, id) => `invoice:${id}`, lock: distributedLock },
|
|
1762
|
+
live.validated(InvoiceIdSchema, async (ctx, id) => settle(id))
|
|
1763
|
+
);
|
|
1238
1764
|
```
|
|
1239
1765
|
|
|
1240
|
-
|
|
1766
|
+
Concurrent callers wait in FIFO order for the holder's result. A `null` / `undefined` / empty key bypasses the lock (the handler runs unguarded for that call). Custom lock implementations need a single method: `withLock(key, fn, opts?) -> Promise<result>`.
|
|
1767
|
+
|
|
1768
|
+
Default lock is in-process and bounded. For multi-instance deployments, pass `lock: createDistributedLock(redis)` from the extensions package -- any object exposing the `withLock(key, fn, opts?)` contract works.
|
|
1769
|
+
|
|
1770
|
+
### Bounded wait with `maxWaitMs`
|
|
1771
|
+
|
|
1772
|
+
Pass `maxWaitMs` (in the config-object form) to bound how long a queued caller will wait before giving up. On timeout the wrapper rejects with `LiveError('LOCK_TIMEOUT', ...)` so the client receives a typed error with `.code === 'LOCK_TIMEOUT'` (plus `.key` and `.maxWaitMs` fields for observability).
|
|
1241
1773
|
|
|
1242
1774
|
```js
|
|
1243
|
-
|
|
1775
|
+
export const settleInvoice = live.lock(
|
|
1776
|
+
{ key: (ctx, id) => `invoice:${id}`, maxWaitMs: 5000 },
|
|
1777
|
+
async (ctx, id) => settle(id)
|
|
1778
|
+
);
|
|
1244
1779
|
```
|
|
1245
1780
|
|
|
1246
|
-
The
|
|
1781
|
+
The current holder is **not** interrupted when a waiter times out -- only the waiting caller gives up. Subsequent waiters on the same key are unaffected and continue in their original order. This is the right primitive for "fail fast under contention" rather than "cancel work in flight."
|
|
1782
|
+
|
|
1783
|
+
For custom lock implementations, the option is forwarded as the third argument: `lockInst.withLock(key, fn, { maxWaitMs })`. The default in-process lock and `createDistributedLock` from the extensions package both honor it.
|
|
1784
|
+
|
|
1785
|
+
---
|
|
1786
|
+
|
|
1787
|
+
## Server-initiated push
|
|
1788
|
+
|
|
1789
|
+
`live.push({ userId }, event, data, options?)` sends a server-initiated request to a connected user and awaits the reply. Routes through a per-instance userId -> WebSocket registry maintained by a small pair of hooks.
|
|
1790
|
+
|
|
1791
|
+
```js
|
|
1792
|
+
// hooks.ws.js -- wire the registry once
|
|
1793
|
+
import { pushHooks } from 'svelte-realtime/server';
|
|
1247
1794
|
|
|
1248
|
-
|
|
1795
|
+
export const open = pushHooks.open;
|
|
1796
|
+
export const close = pushHooks.close;
|
|
1797
|
+
```
|
|
1798
|
+
|
|
1799
|
+
```js
|
|
1800
|
+
// Anywhere on the server (admin RPC, cron, webhook receiver, etc.)
|
|
1801
|
+
import { live } from 'svelte-realtime/server';
|
|
1802
|
+
|
|
1803
|
+
const reply = await live.push(
|
|
1804
|
+
{ userId: 'u-123' },
|
|
1805
|
+
'confirm-delete',
|
|
1806
|
+
{ itemId: 42 },
|
|
1807
|
+
{ timeoutMs: 30_000 }
|
|
1808
|
+
);
|
|
1809
|
+
if (reply.confirmed) await actuallyDelete(42);
|
|
1810
|
+
```
|
|
1811
|
+
|
|
1812
|
+
```svelte
|
|
1813
|
+
<!-- Client: register handlers per event -->
|
|
1814
|
+
<script>
|
|
1815
|
+
import { onPush } from 'svelte-realtime/client';
|
|
1816
|
+
|
|
1817
|
+
onPush('confirm-delete', async ({ itemId }) => {
|
|
1818
|
+
return { confirmed: confirm(`Delete item ${itemId}?`) };
|
|
1819
|
+
});
|
|
1820
|
+
</script>
|
|
1821
|
+
```
|
|
1249
1822
|
|
|
1823
|
+
The default identifier reads `ws.getUserData()?.user_id ?? ws.getUserData()?.userId`. Override for custom userData shapes:
|
|
1824
|
+
|
|
1825
|
+
```js
|
|
1826
|
+
import { live } from 'svelte-realtime/server';
|
|
1827
|
+
live.configurePush({ identify: (ws) => ws.getUserData()?.account?.id });
|
|
1828
|
+
```
|
|
1829
|
+
|
|
1830
|
+
Throws `LiveError('NOT_FOUND')` when no connection is registered for the userId. Propagates `Error('request timed out')` from the underlying primitive on the configurable `timeoutMs` (default 5000ms), and `Error('connection closed')` if the WebSocket closes before reply.
|
|
1831
|
+
|
|
1832
|
+
Multi-device users see most-recent-connection-wins routing within each instance, and cluster-wide most-recent-wins via the registry's Redis hash when cluster routing is configured (see [Cluster routing](#cluster-routing) below). Older connections still receive topic publishes via their own subscriptions; only push routing flips. Anonymous connections (identify returning null/undefined) are silently skipped at registration so they cannot be push targets.
|
|
1833
|
+
|
|
1834
|
+
`onPush(event, handler)` multiplexes multiple events over the adapter's single `onRequest` channel. Returning a value sends it as the reply; throwing rejects the server-side promise. Returns an unsubscribe function.
|
|
1835
|
+
|
|
1836
|
+
### Cluster routing
|
|
1837
|
+
|
|
1838
|
+
For multi-instance deploys, wire the connection registry from `svelte-adapter-uws-extensions` so a `live.push` originating on any instance reaches the user's owning instance:
|
|
1839
|
+
|
|
1840
|
+
```js
|
|
1841
|
+
// hooks.ws.js
|
|
1842
|
+
import { pushHooks, live } from 'svelte-realtime/server';
|
|
1843
|
+
import { createRedisClient } from 'svelte-adapter-uws-extensions/redis';
|
|
1844
|
+
import { createConnectionRegistry } from 'svelte-adapter-uws-extensions/redis/registry';
|
|
1845
|
+
|
|
1846
|
+
const redis = createRedisClient({ url: process.env.REDIS_URL });
|
|
1847
|
+
const registry = createConnectionRegistry(redis, {
|
|
1848
|
+
identify: (ws) => ws.getUserData()?.userId
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
// Tell live.push to fall back to the registry for cross-instance lookups
|
|
1852
|
+
live.configurePush({ remoteRegistry: registry });
|
|
1853
|
+
|
|
1854
|
+
// Wire the registry's own connection hooks (NOT pushHooks.* in this mode --
|
|
1855
|
+
// the registry tracks ownership in Redis and short-circuits same-instance
|
|
1856
|
+
// requests internally).
|
|
1857
|
+
export const open = registry.hooks.open;
|
|
1858
|
+
export const close = registry.hooks.close;
|
|
1859
|
+
```
|
|
1860
|
+
|
|
1861
|
+
Lookup order inside `live.push`:
|
|
1862
|
+
|
|
1863
|
+
1. **Local registry** -- the per-instance Map populated by `pushHooks.open` / `pushHooks.close`. Resolves directly via `platform.request(ws, ...)` with no I/O.
|
|
1864
|
+
2. **Remote registry** -- when configured via `live.configurePush({ remoteRegistry })`, used as a fallback when the userId is not registered locally. The extensions registry looks up the owning instance in Redis and either short-circuits to a local `platform.request` or forwards the envelope on a per-instance push channel and awaits the reply.
|
|
1865
|
+
|
|
1866
|
+
Errors with a remote registry come from the registry layer: typically an offline rejection when the user has no active connection cluster-wide, a timeout when routing succeeded but the client did not reply within `timeoutMs`, or a propagated handler error from the receiving instance. The realtime layer does NOT translate these to `LiveError('NOT_FOUND')`; let your caller distinguish and surface them.
|
|
1867
|
+
|
|
1868
|
+
You can wire BOTH (`pushHooks.*` + `remoteRegistry`); the local Map wins when an entry is present and the remote registry is consulted only as fallback. In practice pick one of the two patterns -- the registry-only setup is simpler and the registry already does same-instance short-circuit on its own.
|
|
1869
|
+
|
|
1870
|
+
Single-instance setups without a registry continue to throw `LiveError('NOT_FOUND')` for unknown userIds, unchanged.
|
|
1871
|
+
|
|
1872
|
+
---
|
|
1873
|
+
|
|
1874
|
+
## Prometheus metrics
|
|
1875
|
+
|
|
1876
|
+
Opt-in instrumentation for RPC calls, stream subscriptions, and cron executions. Zero overhead if not called.
|
|
1877
|
+
|
|
1878
|
+
```js
|
|
1879
|
+
import { live } from 'svelte-realtime/server';
|
|
1880
|
+
import { createMetricsRegistry } from 'svelte-adapter-uws-extensions/prometheus';
|
|
1881
|
+
|
|
1882
|
+
const registry = createMetricsRegistry();
|
|
1883
|
+
live.metrics(registry);
|
|
1884
|
+
```
|
|
1885
|
+
|
|
1886
|
+
This registers counters/histograms for:
|
|
1250
1887
|
- `svelte_realtime_rpc_total` -- RPC call count by path and status
|
|
1251
1888
|
- `svelte_realtime_rpc_duration_seconds` -- RPC latency by path
|
|
1252
1889
|
- `svelte_realtime_rpc_errors_total` -- RPC errors by path and code
|
|
1253
|
-
- `svelte_realtime_stream_subscriptions` -- active stream subscription gauge
|
|
1890
|
+
- `svelte_realtime_stream_subscriptions` -- active stream subscription gauge by topic
|
|
1254
1891
|
- `svelte_realtime_cron_total` -- cron execution count by path and status
|
|
1255
1892
|
- `svelte_realtime_cron_errors_total` -- cron errors by path
|
|
1893
|
+
- `svelte_realtime_assertion_violations_total` -- production-assertion violations by category (see "Production assertions" below)
|
|
1894
|
+
|
|
1895
|
+
---
|
|
1896
|
+
|
|
1897
|
+
## Production assertions
|
|
1256
1898
|
|
|
1257
|
-
|
|
1899
|
+
`svelte-realtime` instruments each internal invariant with `assert(cond, category, context)`. Behavior:
|
|
1258
1900
|
|
|
1259
|
-
`live.metrics()`
|
|
1901
|
+
- **In production**, a violation increments an in-memory per-category counter, fires the Prometheus counter `svelte_realtime_assertion_violations_total{category}` (when `live.metrics(...)` is wired), and logs a single `[realtime/assert] {...}` line at `console.error`. The assert does NOT throw -- a thrown exception inside a publish hot-path microtask or a subscribe callback could leave a half-applied bookkeeping update or a corrupted index. Counter + log give observability without the corruption risk.
|
|
1902
|
+
- **In test mode** (`process.env.VITEST` or `NODE_ENV === 'test'`) the assert THROWS so vitest surfaces the failure as a normal test error.
|
|
1260
1903
|
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1904
|
+
Categories are stable strings prefixed `realtime/<module>.<invariant>` (so the Prometheus label cardinality is bounded and won't collide with the adapter's `extensions_assertion_violations_total`). Today's categories:
|
|
1905
|
+
|
|
1906
|
+
| Category | Where |
|
|
1907
|
+
| ----------------------------------------------------- | ------------------------------------------- |
|
|
1908
|
+
| `realtime/handleRpc.envelope.non-empty` | RPC frame has non-empty `rpc` and `id` |
|
|
1909
|
+
| `realtime/subscription.bookkeeping.ws-was-tracked` | Unsubscribe path: ws was in the topic set |
|
|
1910
|
+
| `realtime/push-registry.entry-tracked` | Close hook: registry entry exists for userId |
|
|
1911
|
+
| `realtime/lock.waiter.shape` | Dequeued lock waiter has resolve+reject |
|
|
1912
|
+
| `realtime/optimistic.queue.serverValue-iff-nonempty` | Server-merge path: `_serverValue` set when queue non-empty |
|
|
1913
|
+
| `realtime/optimistic.queue.drain-precondition` | `_drainQueue` called only with empty queue |
|
|
1914
|
+
| `realtime/optimistic.queue.entry.shape` | Settle path: queue entry has expected fields |
|
|
1915
|
+
|
|
1916
|
+
Read the live counter map programmatically:
|
|
1917
|
+
|
|
1918
|
+
```js
|
|
1919
|
+
import { getAssertionCounters } from 'svelte-realtime/server';
|
|
1920
|
+
|
|
1921
|
+
setInterval(() => {
|
|
1922
|
+
for (const [category, count] of getAssertionCounters()) {
|
|
1923
|
+
if (count > 0) console.warn(`assertion ${category}: ${count} violations`);
|
|
1924
|
+
}
|
|
1925
|
+
}, 60_000);
|
|
1926
|
+
```
|
|
1264
1927
|
|
|
1265
|
-
|
|
1928
|
+
The client-side assert helper is exported from `svelte-realtime/client` with the same shape (sans Prometheus wiring -- the browser has no metrics registry; use the in-memory counter or log shipping instead).
|
|
1266
1929
|
|
|
1267
1930
|
---
|
|
1268
1931
|
|
|
@@ -1287,6 +1950,57 @@ If `fallback` is omitted and the circuit is open, the call throws `LiveError('SE
|
|
|
1287
1950
|
|
|
1288
1951
|
---
|
|
1289
1952
|
|
|
1953
|
+
## Request correlation
|
|
1954
|
+
|
|
1955
|
+
Every live function receives `ctx.requestId` -- a stable identifier for the originating RPC envelope. The id flows in three directions automatically:
|
|
1956
|
+
|
|
1957
|
+
- **From clients**: WebSocket clients tag every RPC envelope with a generated id; HTTP request handlers honor `X-Request-ID` headers (and generate one if absent). The adapter writes the resolved id to `platform.requestId`, and the realtime layer copies it to `ctx.requestId` for the duration of that handler.
|
|
1958
|
+
- **Through your handlers**: pass it to whatever you do downstream so log lines, traces, and persisted rows all share one key.
|
|
1959
|
+
- **Into background work**: the `svelte-adapter-uws-extensions` postgres tasks/jobs APIs persist `request_id` on every row. Pass it explicitly or pipe `ctx.platform` and the helpers extract it for you.
|
|
1960
|
+
|
|
1961
|
+
```js
|
|
1962
|
+
import { live } from 'svelte-realtime/server';
|
|
1963
|
+
import { createTasks } from 'svelte-adapter-uws-extensions/postgres/tasks';
|
|
1964
|
+
import { createJobs } from 'svelte-adapter-uws-extensions/postgres/jobs';
|
|
1965
|
+
|
|
1966
|
+
const tasks = createTasks({ client: pgClient });
|
|
1967
|
+
const jobs = createJobs({ client: pgClient });
|
|
1968
|
+
|
|
1969
|
+
export const submitOrder = live(async (ctx, input) => {
|
|
1970
|
+
// Option A: explicit -- works anywhere ctx.requestId is in scope
|
|
1971
|
+
const order = await tasks.run('processOrder', input, {
|
|
1972
|
+
requestId: ctx.requestId
|
|
1973
|
+
});
|
|
1974
|
+
|
|
1975
|
+
// Option B: pipe ctx.platform; the helpers read platform.requestId for you
|
|
1976
|
+
await jobs.enqueue('shipment-notice', { orderId: order.id }, {
|
|
1977
|
+
platform: ctx.platform
|
|
1978
|
+
});
|
|
1979
|
+
|
|
1980
|
+
return order;
|
|
1981
|
+
});
|
|
1982
|
+
```
|
|
1983
|
+
|
|
1984
|
+
Once the rows land, you can join them back to the originating RPC for debugging or audit:
|
|
1985
|
+
|
|
1986
|
+
```sql
|
|
1987
|
+
-- Trace one user request across the websocket -> task -> job pipeline
|
|
1988
|
+
SELECT
|
|
1989
|
+
t.svti_tasks_id, t.name AS task_name, t.status, t.created_at AS task_created,
|
|
1990
|
+
j.svti_jobs_id, j.queue, j.attempts,
|
|
1991
|
+
t.request_id
|
|
1992
|
+
FROM ws_tasks t
|
|
1993
|
+
LEFT JOIN ws_jobs j ON j.request_id = t.request_id
|
|
1994
|
+
WHERE t.request_id = $1
|
|
1995
|
+
ORDER BY task_created;
|
|
1996
|
+
```
|
|
1997
|
+
|
|
1998
|
+
The id passes opaquely -- no validation, no length cap from the realtime layer. If you generate ids with a structured prefix (`o-` for orders, `a-` for audits), every downstream record carries that prefix too.
|
|
1999
|
+
|
|
2000
|
+
If you also instrument with [Prometheus metrics](#prometheus-metrics), include `requestId` in your log fields rather than as a metric label -- it's high-cardinality and would blow up your label space.
|
|
2001
|
+
|
|
2002
|
+
---
|
|
2003
|
+
|
|
1290
2004
|
## Cron scheduling
|
|
1291
2005
|
|
|
1292
2006
|
Use `live.cron()` to run server-side functions on a schedule and publish results to a topic.
|
|
@@ -1728,6 +2442,32 @@ How it works:
|
|
|
1728
2442
|
- If versions differ: server calls `diff(sinceVersion)` and sends only the changes
|
|
1729
2443
|
- If diff returns `null`: falls back to full refetch
|
|
1730
2444
|
|
|
2445
|
+
### Three-tier reconnect with `delta.fromSeq`
|
|
2446
|
+
|
|
2447
|
+
For seq-based reconnects where the bounded replay buffer cannot satisfy the gap (the client's `lastSeenSeq` is older than the oldest entry in the buffer), `delta.fromSeq(sinceSeq)` is the user-supplied bridge to the durable store. The server falls back to full rehydrate only when `fromSeq` returns `null` / `undefined`.
|
|
2448
|
+
|
|
2449
|
+
```js
|
|
2450
|
+
export const auditFeed = live.stream(
|
|
2451
|
+
(ctx, orgId) => `audit:${orgId}`,
|
|
2452
|
+
async (ctx, orgId) => loadAudit(orgId, { limit: 200 }),
|
|
2453
|
+
{
|
|
2454
|
+
merge: 'crud',
|
|
2455
|
+
key: 'id',
|
|
2456
|
+
replay: true,
|
|
2457
|
+
delta: {
|
|
2458
|
+
fromSeq: async (sinceSeq) => {
|
|
2459
|
+
const events = await db.auditEvents.where('seq', '>', sinceSeq).orderBy('seq').limit(500);
|
|
2460
|
+
if (events.length === 0) return []; // nothing missed, no-op
|
|
2461
|
+
if (events.length === 500) return null; // too many -> fall through to full rehydrate
|
|
2462
|
+
return events;
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
);
|
|
2467
|
+
```
|
|
2468
|
+
|
|
2469
|
+
Resolution order on reconnect-with-seq: replay buffer (bounded, fast) -> `delta.fromSeq(clientSeq)` (this hook) -> full rehydrate via the loader (always safe). Returning `[]` signals "nothing missed" (client no-op). Each event should carry a `seq` field so the client's `_lastSeq` advances; if the events come from a Postgres column with a `seq` per row, the client tracks correctly without extra plumbing.
|
|
2470
|
+
|
|
1731
2471
|
### Replay
|
|
1732
2472
|
|
|
1733
2473
|
Enable seq-based replay for gap-free stream reconnection. When a client reconnects, it sends its last known sequence number. If the server has the missed events buffered, it sends only those instead of a full refetch.
|
|
@@ -1883,6 +2623,70 @@ Workers are health-checked every 10 seconds. If a worker fails to respond within
|
|
|
1883
2623
|
|
|
1884
2624
|
---
|
|
1885
2625
|
|
|
2626
|
+
## Capacity model
|
|
2627
|
+
|
|
2628
|
+
Every internal Map / Set / array with caller-driven growth is bounded by default. Numbers are deliberately generous -- far above any healthy single-instance workload -- so they catch obvious bugs (subscribe-leak, register-without-deregister) without biting real apps.
|
|
2629
|
+
|
|
2630
|
+
Each cap is one of three saturation behaviors:
|
|
2631
|
+
|
|
2632
|
+
- **REJECT** -- caller gets an explicit error or a documented silent skip.
|
|
2633
|
+
- **WARN-ONLY** -- logs once per category; structure keeps growing because eviction would corrupt routing.
|
|
2634
|
+
- **FIFO-evict** -- drops oldest insertion-order entries; safe for dedup state where re-warn or duplicate is acceptable.
|
|
2635
|
+
|
|
2636
|
+
| Cap | Default | Behavior | Notes |
|
|
2637
|
+
| ------------------------------------ | ----------- | ------------ | ------------------------------------------------------------------- |
|
|
2638
|
+
| `MAX_PUSH_REGISTRY` | 10,000,000 | WARN+skip | Per-userId connection registry for `live.push({ userId })`. |
|
|
2639
|
+
| `TOPIC_WS_COUNTS_WARN_THRESHOLD` | 1,000,000 | WARN-only | Per-topic subscriber index. Eviction would corrupt routing. |
|
|
2640
|
+
| `SILENT_TOPIC_WARN_DEDUP_MAX` | 1,000,000 | FIFO-evict | Dev-mode silent-topic warning dedup set. |
|
|
2641
|
+
| `PUBLISH_RATE_WARN_DEDUP_MAX` | 1,000,000 | FIFO-evict | Dev-mode publish-rate warning dedup set. |
|
|
2642
|
+
| `MAX_OPTIMISTIC_QUEUE_DEPTH` | 1,000 | REJECT | Per-stream in-flight `mutate()` queue depth. |
|
|
2643
|
+
| Rate-limit identities | 5,000 | REJECT | Per-function `live.rateLimit()` buckets after stale-sweep. |
|
|
2644
|
+
| Throttle/debounce timers | 5,000 | direct | At cap, publishes immediately instead of dropping. |
|
|
2645
|
+
| Idempotency results | 10,000 | FIFO-evict | In-process `live.idempotent()` store; evicts oldest 10%. |
|
|
2646
|
+
| Presence refs | 10,000 | evict+drop | Suspended entries evicted first; full -> join dropped silently. |
|
|
2647
|
+
| Per-stream history | 50 | FIFO | `store.history` undo/redo ring; configurable via `enableHistory`. |
|
|
2648
|
+
| Per-stream devtools events | 20 | FIFO | Recent-events ring shown in `__devtools`. |
|
|
2649
|
+
|
|
2650
|
+
The first five are exported as named constants from `svelte-realtime/server` and `svelte-realtime/client` so apps writing tools or dashboards can read them programmatically. The remaining caps are internal; their values are documented here for capacity planning.
|
|
2651
|
+
|
|
2652
|
+
### MAX_PUSH_REGISTRY (10,000,000)
|
|
2653
|
+
|
|
2654
|
+
Per-process Map of userId -> { ws, platform } populated by `pushHooks.open` and drained by `pushHooks.close`. When the registry reaches the cap, new userIds are not registered (the connection still works, it just can't be the target of `live.push({ userId })` until existing entries clear). A one-shot warning surfaces the saturation. Hitting this typically means push registrations are not being released on disconnect -- check `hooks.ws.js` wires `pushHooks.close`.
|
|
2655
|
+
|
|
2656
|
+
### TOPIC_WS_COUNTS_WARN_THRESHOLD (1,000,000)
|
|
2657
|
+
|
|
2658
|
+
Per-process Map<topic, Set<ws>> tracking which sockets hold an active stream subscription per topic. Used by the realtime layer to pass `remainingSubscribers` to `__onUnsubscribe`. Eviction would corrupt subscribe / unsubscribe routing, so this is WARN-ONLY: the map keeps growing past the threshold, but a one-shot warning surfaces. Hitting this typically means runaway dynamic-topic generation (per-request topic strings); prefer aggregating into stable topic names.
|
|
2659
|
+
|
|
2660
|
+
### SILENT_TOPIC_WARN_DEDUP_MAX (1,000,000)
|
|
2661
|
+
|
|
2662
|
+
Dev-mode set of topics that have already fired the silent-topic warning. FIFO-evicts at the cap (dropping the oldest topic just lets it re-warn on its next over-threshold subscribe). Dev-only; production code paths are constant-folded out.
|
|
2663
|
+
|
|
2664
|
+
### PUBLISH_RATE_WARN_DEDUP_MAX (1,000,000)
|
|
2665
|
+
|
|
2666
|
+
Dev-mode set of topics that have already fired the high-frequency publish-rate warning. FIFO-evicts at the cap (dropping the oldest topic just lets it re-warn on its next sample tick). Dev-only.
|
|
2667
|
+
|
|
2668
|
+
### MAX_OPTIMISTIC_QUEUE_DEPTH (1,000)
|
|
2669
|
+
|
|
2670
|
+
Per-stream upper bound on concurrent in-flight `store.mutate(asyncOp, change)` calls. When the queue depth equals the cap, the next `mutate()` rejects with `MAX_OPTIMISTIC_QUEUE_DEPTH=1000`. Hitting this means either the server is unresponsive (mutates are not settling), the call site is firing mutates faster than the server can confirm, or a bulk operation is being submitted as N individual mutates instead of one batch. For bulk actions over 1000 items, prefer `batch()` or a single bulk-RPC handler. The cap protects the worst-case display-recompute cost during slow-server scenarios; recompute walks the full queue on every server event so cost grows linearly.
|
|
2671
|
+
|
|
2672
|
+
### Rate-limit identities (5,000)
|
|
2673
|
+
|
|
2674
|
+
Per-function rate limiting (`live.rateLimit()`) tracks sliding-window buckets in memory. When the bucket map reaches the cap, stale buckets are swept first. If still full, new identities are rejected with a `RATE_LIMITED` error. Existing identities are unaffected.
|
|
2675
|
+
|
|
2676
|
+
### Throttle/debounce timers (5,000)
|
|
2677
|
+
|
|
2678
|
+
Active per-key throttle and debounce entries (`ctx.throttle()` / `ctx.debounce()`). At capacity, new entries bypass the timer and publish immediately so data is never silently dropped.
|
|
2679
|
+
|
|
2680
|
+
### Presence refs (10,000)
|
|
2681
|
+
|
|
2682
|
+
The server tracks presence join/leave refcounts in memory. When the map reaches the cap, suspended entries (those with a pending leave timer) are evicted first. If the map is still full after eviction, the join is dropped silently.
|
|
2683
|
+
|
|
2684
|
+
### Idempotency results (10,000)
|
|
2685
|
+
|
|
2686
|
+
In-process `live.idempotent()` store. At capacity, evicts the oldest 10% of entries to make room. The TTL set per-call also prunes expired entries every 30 seconds.
|
|
2687
|
+
|
|
2688
|
+
---
|
|
2689
|
+
|
|
1886
2690
|
## Production limits
|
|
1887
2691
|
|
|
1888
2692
|
### maxPayloadLength (default: 16KB)
|
|
@@ -1901,18 +2705,6 @@ The adapter's `sendQueued()` drops the oldest item when the queue exceeds 1000 m
|
|
|
1901
2705
|
|
|
1902
2706
|
A single `batch()` call is limited to 50 RPC calls. The client rejects before sending if the limit is exceeded, and the server enforces the same limit as a safety net. Split into multiple `batch()` calls if you need more.
|
|
1903
2707
|
|
|
1904
|
-
### Presence refs (max 10,000)
|
|
1905
|
-
|
|
1906
|
-
The server tracks presence join/leave refcounts in memory. When the map reaches 10,000 entries, suspended entries (those with a pending leave timer) are evicted first. If the map is still full after eviction, the join is dropped silently.
|
|
1907
|
-
|
|
1908
|
-
### Rate-limit identities (max 5,000)
|
|
1909
|
-
|
|
1910
|
-
Per-function rate limiting (`live.rateLimit()`) tracks sliding-window buckets in memory. When the bucket map reaches 5,000 entries, stale buckets are swept first. If still full, new identities are rejected with a `RATE_LIMITED` error. Existing identities are unaffected.
|
|
1911
|
-
|
|
1912
|
-
### Throttle/debounce timers (max 5,000)
|
|
1913
|
-
|
|
1914
|
-
The server tracks active throttle and debounce entries globally. When at capacity, new entries bypass the timer and publish immediately so data is never silently dropped.
|
|
1915
|
-
|
|
1916
2708
|
### Topic length (max 256 characters)
|
|
1917
2709
|
|
|
1918
2710
|
The adapter rejects topic names longer than 256 characters or containing control characters (byte value < 32). This applies to subscribe, unsubscribe, and batch-subscribe messages.
|
|
@@ -1954,6 +2746,59 @@ onError((path, error) => {
|
|
|
1954
2746
|
|
|
1955
2747
|
> `onCronError` still works but is deprecated -- use `onError` instead.
|
|
1956
2748
|
|
|
2749
|
+
### Dev-mode publish rate warning
|
|
2750
|
+
|
|
2751
|
+
In development, the framework samples `platform.pressure.topPublishers` at a configurable interval and logs a one-shot warning per topic when message rate crosses a threshold. Suggests `coalesceBy` and `volatile: true` mitigations and suppresses automatically when the topic is already configured with either.
|
|
2752
|
+
|
|
2753
|
+
```js
|
|
2754
|
+
import { live } from 'svelte-realtime/server';
|
|
2755
|
+
|
|
2756
|
+
// Defaults: enabled, threshold 200 events/sec, intervalMs 5000
|
|
2757
|
+
live.publishRateWarning({ threshold: 500, intervalMs: 10_000 });
|
|
2758
|
+
|
|
2759
|
+
// Disable entirely
|
|
2760
|
+
live.publishRateWarning(false);
|
|
2761
|
+
```
|
|
2762
|
+
|
|
2763
|
+
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).
|
|
2764
|
+
|
|
2765
|
+
### Dev-mode silent-topic warning
|
|
2766
|
+
|
|
2767
|
+
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:
|
|
2768
|
+
|
|
2769
|
+
```
|
|
2770
|
+
[svelte-realtime] Topic 'audit:org-1' has subscribers but no events arrived within 30000ms.
|
|
2771
|
+
Common causes:
|
|
2772
|
+
- missing pg_notify trigger on the underlying table
|
|
2773
|
+
- no ctx.publish() call in the relevant handler
|
|
2774
|
+
- intentionally low-traffic topic (extend threshold or suppress)
|
|
2775
|
+
Configure: live.silentTopicWarning({ thresholdMs: 60000 })
|
|
2776
|
+
Suppress: live.silentTopicWarning({ suppress: ['audit:org-1'] })
|
|
2777
|
+
Disable: live.silentTopicWarning(false)
|
|
2778
|
+
See: https://svti.me/silent-topic
|
|
2779
|
+
```
|
|
2780
|
+
|
|
2781
|
+
```js
|
|
2782
|
+
import { live } from 'svelte-realtime/server';
|
|
2783
|
+
|
|
2784
|
+
// Lower the bar (default 30s)
|
|
2785
|
+
live.silentTopicWarning({ thresholdMs: 5000 });
|
|
2786
|
+
|
|
2787
|
+
// Suppress per-topic for known-quiet streams (admin views, scheduled reports)
|
|
2788
|
+
live.silentTopicWarning({ suppress: ['admin:audit', 'cron:reports'] });
|
|
2789
|
+
|
|
2790
|
+
// Disable globally
|
|
2791
|
+
live.silentTopicWarning(false);
|
|
2792
|
+
```
|
|
2793
|
+
|
|
2794
|
+
Topics starting with `__` (system topics: `__realtime`, `__signal:*`, `__custom`) are always skipped automatically; you don't need to add them to `suppress`. Each topic warns at most once per process; the warning never fires for a topic that has been live, and re-subscribing after a warn does not re-fire. Hard-gated to development -- production builds constant-fold the activation branch to dead code, so apps not in dev mode pay zero cost regardless of configuration.
|
|
2795
|
+
|
|
2796
|
+
The watchdog reuses the same lifecycle hooks as the staleness watchdog (`staleAfterMs`): arms on first sub for the topic, observed on every publish, disarms on last unsub. Apps using both features share the per-topic timer machinery without paying twice.
|
|
2797
|
+
|
|
2798
|
+
### Per-stream `onError` for loader observability
|
|
2799
|
+
|
|
2800
|
+
For streams whose loader can fail, add `onError(err, ctx, topic)` to the stream options. See [Stream lifecycle hooks](#stream-lifecycle-hooks) for the full pattern. Per-stream observers fire alongside the global `onError` setter, not instead of it.
|
|
2801
|
+
|
|
1957
2802
|
---
|
|
1958
2803
|
|
|
1959
2804
|
## Custom message handling
|
|
@@ -2005,6 +2850,32 @@ This applies to all handler types -- `live()`, `live.stream()`, `live.cron()`, `
|
|
|
2005
2850
|
|
|
2006
2851
|
In dev mode, the Vite plugin injects an in-browser overlay that shows active streams, RPC history, and connection status. Toggle with `Ctrl+Shift+L`.
|
|
2007
2852
|
|
|
2853
|
+
The Streams tab lists every store currently mounted on the page. For each one it shows:
|
|
2854
|
+
|
|
2855
|
+
| Field | Meaning |
|
|
2856
|
+
|---|---|
|
|
2857
|
+
| topic | The wire topic the store is subscribed to (or `?` while loading). |
|
|
2858
|
+
| merge | The store's merge strategy (`crud`, `latest`, `set`, `presence`, `cursor`). |
|
|
2859
|
+
| subs | Active subscriber count; the entry disappears when this hits zero. |
|
|
2860
|
+
| last | Event name of the most recent pub/sub frame and its relative age (`12s ago`). |
|
|
2861
|
+
| err | Error code + message if the stream is in the error state; cleared on recovery. |
|
|
2862
|
+
|
|
2863
|
+
**Click any stream row to expand a per-stream payload preview** -- the most recent 20 envelopes, time + event name + JSON data. Toggle Pretty / Raw via the header buttons (Raw shows full JSON up to ~500 chars; Pretty truncates at ~200 with overflow indicator). Pause stops capturing new events without affecting the live `last:` timestamp; Clear events drops every stream's ring buffer in one click. Pretty/Raw + Pause states persist across reloads via `localStorage`.
|
|
2864
|
+
|
|
2865
|
+
**Privacy.** Captured payloads are walked once at write time with key-based redaction. The default redact list covers `password`, `token`, `apiKey` / `api_key`, `secret`, `authorization`, `cookie`, `sessionid` / `session_id`, `csrf` / `csrftoken`. Override or extend at runtime:
|
|
2866
|
+
|
|
2867
|
+
```js
|
|
2868
|
+
import { __devtools } from 'svelte-realtime/client';
|
|
2869
|
+
if (__devtools) {
|
|
2870
|
+
__devtools.redactKeys.add('paymentMethod');
|
|
2871
|
+
__devtools.redactKeys.add('ssn');
|
|
2872
|
+
}
|
|
2873
|
+
```
|
|
2874
|
+
|
|
2875
|
+
Match is case-insensitive and exact-key (no substring fuzz). Redacted values render as `'[REDACTED]'`. Recursion is capped at depth 5 (deeper structures show `'[depth-cap]'`) and arrays at 50 items so a malformed-large payload doesn't pin a graph in memory.
|
|
2876
|
+
|
|
2877
|
+
The RPC tab shows pending calls (with elapsed time) and a 50-entry ring buffer of recent results (ok/err, duration). The Connection tab summarizes the same counters.
|
|
2878
|
+
|
|
2008
2879
|
The overlay is stripped from production builds. Disable it in dev with:
|
|
2009
2880
|
|
|
2010
2881
|
```js
|
|
@@ -2075,6 +2946,82 @@ describe('chat module', () => {
|
|
|
2075
2946
|
| `events` | All pub/sub events received |
|
|
2076
2947
|
| `hasMore` | Whether more pages are available |
|
|
2077
2948
|
| `waitFor(predicate, timeout?)` | Wait for a value matching a predicate |
|
|
2949
|
+
| `simulatePublish(event, data)` | Publish a server-side event to this stream's topic. Equivalent to `env.platform.publish(stream.topic, event, data)`, but discoverable on the stream return where the test is already focused. Throws if the topic is not yet known (await the initial subscribe first). |
|
|
2950
|
+
|
|
2951
|
+
### Chaos harness
|
|
2952
|
+
|
|
2953
|
+
`createTestEnv({ chaos: { dropRate, seed } })` enables fault injection on `platform.publish` so tests can verify resilience to message drops without spinning a real cluster.
|
|
2954
|
+
|
|
2955
|
+
```js
|
|
2956
|
+
import { createTestEnv } from 'svelte-realtime/test';
|
|
2957
|
+
|
|
2958
|
+
// 50% drop rate, deterministic via seed
|
|
2959
|
+
const env = createTestEnv({
|
|
2960
|
+
chaos: { dropRate: 0.5, seed: 'rep-1234' }
|
|
2961
|
+
});
|
|
2962
|
+
|
|
2963
|
+
env.register('chat', chat);
|
|
2964
|
+
const client = env.connect({ id: 'u1' });
|
|
2965
|
+
const stream = client.subscribe('chat/messages');
|
|
2966
|
+
|
|
2967
|
+
// Publish 100 events; ~50 of them get dropped.
|
|
2968
|
+
for (let i = 0; i < 100; i++) {
|
|
2969
|
+
env.platform.publish('chat-messages', 'created', { id: i });
|
|
2970
|
+
}
|
|
2971
|
+
// Same seed -> same drop sequence across runs, so failures replay.
|
|
2972
|
+
```
|
|
2973
|
+
|
|
2974
|
+
Runtime control via `env.chaos`:
|
|
2975
|
+
|
|
2976
|
+
| Method/property | Description |
|
|
2977
|
+
|---|---|
|
|
2978
|
+
| `env.chaos.set({ dropRate?, seed? })` | Apply (or replace) chaos config mid-test. |
|
|
2979
|
+
| `env.chaos.disable()` | Equivalent to `set(null)`. |
|
|
2980
|
+
| `env.chaos.config` | Current `{ dropRate, seed }` or `null`. |
|
|
2981
|
+
| `env.chaos.dropped` | Running count of `platform.publish` drops. |
|
|
2982
|
+
| `env.chaos.resetCounter()` | Zero the counter without changing config. |
|
|
2983
|
+
|
|
2984
|
+
Currently models the `drop-outbound` scenario only -- `platform.publish` events to subscribers are dropped at the platform layer. RPC replies (`platform.send`) are exempt because timing them out would just hang test code; the chaos harness is for testing pub/sub resilience, not RPC retry behavior.
|
|
2985
|
+
|
|
2986
|
+
### Direct ctx unit tests
|
|
2987
|
+
|
|
2988
|
+
`createTestContext({ user })` builds a `ctx`-shaped object suitable for direct unit tests of guards and predicates -- helper methods are no-ops, the user / cursor / requestId can be overridden. Use this when the function under test takes `ctx` and synchronously returns a value; reach for `createTestEnv()` only when you need full publish/subscribe round-trips.
|
|
2989
|
+
|
|
2990
|
+
```js
|
|
2991
|
+
import { createTestContext } from 'svelte-realtime/test';
|
|
2992
|
+
|
|
2993
|
+
const adminOnly = (ctx) => ctx.user?.role === 'admin';
|
|
2994
|
+
|
|
2995
|
+
expect(adminOnly(createTestContext({ user: { role: 'admin' } }))).toBe(true);
|
|
2996
|
+
expect(adminOnly(createTestContext({ user: { role: 'viewer' } }))).toBe(false);
|
|
2997
|
+
expect(adminOnly(createTestContext())).toBe(false);
|
|
2998
|
+
```
|
|
2999
|
+
|
|
3000
|
+
The returned shape mirrors the production `_buildCtx`: `user`, `ws`, `platform`, `publish`, `cursor`, `throttle`, `debounce`, `signal`, `batch`, `shed`, `requestId`. Helpers default to no-op stubs (`publish` returns `true`, `shed` returns `false`, etc.), which is correct for predicates that only read `ctx.user` or `ctx.cursor`.
|
|
3001
|
+
|
|
3002
|
+
### Asserting guard rejections
|
|
3003
|
+
|
|
3004
|
+
`expectGuardRejects(promise, expectedCode?)` is a small ergonomic wrapper for the common "this call should be denied" pattern. It awaits the promise, asserts it rejected with a `LiveError` of the expected code (default `'FORBIDDEN'`), and returns the error so further assertions can run on it.
|
|
3005
|
+
|
|
3006
|
+
```js
|
|
3007
|
+
import { createTestEnv, expectGuardRejects } from 'svelte-realtime/test';
|
|
3008
|
+
|
|
3009
|
+
const env = createTestEnv();
|
|
3010
|
+
env.register('admin', adminModule);
|
|
3011
|
+
|
|
3012
|
+
const user = env.connect({ role: 'viewer' });
|
|
3013
|
+
|
|
3014
|
+
// Guards that throw FORBIDDEN are the default case
|
|
3015
|
+
await expectGuardRejects(user.call('admin/destroyAll'));
|
|
3016
|
+
|
|
3017
|
+
// Anonymous calls typically reject with UNAUTHENTICATED
|
|
3018
|
+
const anon = env.connect(null);
|
|
3019
|
+
await expectGuardRejects(anon.call('admin/destroyAll'), 'UNAUTHENTICATED');
|
|
3020
|
+
|
|
3021
|
+
// The rejected error is returned for further assertions
|
|
3022
|
+
const err = await expectGuardRejects(user.call('admin/destroyAll'));
|
|
3023
|
+
expect(err.message).toMatch(/admin role/);
|
|
3024
|
+
```
|
|
2078
3025
|
|
|
2079
3026
|
---
|
|
2080
3027
|
|
|
@@ -2097,6 +3044,7 @@ Import from `svelte-realtime/server`.
|
|
|
2097
3044
|
| `live.webhook(topic, config)` | HTTP webhook-to-stream bridge |
|
|
2098
3045
|
| `live.gate(predicate, fn)` | Conditional stream activation |
|
|
2099
3046
|
| `live.rateLimit(config, fn)` | Per-function sliding window rate limiter |
|
|
3047
|
+
| `live.rateLimits(config)` | Registry-level rate limits with `default` / `overrides` / `exempt` |
|
|
2100
3048
|
| `live.middleware(fn)` | Global middleware (runs before guards) |
|
|
2101
3049
|
| `live.access.*` | Subscribe-time access control helpers |
|
|
2102
3050
|
| `guard(...fns)` | Per-module auth middleware |
|