on-zero 0.6.2 → 0.6.4

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.
Files changed (82) hide show
  1. package/dist/cjs/createZeroClient.cjs +91 -12
  2. package/dist/cjs/createZeroClient.connection.test.cjs +163 -0
  3. package/dist/cjs/createZeroClient.connection.test.native.js +242 -0
  4. package/dist/cjs/createZeroClient.connection.test.native.js.map +1 -0
  5. package/dist/cjs/createZeroClient.native.js +99 -12
  6. package/dist/cjs/createZeroClient.native.js.map +1 -1
  7. package/dist/cjs/createZeroClient.recovery.test.cjs +52 -0
  8. package/dist/cjs/createZeroClient.recovery.test.native.js +52 -0
  9. package/dist/cjs/createZeroClient.recovery.test.native.js.map +1 -1
  10. package/dist/cjs/helpers/recoverZeroClient.cjs +197 -14
  11. package/dist/cjs/helpers/recoverZeroClient.native.js +220 -24
  12. package/dist/cjs/helpers/recoverZeroClient.native.js.map +1 -1
  13. package/dist/cjs/helpers/recoverZeroClient.test.cjs +241 -0
  14. package/dist/cjs/helpers/recoverZeroClient.test.native.js +269 -0
  15. package/dist/cjs/helpers/recoverZeroClient.test.native.js.map +1 -1
  16. package/dist/cjs/index.cjs +2 -0
  17. package/dist/cjs/index.native.js +2 -0
  18. package/dist/cjs/index.native.js.map +1 -1
  19. package/dist/cjs/multi.cjs +13 -0
  20. package/dist/cjs/multi.native.js +35 -0
  21. package/dist/cjs/multi.native.js.map +1 -1
  22. package/dist/cjs/multiPartition.test.cjs +42 -0
  23. package/dist/cjs/multiPartition.test.native.js +51 -0
  24. package/dist/cjs/multiPartition.test.native.js.map +1 -0
  25. package/dist/esm/createZeroClient.connection.test.mjs +164 -0
  26. package/dist/esm/createZeroClient.connection.test.mjs.map +1 -0
  27. package/dist/esm/createZeroClient.connection.test.native.js +240 -0
  28. package/dist/esm/createZeroClient.connection.test.native.js.map +1 -0
  29. package/dist/esm/createZeroClient.mjs +92 -13
  30. package/dist/esm/createZeroClient.mjs.map +1 -1
  31. package/dist/esm/createZeroClient.native.js +100 -13
  32. package/dist/esm/createZeroClient.native.js.map +1 -1
  33. package/dist/esm/createZeroClient.recovery.test.mjs +52 -0
  34. package/dist/esm/createZeroClient.recovery.test.mjs.map +1 -1
  35. package/dist/esm/createZeroClient.recovery.test.native.js +52 -0
  36. package/dist/esm/createZeroClient.recovery.test.native.js.map +1 -1
  37. package/dist/esm/helpers/recoverZeroClient.mjs +196 -15
  38. package/dist/esm/helpers/recoverZeroClient.mjs.map +1 -1
  39. package/dist/esm/helpers/recoverZeroClient.native.js +219 -25
  40. package/dist/esm/helpers/recoverZeroClient.native.js.map +1 -1
  41. package/dist/esm/helpers/recoverZeroClient.test.mjs +242 -1
  42. package/dist/esm/helpers/recoverZeroClient.test.mjs.map +1 -1
  43. package/dist/esm/helpers/recoverZeroClient.test.native.js +270 -1
  44. package/dist/esm/helpers/recoverZeroClient.test.native.js.map +1 -1
  45. package/dist/esm/index.js +2 -2
  46. package/dist/esm/index.js.map +1 -1
  47. package/dist/esm/index.mjs +2 -2
  48. package/dist/esm/index.mjs.map +1 -1
  49. package/dist/esm/index.native.js +2 -2
  50. package/dist/esm/index.native.js.map +1 -1
  51. package/dist/esm/multi.mjs +13 -1
  52. package/dist/esm/multi.mjs.map +1 -1
  53. package/dist/esm/multi.native.js +35 -1
  54. package/dist/esm/multi.native.js.map +1 -1
  55. package/dist/esm/multiPartition.test.mjs +43 -0
  56. package/dist/esm/multiPartition.test.mjs.map +1 -0
  57. package/dist/esm/multiPartition.test.native.js +49 -0
  58. package/dist/esm/multiPartition.test.native.js.map +1 -0
  59. package/lint/ssr-guards.js +164 -0
  60. package/package.json +2 -2
  61. package/readme.md +126 -0
  62. package/src/createZeroClient.connection.test.tsx +162 -0
  63. package/src/createZeroClient.recovery.test.tsx +68 -0
  64. package/src/createZeroClient.tsx +159 -13
  65. package/src/helpers/recoverZeroClient.test.ts +270 -1
  66. package/src/helpers/recoverZeroClient.ts +347 -50
  67. package/src/index.ts +6 -0
  68. package/src/multi.ts +33 -0
  69. package/src/multiPartition.test.ts +38 -0
  70. package/types/createZeroClient.connection.test.d.ts +5 -0
  71. package/types/createZeroClient.connection.test.d.ts.map +1 -0
  72. package/types/createZeroClient.d.ts +17 -0
  73. package/types/createZeroClient.d.ts.map +1 -1
  74. package/types/createZeroServer.d.ts +4 -4
  75. package/types/helpers/recoverZeroClient.d.ts +23 -1
  76. package/types/helpers/recoverZeroClient.d.ts.map +1 -1
  77. package/types/index.d.ts +1 -1
  78. package/types/index.d.ts.map +1 -1
  79. package/types/multi.d.ts +1 -0
  80. package/types/multi.d.ts.map +1 -1
  81. package/types/multiPartition.test.d.ts +2 -0
  82. package/types/multiPartition.test.d.ts.map +1 -0
