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 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, idempotency, durable } }
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 (the push registry, presence rosters - clearing the grace timer and decrementing the cluster count, rate-limit buckets, and idempotency cached results via a per-user reverse index), 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
+ 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. 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 - use the `onForget` hook to delete app-owned documents.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-realtime",
3
- "version": "0.6.0-next.78",
3
+ "version": "0.6.0-next.79",
4
4
  "publishConfig": {
5
5
  "tag": "next"
6
6
  },
@@ -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
- * @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' | 'batch' | ((queue: OfflineEntry[]) => OfflineEntry[]), beforeReplay?: (call: { path: string, args: any[], queuedAt: number }) => boolean, onReplayError?: (call: { path: string, args: any[], queuedAt: number }, error: any) => void } }} config
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 (_offlineQueue.length === 0 || _replayingQueue) return;
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
- const offlineOpts = clientState.config.offline;
115
- const beforeReplay = offlineOpts?.beforeReplay;
116
- const onReplayError = offlineOpts?.onReplayError;
117
- const maxAge = offlineOpts?.maxAge || 0;
118
- const nowMs = now();
119
-
120
- // Filter the queue
121
- /** @type {OfflineEntry[]} */
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
- if (beforeReplay) {
129
- const keep = beforeReplay({ path: entry.path, args: entry.args, queuedAt: entry.queuedAt });
130
- if (!keep) {
131
- entry.reject(new RpcError('STALE', 'Offline mutation dropped by beforeReplay filter'));
132
- continue;
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
- // Apply custom filter function
140
- if (typeof offlineOpts?.replay === 'function') {
141
- queue = offlineOpts.replay(queue);
142
- }
143
-
144
- // Replay using the configured strategy
145
- const strategy = offlineOpts?.replay;
146
- if ((strategy === 'concurrent' || strategy === 'batch') && queue.length > 0) {
147
- // Concurrent strategy: send queued calls with concurrency limit to avoid flooding
148
- const concurrency = 10;
149
- for (let i = 0; i < queue.length; i += concurrency) {
150
- const chunk = queue.slice(i, i + concurrency);
151
- const promises = chunk.map(entry => {
152
- const promise = _sendRpc(entry.path, entry.args, entry.idempotencyKey, entry.timeout);
153
- promise.then(
154
- (result) => entry.resolve(result),
155
- (err) => {
156
- if (onReplayError) {
157
- onReplayError({ path: entry.path, args: entry.args, queuedAt: entry.queuedAt }, err);
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
- entry.reject(err);
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
- return promise.catch(() => {}); // swallow for Promise.all
163
- });
164
- await Promise.all(promises);
220
+ settleFail(entry, err);
221
+ return;
222
+ }
223
+ }
165
224
  }
166
- } else {
167
- // Sequential strategy (default)
168
- for (const entry of queue) {
169
- try {
170
- const result = await _sendRpc(entry.path, entry.args, entry.idempotencyKey, entry.timeout);
171
- entry.resolve(result);
172
- } catch (err) {
173
- if (onReplayError) {
174
- onReplayError({ path: entry.path, args: entry.args, queuedAt: entry.queuedAt }, err);
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
- _replayingQueue = false;
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
  /**