svelte-adapter-uws-extensions 0.5.10 → 0.5.11
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/MIGRATION.md +396 -396
- package/README.md +3378 -3329
- package/package.json +187 -186
- package/postgres/_tasks-errors.js +76 -76
- package/postgres/_tasks-sql.js +329 -329
- package/postgres/_tasks-worker-pool.js +206 -206
- package/postgres/_worker-harness.js +96 -96
- package/postgres/idempotency.d.ts +54 -48
- package/postgres/idempotency.js +302 -302
- package/postgres/index.d.ts +47 -46
- package/postgres/index.js +143 -141
- package/postgres/jobs.d.ts +99 -99
- package/postgres/jobs.js +292 -292
- package/postgres/notify.d.ts +90 -90
- package/postgres/notify.js +384 -384
- package/postgres/replay.d.ts +161 -161
- package/postgres/replay.js +445 -445
- package/postgres/tasks.d.ts +332 -329
- package/postgres/tasks.js +836 -843
- package/prometheus/index.d.ts +141 -141
- package/prometheus/index.js +877 -877
- package/redis/cursor.d.ts +209 -209
- package/redis/cursor.js +22 -15
- package/redis/fence.d.ts +31 -31
- package/redis/fence.js +142 -142
- package/redis/functions.d.ts +50 -50
- package/redis/functions.js +136 -136
- package/redis/groups.d.ts +89 -89
- package/redis/groups.js +549 -549
- package/redis/idempotency.d.ts +65 -65
- package/redis/idempotency.js +219 -219
- package/redis/index.d.ts +1 -1
- package/redis/index.js +120 -118
- package/redis/leader.d.ts +72 -72
- package/redis/leader.js +228 -228
- package/redis/lock.d.ts +123 -123
- package/redis/lock.js +273 -273
- package/redis/presence.d.ts +139 -139
- package/redis/presence.js +1474 -1472
- package/redis/publish-rate.d.ts +116 -116
- package/redis/publish-rate.js +345 -345
- package/redis/pubsub.d.ts +102 -102
- package/redis/pubsub.js +469 -427
- package/redis/ratelimit.js +255 -255
- package/redis/registry.d.ts +220 -220
- package/redis/registry.js +1133 -1133
- package/redis/replay-stream.js +430 -430
- package/redis/replay.d.ts +249 -249
- package/redis/replay.js +347 -347
- package/redis/session.d.ts +83 -83
- package/redis/session.js +202 -202
- package/redis/sharded-pubsub.d.ts +157 -157
- package/redis/sharded-pubsub.js +644 -644
- package/shared/admission.d.ts +87 -87
- package/shared/admission.js +169 -169
- package/shared/assert.d.ts +41 -41
- package/shared/assert.js +150 -150
- package/shared/breaker.d.ts +50 -50
- package/shared/breaker.js +182 -182
- package/shared/bus-validate.d.ts +105 -105
- package/shared/bus-validate.js +169 -169
- package/shared/caps.d.ts +27 -27
- package/shared/caps.js +110 -110
- package/shared/errors.js +97 -97
- package/shared/lease-scripts.js +50 -50
- package/shared/pg-migrate.js +95 -95
- package/shared/pg-tx.js +50 -50
- package/shared/redis-scan.js +55 -55
- package/shared/redis-version.js +15 -15
- package/shared/replay-gate.js +25 -25
- package/shared/replay-helpers.js +146 -146
- package/shared/scripts.js +136 -136
- package/shared/sensitive.d.ts +86 -86
- package/shared/sensitive.js +188 -188
- package/shared/time.js +74 -74
- package/testing/index.d.ts +157 -157
- package/testing/index.js +35 -35
- package/testing/mock-pg.js +835 -835
- package/testing/mock-platform.js +159 -159
- package/testing/mock-redis.js +1239 -1239
package/MIGRATION.md
CHANGED
|
@@ -1,396 +1,396 @@
|
|
|
1
|
-
# Migration guide: svelte-adapter-uws-extensions 0.4.x to 0.5.x
|
|
2
|
-
|
|
3
|
-
This guide is organized by **tier**. Most apps only need to read the first two sections.
|
|
4
|
-
|
|
5
|
-
- **[Critical](#critical-read-first)** - security-class behavior changes; audit required.
|
|
6
|
-
- **[Required source changes](#required-source-changes)** - won't run cleanly without these.
|
|
7
|
-
- **[Notable defaults and behaviors](#notable-defaults-and-behaviors)** - probably fine, but you may notice.
|
|
8
|
-
- **[Cosmetic](#cosmetic)** - type-only, internal refactors, niche edge cases.
|
|
9
|
-
|
|
10
|
-
`svelte-adapter-uws-extensions` 0.5 raises its peerDep on `svelte-adapter-uws` to `^0.5.0`. See the adapter's MIGRATION.md for breaking changes on that side.
|
|
11
|
-
|
|
12
|
-
If you have a small app and want the 5-minute version, see the [docs site upgrade quickstart](https://svelte-realtime.dev/docs/upgrade-quickstart).
|
|
13
|
-
|
|
14
|
-
---
|
|
15
|
-
|
|
16
|
-
## Critical (read first)
|
|
17
|
-
|
|
18
|
-
These close real security bugs in idiomatic 0.4 code paths.
|
|
19
|
-
|
|
20
|
-
### `createPresence` requires Redis 7.4+ (was 7.0+)
|
|
21
|
-
|
|
22
|
-
**What changed.** `createPresence` was rewritten to use Redis 7.4+'s per-field hash TTL primitive (`HEXPIRE` family) so a single LEAVE_SCRIPT call is now O(1) Redis-blocked Lua time rather than O(M) where M is the topic's total presence-field count. Mass-disconnect of N users on instance restart is now O(N) rather than O(N x M); the previous quadratic behavior was reachable at realistic auctions/chat scale (10k users x 10 instances) and could block Redis for seconds during a server restart. The new code probes `INFO server` on first use and throws on older Redis with the message `redis presence: requires Redis 7.4+ for per-field TTL (HEXPIRE); got X.Y.Z`.
|
|
23
|
-
|
|
24
|
-
**Storage layout.** The pre-fix per-topic hash was `presence:{topic}` with fields keyed by `{instanceId}|{userKey}`. The new layout splits storage:
|
|
25
|
-
|
|
26
|
-
- `presence:topic:{topic}` HASH, field=`userKey`, value=JSON `{data, ts}`. One field per unique user on the topic. Backs `list()` / `count()`.
|
|
27
|
-
- `presence:user:{topic}:{userKey}` HASH, field=`instanceId`, value=`ts`. One field per instance currently presenting this user. Backs the leave HLEN check.
|
|
28
|
-
|
|
29
|
-
Both hashes have per-field TTLs via `HPEXPIRE`. The pre-fix whole-key EXPIRE is gone; keys implicitly disappear when their last field expires.
|
|
30
|
-
|
|
31
|
-
**How to migrate.**
|
|
32
|
-
|
|
33
|
-
1. **Upgrade Redis to 7.4 or newer.** Every major managed Redis (AWS ElastiCache, Upstash, Redis Cloud, MemoryDB) supports it. Self-hosters bump the image tag (`redis:7.4-alpine` is the canonical pin).
|
|
34
|
-
|
|
35
|
-
2. **Rolling deploy.** Old (0.4) and new (0.5) instances coexist by writing to disjoint key namespaces. Old keys at `presence:{topic}` are ignored by the new code and expire naturally as the old instances drain. During the rollout window, `list()` / `count()` from new instances miss old-instance presence; convergence completes within one TTL of full rollout. If your TTL is the default 90 seconds, plan a ~2-minute rollout completion before relying on accurate presence counts.
|
|
36
|
-
|
|
37
|
-
3. **`metrics().staleCleanedTotal` is now always 0.** Per-field staleness is enforced atomically by Redis HPEXPIRE rather than by an application-side cleanup script, so there is nothing to count. The field stays in the return shape for backward-compatibility. The corresponding Prometheus counter `presence_stale_cleaned_total` is no longer registered.
|
|
38
|
-
|
|
39
|
-
4. **`keyspaceNotifications: true` scope is narrower.** Pre-fix, the option subscribed to whole-key `__keyevent@*__:expired` for `presence:{topic}` keys and emitted an empty `state` when one fired. Same shape in 0.5 (the per-topic hash whole-key expiry only fires when every field has expired, i.e. no live instances). Per-field expiry (a single crashed instance) does NOT trigger this notification; users disappear from `list()` / `count()` results lazily. If you need explicit per-field-expiry events, subscribe to `__keyevent@*__:hexpired` separately (separate Redis CONFIG flag).
|
|
40
|
-
|
|
41
|
-
5. **Falling back to single-instance.** If you can't upgrade Redis, switch to the in-memory `createPresence` plugin from `svelte-adapter-uws/plugins/presence`. Public API is the same; you lose cross-instance presence which is the whole point of the Redis variant, but for single-instance deployments it's a clean drop-in.
|
|
42
|
-
|
|
43
|
-
If your app uses `createPresence` and you cannot upgrade Redis immediately, pin `svelte-adapter-uws-extensions` to the last 0.5.x release before this change.
|
|
44
|
-
|
|
45
|
-
### Replay backends consult `platform.checkSubscribe` before reading history
|
|
46
|
-
|
|
47
|
-
**What changed.** Every `replay()` implementation (Redis sorted-set, Redis streams, Postgres) now calls `platform.checkSubscribe(ws, topic)` first. On denial, it sends a single `{event:'denied', data:{code:<denial>, reqId}}` frame on `__replay:{topic}` and returns without reading the buffer. Pre-fix, an attacker could send a crafted `lastSeenSeqs` map and read history for a topic they could not subscribe to live.
|
|
48
|
-
|
|
49
|
-
**How to migrate.** Client-side replay stores must handle the new `denied` event in addition to existing `truncated` / `end` events. Treat it similarly to `truncated`:
|
|
50
|
-
|
|
51
|
-
```js
|
|
52
|
-
function onReplayFrame(event, data) {
|
|
53
|
-
if (event === 'denied') {
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
Older adapters lacking `checkSubscribe` degrade gracefully to the previous behavior.
|
|
60
|
-
|
|
61
|
-
### Tasks `idempotencyKey` is now namespaced by task name, with a 256-char cap
|
|
62
|
-
|
|
63
|
-
**In-flight cache entries become invisible after deploy.**
|
|
64
|
-
|
|
65
|
-
**What changed.** The cache key passed to `idempotency.acquire(...)` for `tasks.run` and `tasks.enqueue` is now `'task:' + name + ':' + idempotencyKey` (was the bare `idempotencyKey`). Pre-fix, two tasks sharing one client-supplied key shared one cache slot, which let a privileged task's cached result leak to a public task. Both methods also reject `idempotencyKey` longer than 256 characters; the underlying stores cap `acquire(key)` at 1024 chars as defense in depth.
|
|
66
|
-
|
|
67
|
-
**How to migrate.** No source change at call sites. In-flight cached entries from before the upgrade become invisible after deploy because the namespaced key does not match the old un-namespaced key. For Redis stores the old keys TTL out; for Postgres the cleanup interval handles them. Audit any code that supplies long `idempotencyKey` strings (request bodies, opaque tokens) and shorten them if they exceed 256 chars.
|
|
68
|
-
|
|
69
|
-
### Inbound bus envelopes are validated before republish
|
|
70
|
-
|
|
71
|
-
**What changed.** Every bus subscriber (`createPubSubBus`, `createShardedBus`, `createNotifyBridge`, `createCursor`) now gates inbound envelopes on raw byte size, topic shape, event shape, and a `__`-prefix denylist before republishing. Default `maxEnvelopeBytes` is 1 MB; default `allowSystemTopics` is `false` (secure-by-default - the wire-level `__`-subscribe gate alone does not cover bus-republish). The bus's own configured `systemChannel` (default `__realtime`) remains in an explicit allowlist so degraded / recovered events still flow.
|
|
72
|
-
|
|
73
|
-
**How to migrate.** No code change required for default deployments. If your application publishes envelopes larger than 1 MB on the bus, raise `maxEnvelopeBytes` explicitly:
|
|
74
|
-
|
|
75
|
-
```js
|
|
76
|
-
createPubSubBus(redis, { maxEnvelopeBytes: 4 * 1024 * 1024 });
|
|
77
|
-
createShardedBus(redis, { maxEnvelopeBytes: 4 * 1024 * 1024 });
|
|
78
|
-
createNotifyBridge(pg, { maxEnvelopeBytes: 4 * 1024 * 1024 });
|
|
79
|
-
createCursor(redis, { maxEnvelopeBytes: 4 * 1024 * 1024 });
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
If your app legitimately bus-relays user-defined `__`-prefixed topics (uncommon), opt back in:
|
|
83
|
-
|
|
84
|
-
```js
|
|
85
|
-
createPubSubBus(redis, { allowSystemTopics: true });
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
Topics longer than 256 chars or containing control characters are also rejected; review any code that constructs topic names dynamically.
|
|
89
|
-
|
|
90
|
-
---
|
|
91
|
-
|
|
92
|
-
## Required source changes
|
|
93
|
-
|
|
94
|
-
These won't run cleanly until you make the change.
|
|
95
|
-
|
|
96
|
-
### Runtime: Node.js 22+ required (was Node 20+)
|
|
97
|
-
|
|
98
|
-
**What changed.** `package.json#engines.node` moved from `>=20.0.0` to `>=22.0.0`. Tracks the adapter's bump, which in turn tracks `uWebSockets.js` v20.67.0 dropping Node 20 support upstream.
|
|
99
|
-
|
|
100
|
-
**How to migrate.** See `svelte-adapter-uws/MIGRATION.md` for the full uWS-side rationale. No extensions-specific action beyond bumping your runtime to Node 22+.
|
|
101
|
-
|
|
102
|
-
### Peer dependency on `svelte-adapter-uws` raised to `^0.5.0`
|
|
103
|
-
|
|
104
|
-
**What changed.** The peerDep range moved from `>=0.4.0` (0.4.x) to `^0.5.0`. `bus.wrap()` now binds adapter members (`maxPayloadLength`, `bufferedAmount`, `subscribe`, `unsubscribe`, `checkSubscribe`, `sendCoalesced`, `request`, `requestId`, `pressure`, `onPressure`, `onPublishRate`, `publishBatched`) unconditionally; an older adapter without these members crashes at wrap-construction time.
|
|
105
|
-
|
|
106
|
-
**How to migrate.** Upgrade the adapter first, then this package:
|
|
107
|
-
|
|
108
|
-
```sh
|
|
109
|
-
npm install svelte-adapter-uws@latest svelte-adapter-uws-extensions@latest
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
See the adapter's MIGRATION.md for adapter-side breaking changes.
|
|
113
|
-
|
|
114
|
-
### Postgres table prefix changed from `ws_` to `svti_`
|
|
115
|
-
|
|
116
|
-
**What changed.** All Postgres factories (`createReplay`, `createIdempotencyStore`, `createJobQueue`, `createTaskRunner`) now default to `svti_*` table names: `svti_replay`, `svti_replay_seq`, `svti_idempotency`, `svti_jobs`, `svti_tasks`. Primary key columns follow the `[tablename]_id` rule (`svti_replay_id`, `svti_jobs_id`, `svti_tasks_id`, `svti_idempotency_key`). The replay timestamp column was renamed `created_date` to `created_at`. JS-level field names on row objects are preserved via SQL `AS` aliases (`job.id`, `task.id`, `task.idempotency_key` are unchanged).
|
|
117
|
-
|
|
118
|
-
**How to migrate.** Pick one of:
|
|
119
|
-
|
|
120
|
-
Option A (zero-downtime, no SQL): override the `table` option on each module to keep the old names.
|
|
121
|
-
|
|
122
|
-
```js
|
|
123
|
-
createReplay(pg, { table: 'ws_replay' });
|
|
124
|
-
createIdempotencyStore(pg, { table: 'ws_idempotency' });
|
|
125
|
-
createJobQueue(pg, { table: 'ws_jobs' });
|
|
126
|
-
createTaskRunner(pg, { table: 'ws_tasks' });
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
Option B (rename in place):
|
|
130
|
-
|
|
131
|
-
```sql
|
|
132
|
-
ALTER TABLE ws_replay RENAME TO svti_replay;
|
|
133
|
-
ALTER TABLE ws_replay RENAME COLUMN id TO svti_replay_id;
|
|
134
|
-
ALTER TABLE ws_replay RENAME COLUMN created_date TO created_at;
|
|
135
|
-
ALTER TABLE ws_replay_seq RENAME TO svti_replay_seq;
|
|
136
|
-
ALTER TABLE ws_idempotency RENAME TO svti_idempotency;
|
|
137
|
-
ALTER TABLE svti_idempotency RENAME COLUMN key TO svti_idempotency_key;
|
|
138
|
-
ALTER TABLE ws_jobs RENAME TO svti_jobs;
|
|
139
|
-
ALTER TABLE svti_jobs RENAME COLUMN id TO svti_jobs_id;
|
|
140
|
-
ALTER TABLE ws_tasks RENAME TO svti_tasks;
|
|
141
|
-
ALTER TABLE svti_tasks RENAME COLUMN task_id TO svti_tasks_id;
|
|
142
|
-
ALTER TABLE svti_tasks RENAME COLUMN idempotency_key TO svti_idempotency_key;
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
### Presence `hooks` now includes `unsubscribe`
|
|
146
|
-
|
|
147
|
-
**What changed.** `presence.hooks` exports a `unsubscribe` hook in addition to `subscribe` and `close`. Required for correct single-topic leave when a client unsubscribes from a topic without disconnecting.
|
|
148
|
-
|
|
149
|
-
**How to migrate.** Update destructuring:
|
|
150
|
-
|
|
151
|
-
```js
|
|
152
|
-
// before
|
|
153
|
-
export const { subscribe, close } = presence.hooks;
|
|
154
|
-
|
|
155
|
-
// after
|
|
156
|
-
export const { subscribe, unsubscribe, close } = presence.hooks;
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
### Sharded pub/sub bus and Redis Functions wrapper require Redis 7+
|
|
160
|
-
|
|
161
|
-
**What changed.** `createShardedBus` (uses SPUBLISH/SSUBSCRIBE) and `createFunctionLibrary` (uses Redis Functions) run `INFO server` on activate and throw on Redis < 7. No EVALSHA or PUBLISH fallback is provided.
|
|
162
|
-
|
|
163
|
-
**How to migrate.** On Redis 6 or older Valkey, use `createPubSubBus` instead of `createShardedBus` and use `redis.eval` directly instead of `createFunctionLibrary`. Upgrade Redis to 7+ to use the new modules.
|
|
164
|
-
|
|
165
|
-
### Pub/sub bus `systemChannel` delivery requires `bus.hooks.open`
|
|
166
|
-
|
|
167
|
-
**What changed.** `svelte-adapter-uws@0.5.0-next.21` closed the wire-level path that put connections into `__`-prefixed subscriber sets. Pre-next.21, the `svelte-realtime` client store wire-subscribed to `__realtime` and the server-side subscriber set was populated as a side effect; the pub/sub bus's auto-emitted `degraded` / `recovered` events reached every connected client through that membership. After the wire-gate change, nothing populated the subscriber set on the server, so the bus published into an empty set and clients never saw the events - any `{#if $health === 'degraded'}` banner was silently dead. `bus.hooks.open` (new in this release) puts every connection into `systemChannel`'s subscriber set via the platform-trust path (`platform.subscribe`), which intentionally bypasses the wire-level gate.
|
|
168
|
-
|
|
169
|
-
**How to migrate.** If you use `createPubSubBus` with a `breaker` and follow the README's degradation-banner pattern, swap your open hook from:
|
|
170
|
-
|
|
171
|
-
```js
|
|
172
|
-
// before
|
|
173
|
-
export function open(ws, { platform }) {
|
|
174
|
-
bus.activate(platform);
|
|
175
|
-
}
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
to:
|
|
179
|
-
|
|
180
|
-
```js
|
|
181
|
-
// after
|
|
182
|
-
export const { open } = bus.hooks;
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
or, if your open hook does additional work:
|
|
186
|
-
|
|
187
|
-
```js
|
|
188
|
-
export async function open(ws, ctx) {
|
|
189
|
-
await bus.hooks.open(ws, ctx);
|
|
190
|
-
// ... other open-hook work
|
|
191
|
-
}
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
`bus.activate(platform)` still works for the subscriber-half; the difference is that `bus.hooks.open` ALSO subscribes the connection to `systemChannel`. If you set `systemChannel: null` or `false`, no migration is required - `bus.hooks.open` still activates the subscriber and skips the per-WS subscribe. Bump the `svelte-adapter-uws` peer minimum to `>=0.5.0-next.23` to also clear the cosmetic `[ws] subscribe denied topic=__realtime` console warn on every page mount; the `bus.hooks.open` fix itself works against any 0.5 adapter.
|
|
195
|
-
|
|
196
|
-
### Cursor delivery requires `tracker.attach(ws, topic, platform)`
|
|
197
|
-
|
|
198
|
-
**What changed.** Same regression class as the pubsub `systemChannel` issue above. `svelte-adapter-uws@0.5.0-next.21` closed the wire-level path that put connections into `__`-prefixed subscriber sets; pre-next.21, the client store's wire subscribe to `__cursor:{topic}` populated the subscriber set as a side effect. After next.21, the wire frame is denied; after next.23, the client does not send it. The cursor extension publishes to `__cursor:{topic}` from `update` / the bulk-flush tick / `remove`, but nothing else subscribed connections to that channel server-side, so every cursor frame fanned out into an empty subscriber set and cross-tab / cross-user cursors were silently dropped. `tracker.attach(ws, topic, platform)` (new in this release) owns membership via the platform-trust path (`platform.subscribe`), which intentionally bypasses the wire-level gate, and folds the snapshot send into the same call.
|
|
199
|
-
|
|
200
|
-
**How to migrate.** Call `cursors.attach(ws, topic, platform)` from your "join room" RPC (the same place you call `presence.join`), and `cursors.detach` from your "leave room" RPC if the user stays connected:
|
|
201
|
-
|
|
202
|
-
```js
|
|
203
|
-
// before - publishes went into an empty subscriber set on adapter next.21+
|
|
204
|
-
export async function joinBoard(ws, { topic, platform }) {
|
|
205
|
-
await presence.join(ws, topic, platform);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// after
|
|
209
|
-
export async function joinBoard(ws, { topic, platform }) {
|
|
210
|
-
await presence.join(ws, topic, platform);
|
|
211
|
-
await cursors.attach(ws, topic, platform);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export function leaveBoard(ws, { topic, platform }) {
|
|
215
|
-
presence.leave(ws, topic, platform);
|
|
216
|
-
cursors.detach(ws, topic, platform);
|
|
217
|
-
}
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
No `close`-hook change required: uWS releases all per-`ws` subscriptions on disconnect, so `cursors.remove(ws, platform)` in your `close` hook still handles cleanup. The legacy `cursors.hooks.subscribe` slot is now a no-op (the adapter's wire gate prevents it from ever firing); it stays exported for source-compat but new code should not rely on it.
|
|
221
|
-
|
|
222
|
-
### Presence wire shape on `__presence:{topic}` migrated to `state` / `diff`
|
|
223
|
-
|
|
224
|
-
**Only impacts apps with hand-rolled WebSocket clients consuming the wire directly.**
|
|
225
|
-
|
|
226
|
-
**What changed.** The `redis/presence` channel previously emitted `list` / `join` / `leave` / `updated` / `heartbeat` events. It now emits:
|
|
227
|
-
|
|
228
|
-
- `state` (sent once on subscribe to a single connection): payload is `{[userKey]: data}` - a flat snapshot.
|
|
229
|
-
- `diff` (broadcast to topic subscribers, microtask-batched): payload is `{joins: {[key]: data}, leaves: {[key]: data}}`. Same-tick joins+leaves on the same key collapse to the latest op; updates appear as a `joins` entry with the new data.
|
|
230
|
-
- `heartbeat` is unchanged.
|
|
231
|
-
|
|
232
|
-
The public JS API on `createPresence` is unchanged (`join`, `leave`, `sync`, `list`, `count`, `metrics`, `clear`, `destroy`, `hooks` keep their signatures). The cross-instance Redis pub/sub envelope on `presence:events:{topic}` is also unchanged. The `keyspaceNotifications: true` mode now emits an empty `state` event (was an empty `list` event) on hash expiry.
|
|
233
|
-
|
|
234
|
-
**How to migrate.** Apps with a custom WebSocket client decoding presence frames must swap their decoder from the four legacy event names to `state` / `diff`:
|
|
235
|
-
|
|
236
|
-
```js
|
|
237
|
-
// before
|
|
238
|
-
case 'list': setAll(payload); break;
|
|
239
|
-
case 'join': add(payload.key, payload.data); break;
|
|
240
|
-
case 'leave': remove(payload.key); break;
|
|
241
|
-
case 'updated': set(payload.key, payload.data); break;
|
|
242
|
-
|
|
243
|
-
// after
|
|
244
|
-
case 'state':
|
|
245
|
-
state.clear();
|
|
246
|
-
for (const k of Object.keys(payload)) state.set(k, payload[k]);
|
|
247
|
-
break;
|
|
248
|
-
case 'diff':
|
|
249
|
-
for (const k of Object.keys(payload.joins)) state.set(k, payload.joins[k]);
|
|
250
|
-
for (const k of Object.keys(payload.leaves)) state.delete(k);
|
|
251
|
-
break;
|
|
252
|
-
```
|
|
253
|
-
|
|
254
|
-
Apps using `svelte-realtime` get the new shape automatically. Apps using only the adapter's bundled in-memory `createPresence` plugin are unaffected; that plugin already used the new shape.
|
|
255
|
-
|
|
256
|
-
---
|
|
257
|
-
|
|
258
|
-
## Notable defaults and behaviors
|
|
259
|
-
|
|
260
|
-
These change observable runtime behavior. Most apps are unaffected; a few will notice.
|
|
261
|
-
|
|
262
|
-
### Postgres factories reject reserved-namespace table names
|
|
263
|
-
|
|
264
|
-
**What changed.** The `idempotency`, `jobs`, `replay`, and `tasks` factories now reject `table` values whose lowercase form starts with `pg_` or `information_schema` with an explicit "reserved Postgres schema" error. Pre-fix, these names passed the identifier-shape regex and could clash with Postgres internals.
|
|
265
|
-
|
|
266
|
-
**How to migrate.** Rename any custom `table` value that matches `pg_*` or `information_schema*` (extremely uncommon in practice). Standard prefixes are unaffected.
|
|
267
|
-
|
|
268
|
-
### Redis `ConnectionError` no longer leaks the URL password
|
|
269
|
-
|
|
270
|
-
**What changed.** When `new Redis(url, ...)` throws (DNS failure, malformed URL, bad TLS), the resulting `ConnectionError` message previously included the raw connection string with embedded password. The redis client factory now redacts the password segment to `***` via the new `shared/sensitive.js#redactConnectionUrl(url)` helper before interpolating into the error.
|
|
271
|
-
|
|
272
|
-
**How to migrate.** No source change. Error-tracker hooks and log pipelines that searched for the raw connection string must update their queries; the redacted form is `redis://:***@host:port`. Other URL forms (no userinfo, no password, query string with `@`, path-segment colons) pass through untouched.
|
|
273
|
-
|
|
274
|
-
### `replay()` end event data changed from `null` to `{ reqId }`
|
|
275
|
-
|
|
276
|
-
**What changed.** The end marker on `__replay:{topic}` now sends `{ reqId: undefined }` (or `{ reqId: 'some-id' }` when a correlation ID is passed) instead of `null`. The signature also accepts an optional `reqId` parameter: `replay(ws, topic, sinceSeq, platform, reqId?)`. A new `truncated` event is sent before replay messages when the buffer has been trimmed past the client's `sinceSeq`. (This change shipped in 0.4.0 but is repeated here because client-side decoders are commonly out of date.)
|
|
277
|
-
|
|
278
|
-
**How to migrate.** Update any client-side end-marker check from `data === null` to an object check. Handle or ignore the new `truncated` event in your replay store.
|
|
279
|
-
|
|
280
|
-
### `replay.publish()` storage failures throw `ReplayStorageError`
|
|
281
|
-
|
|
282
|
-
**What changed.** Storage failures in `publish()` (and `publishIdempotent` on the stream backend) now throw `ReplayStorageError` with the original error preserved on `.cause`. Pre-fix the underlying error propagated raw. Affects `redis/replay`, `redis/replay-stream`, and `postgres/replay`.
|
|
283
|
-
|
|
284
|
-
**How to migrate.** Any consumer that catches a specific underlying class (ioredis errors, `CircuitBrokenError`) must either catch `ReplayStorageError` and inspect `.cause`:
|
|
285
|
-
|
|
286
|
-
```js
|
|
287
|
-
try {
|
|
288
|
-
await replay.publish(topic, event, data);
|
|
289
|
-
} catch (err) {
|
|
290
|
-
if (err instanceof ReplayStorageError) {
|
|
291
|
-
const root = err.cause;
|
|
292
|
-
}
|
|
293
|
-
throw err;
|
|
294
|
-
}
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
Or set `localFanoutOnStorageFailure: true` on the factory to opt into a best-effort `platform.publish` fallback (pure live frames; durability is sacrificed):
|
|
298
|
-
|
|
299
|
-
```js
|
|
300
|
-
createReplay(redis, { localFanoutOnStorageFailure: true });
|
|
301
|
-
```
|
|
302
|
-
|
|
303
|
-
`publishIdempotent` always throws `ReplayStorageError` on storage failure even when this option is set, since silent fanout would break the exactly-once contract.
|
|
304
|
-
|
|
305
|
-
### `createCursor` defaults flipped to a 60Hz world-state tick
|
|
306
|
-
|
|
307
|
-
**What changed.** `throttle` defaults to `16` (was `50`) and `topicThrottle` defaults to `16` (was `0`). Out of the box the broadcast path is now a 60Hz world-state tick: each topic emits at most one bulk frame per 16ms window carrying the latest position for every cursor that moved.
|
|
308
|
-
|
|
309
|
-
**How to migrate.** No action for apps that want the new default. To restore previous behavior:
|
|
310
|
-
|
|
311
|
-
```js
|
|
312
|
-
// previous "every update broadcasts immediately" behavior
|
|
313
|
-
createCursor(redis, { topicThrottle: 0 });
|
|
314
|
-
|
|
315
|
-
// previous 50ms per-cursor floor
|
|
316
|
-
createCursor(redis, { throttle: 50 });
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
For high-density rooms (>200 active movers) raise `topicThrottle` to 33 (30Hz).
|
|
320
|
-
|
|
321
|
-
### Pub/sub bus auto-emits `degraded` / `recovered` system events
|
|
322
|
-
|
|
323
|
-
**What changed.** When `createPubSubBus` shares a circuit breaker with the rest of the extensions, the bus subscribes to the breaker and auto-emits `degraded` / `recovered` events on a configurable system topic (default `'__realtime'`). Replaces the manually wired `breaker.onStateChange` to `distributed.publish('__system', ...)` pattern that the README previously documented.
|
|
324
|
-
|
|
325
|
-
**How to migrate.** Remove any manual wiring of breaker state to a `__system` topic if you previously followed the README pattern. To disable auto-emission, set `systemChannel: null` or `false`. To use a different topic, set `systemChannel: 'my-status-topic'`. The `onDegraded` / `onRecovered` callbacks remain available for server-side reactions regardless. **Delivery to clients also requires wiring `bus.hooks.open` in your `hooks.ws.js`** - see [Pub/sub bus `systemChannel` delivery requires `bus.hooks.open`](#pubsub-bus-systemchannel-delivery-requires-bushooksopen) under Required source changes.
|
|
326
|
-
|
|
327
|
-
### Default `select` strips `__`-prefixed and sensitive keys on presence and cursor
|
|
328
|
-
|
|
329
|
-
**What changed.** The default `select` on `createPresence` and `createCursor` was the identity function in earlier 0.3.x. Since 0.4.0 it strips keys starting with `__` (e.g. `__subscriptions`, `remoteAddress`) and keys matching `/token|secret|password|auth|session|cookie|jwt|credential/i`. Repeated here because it remains the most common upgrade trip-hazard. Presence and cursor also warn once (per-process) if userData contains keys matching the sensitive-key pattern even when not selected. Notify bridge warns when payload approaches Postgres ~8000 byte NOTIFY limit.
|
|
330
|
-
|
|
331
|
-
**How to migrate.** If you relied on any of these fields in presence or cursor data, pass an explicit `select` function:
|
|
332
|
-
|
|
333
|
-
```js
|
|
334
|
-
createPresence(redis, { select: (ud) => ({ name: ud.name, role: ud.role, token: ud.token }) });
|
|
335
|
-
```
|
|
336
|
-
|
|
337
|
-
Treat the warnings as actionable: either rename the offending field, exclude it from upstream user data, or pass a custom `select` that intentionally retains it. Warnings do not throw or alter behavior.
|
|
338
|
-
|
|
339
|
-
### New parse-error counters
|
|
340
|
-
|
|
341
|
-
**What changed.** The Redis pub/sub bus and sharded pub/sub bus previously swallowed malformed envelopes silently on receive. They now bump a `pubsub_parse_errors_total` / `sharded_pubsub_parse_errors_total` counter so a stream of bad messages becomes observable in Prometheus. Not a code break per se, but alert thresholds that assumed zero parse errors must be updated.
|
|
342
|
-
|
|
343
|
-
**How to migrate.** Add the new counters to your dashboards next to the existing `notify_parse_errors_total` on `createNotifyBridge`. No source change.
|
|
344
|
-
|
|
345
|
-
---
|
|
346
|
-
|
|
347
|
-
## Cosmetic
|
|
348
|
-
|
|
349
|
-
Type-only changes, internal refactors, niche edge cases. No action required for most apps.
|
|
350
|
-
|
|
351
|
-
### Lua scripts reject non-numeric ARGV with `redis.error_reply`
|
|
352
|
-
|
|
353
|
-
**What changed.** Defensive `tonumber(x) ~= nil` checks were added at the top of every Lua script (`shared/scripts.js` CLEANUP/COUNT/COUNT_DEDUP/LIST, `redis/groups.js` JOIN, `redis/presence.js` JOIN/LEAVE, `redis/ratelimit.js` CONSUME/BAN, `redis/replay.js` PUBLISH, `redis/replay-stream.js` IDMP_PUBLISH/PUBLISH). Pre-fix, bypassing the JS validation layer (direct `redis.eval`, poisoned hash entries) would crash the script.
|
|
354
|
-
|
|
355
|
-
**How to migrate.** No source change for code that uses the public JS APIs. Code that calls `redis.eval` against extension scripts directly must pass numeric ARGV strings (`'1700000000'`, not `'now'`). Poisoned hash entries with non-numeric `ts` fields are now treated as stale rather than crashing iteration.
|
|
356
|
-
|
|
357
|
-
### `idempotency.acquire(key)` parameter renamed to `idempotencyKey`
|
|
358
|
-
|
|
359
|
-
**What changed.** The first argument of `idempotency.acquire(...)` and `idempotency.purge(...)` is now named `idempotencyKey` instead of `key`, matching the existing `idempotencyKey` option on the task runner.
|
|
360
|
-
|
|
361
|
-
**How to migrate.** Function-argument rename, transparent at call sites that pass positionally. Code that destructures the parameter name from a wrapper signature should update accordingly.
|
|
362
|
-
|
|
363
|
-
### `redis/lock` Lua scripts moved to `shared/lease-scripts.js`
|
|
364
|
-
|
|
365
|
-
**What changed.** The lock module's `HEARTBEAT_SCRIPT` and `RELEASE_SCRIPT` are now imported from `shared/lease-scripts.js` under generic names (`LEASE_RENEW_SCRIPT`, `LEASE_RELEASE_SCRIPT`) so `redis/leader` can reuse them. Pure refactor, no behavior change.
|
|
366
|
-
|
|
367
|
-
**How to migrate.** No action required for callers using the public lock API. Internal-tooling code that imported the script constants by name from `redis/lock` must update its import path and rename:
|
|
368
|
-
|
|
369
|
-
```js
|
|
370
|
-
// before
|
|
371
|
-
import { HEARTBEAT_SCRIPT, RELEASE_SCRIPT } from 'svelte-adapter-uws-extensions/redis/lock';
|
|
372
|
-
|
|
373
|
-
// after
|
|
374
|
-
import { LEASE_RENEW_SCRIPT, LEASE_RELEASE_SCRIPT } from 'svelte-adapter-uws-extensions/shared/lease-scripts';
|
|
375
|
-
```
|
|
376
|
-
|
|
377
|
-
### `JOIN_SCRIPT` in groups returns `[status, ...liveMembers]`
|
|
378
|
-
|
|
379
|
-
**What changed.** Internal change since 0.4.0, but affects anyone calling the Lua script directly. The wire contract on the public groups API is unchanged.
|
|
380
|
-
|
|
381
|
-
**How to migrate.** If you do not call `JOIN_SCRIPT` directly via `redis.eval`, no action. Direct callers must read the array form.
|
|
382
|
-
|
|
383
|
-
---
|
|
384
|
-
|
|
385
|
-
## After upgrading
|
|
386
|
-
|
|
387
|
-
Run your test suite. Pay particular attention to:
|
|
388
|
-
|
|
389
|
-
- Custom WebSocket clients decoding `__presence:{topic}` and `__replay:{topic}` frames.
|
|
390
|
-
- Code that catches storage errors on `replay.publish()`.
|
|
391
|
-
- Postgres deployments that relied on `ws_*` table names.
|
|
392
|
-
- Redis deployments with versioned rate limiter keys (no action needed; old `ratelimit:*` keys expire on their own).
|
|
393
|
-
- Bus subscribers receiving envelopes larger than 1 MB.
|
|
394
|
-
- Tasks paths that pass long `idempotencyKey` strings (now capped at 256 chars).
|
|
395
|
-
|
|
396
|
-
Report regressions against the changelog entry the issue maps to.
|
|
1
|
+
# Migration guide: svelte-adapter-uws-extensions 0.4.x to 0.5.x
|
|
2
|
+
|
|
3
|
+
This guide is organized by **tier**. Most apps only need to read the first two sections.
|
|
4
|
+
|
|
5
|
+
- **[Critical](#critical-read-first)** - security-class behavior changes; audit required.
|
|
6
|
+
- **[Required source changes](#required-source-changes)** - won't run cleanly without these.
|
|
7
|
+
- **[Notable defaults and behaviors](#notable-defaults-and-behaviors)** - probably fine, but you may notice.
|
|
8
|
+
- **[Cosmetic](#cosmetic)** - type-only, internal refactors, niche edge cases.
|
|
9
|
+
|
|
10
|
+
`svelte-adapter-uws-extensions` 0.5 raises its peerDep on `svelte-adapter-uws` to `^0.5.0`. See the adapter's MIGRATION.md for breaking changes on that side.
|
|
11
|
+
|
|
12
|
+
If you have a small app and want the 5-minute version, see the [docs site upgrade quickstart](https://svelte-realtime.dev/docs/upgrade-quickstart).
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Critical (read first)
|
|
17
|
+
|
|
18
|
+
These close real security bugs in idiomatic 0.4 code paths.
|
|
19
|
+
|
|
20
|
+
### `createPresence` requires Redis 7.4+ (was 7.0+)
|
|
21
|
+
|
|
22
|
+
**What changed.** `createPresence` was rewritten to use Redis 7.4+'s per-field hash TTL primitive (`HEXPIRE` family) so a single LEAVE_SCRIPT call is now O(1) Redis-blocked Lua time rather than O(M) where M is the topic's total presence-field count. Mass-disconnect of N users on instance restart is now O(N) rather than O(N x M); the previous quadratic behavior was reachable at realistic auctions/chat scale (10k users x 10 instances) and could block Redis for seconds during a server restart. The new code probes `INFO server` on first use and throws on older Redis with the message `redis presence: requires Redis 7.4+ for per-field TTL (HEXPIRE); got X.Y.Z`.
|
|
23
|
+
|
|
24
|
+
**Storage layout.** The pre-fix per-topic hash was `presence:{topic}` with fields keyed by `{instanceId}|{userKey}`. The new layout splits storage:
|
|
25
|
+
|
|
26
|
+
- `presence:topic:{topic}` HASH, field=`userKey`, value=JSON `{data, ts}`. One field per unique user on the topic. Backs `list()` / `count()`.
|
|
27
|
+
- `presence:user:{topic}:{userKey}` HASH, field=`instanceId`, value=`ts`. One field per instance currently presenting this user. Backs the leave HLEN check.
|
|
28
|
+
|
|
29
|
+
Both hashes have per-field TTLs via `HPEXPIRE`. The pre-fix whole-key EXPIRE is gone; keys implicitly disappear when their last field expires.
|
|
30
|
+
|
|
31
|
+
**How to migrate.**
|
|
32
|
+
|
|
33
|
+
1. **Upgrade Redis to 7.4 or newer.** Every major managed Redis (AWS ElastiCache, Upstash, Redis Cloud, MemoryDB) supports it. Self-hosters bump the image tag (`redis:7.4-alpine` is the canonical pin).
|
|
34
|
+
|
|
35
|
+
2. **Rolling deploy.** Old (0.4) and new (0.5) instances coexist by writing to disjoint key namespaces. Old keys at `presence:{topic}` are ignored by the new code and expire naturally as the old instances drain. During the rollout window, `list()` / `count()` from new instances miss old-instance presence; convergence completes within one TTL of full rollout. If your TTL is the default 90 seconds, plan a ~2-minute rollout completion before relying on accurate presence counts.
|
|
36
|
+
|
|
37
|
+
3. **`metrics().staleCleanedTotal` is now always 0.** Per-field staleness is enforced atomically by Redis HPEXPIRE rather than by an application-side cleanup script, so there is nothing to count. The field stays in the return shape for backward-compatibility. The corresponding Prometheus counter `presence_stale_cleaned_total` is no longer registered.
|
|
38
|
+
|
|
39
|
+
4. **`keyspaceNotifications: true` scope is narrower.** Pre-fix, the option subscribed to whole-key `__keyevent@*__:expired` for `presence:{topic}` keys and emitted an empty `state` when one fired. Same shape in 0.5 (the per-topic hash whole-key expiry only fires when every field has expired, i.e. no live instances). Per-field expiry (a single crashed instance) does NOT trigger this notification; users disappear from `list()` / `count()` results lazily. If you need explicit per-field-expiry events, subscribe to `__keyevent@*__:hexpired` separately (separate Redis CONFIG flag).
|
|
40
|
+
|
|
41
|
+
5. **Falling back to single-instance.** If you can't upgrade Redis, switch to the in-memory `createPresence` plugin from `svelte-adapter-uws/plugins/presence`. Public API is the same; you lose cross-instance presence which is the whole point of the Redis variant, but for single-instance deployments it's a clean drop-in.
|
|
42
|
+
|
|
43
|
+
If your app uses `createPresence` and you cannot upgrade Redis immediately, pin `svelte-adapter-uws-extensions` to the last 0.5.x release before this change.
|
|
44
|
+
|
|
45
|
+
### Replay backends consult `platform.checkSubscribe` before reading history
|
|
46
|
+
|
|
47
|
+
**What changed.** Every `replay()` implementation (Redis sorted-set, Redis streams, Postgres) now calls `platform.checkSubscribe(ws, topic)` first. On denial, it sends a single `{event:'denied', data:{code:<denial>, reqId}}` frame on `__replay:{topic}` and returns without reading the buffer. Pre-fix, an attacker could send a crafted `lastSeenSeqs` map and read history for a topic they could not subscribe to live.
|
|
48
|
+
|
|
49
|
+
**How to migrate.** Client-side replay stores must handle the new `denied` event in addition to existing `truncated` / `end` events. Treat it similarly to `truncated`:
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
function onReplayFrame(event, data) {
|
|
53
|
+
if (event === 'denied') {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Older adapters lacking `checkSubscribe` degrade gracefully to the previous behavior.
|
|
60
|
+
|
|
61
|
+
### Tasks `idempotencyKey` is now namespaced by task name, with a 256-char cap
|
|
62
|
+
|
|
63
|
+
**In-flight cache entries become invisible after deploy.**
|
|
64
|
+
|
|
65
|
+
**What changed.** The cache key passed to `idempotency.acquire(...)` for `tasks.run` and `tasks.enqueue` is now `'task:' + name + ':' + idempotencyKey` (was the bare `idempotencyKey`). Pre-fix, two tasks sharing one client-supplied key shared one cache slot, which let a privileged task's cached result leak to a public task. Both methods also reject `idempotencyKey` longer than 256 characters; the underlying stores cap `acquire(key)` at 1024 chars as defense in depth.
|
|
66
|
+
|
|
67
|
+
**How to migrate.** No source change at call sites. In-flight cached entries from before the upgrade become invisible after deploy because the namespaced key does not match the old un-namespaced key. For Redis stores the old keys TTL out; for Postgres the cleanup interval handles them. Audit any code that supplies long `idempotencyKey` strings (request bodies, opaque tokens) and shorten them if they exceed 256 chars.
|
|
68
|
+
|
|
69
|
+
### Inbound bus envelopes are validated before republish
|
|
70
|
+
|
|
71
|
+
**What changed.** Every bus subscriber (`createPubSubBus`, `createShardedBus`, `createNotifyBridge`, `createCursor`) now gates inbound envelopes on raw byte size, topic shape, event shape, and a `__`-prefix denylist before republishing. Default `maxEnvelopeBytes` is 1 MB; default `allowSystemTopics` is `false` (secure-by-default - the wire-level `__`-subscribe gate alone does not cover bus-republish). The bus's own configured `systemChannel` (default `__realtime`) remains in an explicit allowlist so degraded / recovered events still flow.
|
|
72
|
+
|
|
73
|
+
**How to migrate.** No code change required for default deployments. If your application publishes envelopes larger than 1 MB on the bus, raise `maxEnvelopeBytes` explicitly:
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
createPubSubBus(redis, { maxEnvelopeBytes: 4 * 1024 * 1024 });
|
|
77
|
+
createShardedBus(redis, { maxEnvelopeBytes: 4 * 1024 * 1024 });
|
|
78
|
+
createNotifyBridge(pg, { maxEnvelopeBytes: 4 * 1024 * 1024 });
|
|
79
|
+
createCursor(redis, { maxEnvelopeBytes: 4 * 1024 * 1024 });
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
If your app legitimately bus-relays user-defined `__`-prefixed topics (uncommon), opt back in:
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
createPubSubBus(redis, { allowSystemTopics: true });
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Topics longer than 256 chars or containing control characters are also rejected; review any code that constructs topic names dynamically.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Required source changes
|
|
93
|
+
|
|
94
|
+
These won't run cleanly until you make the change.
|
|
95
|
+
|
|
96
|
+
### Runtime: Node.js 22+ required (was Node 20+)
|
|
97
|
+
|
|
98
|
+
**What changed.** `package.json#engines.node` moved from `>=20.0.0` to `>=22.0.0`. Tracks the adapter's bump, which in turn tracks `uWebSockets.js` v20.67.0 dropping Node 20 support upstream.
|
|
99
|
+
|
|
100
|
+
**How to migrate.** See `svelte-adapter-uws/MIGRATION.md` for the full uWS-side rationale. No extensions-specific action beyond bumping your runtime to Node 22+.
|
|
101
|
+
|
|
102
|
+
### Peer dependency on `svelte-adapter-uws` raised to `^0.5.0`
|
|
103
|
+
|
|
104
|
+
**What changed.** The peerDep range moved from `>=0.4.0` (0.4.x) to `^0.5.0`. `bus.wrap()` now binds adapter members (`maxPayloadLength`, `bufferedAmount`, `subscribe`, `unsubscribe`, `checkSubscribe`, `sendCoalesced`, `request`, `requestId`, `pressure`, `onPressure`, `onPublishRate`, `publishBatched`) unconditionally; an older adapter without these members crashes at wrap-construction time.
|
|
105
|
+
|
|
106
|
+
**How to migrate.** Upgrade the adapter first, then this package:
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
npm install svelte-adapter-uws@latest svelte-adapter-uws-extensions@latest
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
See the adapter's MIGRATION.md for adapter-side breaking changes.
|
|
113
|
+
|
|
114
|
+
### Postgres table prefix changed from `ws_` to `svti_`
|
|
115
|
+
|
|
116
|
+
**What changed.** All Postgres factories (`createReplay`, `createIdempotencyStore`, `createJobQueue`, `createTaskRunner`) now default to `svti_*` table names: `svti_replay`, `svti_replay_seq`, `svti_idempotency`, `svti_jobs`, `svti_tasks`. Primary key columns follow the `[tablename]_id` rule (`svti_replay_id`, `svti_jobs_id`, `svti_tasks_id`, `svti_idempotency_key`). The replay timestamp column was renamed `created_date` to `created_at`. JS-level field names on row objects are preserved via SQL `AS` aliases (`job.id`, `task.id`, `task.idempotency_key` are unchanged).
|
|
117
|
+
|
|
118
|
+
**How to migrate.** Pick one of:
|
|
119
|
+
|
|
120
|
+
Option A (zero-downtime, no SQL): override the `table` option on each module to keep the old names.
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
createReplay(pg, { table: 'ws_replay' });
|
|
124
|
+
createIdempotencyStore(pg, { table: 'ws_idempotency' });
|
|
125
|
+
createJobQueue(pg, { table: 'ws_jobs' });
|
|
126
|
+
createTaskRunner(pg, { table: 'ws_tasks' });
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Option B (rename in place):
|
|
130
|
+
|
|
131
|
+
```sql
|
|
132
|
+
ALTER TABLE ws_replay RENAME TO svti_replay;
|
|
133
|
+
ALTER TABLE ws_replay RENAME COLUMN id TO svti_replay_id;
|
|
134
|
+
ALTER TABLE ws_replay RENAME COLUMN created_date TO created_at;
|
|
135
|
+
ALTER TABLE ws_replay_seq RENAME TO svti_replay_seq;
|
|
136
|
+
ALTER TABLE ws_idempotency RENAME TO svti_idempotency;
|
|
137
|
+
ALTER TABLE svti_idempotency RENAME COLUMN key TO svti_idempotency_key;
|
|
138
|
+
ALTER TABLE ws_jobs RENAME TO svti_jobs;
|
|
139
|
+
ALTER TABLE svti_jobs RENAME COLUMN id TO svti_jobs_id;
|
|
140
|
+
ALTER TABLE ws_tasks RENAME TO svti_tasks;
|
|
141
|
+
ALTER TABLE svti_tasks RENAME COLUMN task_id TO svti_tasks_id;
|
|
142
|
+
ALTER TABLE svti_tasks RENAME COLUMN idempotency_key TO svti_idempotency_key;
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Presence `hooks` now includes `unsubscribe`
|
|
146
|
+
|
|
147
|
+
**What changed.** `presence.hooks` exports a `unsubscribe` hook in addition to `subscribe` and `close`. Required for correct single-topic leave when a client unsubscribes from a topic without disconnecting.
|
|
148
|
+
|
|
149
|
+
**How to migrate.** Update destructuring:
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
// before
|
|
153
|
+
export const { subscribe, close } = presence.hooks;
|
|
154
|
+
|
|
155
|
+
// after
|
|
156
|
+
export const { subscribe, unsubscribe, close } = presence.hooks;
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Sharded pub/sub bus and Redis Functions wrapper require Redis 7+
|
|
160
|
+
|
|
161
|
+
**What changed.** `createShardedBus` (uses SPUBLISH/SSUBSCRIBE) and `createFunctionLibrary` (uses Redis Functions) run `INFO server` on activate and throw on Redis < 7. No EVALSHA or PUBLISH fallback is provided.
|
|
162
|
+
|
|
163
|
+
**How to migrate.** On Redis 6 or older Valkey, use `createPubSubBus` instead of `createShardedBus` and use `redis.eval` directly instead of `createFunctionLibrary`. Upgrade Redis to 7+ to use the new modules.
|
|
164
|
+
|
|
165
|
+
### Pub/sub bus `systemChannel` delivery requires `bus.hooks.open`
|
|
166
|
+
|
|
167
|
+
**What changed.** `svelte-adapter-uws@0.5.0-next.21` closed the wire-level path that put connections into `__`-prefixed subscriber sets. Pre-next.21, the `svelte-realtime` client store wire-subscribed to `__realtime` and the server-side subscriber set was populated as a side effect; the pub/sub bus's auto-emitted `degraded` / `recovered` events reached every connected client through that membership. After the wire-gate change, nothing populated the subscriber set on the server, so the bus published into an empty set and clients never saw the events - any `{#if $health === 'degraded'}` banner was silently dead. `bus.hooks.open` (new in this release) puts every connection into `systemChannel`'s subscriber set via the platform-trust path (`platform.subscribe`), which intentionally bypasses the wire-level gate.
|
|
168
|
+
|
|
169
|
+
**How to migrate.** If you use `createPubSubBus` with a `breaker` and follow the README's degradation-banner pattern, swap your open hook from:
|
|
170
|
+
|
|
171
|
+
```js
|
|
172
|
+
// before
|
|
173
|
+
export function open(ws, { platform }) {
|
|
174
|
+
bus.activate(platform);
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
to:
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
// after
|
|
182
|
+
export const { open } = bus.hooks;
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
or, if your open hook does additional work:
|
|
186
|
+
|
|
187
|
+
```js
|
|
188
|
+
export async function open(ws, ctx) {
|
|
189
|
+
await bus.hooks.open(ws, ctx);
|
|
190
|
+
// ... other open-hook work
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`bus.activate(platform)` still works for the subscriber-half; the difference is that `bus.hooks.open` ALSO subscribes the connection to `systemChannel`. If you set `systemChannel: null` or `false`, no migration is required - `bus.hooks.open` still activates the subscriber and skips the per-WS subscribe. Bump the `svelte-adapter-uws` peer minimum to `>=0.5.0-next.23` to also clear the cosmetic `[ws] subscribe denied topic=__realtime` console warn on every page mount; the `bus.hooks.open` fix itself works against any 0.5 adapter.
|
|
195
|
+
|
|
196
|
+
### Cursor delivery requires `tracker.attach(ws, topic, platform)`
|
|
197
|
+
|
|
198
|
+
**What changed.** Same regression class as the pubsub `systemChannel` issue above. `svelte-adapter-uws@0.5.0-next.21` closed the wire-level path that put connections into `__`-prefixed subscriber sets; pre-next.21, the client store's wire subscribe to `__cursor:{topic}` populated the subscriber set as a side effect. After next.21, the wire frame is denied; after next.23, the client does not send it. The cursor extension publishes to `__cursor:{topic}` from `update` / the bulk-flush tick / `remove`, but nothing else subscribed connections to that channel server-side, so every cursor frame fanned out into an empty subscriber set and cross-tab / cross-user cursors were silently dropped. `tracker.attach(ws, topic, platform)` (new in this release) owns membership via the platform-trust path (`platform.subscribe`), which intentionally bypasses the wire-level gate, and folds the snapshot send into the same call.
|
|
199
|
+
|
|
200
|
+
**How to migrate.** Call `cursors.attach(ws, topic, platform)` from your "join room" RPC (the same place you call `presence.join`), and `cursors.detach` from your "leave room" RPC if the user stays connected:
|
|
201
|
+
|
|
202
|
+
```js
|
|
203
|
+
// before - publishes went into an empty subscriber set on adapter next.21+
|
|
204
|
+
export async function joinBoard(ws, { topic, platform }) {
|
|
205
|
+
await presence.join(ws, topic, platform);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// after
|
|
209
|
+
export async function joinBoard(ws, { topic, platform }) {
|
|
210
|
+
await presence.join(ws, topic, platform);
|
|
211
|
+
await cursors.attach(ws, topic, platform);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function leaveBoard(ws, { topic, platform }) {
|
|
215
|
+
presence.leave(ws, topic, platform);
|
|
216
|
+
cursors.detach(ws, topic, platform);
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
No `close`-hook change required: uWS releases all per-`ws` subscriptions on disconnect, so `cursors.remove(ws, platform)` in your `close` hook still handles cleanup. The legacy `cursors.hooks.subscribe` slot is now a no-op (the adapter's wire gate prevents it from ever firing); it stays exported for source-compat but new code should not rely on it.
|
|
221
|
+
|
|
222
|
+
### Presence wire shape on `__presence:{topic}` migrated to `state` / `diff`
|
|
223
|
+
|
|
224
|
+
**Only impacts apps with hand-rolled WebSocket clients consuming the wire directly.**
|
|
225
|
+
|
|
226
|
+
**What changed.** The `redis/presence` channel previously emitted `list` / `join` / `leave` / `updated` / `heartbeat` events. It now emits:
|
|
227
|
+
|
|
228
|
+
- `state` (sent once on subscribe to a single connection): payload is `{[userKey]: data}` - a flat snapshot.
|
|
229
|
+
- `diff` (broadcast to topic subscribers, microtask-batched): payload is `{joins: {[key]: data}, leaves: {[key]: data}}`. Same-tick joins+leaves on the same key collapse to the latest op; updates appear as a `joins` entry with the new data.
|
|
230
|
+
- `heartbeat` is unchanged.
|
|
231
|
+
|
|
232
|
+
The public JS API on `createPresence` is unchanged (`join`, `leave`, `sync`, `list`, `count`, `metrics`, `clear`, `destroy`, `hooks` keep their signatures). The cross-instance Redis pub/sub envelope on `presence:events:{topic}` is also unchanged. The `keyspaceNotifications: true` mode now emits an empty `state` event (was an empty `list` event) on hash expiry.
|
|
233
|
+
|
|
234
|
+
**How to migrate.** Apps with a custom WebSocket client decoding presence frames must swap their decoder from the four legacy event names to `state` / `diff`:
|
|
235
|
+
|
|
236
|
+
```js
|
|
237
|
+
// before
|
|
238
|
+
case 'list': setAll(payload); break;
|
|
239
|
+
case 'join': add(payload.key, payload.data); break;
|
|
240
|
+
case 'leave': remove(payload.key); break;
|
|
241
|
+
case 'updated': set(payload.key, payload.data); break;
|
|
242
|
+
|
|
243
|
+
// after
|
|
244
|
+
case 'state':
|
|
245
|
+
state.clear();
|
|
246
|
+
for (const k of Object.keys(payload)) state.set(k, payload[k]);
|
|
247
|
+
break;
|
|
248
|
+
case 'diff':
|
|
249
|
+
for (const k of Object.keys(payload.joins)) state.set(k, payload.joins[k]);
|
|
250
|
+
for (const k of Object.keys(payload.leaves)) state.delete(k);
|
|
251
|
+
break;
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Apps using `svelte-realtime` get the new shape automatically. Apps using only the adapter's bundled in-memory `createPresence` plugin are unaffected; that plugin already used the new shape.
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Notable defaults and behaviors
|
|
259
|
+
|
|
260
|
+
These change observable runtime behavior. Most apps are unaffected; a few will notice.
|
|
261
|
+
|
|
262
|
+
### Postgres factories reject reserved-namespace table names
|
|
263
|
+
|
|
264
|
+
**What changed.** The `idempotency`, `jobs`, `replay`, and `tasks` factories now reject `table` values whose lowercase form starts with `pg_` or `information_schema` with an explicit "reserved Postgres schema" error. Pre-fix, these names passed the identifier-shape regex and could clash with Postgres internals.
|
|
265
|
+
|
|
266
|
+
**How to migrate.** Rename any custom `table` value that matches `pg_*` or `information_schema*` (extremely uncommon in practice). Standard prefixes are unaffected.
|
|
267
|
+
|
|
268
|
+
### Redis `ConnectionError` no longer leaks the URL password
|
|
269
|
+
|
|
270
|
+
**What changed.** When `new Redis(url, ...)` throws (DNS failure, malformed URL, bad TLS), the resulting `ConnectionError` message previously included the raw connection string with embedded password. The redis client factory now redacts the password segment to `***` via the new `shared/sensitive.js#redactConnectionUrl(url)` helper before interpolating into the error.
|
|
271
|
+
|
|
272
|
+
**How to migrate.** No source change. Error-tracker hooks and log pipelines that searched for the raw connection string must update their queries; the redacted form is `redis://:***@host:port`. Other URL forms (no userinfo, no password, query string with `@`, path-segment colons) pass through untouched.
|
|
273
|
+
|
|
274
|
+
### `replay()` end event data changed from `null` to `{ reqId }`
|
|
275
|
+
|
|
276
|
+
**What changed.** The end marker on `__replay:{topic}` now sends `{ reqId: undefined }` (or `{ reqId: 'some-id' }` when a correlation ID is passed) instead of `null`. The signature also accepts an optional `reqId` parameter: `replay(ws, topic, sinceSeq, platform, reqId?)`. A new `truncated` event is sent before replay messages when the buffer has been trimmed past the client's `sinceSeq`. (This change shipped in 0.4.0 but is repeated here because client-side decoders are commonly out of date.)
|
|
277
|
+
|
|
278
|
+
**How to migrate.** Update any client-side end-marker check from `data === null` to an object check. Handle or ignore the new `truncated` event in your replay store.
|
|
279
|
+
|
|
280
|
+
### `replay.publish()` storage failures throw `ReplayStorageError`
|
|
281
|
+
|
|
282
|
+
**What changed.** Storage failures in `publish()` (and `publishIdempotent` on the stream backend) now throw `ReplayStorageError` with the original error preserved on `.cause`. Pre-fix the underlying error propagated raw. Affects `redis/replay`, `redis/replay-stream`, and `postgres/replay`.
|
|
283
|
+
|
|
284
|
+
**How to migrate.** Any consumer that catches a specific underlying class (ioredis errors, `CircuitBrokenError`) must either catch `ReplayStorageError` and inspect `.cause`:
|
|
285
|
+
|
|
286
|
+
```js
|
|
287
|
+
try {
|
|
288
|
+
await replay.publish(topic, event, data);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
if (err instanceof ReplayStorageError) {
|
|
291
|
+
const root = err.cause;
|
|
292
|
+
}
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Or set `localFanoutOnStorageFailure: true` on the factory to opt into a best-effort `platform.publish` fallback (pure live frames; durability is sacrificed):
|
|
298
|
+
|
|
299
|
+
```js
|
|
300
|
+
createReplay(redis, { localFanoutOnStorageFailure: true });
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
`publishIdempotent` always throws `ReplayStorageError` on storage failure even when this option is set, since silent fanout would break the exactly-once contract.
|
|
304
|
+
|
|
305
|
+
### `createCursor` defaults flipped to a 60Hz world-state tick
|
|
306
|
+
|
|
307
|
+
**What changed.** `throttle` defaults to `16` (was `50`) and `topicThrottle` defaults to `16` (was `0`). Out of the box the broadcast path is now a 60Hz world-state tick: each topic emits at most one bulk frame per 16ms window carrying the latest position for every cursor that moved.
|
|
308
|
+
|
|
309
|
+
**How to migrate.** No action for apps that want the new default. To restore previous behavior:
|
|
310
|
+
|
|
311
|
+
```js
|
|
312
|
+
// previous "every update broadcasts immediately" behavior
|
|
313
|
+
createCursor(redis, { topicThrottle: 0 });
|
|
314
|
+
|
|
315
|
+
// previous 50ms per-cursor floor
|
|
316
|
+
createCursor(redis, { throttle: 50 });
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
For high-density rooms (>200 active movers) raise `topicThrottle` to 33 (30Hz).
|
|
320
|
+
|
|
321
|
+
### Pub/sub bus auto-emits `degraded` / `recovered` system events
|
|
322
|
+
|
|
323
|
+
**What changed.** When `createPubSubBus` shares a circuit breaker with the rest of the extensions, the bus subscribes to the breaker and auto-emits `degraded` / `recovered` events on a configurable system topic (default `'__realtime'`). Replaces the manually wired `breaker.onStateChange` to `distributed.publish('__system', ...)` pattern that the README previously documented.
|
|
324
|
+
|
|
325
|
+
**How to migrate.** Remove any manual wiring of breaker state to a `__system` topic if you previously followed the README pattern. To disable auto-emission, set `systemChannel: null` or `false`. To use a different topic, set `systemChannel: 'my-status-topic'`. The `onDegraded` / `onRecovered` callbacks remain available for server-side reactions regardless. **Delivery to clients also requires wiring `bus.hooks.open` in your `hooks.ws.js`** - see [Pub/sub bus `systemChannel` delivery requires `bus.hooks.open`](#pubsub-bus-systemchannel-delivery-requires-bushooksopen) under Required source changes.
|
|
326
|
+
|
|
327
|
+
### Default `select` strips `__`-prefixed and sensitive keys on presence and cursor
|
|
328
|
+
|
|
329
|
+
**What changed.** The default `select` on `createPresence` and `createCursor` was the identity function in earlier 0.3.x. Since 0.4.0 it strips keys starting with `__` (e.g. `__subscriptions`, `remoteAddress`) and keys matching `/token|secret|password|auth|session|cookie|jwt|credential/i`. Repeated here because it remains the most common upgrade trip-hazard. Presence and cursor also warn once (per-process) if userData contains keys matching the sensitive-key pattern even when not selected. Notify bridge warns when payload approaches Postgres ~8000 byte NOTIFY limit.
|
|
330
|
+
|
|
331
|
+
**How to migrate.** If you relied on any of these fields in presence or cursor data, pass an explicit `select` function:
|
|
332
|
+
|
|
333
|
+
```js
|
|
334
|
+
createPresence(redis, { select: (ud) => ({ name: ud.name, role: ud.role, token: ud.token }) });
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Treat the warnings as actionable: either rename the offending field, exclude it from upstream user data, or pass a custom `select` that intentionally retains it. Warnings do not throw or alter behavior.
|
|
338
|
+
|
|
339
|
+
### New parse-error counters
|
|
340
|
+
|
|
341
|
+
**What changed.** The Redis pub/sub bus and sharded pub/sub bus previously swallowed malformed envelopes silently on receive. They now bump a `pubsub_parse_errors_total` / `sharded_pubsub_parse_errors_total` counter so a stream of bad messages becomes observable in Prometheus. Not a code break per se, but alert thresholds that assumed zero parse errors must be updated.
|
|
342
|
+
|
|
343
|
+
**How to migrate.** Add the new counters to your dashboards next to the existing `notify_parse_errors_total` on `createNotifyBridge`. No source change.
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## Cosmetic
|
|
348
|
+
|
|
349
|
+
Type-only changes, internal refactors, niche edge cases. No action required for most apps.
|
|
350
|
+
|
|
351
|
+
### Lua scripts reject non-numeric ARGV with `redis.error_reply`
|
|
352
|
+
|
|
353
|
+
**What changed.** Defensive `tonumber(x) ~= nil` checks were added at the top of every Lua script (`shared/scripts.js` CLEANUP/COUNT/COUNT_DEDUP/LIST, `redis/groups.js` JOIN, `redis/presence.js` JOIN/LEAVE, `redis/ratelimit.js` CONSUME/BAN, `redis/replay.js` PUBLISH, `redis/replay-stream.js` IDMP_PUBLISH/PUBLISH). Pre-fix, bypassing the JS validation layer (direct `redis.eval`, poisoned hash entries) would crash the script.
|
|
354
|
+
|
|
355
|
+
**How to migrate.** No source change for code that uses the public JS APIs. Code that calls `redis.eval` against extension scripts directly must pass numeric ARGV strings (`'1700000000'`, not `'now'`). Poisoned hash entries with non-numeric `ts` fields are now treated as stale rather than crashing iteration.
|
|
356
|
+
|
|
357
|
+
### `idempotency.acquire(key)` parameter renamed to `idempotencyKey`
|
|
358
|
+
|
|
359
|
+
**What changed.** The first argument of `idempotency.acquire(...)` and `idempotency.purge(...)` is now named `idempotencyKey` instead of `key`, matching the existing `idempotencyKey` option on the task runner.
|
|
360
|
+
|
|
361
|
+
**How to migrate.** Function-argument rename, transparent at call sites that pass positionally. Code that destructures the parameter name from a wrapper signature should update accordingly.
|
|
362
|
+
|
|
363
|
+
### `redis/lock` Lua scripts moved to `shared/lease-scripts.js`
|
|
364
|
+
|
|
365
|
+
**What changed.** The lock module's `HEARTBEAT_SCRIPT` and `RELEASE_SCRIPT` are now imported from `shared/lease-scripts.js` under generic names (`LEASE_RENEW_SCRIPT`, `LEASE_RELEASE_SCRIPT`) so `redis/leader` can reuse them. Pure refactor, no behavior change.
|
|
366
|
+
|
|
367
|
+
**How to migrate.** No action required for callers using the public lock API. Internal-tooling code that imported the script constants by name from `redis/lock` must update its import path and rename:
|
|
368
|
+
|
|
369
|
+
```js
|
|
370
|
+
// before
|
|
371
|
+
import { HEARTBEAT_SCRIPT, RELEASE_SCRIPT } from 'svelte-adapter-uws-extensions/redis/lock';
|
|
372
|
+
|
|
373
|
+
// after
|
|
374
|
+
import { LEASE_RENEW_SCRIPT, LEASE_RELEASE_SCRIPT } from 'svelte-adapter-uws-extensions/shared/lease-scripts';
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### `JOIN_SCRIPT` in groups returns `[status, ...liveMembers]`
|
|
378
|
+
|
|
379
|
+
**What changed.** Internal change since 0.4.0, but affects anyone calling the Lua script directly. The wire contract on the public groups API is unchanged.
|
|
380
|
+
|
|
381
|
+
**How to migrate.** If you do not call `JOIN_SCRIPT` directly via `redis.eval`, no action. Direct callers must read the array form.
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
## After upgrading
|
|
386
|
+
|
|
387
|
+
Run your test suite. Pay particular attention to:
|
|
388
|
+
|
|
389
|
+
- Custom WebSocket clients decoding `__presence:{topic}` and `__replay:{topic}` frames.
|
|
390
|
+
- Code that catches storage errors on `replay.publish()`.
|
|
391
|
+
- Postgres deployments that relied on `ws_*` table names.
|
|
392
|
+
- Redis deployments with versioned rate limiter keys (no action needed; old `ratelimit:*` keys expire on their own).
|
|
393
|
+
- Bus subscribers receiving envelopes larger than 1 MB.
|
|
394
|
+
- Tasks paths that pass long `idempotencyKey` strings (now capped at 256 chars).
|
|
395
|
+
|
|
396
|
+
Report regressions against the changelog entry the issue maps to.
|