@@ -29,7 +29,10 @@ import { registerClientInstance } from './instanceRegistry'
29
29
  import { createMutators } from './helpers/createMutators'
30
30
  import {
31
31
  composeRecoveryLogSink,
32
+ isRecoverableZeroStalePokeMessage,
32
33
  makeZeroRecovery,
34
+ type RecoveryGuardStorage,
35
+ type ScheduleReloadContext,
33
36
  type ZeroRecoveryDeps,
34
37
  } from './helpers/recoverZeroClient'
35
38
  import { getAuth } from './helpers/getAuth'
@@ -377,6 +380,20 @@ export function createZeroClientInternal<
377
380
  // with different identities would thrash this slot.
378
381
  let cachedZero: { key: string; instance: ZeroInstance } | null = null
379
382
 
383
+ // in-place re-mint: drop the current instance's local state then reconstruct a
384
+ // fresh client WITHOUT a page reload — the native-safe recovery path (a reload
385
+ // may never land on prod native, wedging the module latch). the mounted
386
+ // provider registers a bump() that changes its instanceKey; remint() drives it
387
+ // through the same rotate effect a real identity change uses. guarded in-memory
388
+ // (Hermes has no sessionStorage) so a client-not-found storm can't reconstruct
389
+ // in a tight loop.
390
+ const REMINT_GUARD_MS = 12_000
391
+ const REMINT_MAX_ATTEMPTS = 5
392
+ const REMINT_ATTEMPT_RESET_MS = 60_000
393
+ const remintControl: { bump: (() => void) | null } = { bump: null }
394
+ let lastRemintAt = 0
395
+ let remintAttempts = 0
396
+
380
397
  function invalidateZeroInstance(instanceToInvalidate: ZeroInstance | null): void {
381
398
  if (!instanceToInvalidate) return
382
399
 
@@ -406,6 +423,34 @@ export function createZeroClientInternal<
406
423
  }
407
424
  }
408
425
 
