svelte-realtime 0.6.0-next.78 → 0.6.0-next.79
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 +191 -4
- package/package.json +1 -1
- package/src/client/misc.js +155 -61
- package/src/client/offline-store.js +191 -0
- package/src/client/offline.js +212 -0
- package/src/client/rpc.js +11 -2
- package/src/client/stream.js +139 -12
- package/src/client.d.ts +130 -0
- package/src/client.js +3 -1
- package/src/server/admin.js +23 -0
- package/src/server/crdt.js +44 -1
- package/src/server/ctx.js +80 -1
- package/src/server/dead-letter.js +46 -1
- package/src/server/dispatch.js +4 -4
- package/src/server/forget.js +57 -1
- package/src/server/interest.js +20 -0
- package/src/server/metrics.js +262 -48
- package/src/server/push.js +33 -18
- package/src/server/reactive.js +42 -0
- package/src/server/smooth.js +44 -0
- package/src/server/upload.js +3 -3
- package/src/server.d.ts +118 -6
- package/src/testing.d.ts +18 -0
- package/src/testing.js +89 -3
package/README.md
CHANGED
|
@@ -449,10 +449,11 @@ const res = await live.forget(targetUserId, {
|
|
|
449
449
|
onForget: ({ userIdHash, tenantId, rowsAffected }) =>
|
|
450
450
|
auditLog.write({ action: 'erasure', userIdHash, tenantId, rowsAffected })
|
|
451
451
|
});
|
|
452
|
-
// res = { ok: true, at, rowsAffected, surfaces: { push, presence, rateLimit,
|
|
452
|
+
// res = { ok: true, at, rowsAffected, surfaces: { push, presence, rateLimit,
|
|
453
|
+
// idempotency, smooth, webhookDeadLetter, aggregateCohorts, durable, crdtDocs? } }
|
|
453
454
|
```
|
|
454
455
|
|
|
455
|
-
It cascades over the framework's in-memory user state
|
|
456
|
+
It cascades over the framework's in-memory user state - the push registry AND every push session the user holds across sockets, presence rosters (clearing the grace timer and decrementing the cluster count; a held room-owner role releases too), rate-limit buckets, idempotency cached results via a per-user reverse index, the smooth/game state the user leaves behind (subscriber registry entry with its RTT tracker, cross-instance surrogates, interest state including the reported center - a literal user location - and the lag-comp movement ring when the entity key is the identity), the webhook dead-letter queue (see the extractor note below), and the k-anonymity cohorts of every live aggregate (the user is withdrawn from the contributor sets so the k-gate re-evaluates without them; reducer state is untouched - folds are non-invertible, and the cohort is what governs publication) - then awaits the durable store's `purgeUser` and resolves ONLY after the durable delete confirms. A durable failure rejects with `LiveError('FORGET_STORE_FAILED')` so an incomplete erasure can be retried.
|
|
456
457
|
|
|
457
458
|
The `onForget` audit hook receives a HASHED userId (never the raw id), so your audit log stays PII-free. The result is constant-shape, so if you ever re-expose `forget` to untrusted clients, map it to a fixed shape first - a `0`-vs-`N` `rowsAffected` would otherwise reveal whether a user exists.
|
|
458
459
|
|
|
@@ -468,7 +469,17 @@ configureForget({
|
|
|
468
469
|
});
|
|
469
470
|
```
|
|
470
471
|
|
|
471
|
-
Stores whose payloads are app-defined (sessions, dead-letter, replay buffers, task inputs) take a `forgetUserId(payload)` extractor so they can find the user; without it they are a documented no-op.
|
|
472
|
+
Stores whose payloads are app-defined (sessions, dead-letter, replay buffers, task inputs) take a `forgetUserId(payload)` extractor so they can find the user; without it they are a documented no-op. The in-memory webhook dead-letter store follows the same contract: `configureWebhooks({ deadLetter: createDeadLetterStore({ forgetUserId: ({ data }) => data.authorId }) })` stamps each retained event with its authoring user at capture time so forget can drop them.
|
|
473
|
+
|
|
474
|
+
CRDT documents (`live.doc`/`map`/`array`) merge edits from many users into shared state, so a forgotten user's merged content is not surgically erasable BY CONSTRUCTION - the only true erasure is dropping the whole document, and only your app knows which documents the user contributed to. Name them in the cascade:
|
|
475
|
+
|
|
476
|
+
```js
|
|
477
|
+
await live.forget(targetUserId, {
|
|
478
|
+
cascade: { crdt: ['notes:42', 'board:7'] } // whole-document drops
|
|
479
|
+
});
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
The named documents' loaded server replicas are destroyed WITHOUT persisting (an erasure never writes back the state it erases; requires `svelte-adapter-uws >= 0.6.0-next.71`), connected editors observe them as unloaded, and a re-open cold-loads from your persistence - deleting the persisted copies is your `persist`-store's half of the erasure. There is deliberately no `anonymize` mode: for durable rows the row delete is the only clean erasure, and for statistical retention `live.aggregate({ privacy })` already keeps k-gated aggregates without per-user rows.
|
|
472
483
|
|
|
473
484
|
### Reconnection
|
|
474
485
|
|
|
@@ -561,6 +572,25 @@ Returns an object with a single `current` getter, backed by Svelte's `fromStore`
|
|
|
561
572
|
|
|
562
573
|
`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.
|
|
563
574
|
|
|
575
|
+
### `createReactiveStream(source)` - the first-class primitive
|
|
576
|
+
|
|
577
|
+
`rune()` delegates to `createReactiveStream`, exported so ANY store-shaped source (or a bare subscribe function) gets the same fine-grained handle - only the `$derived`/template expressions that actually read `.current` re-evaluate on updates, and the upstream subscription starts on first read and stops when the last reader's effect tears down:
|
|
578
|
+
|
|
579
|
+
```svelte
|
|
580
|
+
<script>
|
|
581
|
+
import { createReactiveStream } from 'svelte-realtime/client';
|
|
582
|
+
import { todos } from '$live/todos';
|
|
583
|
+
|
|
584
|
+
const items = createReactiveStream(todos); // same handle todos.rune() returns
|
|
585
|
+
const clock = createReactiveStream((set) => { // any subscribe fn works
|
|
586
|
+
const t = setInterval(() => set(Date.now()), 1000);
|
|
587
|
+
return () => clearInterval(t);
|
|
588
|
+
});
|
|
589
|
+
</script>
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
Same Svelte 5 requirement as `rune()`; the `{ subscribe }` store contract stays the version-portable baseline.
|
|
593
|
+
|
|
564
594
|
### `store.map(fn)` - per-item projection
|
|
565
595
|
|
|
566
596
|
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.
|
|
@@ -641,6 +671,27 @@ To show errors, subscribe to the `.error` store:
|
|
|
641
671
|
|
|
642
672
|
Defensive patterns like `($store ?? []).filter(...)` work correctly because `$store` is always an array or `undefined`.
|
|
643
673
|
|
|
674
|
+
### Attach lifecycle: `store.phase`, `attach()`, `detach()`
|
|
675
|
+
|
|
676
|
+
Beside `status` (a health projection), every stream store exposes the per-subscription attach machine as a read-only `phase` store - `initialized -> attaching -> attached -> detached | failed` - plus explicit controls:
|
|
677
|
+
|
|
678
|
+
```js
|
|
679
|
+
import { board } from '$live/game';
|
|
680
|
+
|
|
681
|
+
// "Don't publish until fully attached": once attach() resolves, the server
|
|
682
|
+
// has confirmed this connection's subscription, so the broadcast your RPC
|
|
683
|
+
// triggers is guaranteed to reach you - no race with the subscribe.
|
|
684
|
+
await board.attach();
|
|
685
|
+
await placePiece(x, y);
|
|
686
|
+
|
|
687
|
+
// Later - done for real (no resume-grace retention):
|
|
688
|
+
board.detach();
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
- `attach()` holds an internal retain, so the stream stays attached with no UI subscriber and auto-reattaches across outages; it resolves on the server's confirmation (the loader response - no extra wire frame) and rejects when the attach fails (e.g. a `FORBIDDEN` denial). Idempotent.
|
|
692
|
+
- `detach()` releases the retain and, when no other subscriber remains, tears the subscription down immediately - detach means "done", not "component unmounted, maybe coming back" (that case is the automatic resume-grace window). With live UI subscribers the stream stays attached on their behalf.
|
|
693
|
+
- `attaching` covers every in-flight subscribe (first attach, reconnect, resume); `failed` holds until a retry re-enters `attaching`; the resume-grace release reads as `detached`.
|
|
694
|
+
|
|
644
695
|
For RPC calls, errors are thrown as `RpcError` with a `code` field:
|
|
645
696
|
|
|
646
697
|
```js
|
|
@@ -1057,6 +1108,28 @@ export const resetBoard = live(async (ctx, boardId) => {
|
|
|
1057
1108
|
});
|
|
1058
1109
|
```
|
|
1059
1110
|
|
|
1111
|
+
### Atomic publishing (`ctx.batch(fn)`)
|
|
1112
|
+
|
|
1113
|
+
Pass a function instead of a list and `ctx.batch` becomes an all-or-nothing collector: every `ctx.publish` the function makes - **including after `await`s** - is buffered, published together when the function returns (or its promise resolves), and dropped entirely when it throws (or rejects). A rejected handler never leaves a partial publish trail:
|
|
1114
|
+
|
|
1115
|
+
```js
|
|
1116
|
+
export const transferFunds = live(async (ctx, from, to, amount) => {
|
|
1117
|
+
return ctx.batch(async () => {
|
|
1118
|
+
ctx.publish(`account:${from}`, 'debited', { amount });
|
|
1119
|
+
await db.transfer(from, to, amount); // throws -> NOTHING above is published
|
|
1120
|
+
ctx.publish(`account:${to}`, 'credited', { amount });
|
|
1121
|
+
return 'ok';
|
|
1122
|
+
});
|
|
1123
|
+
});
|
|
1124
|
+
```
|
|
1125
|
+
|
|
1126
|
+
Two contrasts worth knowing:
|
|
1127
|
+
|
|
1128
|
+
- **Bare `ctx.publish` flushes at each microtask boundary** - an `await` splits publishes into separate wire batches, and a publish made before the `await` is already sent when a later throw happens. Inside `ctx.batch(fn)` the publishes are held across awaits precisely so a post-await throw can retract them; atomicity is the point of the collector form.
|
|
1129
|
+
- **The client-side `batch(fn)` shares the name and shape but not the failure contract**: it collects RPC calls into one frame and each settles independently. The server collector is deliberately all-or-nothing.
|
|
1130
|
+
|
|
1131
|
+
Only `ctx.publish` is collected. `ctx.publishThrottled` / `ctx.publishDebounced` (timer-deferred), `ctx.signal` (point-to-point), and `ctx.tenant(id).publish` (the explicit cross-tenant escape) pass through immediately. Buffered messages flush through the real publish path, so tenant scoping, redaction, replay routing, and the microtask auto-batch all still apply. Nesting composes: an inner collector flushes into the outer one. The collector returns the function's return value.
|
|
1132
|
+
|
|
1060
1133
|
---
|
|
1061
1134
|
|
|
1062
1135
|
## Volatile RPC (fire-and-forget)
|
|
@@ -1394,6 +1467,58 @@ configure({
|
|
|
1394
1467
|
|
|
1395
1468
|
When offline queuing is enabled, RPC calls made while disconnected return promises that resolve when the call is replayed after reconnection. If the queue overflows, the oldest entry is dropped and its promise rejects with `QUEUE_FULL`. If `maxAge` is set, queued calls older than that threshold are rejected with `STALE` at replay time.
|
|
1396
1469
|
|
|
1470
|
+
### Durable persistence (survive a reload)
|
|
1471
|
+
|
|
1472
|
+
By default the queue lives in memory and dies with the tab. Turn on `persist` and queued mutations survive reloads and browser restarts - stored in IndexedDB, restored on the next load, and replayed on reconnect:
|
|
1473
|
+
|
|
1474
|
+
```js
|
|
1475
|
+
configure({
|
|
1476
|
+
offline: {
|
|
1477
|
+
queue: true,
|
|
1478
|
+
persist: true, // IndexedDB (in-memory fallback under SSR/node)
|
|
1479
|
+
persistKey: currentUserId // scope the stored queue per user
|
|
1480
|
+
}
|
|
1481
|
+
});
|
|
1482
|
+
```
|
|
1483
|
+
|
|
1484
|
+
- **Server-side dedup is guaranteed.** Every persisted mutation carries an idempotency key (synthesized when the call did not supply one), so a replay after reload always dedups: a mutation that reached the server before the crash answers with its original result instead of applying twice. Pair the handlers with `live.idempotent` server-side.
|
|
1485
|
+
- **`persistKey` prevents cross-user replay.** One browser profile, two logins: without a per-user key, user B's session would replay user A's stored mutations. Pass your user id (or any stable scope).
|
|
1486
|
+
- **Restored mutations have no promise holders** (the page reloaded); their outcomes surface through `onReplayError` / `onConflict` only.
|
|
1487
|
+
- **Storage failures degrade, never break.** A blocked or broken IndexedDB (private windows, storage pressure) falls back to in-memory with one dev warning. A custom store implementing `{ getAll, put, delete, clear }` (see `OfflineStore`) can replace IndexedDB entirely.
|
|
1488
|
+
|
|
1489
|
+
### Upload checkpoint and consumer stores
|
|
1490
|
+
|
|
1491
|
+
```js
|
|
1492
|
+
import { pendingMutations, uploading, offlineCheckpoint } from 'svelte-realtime/client';
|
|
1493
|
+
```
|
|
1494
|
+
|
|
1495
|
+
- `pendingMutations` - readable store with the live queued-mutation count; the data source for a "3 pending edits" indicator.
|
|
1496
|
+
- `uploading` - readable store, `true` while a reconnect drain is replaying: the UI backpressure signal ("pause local writes while the upload pipe is busy").
|
|
1497
|
+
- `offlineCheckpoint()` - `{ lastUploadedSeq, gapDetected }`: the highest enqueue seq that replayed successfully, and whether a later mutation succeeded while an earlier one failed (a hole in the upload order - consider refetching affected data). Persisted alongside the queue; `gapDetected` clears on the next fully-clean drain.
|
|
1498
|
+
|
|
1499
|
+
### Conflict resolution (replayed mutations)
|
|
1500
|
+
|
|
1501
|
+
When a REPLAYED mutation is rejected by the server with `LiveError('CONFLICT')` - the canonical app-thrown code for "the server state moved under this write" - the queue resolves it per policy instead of the plain error path:
|
|
1502
|
+
|
|
1503
|
+
```js
|
|
1504
|
+
configure({
|
|
1505
|
+
offline: {
|
|
1506
|
+
queue: true,
|
|
1507
|
+
conflictResolution: 'custom', // 'server-win' (default) | 'lww' | 'custom'
|
|
1508
|
+
onConflict(call, error) {
|
|
1509
|
+
// Return an args array to re-issue ONCE with merged args; anything else drops.
|
|
1510
|
+
return [mergeArgs(call.args, error.data)];
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
});
|
|
1514
|
+
```
|
|
1515
|
+
|
|
1516
|
+
- `'server-win'` (default): drop the local mutation - the server state stands. `onConflict` is notified (return value ignored).
|
|
1517
|
+
- `'lww'`: re-issue the same call once - the local write wins by being applied last.
|
|
1518
|
+
- `'custom'`: `onConflict(call, error)` decides (args array = one re-issue with merged args, anything else = drop).
|
|
1519
|
+
|
|
1520
|
+
One retry ever - a second CONFLICT drops. Server-side, apps encode their preconditions in the handler (a version check, an updated-at compare) and throw `LiveError('CONFLICT')` when they fail. CRDT documents never enter this path: merging is the type's own semantics.
|
|
1521
|
+
|
|
1397
1522
|
---
|
|
1398
1523
|
|
|
1399
1524
|
## Connection hooks
|
|
@@ -2334,6 +2459,49 @@ The six-line shim adapts realtime's options-object call shape to the extensions
|
|
|
2334
2459
|
- `svelte_realtime_cron_errors_total` - cron errors by path
|
|
2335
2460
|
- `svelte_realtime_assertion_violations_total` - production-assertion violations by category (see "Production assertions" below)
|
|
2336
2461
|
|
|
2462
|
+
### Cohort-stratified metrics - never trust the aggregate
|
|
2463
|
+
|
|
2464
|
+
An aggregate p99 that looks healthy can hide a cohort - old devices, cellular links, a far region - that is entirely broken, because the fast majority drowns it in the quantile. Pass a `cohort` classifier and every RPC series carries the caller's cohort label:
|
|
2465
|
+
|
|
2466
|
+
```js
|
|
2467
|
+
live.metrics(registry, {
|
|
2468
|
+
// Classified ONCE per connection from whatever your upgrade hook stashed
|
|
2469
|
+
// on the connection's user data. Keep the cardinality SMALL.
|
|
2470
|
+
cohort: (userData) => `${userData.deviceClass}-${userData.netClass}` // 'desktop-fast', 'mobile-3g', ...
|
|
2471
|
+
});
|
|
2472
|
+
```
|
|
2473
|
+
|
|
2474
|
+
Distinct labels are bounded at 16 (overflow folds into `'other'`; a pre-identification guard path or a throwing classifier reads `'unknown'`). The unlabeled aggregate stays derivable by summing - dashboard the per-cohort series first and the aggregate as context, not the other way around.
|
|
2475
|
+
|
|
2476
|
+
### Per-subsystem performance budgets
|
|
2477
|
+
|
|
2478
|
+
Declare how long a subsystem is ALLOWED to take, measure what it ACTUALLY takes, and let the dashboard render budget vs actual over time - the drift is the early warning, the exceeded counter is the alert:
|
|
2479
|
+
|
|
2480
|
+
```js
|
|
2481
|
+
const tickBudget = live.perfBudget('tick', 8); // 8ms per game tick
|
|
2482
|
+
|
|
2483
|
+
// Per iteration - either wrap:
|
|
2484
|
+
tickBudget.measure(() => runTick());
|
|
2485
|
+
// ...or record a duration you already have:
|
|
2486
|
+
tickBudget.track(elapsedMs);
|
|
2487
|
+
```
|
|
2488
|
+
|
|
2489
|
+
Emits `svelte_realtime_perf_budget_seconds` (gauge - the declaration), `svelte_realtime_perf_actual_seconds` (histogram), and `svelte_realtime_perf_budget_exceeded_total` (counter), all labeled by `subsystem`. Requires `live.metrics()` first.
|
|
2490
|
+
|
|
2491
|
+
### Pause-aware lifeline `/metrics`
|
|
2492
|
+
|
|
2493
|
+
A `/metrics` handler that serializes the registry inline competes with whatever is melting the event loop - exactly when you need the numbers most, the scrape adds work or stalls. The lifeline decouples them: the registry is pre-serialized on a background interval into an in-memory snapshot, and the admin route serves THAT string in O(1):
|
|
2494
|
+
|
|
2495
|
+
```js
|
|
2496
|
+
live.metrics(registry, { lifeline: true }); // or { lifeline: { intervalMs: 5000 } }
|
|
2497
|
+
```
|
|
2498
|
+
|
|
2499
|
+
`GET <adminPath>/metrics` (behind the same fail-closed `realtime({ admin })` auth gate as `/dlq` and `/introspect`) answers with the snapshot; its age rides the `x-snapshot-age-ms` header, so the scraper can tell a fresh read from a wedged renderer. Requires a registry with `serialize()` - the extensions `createMetrics()` qualifies. The plain `app.get('/metrics', metrics.handler)` mount above remains fine for normal operation; the lifeline is the degraded-day path.
|
|
2500
|
+
|
|
2501
|
+
### Push, not poll
|
|
2502
|
+
|
|
2503
|
+
Polling burns CPU and database work re-reading unchanged data and adds up to a full interval of latency; a publish costs nothing until something actually changes. That principle is structural in this framework - loaders re-run on `invalidateOn` topic publishes, `live.derived` recomputes on source publishes, `live.alarm` schedules per-room work - so reaching for a poll loop almost always means a push primitive fits better. `live.poll(fn, intervalMs)` exists for the genuine leftovers (an external system with no change feed) and works exactly as named, but it nudges once per process (dev only) toward the push shapes before you build on it. It returns a stop function.
|
|
2504
|
+
|
|
2337
2505
|
---
|
|
2338
2506
|
|
|
2339
2507
|
## Server introspection
|
|
@@ -4772,7 +4940,26 @@ expect(adminOnly(createTestContext({ user: { role: 'viewer' } }))).toBe(false);
|
|
|
4772
4940
|
expect(adminOnly(createTestContext())).toBe(false);
|
|
4773
4941
|
```
|
|
4774
4942
|
|
|
4775
|
-
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`.
|
|
4943
|
+
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`. `ctx.batch` mirrors the production semantics in both forms - the list form publishes through `ctx.publish` (observable when your test overrides it), and the collector form buffers/flushes/drops exactly like the server, so handler code that relies on the all-or-nothing guarantee tests truthfully.
|
|
4944
|
+
|
|
4945
|
+
### Recorded-response fixtures
|
|
4946
|
+
|
|
4947
|
+
Capture a real response once - from a live run, an integration test, or by hand - and replay it deterministically in unit tests, instead of re-hitting a source that is expensive or flaky to produce live:
|
|
4948
|
+
|
|
4949
|
+
```js
|
|
4950
|
+
import { recordResponse, replayResponse, clearRecordedResponses } from 'svelte-realtime/testing';
|
|
4951
|
+
|
|
4952
|
+
// Once (e.g. pasted from a live capture, or written in a beforeAll):
|
|
4953
|
+
recordResponse('billing/invoice@paid', { ok: true, data: { id: 'inv_1', status: 'paid', total: 4200 } });
|
|
4954
|
+
|
|
4955
|
+
// In tests - a fresh deep copy per call, so mutation never leaks across tests:
|
|
4956
|
+
const res = replayResponse('billing/invoice@paid');
|
|
4957
|
+
expect(renderInvoice(res.data)).toContain('paid');
|
|
4958
|
+
|
|
4959
|
+
afterEach(() => clearRecordedResponses());
|
|
4960
|
+
```
|
|
4961
|
+
|
|
4962
|
+
Responses must be JSON-serializable (wire responses always are); an unknown ref throws with the list of recorded refs, so a typo'd fixture name fails loudly instead of returning `undefined`.
|
|
4776
4963
|
|
|
4777
4964
|
### Asserting guard rejections
|
|
4778
4965
|
|
package/package.json
CHANGED
package/src/client/misc.js
CHANGED
|
@@ -4,11 +4,14 @@ import { now } from '../client-runtime.js';
|
|
|
4
4
|
import { clientState, RpcError, _offlineQueue } from './internal-state.js';
|
|
5
5
|
import { _sendRpc } from './rpc.js';
|
|
6
6
|
import { _ensureHealthSubscription } from './health.js';
|
|
7
|
+
import { _configureOfflinePersistence, _offlineReady, _settlePersist, _bumpPending, _setUploading, _clearGapIfClean } from './offline.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
|
-
* @typedef {{ path: string, args: any[], queuedAt: number, resolve: Function, reject: Function, idempotencyKey?: string, timeout?: number }} OfflineEntry
|
|
10
|
+
* @typedef {{ path: string, args: any[], queuedAt: number, resolve: Function, reject: Function, idempotencyKey?: string, timeout?: number, seq?: number, restored?: boolean }} OfflineEntry
|
|
10
11
|
*/
|
|
11
12
|
|
|
13
|
+
const _CONFLICT_RESOLUTIONS = new Set(['lww', 'server-win', 'custom']);
|
|
14
|
+
|
|
12
15
|
/** @type {boolean} */
|
|
13
16
|
let _configListenerAttached = false;
|
|
14
17
|
|
|
@@ -52,11 +55,36 @@ let _replayingQueue = false;
|
|
|
52
55
|
* a long-lived client running a stale bundle after a breaking deploy knows to reload.
|
|
53
56
|
* Omit it to leave the signal off.
|
|
54
57
|
*
|
|
55
|
-
*
|
|
58
|
+
* The `offline` object grows the durable-queue options: `persist` (true =
|
|
59
|
+
* IndexedDB in a browser, in-memory elsewhere; or a custom store) makes
|
|
60
|
+
* queued mutations survive a reload, `persistKey` scopes the persisted queue
|
|
61
|
+
* (pass your user id so one browser profile never replays user A's mutations
|
|
62
|
+
* as user B), and `conflictResolution` + `onConflict` decide what a REPLAYED
|
|
63
|
+
* mutation does when the server rejects it with `LiveError('CONFLICT')`:
|
|
64
|
+
* `'server-win'` (default) drops it, `'lww'` re-issues it once (the local
|
|
65
|
+
* write wins by being applied last), `'custom'` asks `onConflict(call, error)`
|
|
66
|
+
* - return an args array to re-issue once with merged args, anything else to
|
|
67
|
+
* drop. The `'batch'` replay strategy is an alias of `'concurrent'`.
|
|
68
|
+
*
|
|
69
|
+
* @param {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, timeout?: number, resumeGraceMs?: number, resumeMaxCursorAgeMs?: number, volatileBackpressureBytes?: number, publishRateHint?: boolean, protocolVersion?: number, offline?: { queue?: boolean, maxQueue?: number, maxAge?: number, replay?: 'sequential' | 'concurrent' | 'batch' | ((queue: OfflineEntry[]) => OfflineEntry[]), beforeReplay?: (call: { path: string, args: any[], queuedAt: number }) => boolean, onReplayError?: (call: { path: string, args: any[], queuedAt: number }, error: any) => void, persist?: boolean | import('./offline-store.js').OfflineStore, persistKey?: string, conflictResolution?: 'lww' | 'server-win' | 'custom', onConflict?: (call: { path: string, args: any[], queuedAt: number }, error: any) => any } }} config
|
|
56
70
|
*/
|
|
57
71
|
export function configure(config) {
|
|
58
72
|
clientState.config = config;
|
|
59
73
|
|
|
74
|
+
if (config.offline) {
|
|
75
|
+
const cr = config.offline.conflictResolution;
|
|
76
|
+
if (cr !== undefined && !_CONFLICT_RESOLUTIONS.has(cr)) {
|
|
77
|
+
throw new Error("[svelte-realtime] configure({ offline.conflictResolution }): must be 'lww', 'server-win', or 'custom'");
|
|
78
|
+
}
|
|
79
|
+
if (config.offline.onConflict !== undefined && typeof config.offline.onConflict !== 'function') {
|
|
80
|
+
throw new Error('[svelte-realtime] configure({ offline.onConflict }): must be a function');
|
|
81
|
+
}
|
|
82
|
+
if (cr === 'custom' && typeof config.offline.onConflict !== 'function') {
|
|
83
|
+
throw new Error("[svelte-realtime] configure({ offline }): conflictResolution 'custom' requires an onConflict hook");
|
|
84
|
+
}
|
|
85
|
+
_configureOfflinePersistence(config.offline);
|
|
86
|
+
}
|
|
87
|
+
|
|
60
88
|
// Mirror the server's realtime({ protocolVersion }) integer validation so the
|
|
61
89
|
// shared-constant contract is symmetric and a typo (null / float) cannot quietly
|
|
62
90
|
// advertise a bad version that latches the client to a permanent 'outdated'.
|
|
@@ -105,80 +133,146 @@ export function configure(config) {
|
|
|
105
133
|
}
|
|
106
134
|
|
|
107
135
|
/**
|
|
108
|
-
* Drain the offline queue on reconnection.
|
|
136
|
+
* Drain the offline queue on reconnection. Awaits the one-shot persistence
|
|
137
|
+
* rehydrate first (a drain must never race the restore and replay a
|
|
138
|
+
* half-loaded queue), holds the `uploading` backpressure signal while
|
|
139
|
+
* replaying, settles each entry's durability (persisted copy dropped,
|
|
140
|
+
* checkpoint advanced on success), and routes CONFLICT rejections through
|
|
141
|
+
* the configured resolution instead of the plain error path.
|
|
109
142
|
*/
|
|
110
143
|
async function _drainOfflineQueue() {
|
|
111
|
-
if (
|
|
144
|
+
if (_replayingQueue) return;
|
|
112
145
|
_replayingQueue = true;
|
|
146
|
+
try {
|
|
147
|
+
await _offlineReady();
|
|
148
|
+
if (_offlineQueue.length === 0) return;
|
|
149
|
+
_setUploading(true);
|
|
150
|
+
|
|
151
|
+
const offlineOpts = clientState.config.offline;
|
|
152
|
+
const beforeReplay = offlineOpts?.beforeReplay;
|
|
153
|
+
const onReplayError = offlineOpts?.onReplayError;
|
|
154
|
+
const onConflict = offlineOpts?.onConflict;
|
|
155
|
+
const conflictResolution = offlineOpts?.conflictResolution || 'server-win';
|
|
156
|
+
const maxAge = offlineOpts?.maxAge || 0;
|
|
157
|
+
const nowMs = now();
|
|
113
158
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
let queue = [];
|
|
123
|
-
for (const entry of _offlineQueue) {
|
|
124
|
-
if (maxAge > 0 && nowMs - entry.queuedAt > maxAge) {
|
|
125
|
-
entry.reject(new RpcError('STALE', 'Offline mutation expired'));
|
|
126
|
-
continue;
|
|
159
|
+
// Track whether any earlier-seq entry failed in THIS drain: a later
|
|
160
|
+
// success then leaves a hole in the upload order (gapDetected).
|
|
161
|
+
let anyFailure = false;
|
|
162
|
+
|
|
163
|
+
/** @param {OfflineEntry} entry @param {any} result */
|
|
164
|
+
function settleOk(entry, result) {
|
|
165
|
+
_settlePersist(entry, true, anyFailure);
|
|
166
|
+
entry.resolve(result);
|
|
127
167
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
168
|
+
|
|
169
|
+
/** @param {OfflineEntry} entry @param {any} err */
|
|
170
|
+
function settleFail(entry, err) {
|
|
171
|
+
anyFailure = true;
|
|
172
|
+
_settlePersist(entry, false);
|
|
173
|
+
if (onReplayError) {
|
|
174
|
+
onReplayError({ path: entry.path, args: entry.args, queuedAt: entry.queuedAt }, err);
|
|
133
175
|
}
|
|
176
|
+
entry.reject(err);
|
|
134
177
|
}
|
|
135
|
-
queue.push(entry);
|
|
136
|
-
}
|
|
137
|
-
_offlineQueue.length = 0;
|
|
138
178
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
179
|
+
/**
|
|
180
|
+
* Replay one entry, applying the conflict stance: a CONFLICT rejection
|
|
181
|
+
* (the canonical app-thrown "server state moved under this write" code)
|
|
182
|
+
* resolves per policy - server-win drops, lww re-issues the same call
|
|
183
|
+
* once, custom asks onConflict for merged args (array = one re-issue,
|
|
184
|
+
* anything else = drop). One retry ever; a second CONFLICT drops.
|
|
185
|
+
* @param {OfflineEntry} entry
|
|
186
|
+
*/
|
|
187
|
+
async function replayOne(entry) {
|
|
188
|
+
let args = entry.args;
|
|
189
|
+
let retried = false;
|
|
190
|
+
for (;;) {
|
|
191
|
+
try {
|
|
192
|
+
const result = await _sendRpc(entry.path, args, entry.idempotencyKey, entry.timeout);
|
|
193
|
+
settleOk(entry, result);
|
|
194
|
+
return;
|
|
195
|
+
} catch (err) {
|
|
196
|
+
if (err && err.code === 'CONFLICT' && !retried) {
|
|
197
|
+
const call = { path: entry.path, args, queuedAt: entry.queuedAt };
|
|
198
|
+
if (conflictResolution === 'lww') {
|
|
199
|
+
retried = true;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (conflictResolution === 'custom') {
|
|
203
|
+
let merged;
|
|
204
|
+
try { merged = onConflict ? onConflict(call, err) : undefined; } catch { merged = undefined; }
|
|
205
|
+
if (Array.isArray(merged)) {
|
|
206
|
+
retried = true;
|
|
207
|
+
args = merged;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
settleFail(entry, err);
|
|
211
|
+
return;
|
|
158
212
|
}
|
|
159
|
-
|
|
213
|
+
// server-win: the server state stands; notify and drop.
|
|
214
|
+
if (onConflict) {
|
|
215
|
+
try { onConflict(call, err); } catch { /* observer only */ }
|
|
216
|
+
}
|
|
217
|
+
settleFail(entry, err);
|
|
218
|
+
return;
|
|
160
219
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
220
|
+
settleFail(entry, err);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
165
224
|
}
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
225
|
+
|
|
226
|
+
// Filter the queue
|
|
227
|
+
/** @type {OfflineEntry[]} */
|
|
228
|
+
let queue = [];
|
|
229
|
+
for (const entry of _offlineQueue) {
|
|
230
|
+
if (maxAge > 0 && nowMs - entry.queuedAt > maxAge) {
|
|
231
|
+
settleFail(entry, new RpcError('STALE', 'Offline mutation expired'));
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (beforeReplay) {
|
|
235
|
+
const keep = beforeReplay({ path: entry.path, args: entry.args, queuedAt: entry.queuedAt });
|
|
236
|
+
if (!keep) {
|
|
237
|
+
settleFail(entry, new RpcError('STALE', 'Offline mutation dropped by beforeReplay filter'));
|
|
238
|
+
continue;
|
|
175
239
|
}
|
|
176
|
-
entry.reject(err);
|
|
177
240
|
}
|
|
241
|
+
queue.push(entry);
|
|
178
242
|
}
|
|
179
|
-
|
|
243
|
+
_offlineQueue.length = 0;
|
|
244
|
+
_bumpPending();
|
|
180
245
|
|
|
181
|
-
|
|
246
|
+
// Apply custom filter function
|
|
247
|
+
if (typeof offlineOpts?.replay === 'function') {
|
|
248
|
+
queue = offlineOpts.replay(queue);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Replay using the configured strategy
|
|
252
|
+
const strategy = offlineOpts?.replay;
|
|
253
|
+
if ((strategy === 'concurrent' || strategy === 'batch') && queue.length > 0) {
|
|
254
|
+
// Concurrent strategy: send queued calls with concurrency limit to avoid flooding
|
|
255
|
+
const concurrency = 10;
|
|
256
|
+
for (let i = 0; i < queue.length; i += concurrency) {
|
|
257
|
+
const chunk = queue.slice(i, i + concurrency);
|
|
258
|
+
await Promise.all(chunk.map((entry) => replayOne(entry)));
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
// Sequential strategy (default)
|
|
262
|
+
for (const entry of queue) {
|
|
263
|
+
await replayOne(entry);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// A fully-clean drain (no failure, nothing left queued) closes any
|
|
268
|
+
// upload-order hole a PREVIOUS drain left; a drain that itself failed
|
|
269
|
+
// must leave the gap visible for the app to act on.
|
|
270
|
+
if (!anyFailure) _clearGapIfClean();
|
|
271
|
+
} finally {
|
|
272
|
+
_bumpPending();
|
|
273
|
+
_setUploading(false);
|
|
274
|
+
_replayingQueue = false;
|
|
275
|
+
}
|
|
182
276
|
}
|
|
183
277
|
|
|
184
278
|
/**
|