426
+ // supported in-place recovery: reconstruct a fresh Zero client without a page
427
+ // reload. by default drops the current instance's local store first (a
428
+ // ClientNotFound / desync means it's unusable), then bumps the provider's
429
+ // instanceKey so the rotate effect mints a clean client. returns false when
430
+ // suppressed by the rate guard or when no provider is mounted.
431
+ async function remint(opts: { dropLocalState?: boolean } = {}): Promise<boolean> {
432
+ // no mounted provider to reconstruct through — bail BEFORE the guard so an
433
+ // unmounted call doesn't burn an attempt or start the cooldown.
434
+ if (!remintControl.bump) return false
435
+ const now = Date.now()
436
+ const sinceLast = now - lastRemintAt
437
+ if (lastRemintAt > 0 && sinceLast < REMINT_GUARD_MS) return false
438
+ if (sinceLast > REMINT_ATTEMPT_RESET_MS) remintAttempts = 0
439
+ if (remintAttempts >= REMINT_MAX_ATTEMPTS) return false
440
+ lastRemintAt = now
441
+ remintAttempts += 1
442
+
443
+ const { dropLocalState = true } = opts
444
+ if (dropLocalState && latestZeroInstance) {
445
+ await deleteZeroInstance(latestZeroInstance).catch(() => {})
446
+ }
447
+ // re-check: the provider may have unmounted during the async delete.
448
+ const bump = remintControl.bump
449
+ if (!bump) return false
450
+ bump()
451
+ return true
452
+ }
453
+
409
454
  // when ProvideZero is rendered without a real Zero instance (SSG, disable=true,
410
455
  // or transiently while the active path is still creating its first instance),
411
456
  // we want descendants' useZero() / useConnectionState() / on-zero useQuery to
@@ -459,6 +504,23 @@ export function createZeroClientInternal<
459
504
  // awaited before a self-healing recovery reload — e.g. wait for the dev
460
505
  // origin to be reachable so the reload doesn't hit a restarting server.
461
506
  beforeReload?: () => Promise<void>
507
+ // take over WHEN/HOW the recovery reload happens (IDE gate + countdown,
508
+ // native expo-updates reload, …) while still driving the same
509
+ // deletes-then-reload work via ctx.performReload. default: immediate reload.
510
+ scheduleReload?: (ctx: ScheduleReloadContext) => void
511
+ // cross-reload loop-guard backing store; defaults to sessionStorage on web.
512
+ // inject a native KV so Hermes gets real cross-reload protection.
513
+ guardStorage?: RecoveryGuardStorage
514
+ // return true for a classified recovery log the app wants treated as benign
515
+ // (its own cold-boot timeout, say) so it does NOT trigger recovery.
516
+ benignLogFilter?: (message: string) => boolean
517
+ // called when the connection needs auth; return a fresh token to reconnect
518
+ // in place. lets an expired token auto-recover without a reload.
519
+ refreshAuth?: () => Promise<string | undefined>
520
+ // when true, mirror this instance's connection state onto
521
+ // document.body.dataset.zero* for e2e/diagnostics. enable on ONE instance
522
+ // (the control/primary) so multiple instances don't fight over the dataset.
523
+ connectionDataset?: boolean
462
524
  }
463
525
 
464
526
  // providezero keeps the same provider/fiber layout on ssr and client so
@@ -493,6 +555,11 @@ export function createZeroClientInternal<
493
555
  transport,
494
556
  pullIntervalMs = 30_000,
495
557
  beforeReload,
558
+ scheduleReload,
559
+ guardStorage,
560
+ benignLogFilter,
561
+ refreshAuth,
562
+ connectionDataset,
496
563
  disable,
497
564
  ...props
498
565
  }: ProvideZeroProps) => {
@@ -532,10 +599,23 @@ export function createZeroClientInternal<
532
599
  // eslint-disable-next-line react-hooks/exhaustive-deps
533
600
  }, [])
534
601
 
602
+ // remint() reconstructs the client in place by bumping this counter, which
603
+ // changes instanceKey and drives the rotate effect exactly as a real
604
+ // identity change does. register the bump so the imperative remint() API can
605
+ // reach this mounted provider.
606
+ const [remintGeneration, setRemintGeneration] = useState(0)
607
+ useEffect(() => {
608
+ remintControl.bump = () => setRemintGeneration((generation) => generation + 1)
609
+ return () => {
610
+ remintControl.bump = null
611
+ }
612
+ }, [])
613
+
535
614
  // identity = every primitive option except the auth token value (token
536
615
  // changes refresh in place below; logged-in <-> logged-out still rotates
537
616
  // via hasAuth, matching zero's documented provider semantics). function
538
617
  // props (callbacks, logSink, batchViewUpdates) are bound at construction.
618
+ // remintGeneration is included so remint() forces a fresh instance.
539
619
  const instanceKey = JSON.stringify([
540
620
  Object.entries({ kvStore: 'mem', ...props })
541
621
  .filter(
@@ -545,6 +625,7 @@ export function createZeroClientInternal<
545
625
  .sort(([a], [b]) => (a < b ? -1 : 1)),
546
626
  hasAuth,
547
627
  transport,
628
+ remintGeneration,
548
629
  ])
549
630
 
550
631
  // create/rotate in an effect — commit-safe: a discarded concurrent render
@@ -589,6 +670,9 @@ export function createZeroClientInternal<
589
670
  deleteLocalState: () => deleteZeroInstance(instanceRef.current),
590
671
  zeroEvents,
591
672
  beforeReload,
673
+ scheduleReload,
674
+ guardStorage,
675
+ benignLogFilter,
592
676
  }
593
677
  const recovery = makeZeroRecovery(recoveryDeps)
594
678
  const createdInstance = new ZeroClient<Schema, ZeroMutators>({
@@ -653,7 +737,19 @@ export function createZeroClientInternal<
653
737
  <DisabledContext.Provider value={liveInstance ? false : 'empty'}>
654
738
  <ZeroContext.Provider value={liveInstance ?? (DISABLED_ZERO_STUB as any)}>
655
739
  {liveInstance ? <SetZeroInstance /> : null}
656
- {liveInstance ? <ConnectionMonitor zeroEvents={zeroEvents} /> : null}
740
+ {liveInstance ? (
741
+ <ConnectionMonitor
742
+ zeroEvents={zeroEvents}
743
+ refreshAuth={refreshAuth}
744
+ exposeDataset={connectionDataset}
745
+ datasetCacheUrl={
746
+ connectionDataset
747
+ ? ((props as { cacheURL?: string; server?: string }).cacheURL ??
748
+ (props as { cacheURL?: string; server?: string }).server)
749
+ : undefined
750
+ }
751
+ />
752
+ ) : null}
657
753
  {children}
658
754
  </ZeroContext.Provider>
659
755
  </DisabledContext.Provider>
@@ -689,30 +785,79 @@ export function createZeroClientInternal<
689
785
  return null
690
786
  }
691
787
 
692
- // monitors connection state and emits events (replaces onError callback removed in 0.25)
788
+ // monitors connection state and emits events (replaces onError callback removed
789
+ // in 0.25). also owns the generic-Zero connection recovery that used to live in
790
+ // each consumer: stale-poke reconnect, needs-auth token refresh, and optional
791
+ // e2e dataset bookkeeping.
693
792
  const ConnectionMonitor = memo(
694
793
  ({
695
794
  zeroEvents,
795
+ refreshAuth,
796
+ exposeDataset,
797
+ datasetCacheUrl,
696
798
  }: {
697
799
  zeroEvents: ReturnType<typeof createEmitter<ZeroEvent | null>>
800
+ refreshAuth?: () => Promise<string | undefined>
801
+ exposeDataset?: boolean
802
+ datasetCacheUrl?: string
698
803
  }) => {
804
+ const zeroInstance = useZero<Schema, ZeroMutators>()
699
805
  const state = useConnectionState()
700
806
  const prevState = useRef(state.name)
807
+ // one reconnect per distinct stale-poke reason / one refresh per needs-auth
808
+ // transition, so a stuck error state doesn't retry-storm.
809
+ const staleReconnectRef = useRef<string | null>(null)
810
+ const needsAuthRef = useRef(false)
701
811
 
702
812
  useEffect(() => {
703
- if (state.name !== prevState.current) {
704
- const reason = 'reason' in state ? state.reason : ''
705
- prevState.current = state.name
706
-
707
- if (state.name === 'error' || state.name === 'needs-auth') {
708
- const message = typeof reason === 'string' ? reason : state.name
709
- zeroEvents.emit({
710
- type: 'error',
711
- message,
712
- })
813
+ const name = state.name
814
+ const reason =
815
+ 'reason' in state && typeof state.reason === 'string' ? state.reason : ''
816
+
817
+ // mirror connection state onto the body dataset for e2e/diagnostics
818
+ // (enabled on one instance so instances don't clobber each other).
819
+ if (exposeDataset && typeof document !== 'undefined' && document.body) {
820
+ document.body.dataset.zeroState = name
821
+ if (datasetCacheUrl) document.body.dataset.zeroCacheUrl = datasetCacheUrl
822
+ if (reason) document.body.dataset.zeroReason = reason.slice(0, 200)
823
+ else delete document.body.dataset.zeroReason
824
+ if (name === 'connected') document.body.dataset.zeroConnected = 'true'
825
+ else delete document.body.dataset.zeroConnected
826
+ }
827
+
828
+ // stale-poke / stale-cookie: the local view is behind the server
829
+ // snapshot; a plain reconnect resolves it. not fatal — don't emit error.
830
+ if (name === 'error' && isRecoverableZeroStalePokeMessage(reason)) {
831
+ if (staleReconnectRef.current !== reason) {
832
+ staleReconnectRef.current = reason
833
+ void Promise.resolve(zeroInstance.connection?.connect?.()).catch(() => {})
834
+ }
835
+ return
836
+ }
837
+ if (name !== 'error') staleReconnectRef.current = null
838
+
839
+ // needs-auth: the token expired and zero won't auto-resume unless the
840
+ // auth string changes. refresh it and reconnect in place, once.
841
+ if (name === 'needs-auth') {
842
+ if (refreshAuth && !needsAuthRef.current) {
843
+ needsAuthRef.current = true
844
+ void refreshAuth()
845
+ .then((token) => {
846
+ if (token) zeroInstance.connection?.connect?.({ auth: token })
847
+ })
848
+ .catch(() => {})
849
+ }
850
+ } else {
851
+ needsAuthRef.current = false
852
+ }
853
+
854
+ if (name !== prevState.current) {
855
+ prevState.current = name
856
+ if (name === 'error' || name === 'needs-auth') {
857
+ zeroEvents.emit({ type: 'error', message: reason || name })
713
858
  }
714
859
  }
715
- }, [state, zeroEvents])
860
+ }, [state, zeroEvents, zeroInstance, refreshAuth, exposeDataset, datasetCacheUrl])
716
861
 
717
862
  return null
718
863
  },
@@ -779,5 +924,6 @@ export function createZeroClientInternal<
779
924
  zero,
780
925
  preload,
781
926
  getQuery,
927
+ remint,
782
928
  }
783
929
  }
@@ -4,12 +4,14 @@ import { UpdateNeededReasonType } from '@rocicorp/zero'
4
4
  import { beforeEach, describe, expect, test, vi } from 'vitest'
5
5
 
6
6
  import {
7
+ classifyZeroRecoveryLog,
7
8
  composeRecoveryLogSink,
9
+ isRecoverableZeroStalePokeMessage,
8
10
  makeZeroRecovery,
9
11
  resetRecoveryStateForTests,
10
12
  } from './recoverZeroClient'
11
13
 
12
- import type { ZeroRecoveryDeps } from './recoverZeroClient'
14
+ import type { ScheduleReloadContext, ZeroRecoveryDeps } from './recoverZeroClient'
13
15
  import type { ZeroEvent } from '../types'
14
16
 
15
17
  let emitterSeq = 0
@@ -131,6 +133,38 @@ describe('zero recovery', () => {
131
133
  expect(reload).toHaveBeenCalledTimes(1)
132
134
  })
133
135
 
136
+ test('logSink recovers on native sqlite finalized-statement local-store loss', async () => {
137
+ const { deps, deleteLocalState, reload } = setup()
138
+ const sqliteError = new Error('This statement has been finalized')
139
+ sqliteError.name = 'SqliteError'
140
+ const sink = composeRecoveryLogSink(deps)
141
+ sink.log('error', { bgIntervalProcess: 'Heartbeat' }, 'Error running.', sqliteError)
142
+ await flush()
143
+ expect(deleteLocalState).toHaveBeenCalledTimes(1)
144
+ expect(reload).toHaveBeenCalledTimes(1)
145
+ })
146
+
147
+ test('logSink recovers on repeated store-closed local-store loss', async () => {
148
+ const nowSpy = vi.spyOn(Date, 'now')
149
+ const { deps, deleteLocalState, reload } = setup()
150
+ try {
151
+ const sink = composeRecoveryLogSink(deps)
152
+ nowSpy.mockReturnValue(10_000)
153
+ sink.log('error', undefined, 'Failed to connect.', new Error('Store is closed'))
154
+ await flush()
155
+ expect(deleteLocalState).not.toHaveBeenCalled()
156
+ expect(reload).not.toHaveBeenCalled()
157
+
158
+ nowSpy.mockReturnValue(13_000)
159
+ sink.log('error', undefined, 'Failed to connect.', new Error('Store is closed'))
160
+ await flush()
161
+ expect(deleteLocalState).toHaveBeenCalledTimes(1)
162
+ expect(reload).toHaveBeenCalledTimes(1)
163
+ } finally {
164
+ nowSpy.mockRestore()
165
+ }
166
+ })
167
+
134
168
  test('logSink with no consumer preserves console output and still watches', async () => {
135
169
  const { deps, deleteLocalState, reload } = setup()
136
170
  const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
@@ -170,6 +204,39 @@ describe('zero recovery', () => {
170
204
  expect(reload).not.toHaveBeenCalled()
171
205
  })
172
206
 
207
+ test('recovery log classification is narrow', () => {
208
+ const sqliteError = new Error('This statement has been finalized')
209
+ sqliteError.name = 'SqliteError'
210
+ expect(
211
+ classifyZeroRecoveryLog('error', ['Error running.', sqliteError]),
212
+ ).toMatchObject({
213
+ reason: 'sqlite-statement-finalized',
214
+ dropLocalState: true,
215
+ })
216
+ expect(
217
+ classifyZeroRecoveryLog('error', [
218
+ { name: 'SqliteError', message: 'database is locked' },
219
+ ]),
220
+ ).toBeUndefined()
221
+ expect(
222
+ classifyZeroRecoveryLog('warn', ['Expected IndexedDB not found']),
223
+ ).toBeUndefined()
224
+ expect(classifyZeroRecoveryLog('error', ['Store is closed'], 10_000)).toBeUndefined()
225
+ expect(classifyZeroRecoveryLog('error', ['Store is closed'], 11_000)).toBeUndefined()
226
+ expect(classifyZeroRecoveryLog('error', ['Store is closed'], 14_000)).toMatchObject({
227
+ reason: 'store-closed-repeat',
228
+ dropLocalState: true,
229
+ })
230
+ resetRecoveryStateForTests()
231
+ expect(
232
+ classifyZeroRecoveryLog(
233
+ 'error',
234
+ ['Mutator "send" error on server', 'Store is closed'],
235
+ 20_000,
236
+ ),
237
+ ).toBeUndefined()
238
+ })
239
+
173
240
  // a host with a `window` shim but no real `location` — the sootsim tenant
174
241
  // render-worker, which hides `location` for isolation. the DEFAULT reload
175
242
  // path (no injected `deps.reload`) must still drop stale IDB and not throw
@@ -208,4 +275,206 @@ describe('zero recovery', () => {
208
275
  reason: 'client state not found',
209
276
  })
210
277
  })
278
+
279
+ test('scheduleReload defers the reload AND the store delete until performReload runs', async () => {
280
+ const { deps, deleteLocalState, reload } = setup()
281
+ let captured: ScheduleReloadContext | undefined
282
+ deps.scheduleReload = (ctx) => {
283
+ captured = ctx
284
+ }
285
+ makeZeroRecovery(deps).onClientStateNotFound()
286
+ await flush()
287
+ // deferred: nothing reloaded and the store is NOT yet deleted (so the app
288
+ // isn't left running on a deleted store while the reload is gated).
289
+ expect(reload).not.toHaveBeenCalled()
290
+ expect(deleteLocalState).not.toHaveBeenCalled()
291
+ expect(captured).toMatchObject({
292
+ reasonKey: 'client-state-not-found',
293
+ dropLocalState: true,
294
+ })
295
+ await captured!.performReload()
296
+ await flush()
297
+ expect(deleteLocalState).toHaveBeenCalledTimes(1)
298
+ expect(reload).toHaveBeenCalledTimes(1)
299
+ })
300
+
301
+ test('scheduleReload that reloads directly skips the delete and beforeReload', async () => {
302
+ const { deps, deleteLocalState, reload } = setup()
303
+ const nativeReload = vi.fn()
304
+ const beforeReload = vi.fn(async () => {})
305
+ deps.beforeReload = beforeReload
306
+ deps.scheduleReload = () => {
307
+ nativeReload()
308
+ }
309
+
310
+ makeZeroRecovery(deps).onClientStateNotFound()
311
+ await flush()
312
+
313
+ expect(nativeReload).toHaveBeenCalledTimes(1)
314
+ expect(deleteLocalState).not.toHaveBeenCalled()
315
+ expect(beforeReload).not.toHaveBeenCalled()
316
+ expect(reload).not.toHaveBeenCalled()
317
+ })
318
+
319
+ test('scheduleReload performReload is idempotent (one reload even if called twice)', async () => {
320
+ const { deps, deleteLocalState, reload } = setup()
321
+ let captured: ScheduleReloadContext | undefined
322
+ deps.scheduleReload = (ctx) => {
323
+ captured = ctx
324
+ }
325
+ makeZeroRecovery(deps).onClientStateNotFound()
326
+ await flush()
327
+ await captured!.performReload()
328
+ await captured!.performReload()
329
+ await flush()
330
+ expect(deleteLocalState).toHaveBeenCalledTimes(1)
331
+ expect(reload).toHaveBeenCalledTimes(1)
332
+ })
333
+
334
+ test('scheduleReload deferred PAST the latch timeout still deletes and reloads exactly once', async () => {
335
+ vi.useFakeTimers()
336
+ try {
337
+ const { deps, deleteLocalState, reload } = setup()
338
+ let captured: ScheduleReloadContext | undefined
339
+ deps.scheduleReload = (ctx) => {
340
+ captured = ctx
341
+ }
342
+ makeZeroRecovery(deps).onClientStateNotFound()
343
+ expect(captured).toBeDefined()
344
+
345
+ // the consumer holds performReload behind a gate for longer than the latch
346
+ // timeout (soot's IDE gate can hold for minutes). the timeout re-opens
347
+ // scheduling but must NOT drop the pending delete thunk.
348
+ await vi.advanceTimersByTimeAsync(20_000)
349
+ expect(deleteLocalState).not.toHaveBeenCalled()
350
+ expect(reload).not.toHaveBeenCalled()
351
+
352
+ // when the consumer finally commits, the store STILL gets dropped and the
353
+ // reload fires exactly once — never a bare reload back onto the bad store.
354
+ await captured!.performReload()
355
+ expect(deleteLocalState).toHaveBeenCalledTimes(1)
356
+ expect(reload).toHaveBeenCalledTimes(1)
357
+ } finally {
358
+ vi.useRealTimers()
359
+ }
360
+ })
361
+
362
+ test('a slow beforeReload holds the latch through the timeout window (no double reload)', async () => {
363
+ vi.useFakeTimers()
364
+ try {
365
+ const { deps, reload } = setup()
366
+ let releaseBeforeReload: () => void = () => {}
367
+ deps.beforeReload = () =>
368
+ new Promise<void>((resolve) => {
369
+ releaseBeforeReload = resolve
370
+ })
371
+ const recovery = makeZeroRecovery(deps)
372
+ // default path: performReload runs immediately, disarms the latch, then
373
+ // blocks on beforeReload (soot's waitForOriginReachable can exceed 15s).
374
+ recovery.onClientStateNotFound()
375
+ await vi.advanceTimersByTimeAsync(20_000)
376
+ // a second recovery arrives while the first reload is still in-flight.
377
+ recovery.onClientStateNotFound()
378
+ await vi.advanceTimersByTimeAsync(0)
379
+ expect(reload).not.toHaveBeenCalled()
380
+ // releasing the first beforeReload reloads exactly once, never twice.
381
+ releaseBeforeReload()
382
+ await vi.advanceTimersByTimeAsync(0)
383
+ expect(reload).toHaveBeenCalledTimes(1)
384
+ } finally {
385
+ vi.useRealTimers()
386
+ }
387
+ })
388
+
389
+ test('injectable guardStorage gives cross-reload loop protection (Hermes has no sessionStorage)', async () => {
390
+ const store = new Map<string, string>()
391
+ const { deps, reload, events } = setup()
392
+ deps.guardStorage = {
393
+ getItem: (key) => store.get(key) ?? null,
394
+ setItem: (key, value) => {
395
+ store.set(key, value)
396
+ },
397
+ }
398
+ const recovery = makeZeroRecovery(deps)
399
+ recovery.onClientStateNotFound()
400
+ await flush()
401
+ expect(reload).toHaveBeenCalledTimes(1)
402
+ // a reload wipes in-memory state but NOT the injected store.
403
+ resetRecoveryStateForTests()
404
+ recovery.onClientStateNotFound()
405
+ await flush()
406
+ // the injected store catches the immediate re-fire → fatal, no reload storm.
407
+ expect(reload).toHaveBeenCalledTimes(1)
408
+ expect(events.some((event) => event.type === 'fatal')).toBe(true)
409
+ })
410
+
411
+ test('a guardStorage that throws never crashes recovery (in-memory is the floor)', async () => {
412
+ const { deps, reload } = setup()
413
+ deps.guardStorage = {
414
+ getItem: () => {
415
+ throw new Error('no storage on this platform')
416
+ },
417
+ setItem: () => {
418
+ throw new Error('no storage on this platform')
419
+ },
420
+ }
421
+ makeZeroRecovery(deps).onClientStateNotFound()
422
+ await flush()
423
+ expect(reload).toHaveBeenCalledTimes(1)
424
+ })
425
+
426
+ test('classifier recognizes the mutation / connection desync class', () => {
427
+ const cases: Array<[readonly unknown[], string]> = [
428
+ [['sent mutation ID 5 but expected 4'], 'mutation-desync'],
429
+ [['oooMutation detected'], 'mutation-desync'],
430
+ [['Server reported an out-of-order mutation'], 'mutation-desync'],
431
+ [['Ignoring mutation 3, already processed. Expected: 4'], 'mutation-desync'],
432
+ [['InvalidConnectionRequestLastMutationID'], 'mutation-desync'],
433
+ [['InvalidConnectionRequestBaseCookie'], 'connection-cookie-invalid'],
434
+ [['ClientNotFound: client gone'], 'client-not-found'],
435
+ [['connection userID mismatch'], 'connection-userid-mismatch'],
436
+ ]
437
+ for (const [args, reason] of cases) {
438
+ expect(classifyZeroRecoveryLog('error', args)).toMatchObject({
439
+ reason,
440
+ dropLocalState: true,
441
+ })
442
+ }
443
+ // still narrow: app-infra strings and non-error levels do NOT classify.
444
+ expect(
445
+ classifyZeroRecoveryLog('error', ['consecutive server-ack timeouts']),
446
+ ).toBeUndefined()
447
+ expect(
448
+ classifyZeroRecoveryLog('error', ['Connection attempt timed out after 10 seconds']),
449
+ ).toBeUndefined()
450
+ expect(classifyZeroRecoveryLog('warn', ['ClientNotFound'])).toBeUndefined()
451
+ })
452
+
453
+ test('benignLogFilter suppresses recovery for a matching classified log', async () => {
454
+ const { deps, deleteLocalState, reload } = setup()
455
+ deps.benignLogFilter = (message) => message.includes('ClientNotFound')
456
+ const sink = composeRecoveryLogSink(deps)
457
+ sink.log('error', undefined, 'ClientNotFound: gone')
458
+ await flush()
459
+ expect(deleteLocalState).not.toHaveBeenCalled()
460
+ expect(reload).not.toHaveBeenCalled()
461
+ // a non-benign desync still recovers through the same sink.
462
+ sink.log('error', undefined, 'sent mutation ID 5 but expected 4')
463
+ await flush()
464
+ expect(reload).toHaveBeenCalledTimes(1)
465
+ })
466
+
467
+ test('isRecoverableZeroStalePokeMessage matches only the stale-cookie signatures', () => {
468
+ expect(
469
+ isRecoverableZeroStalePokeMessage(
470
+ 'Server returned unexpected base cookie during sync',
471
+ ),
472
+ ).toBe(true)
473
+ expect(
474
+ isRecoverableZeroStalePokeMessage(
475
+ 'Received cookie 5 is < than last snapshot cookie 9, ignoring client view',
476
+ ),
477
+ ).toBe(true)
478
+ expect(isRecoverableZeroStalePokeMessage('client state not found')).toBe(false)
479
+ })
211
480
  })