on-zero 0.16.19 → 0.16.20

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 (120) hide show
  1. package/dist/cjs/createUseQuery.cjs +2 -1
  2. package/dist/cjs/createUseQuery.native.js +2 -1
  3. package/dist/cjs/createUseQuery.native.js.map +1 -1
  4. package/dist/cjs/createUseQueryDirect.cjs +2 -1
  5. package/dist/cjs/createUseQueryDirect.native.js +2 -1
  6. package/dist/cjs/createUseQueryDirect.native.js.map +1 -1
  7. package/dist/cjs/createZeroClient.cjs +75 -9
  8. package/dist/cjs/createZeroClient.native.js +151 -81
  9. package/dist/cjs/createZeroClient.native.js.map +1 -1
  10. package/dist/cjs/createZeroClient.recovery.test.cjs +351 -10
  11. package/dist/cjs/createZeroClient.recovery.test.native.js +412 -12
  12. package/dist/cjs/createZeroClient.recovery.test.native.js.map +1 -1
  13. package/dist/cjs/generate.cjs +16 -11
  14. package/dist/cjs/generate.native.js +16 -11
  15. package/dist/cjs/generate.native.js.map +1 -1
  16. package/dist/cjs/generate.test.cjs +22 -0
  17. package/dist/cjs/generate.test.native.js +22 -0
  18. package/dist/cjs/generate.test.native.js.map +1 -1
  19. package/dist/cjs/helpers/recoverZeroClient.cjs +542 -52
  20. package/dist/cjs/helpers/recoverZeroClient.native.js +721 -59
  21. package/dist/cjs/helpers/recoverZeroClient.native.js.map +1 -1
  22. package/dist/cjs/helpers/recoverZeroClient.test.cjs +318 -0
  23. package/dist/cjs/helpers/recoverZeroClient.test.native.js +361 -0
  24. package/dist/cjs/helpers/recoverZeroClient.test.native.js.map +1 -1
  25. package/dist/cjs/kvStore.cjs +30 -0
  26. package/dist/cjs/kvStore.native.js +34 -0
  27. package/dist/cjs/kvStore.native.js.map +1 -0
  28. package/dist/cjs/queryJson.cjs +58 -0
  29. package/dist/cjs/queryJson.native.js +86 -0
  30. package/dist/cjs/queryJson.native.js.map +1 -0
  31. package/dist/cjs/resolveQuery.cjs +2 -0
  32. package/dist/cjs/resolveQuery.native.js +2 -0
  33. package/dist/cjs/resolveQuery.native.js.map +1 -1
  34. package/dist/cjs/useQuery.empty.test.cjs +11 -1
  35. package/dist/cjs/useQuery.empty.test.native.js +23 -1
  36. package/dist/cjs/useQuery.empty.test.native.js.map +1 -1
  37. package/dist/cjs/useQuery.ssr.test.cjs +22 -1
  38. package/dist/cjs/useQuery.ssr.test.native.js +30 -1
  39. package/dist/cjs/useQuery.ssr.test.native.js.map +1 -1
  40. package/dist/esm/createUseQuery.mjs +2 -1
  41. package/dist/esm/createUseQuery.mjs.map +1 -1
  42. package/dist/esm/createUseQuery.native.js +2 -1
  43. package/dist/esm/createUseQuery.native.js.map +1 -1
  44. package/dist/esm/createUseQueryDirect.mjs +2 -1
  45. package/dist/esm/createUseQueryDirect.mjs.map +1 -1
  46. package/dist/esm/createUseQueryDirect.native.js +2 -1
  47. package/dist/esm/createUseQueryDirect.native.js.map +1 -1
  48. package/dist/esm/createZeroClient.mjs +76 -10
  49. package/dist/esm/createZeroClient.mjs.map +1 -1
  50. package/dist/esm/createZeroClient.native.js +152 -82
  51. package/dist/esm/createZeroClient.native.js.map +1 -1
  52. package/dist/esm/createZeroClient.recovery.test.mjs +352 -11
  53. package/dist/esm/createZeroClient.recovery.test.mjs.map +1 -1
  54. package/dist/esm/createZeroClient.recovery.test.native.js +413 -13
  55. package/dist/esm/createZeroClient.recovery.test.native.js.map +1 -1
  56. package/dist/esm/generate.mjs +16 -11
  57. package/dist/esm/generate.mjs.map +1 -1
  58. package/dist/esm/generate.native.js +16 -11
  59. package/dist/esm/generate.native.js.map +1 -1
  60. package/dist/esm/generate.test.mjs +22 -0
  61. package/dist/esm/generate.test.mjs.map +1 -1
  62. package/dist/esm/generate.test.native.js +22 -0
  63. package/dist/esm/generate.test.native.js.map +1 -1
  64. package/dist/esm/helpers/recoverZeroClient.mjs +542 -54
  65. package/dist/esm/helpers/recoverZeroClient.mjs.map +1 -1
  66. package/dist/esm/helpers/recoverZeroClient.native.js +721 -61
  67. package/dist/esm/helpers/recoverZeroClient.native.js.map +1 -1
  68. package/dist/esm/helpers/recoverZeroClient.test.mjs +319 -1
  69. package/dist/esm/helpers/recoverZeroClient.test.mjs.map +1 -1
  70. package/dist/esm/helpers/recoverZeroClient.test.native.js +362 -1
  71. package/dist/esm/helpers/recoverZeroClient.test.native.js.map +1 -1
  72. package/dist/esm/kvStore.mjs +5 -0
  73. package/dist/esm/kvStore.mjs.map +1 -0
  74. package/dist/esm/kvStore.native.js +6 -0
  75. package/dist/esm/kvStore.native.js.map +1 -0
  76. package/dist/esm/queryJson.mjs +32 -0
  77. package/dist/esm/queryJson.mjs.map +1 -0
  78. package/dist/esm/queryJson.native.js +57 -0
  79. package/dist/esm/queryJson.native.js.map +1 -0
  80. package/dist/esm/resolveQuery.mjs +2 -0
  81. package/dist/esm/resolveQuery.mjs.map +1 -1
  82. package/dist/esm/resolveQuery.native.js +2 -0
  83. package/dist/esm/resolveQuery.native.js.map +1 -1
  84. package/dist/esm/useQuery.empty.test.mjs +12 -2
  85. package/dist/esm/useQuery.empty.test.mjs.map +1 -1
  86. package/dist/esm/useQuery.empty.test.native.js +24 -2
  87. package/dist/esm/useQuery.empty.test.native.js.map +1 -1
  88. package/dist/esm/useQuery.ssr.test.mjs +23 -2
  89. package/dist/esm/useQuery.ssr.test.mjs.map +1 -1
  90. package/dist/esm/useQuery.ssr.test.native.js +31 -2
  91. package/dist/esm/useQuery.ssr.test.native.js.map +1 -1
  92. package/package.json +11 -3
  93. package/src/createUseQuery.tsx +2 -1
  94. package/src/createUseQueryDirect.tsx +2 -1
  95. package/src/createZeroClient.recovery.test.tsx +370 -7
  96. package/src/createZeroClient.tsx +100 -10
  97. package/src/generate.test.ts +31 -0
  98. package/src/generate.ts +22 -10
  99. package/src/helpers/recoverZeroClient.test.ts +323 -0
  100. package/src/helpers/recoverZeroClient.ts +896 -78
  101. package/src/kvStore.native.ts +7 -0
  102. package/src/kvStore.ts +7 -0
  103. package/src/queryJson.ts +46 -0
  104. package/src/resolveQuery.ts +2 -0
  105. package/src/useQuery.empty.test.tsx +16 -2
  106. package/src/useQuery.ssr.test.ts +23 -2
  107. package/types/createUseQuery.d.ts.map +1 -1
  108. package/types/createUseQueryDirect.d.ts.map +1 -1
  109. package/types/createZeroClient.d.ts.map +1 -1
  110. package/types/createZeroClient.recovery.test.d.ts.map +1 -1
  111. package/types/generate.d.ts.map +1 -1
  112. package/types/helpers/recoverZeroClient.d.ts +6 -0
  113. package/types/helpers/recoverZeroClient.d.ts.map +1 -1
  114. package/types/kvStore.d.ts +2 -0
  115. package/types/kvStore.d.ts.map +1 -0
  116. package/types/kvStore.native.d.ts +3 -0
  117. package/types/kvStore.native.d.ts.map +1 -0
  118. package/types/queryJson.d.ts +3 -0
  119. package/types/queryJson.d.ts.map +1 -0
  120. package/types/resolveQuery.d.ts.map +1 -1
@@ -6,30 +6,65 @@ import { createRoot, type Root } from 'react-dom/client'
6
6
  import { afterEach, beforeEach, expect, test, vi } from 'vitest'
7
7
 
8
8
  const fakeZero = vi.hoisted(() => {
9
+ // the client group the next constructed instance joins: tests bump this to
10
+ // simulate newer code landing (a registry edit) between transitions.
11
+ const group = { current: 'g1' }
12
+
9
13
  class FakeZero {
10
14
  readonly context = {}
11
- readonly connection = {
15
+ readonly connection!: {
12
16
  state: {
13
- current: { name: 'closed' },
14
- subscribe: () => () => {},
15
- },
16
- connect: vi.fn(),
17
+ current: { name: string }
18
+ subscribe: (listener: () => void) => () => void
19
+ }
20
+ connect: ReturnType<typeof vi.fn>
17
21
  }
18
22
  readonly delete = vi.fn(async () => ({ errors: [] }))
19
23
  readonly close = vi.fn()
20
24
  readonly run = vi.fn(async () => [])
25
+ readonly mutate = {
26
+ note: {
27
+ insert: vi.fn(() => ({
28
+ client: Promise.resolve({}),
29
+ server: Promise.resolve({}),
30
+ })),
31
+ },
32
+ }
21
33
  readonly preload = vi.fn(() => ({
22
34
  cleanup: () => {},
23
35
  complete: Promise.resolve(),
24
36
  }))
37
+ readonly clientGroupID: Promise<string>
25
38
 
26
39
  constructor(readonly options: Record<string, any>) {
27
40
  instances.push(this)
41
+ this.clientGroupID = Promise.resolve(group.current)
42
+ const listeners = new Set<() => void>()
43
+ this.connection = {
44
+ state: {
45
+ current: { name: 'closed' },
46
+ subscribe: (listener: () => void) => {
47
+ listeners.add(listener)
48
+ return () => {
49
+ listeners.delete(listener)
50
+ }
51
+ },
52
+ },
53
+ connect: vi.fn(),
54
+ }
55
+ ;(this as any).__connectionListeners = listeners
28
56
  }
29
57
  }
30
58
 
31
59
  const instances: FakeZero[] = []
32
- return { FakeZero, instances }
60
+
61
+ function setConnectionState(instance: FakeZero, name: string) {
62
+ instance.connection.state.current.name = name
63
+ const listeners = (instance as any).__connectionListeners as Set<() => void>
64
+ for (const listener of [...listeners]) listener()
65
+ }
66
+
67
+ return { FakeZero, instances, group, setConnectionState }
33
68
  })
34
69
 
35
70
  vi.mock('@rocicorp/zero', async (importOriginal) => {
@@ -41,7 +76,12 @@ vi.mock('@rocicorp/zero', async (importOriginal) => {
41
76
  })
42
77
 
43
78
  import { createZeroClient } from './createZeroClient'
44
- import { resetRecoveryStateForTests } from './helpers/recoverZeroClient'
79
+ import {
80
+ isGroupTransitionOpen,
81
+ resetRecoveryStateForTests,
82
+ } from './helpers/recoverZeroClient'
83
+
84
+ import type { ZeroEvent } from './types'
45
85
 
46
86
  declare global {
47
87
  // eslint-disable-next-line no-var
@@ -66,6 +106,7 @@ beforeEach(() => {
66
106
  window.sessionStorage.clear()
67
107
  resetRecoveryStateForTests()
68
108
  fakeZero.instances.length = 0
109
+ fakeZero.group.current = 'g1'
69
110
  container = document.createElement('div')
70
111
  root = null
71
112
  })
@@ -486,3 +527,325 @@ test('headless NewClientGroup recovery reconstructs its client without a provide
486
527
 
487
528
  await connection.close()
488
529
  })
530
+
531
+ function makeTransitionClient(instanceName: string) {
532
+ return createZeroClient({
533
+ schema,
534
+ models: {},
535
+ groupedQueries: {},
536
+ instanceName,
537
+ })
538
+ }
539
+
540
+ function collectClientEvents(client: {
541
+ zeroEvents: { listen: (listener: (event: ZeroEvent | null) => void) => () => void }
542
+ }) {
543
+ const events: ZeroEvent[] = []
544
+ const off = client.zeroEvents.listen((event) => {
545
+ if (event) events.push(event)
546
+ })
547
+ return { events, off }
548
+ }
549
+
550
+ async function mountTransitionClient(
551
+ client: ReturnType<typeof makeTransitionClient>,
552
+ userID: string
553
+ ) {
554
+ const el = document.createElement('div')
555
+ const mountedRoot = createRoot(el)
556
+ await act(async () => {
557
+ mountedRoot.render(
558
+ <client.ProvideZero cacheURL="http://127.0.0.1:7777/zero" userID={userID}>
559
+ <span>ok</span>
560
+ </client.ProvideZero>
561
+ )
562
+ await Promise.resolve()
563
+ })
564
+ return { el, root: mountedRoot, instance: fakeZero.instances.at(-1)! }
565
+ }
566
+
567
+ async function unmountTransitionClient(mounted: { root: Root }) {
568
+ await act(async () => {
569
+ mounted.root.unmount()
570
+ })
571
+ }
572
+
573
+ function withoutPageReload() {
574
+ const originalLocation = globalThis.location
575
+ Object.defineProperty(globalThis, 'location', {
576
+ configurable: true,
577
+ value: undefined,
578
+ })
579
+ return () => {
580
+ Object.defineProperty(globalThis, 'location', {
581
+ configurable: true,
582
+ value: originalLocation,
583
+ })
584
+ }
585
+ }
586
+
587
+ test('two providers ride one group transition and keep querying and mutating', async () => {
588
+ const restoreLocation = withoutPageReload()
589
+ const clientA = makeTransitionClient('transition-two-a')
590
+ const clientB = makeTransitionClient('transition-two-b')
591
+ const collectedA = collectClientEvents(clientA)
592
+ const collectedB = collectClientEvents(clientB)
593
+ // siblings of one user ride one transition.
594
+ const mountedA = await mountTransitionClient(clientA, 'transition-user')
595
+ const mountedB = await mountTransitionClient(clientB, 'transition-user')
596
+ const firstA = mountedA.instance
597
+ const firstB = mountedB.instance
598
+ const countBefore = fakeZero.instances.length
599
+ try {
600
+ // one registry change: both stale instances fire in the same tick.
601
+ firstA.options.onUpdateNeeded({ type: 'NewClientGroup' })
602
+ firstB.options.onUpdateNeeded({ type: 'NewClientGroup' })
603
+ await act(async () => {
604
+ await new Promise((resolve) => setTimeout(resolve, 0))
605
+ })
606
+
607
+ // each old instance closed and reminted exactly once.
608
+ expect(fakeZero.instances).toHaveLength(countBefore + 2)
609
+ const secondA = fakeZero.instances[countBefore]!
610
+ const secondB = fakeZero.instances[countBefore + 1]!
611
+ expect(secondA).not.toBe(firstA)
612
+ expect(secondB).not.toBe(firstB)
613
+ expect(firstA.close).toHaveBeenCalledTimes(1)
614
+ expect(firstB.close).toHaveBeenCalledTimes(1)
615
+
616
+ // the transition resolves only when every new group is connected.
617
+ expect(isGroupTransitionOpen()).toBe(true)
618
+ await act(async () => {
619
+ fakeZero.setConnectionState(secondA, 'connected')
620
+ fakeZero.setConnectionState(secondB, 'connected')
621
+ await Promise.resolve()
622
+ })
623
+ await vi.waitFor(() => expect(isGroupTransitionOpen()).toBe(false))
624
+
625
+ // one transition: exactly one recovering across both providers, no fatal.
626
+ const allEvents = [...collectedA.events, ...collectedB.events]
627
+ expect(allEvents.filter((event) => event.type === 'recovering')).toHaveLength(1)
628
+ expect(allEvents.filter((event) => event.type === 'fatal')).toHaveLength(0)
629
+ // NewClientGroup never deletes IndexedDB.
630
+ expect(firstA.delete).not.toHaveBeenCalled()
631
+ expect(firstB.delete).not.toHaveBeenCalled()
632
+
633
+ // both providers still query and mutate through their new instances.
634
+ await clientA.zero.run({} as never)
635
+ await clientB.zero.run({} as never)
636
+ expect(secondA.run).toHaveBeenCalled()
637
+ expect(secondB.run).toHaveBeenCalled()
638
+ // the fake instance carries an untyped mutate stub: the assertion is that
639
+ // the facade resolves it on the NEW instance at call time.
640
+ ;(clientA.zero.mutate as any).note.insert({ id: 'a1' })
641
+ ;(clientB.zero.mutate as any).note.insert({ id: 'b1' })
642
+ expect(secondA.mutate.note.insert).toHaveBeenCalledTimes(1)
643
+ expect(secondB.mutate.note.insert).toHaveBeenCalledTimes(1)
644
+ } finally {
645
+ collectedA.off()
646
+ collectedB.off()
647
+ await unmountTransitionClient(mountedA)
648
+ await unmountTransitionClient(mountedB)
649
+ restoreLocation()
650
+ }
651
+ })
652
+
653
+ test('a second registry change after recovery remints again without fatal', async () => {
654
+ const restoreLocation = withoutPageReload()
655
+ const clientA = makeTransitionClient('transition-second-a')
656
+ const clientB = makeTransitionClient('transition-second-b')
657
+ const collectedA = collectClientEvents(clientA)
658
+ const collectedB = collectClientEvents(clientB)
659
+ const mountedA = await mountTransitionClient(clientA, 'transition-second-user')
660
+ const mountedB = await mountTransitionClient(clientB, 'transition-second-user')
661
+ try {
662
+ const fireBoth = (instances: Array<{ options: Record<string, any> }>) => {
663
+ instances[0]!.options.onUpdateNeeded({ type: 'NewClientGroup' })
664
+ instances[1]!.options.onUpdateNeeded({ type: 'NewClientGroup' })
665
+ }
666
+ const settleRemints = async (countBefore: number) => {
667
+ await act(async () => {
668
+ await new Promise((resolve) => setTimeout(resolve, 0))
669
+ })
670
+ expect(fakeZero.instances).toHaveLength(countBefore + 2)
671
+ const next = fakeZero.instances.slice(countBefore)
672
+ await act(async () => {
673
+ for (const instance of next) fakeZero.setConnectionState(instance, 'connected')
674
+ await Promise.resolve()
675
+ })
676
+ await vi.waitFor(() => expect(isGroupTransitionOpen()).toBe(false))
677
+ return next
678
+ }
679
+
680
+ // first change.
681
+ const countBeforeFirst = fakeZero.instances.length
682
+ fireBoth(fakeZero.instances.slice(-2))
683
+ await settleRemints(countBeforeFirst)
684
+
685
+ // second change with newer code, well inside the old 12s remint cooldown.
686
+ fakeZero.group.current = 'g2'
687
+ const countBefore = fakeZero.instances.length
688
+ fireBoth(fakeZero.instances.slice(-2))
689
+ const third = await settleRemints(countBefore)
690
+
691
+ const allEvents = [...collectedA.events, ...collectedB.events]
692
+ expect(allEvents.filter((event) => event.type === 'recovering')).toHaveLength(2)
693
+ expect(allEvents.filter((event) => event.type === 'fatal')).toHaveLength(0)
694
+ // the facades resolve to the newest instances.
695
+ await clientA.zero.run({} as never)
696
+ await clientB.zero.run({} as never)
697
+ expect(third[0]!.run).toHaveBeenCalled()
698
+ expect(third[1]!.run).toHaveBeenCalled()
699
+ } finally {
700
+ collectedA.off()
701
+ collectedB.off()
702
+ await unmountTransitionClient(mountedA)
703
+ await unmountTransitionClient(mountedB)
704
+ restoreLocation()
705
+ }
706
+ })
707
+
708
+ test('negative control: an incompatible schema emits exactly one terminal error, never a loop', async () => {
709
+ const restoreLocation = withoutPageReload()
710
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
711
+ const clientA = makeTransitionClient('transition-negative-a')
712
+ const clientB = makeTransitionClient('transition-negative-b')
713
+ const collectedA = collectClientEvents(clientA)
714
+ const collectedB = collectClientEvents(clientB)
715
+ const mountedA = await mountTransitionClient(clientA, 'transition-negative-user')
716
+ const mountedB = await mountTransitionClient(clientB, 'transition-negative-user')
717
+ const firstA = mountedA.instance
718
+ const firstB = mountedB.instance
719
+ const countBefore = fakeZero.instances.length
720
+ try {
721
+ firstA.options.onUpdateNeeded({ type: 'SchemaVersionNotSupported' })
722
+ firstB.options.onUpdateNeeded({ type: 'SchemaVersionNotSupported' })
723
+ await act(async () => {
724
+ await new Promise((resolve) => setTimeout(resolve, 0))
725
+ })
726
+ // schema incompatibility drops the stale stores, then remints once each.
727
+ expect(firstA.delete).toHaveBeenCalledTimes(1)
728
+ expect(firstB.delete).toHaveBeenCalledTimes(1)
729
+ expect(fakeZero.instances).toHaveLength(countBefore + 2)
730
+ const secondA = fakeZero.instances[countBefore]!
731
+ const secondB = fakeZero.instances[countBefore + 1]!
732
+ expect(isGroupTransitionOpen()).toBe(true)
733
+
734
+ // the new code still cannot join the server group: same group fires again.
735
+ secondA.options.onUpdateNeeded({ type: 'SchemaVersionNotSupported' })
736
+ await act(async () => {
737
+ await new Promise((resolve) => setTimeout(resolve, 0))
738
+ })
739
+
740
+ const allEvents = () => [...collectedA.events, ...collectedB.events]
741
+ expect(allEvents().filter((event) => event.type === 'fatal')).toHaveLength(1)
742
+ const [fatal] = allEvents().filter((event) => event.type === 'fatal')
743
+ expect(fatal).toMatchObject({
744
+ reasonKey: 'SchemaVersionNotSupported',
745
+ reason: expect.stringContaining('g1'),
746
+ })
747
+ expect(consoleError).toHaveBeenCalledTimes(1)
748
+ expect(consoleError.mock.calls[0]![0]).toContain('g1')
749
+
750
+ // every later re-fire stays silent and remints nothing: never a loop.
751
+ secondB.options.onUpdateNeeded({ type: 'SchemaVersionNotSupported' })
752
+ secondA.options.onUpdateNeeded({ type: 'SchemaVersionNotSupported' })
753
+ await act(async () => {
754
+ await new Promise((resolve) => setTimeout(resolve, 0))
755
+ })
756
+ expect(allEvents().filter((event) => event.type === 'fatal')).toHaveLength(1)
757
+ expect(consoleError).toHaveBeenCalledTimes(1)
758
+ expect(fakeZero.instances).toHaveLength(countBefore + 2)
759
+ } finally {
760
+ collectedA.off()
761
+ collectedB.off()
762
+ await unmountTransitionClient(mountedA)
763
+ await unmountTransitionClient(mountedB)
764
+ restoreLocation()
765
+ consoleError.mockRestore()
766
+ }
767
+ })
768
+
769
+ test('concurrent update signals across providers schedule a single reload', async () => {
770
+ const reloadPage = vi.fn()
771
+ const originalLocation = globalThis.location
772
+ Object.defineProperty(globalThis, 'location', {
773
+ configurable: true,
774
+ value: { href: 'http://localhost/reload-test', reload: reloadPage },
775
+ })
776
+ const scheduleA = vi.fn()
777
+ const scheduleB = vi.fn()
778
+ const clientA = makeTransitionClient('transition-reload-a')
779
+ const clientB = makeTransitionClient('transition-reload-b')
780
+ const collectedA = collectClientEvents(clientA)
781
+ const collectedB = collectClientEvents(clientB)
782
+ const elA = document.createElement('div')
783
+ const elB = document.createElement('div')
784
+ const rootA = createRoot(elA)
785
+ const rootB = createRoot(elB)
786
+ try {
787
+ await act(async () => {
788
+ rootA.render(
789
+ <clientA.ProvideZero
790
+ cacheURL="http://127.0.0.1:7777/zero"
791
+ userID="transition-reload-user"
792
+ scheduleReload={scheduleA}
793
+ >
794
+ <span>ok</span>
795
+ </clientA.ProvideZero>
796
+ )
797
+ await Promise.resolve()
798
+ })
799
+ await act(async () => {
800
+ rootB.render(
801
+ <clientB.ProvideZero
802
+ cacheURL="http://127.0.0.1:7777/zero"
803
+ userID="transition-reload-user"
804
+ scheduleReload={scheduleB}
805
+ >
806
+ <span>ok</span>
807
+ </clientB.ProvideZero>
808
+ )
809
+ await Promise.resolve()
810
+ })
811
+ const firstA = fakeZero.instances.at(-2)!
812
+ const firstB = fakeZero.instances.at(-1)!
813
+
814
+ firstA.options.onUpdateNeeded({ type: 'NewClientGroup' })
815
+ firstB.options.onUpdateNeeded({ type: 'NewClientGroup' })
816
+ await act(async () => {
817
+ await new Promise((resolve) => setTimeout(resolve, 0))
818
+ })
819
+
820
+ // one transition drives one deferred reload, however many providers join.
821
+ expect(scheduleA.mock.calls.length + scheduleB.mock.calls.length).toBe(1)
822
+ expect(reloadPage).not.toHaveBeenCalled()
823
+ const allEvents = [...collectedA.events, ...collectedB.events]
824
+ expect(allEvents.filter((event) => event.type === 'fatal')).toHaveLength(0)
825
+
826
+ const ctx = (scheduleA.mock.calls[0] ?? scheduleB.mock.calls[0])![0]
827
+ await act(async () => {
828
+ await ctx.performReload()
829
+ })
830
+ expect(reloadPage).toHaveBeenCalledTimes(1)
831
+ expect(isGroupTransitionOpen()).toBe(false)
832
+ // the reload persisted the recovered-from groups for the next page load.
833
+ const marker = window.sessionStorage.getItem(
834
+ 'on-zero-recover-http://localhost/reload-test-NewClientGroup'
835
+ )
836
+ expect(marker).toContain('g1')
837
+ } finally {
838
+ collectedA.off()
839
+ collectedB.off()
840
+ await act(async () => {
841
+ rootA.unmount()
842
+ })
843
+ await act(async () => {
844
+ rootB.unmount()
845
+ })
846
+ Object.defineProperty(globalThis, 'location', {
847
+ configurable: true,
848
+ value: originalLocation,
849
+ })
850
+ }
851
+ })
@@ -39,8 +39,10 @@ import { createMutationLifecycle } from './helpers/mutationLifecycle'
39
39
  import { IS_SERVER_RUNTIME } from './helpers/platform'
40
40
  import {
41
41
  composeRecoveryLogSink,
42
+ isGroupTransitionOpen,
42
43
  isRecoverableZeroStalePokeMessage,
43
44
  makeZeroRecovery,
45
+ primeGroupCache,
44
46
  type RecoveryGuardStorage,
45
47
  type ScheduleReloadContext,
46
48
  type ZeroLogPattern,
@@ -49,6 +51,7 @@ import {
49
51
  import { observeMutation, reportMutationInvocationError } from './helpers/useMutation'
50
52
  import { registerClientInstance } from './instanceRegistry'
51
53
  import { getAllMutationsPermissions, getMutationsPermissions } from './modelRegistry'
54
+ import { assertQueryJson } from './queryJson'
52
55
  import { registerQuery } from './queryRegistry'
53
56
  import { resolveQuery, type PlainQueryFn } from './resolveQuery'
54
57
  import { setCustomQueries } from './run'
@@ -207,9 +210,11 @@ export function createZeroClientInternal<
207
210
  for (const [name, fn] of Object.entries(queries)) {
208
211
  registerQuery(fn, `${namespace}.${name}`)
209
212
  // wrap each plain function in defineQuery
210
- wrappedNamespaces[namespace][name] = defineQuery(({ args }: { args: any }) =>
211
- fn(args)
212
- )
213
+ wrappedNamespaces[namespace][name] = defineQuery(({ args }: { args: any }) => {
214
+ const query = fn(args)
215
+ assertQueryJson(`${namespace}.${name}`, query.ast, 'ast')
216
+ return query
217
+ })
213
218
  }
214
219
  }
215
220
 
@@ -351,6 +356,7 @@ export function createZeroClientInternal<
351
356
  guardStorage?: RecoveryGuardStorage
352
357
  benignLogPatterns?: readonly ZeroLogPattern[]
353
358
  recoverInPlace?: () => Promise<boolean>
359
+ awaitReconnected?: () => Promise<boolean>
354
360
  reload?: () => void | boolean
355
361
  }
356
362
 
@@ -366,6 +372,7 @@ export function createZeroClientInternal<
366
372
  guardStorage,
367
373
  benignLogPatterns,
368
374
  recoverInPlace,
375
+ awaitReconnected,
369
376
  reload,
370
377
  }: ConstructZeroInstanceArgs): ZeroInstance {
371
378
  let installedTransport: Pick<HttpPullTransport, 'subscribeLifecycle'> | undefined
@@ -397,7 +404,13 @@ export function createZeroClientInternal<
397
404
  ],
398
405
  onRecovery: () => mutationLifecycle.fence(),
399
406
  recoverInPlace: recoverInPlace ?? (() => remint({ dropLocalState: false })),
407
+ awaitReconnected: awaitReconnected ?? awaitReconnectedAfterRemint,
400
408
  reload,
409
+ // sibling instances of one user ride one group transition.
410
+ clientIdentity: typeof options.userID === 'string' ? options.userID : undefined,
411
+ getGroupID: () =>
412
+ instanceRef.current?.clientGroupID ??
413
+ Promise.reject(new Error('[on-zero] no zero instance')),
401
414
  }
402
415
  const recovery = makeZeroRecovery(recoveryDeps)
403
416
  const createdInstance = new ZeroClient<Schema, ZeroMutators>({
@@ -421,6 +434,7 @@ export function createZeroClientInternal<
421
434
  })
422
435
  instanceRef.current = createdInstance
423
436
  if (installedTransport) installedTransports.set(createdInstance, installedTransport)
437
+ primeGroupCache(recoveryDeps)
424
438
  return createdInstance
425
439
  }
426
440
 
@@ -689,6 +703,67 @@ export function createZeroClientInternal<
689
703
  // revives that instance instead of constructing a twin beside it.
690
704
  const retiredZero = new Map<string, CachedZeroEntry>()
691
705
 
706
+ // resolve when this instance's connection reports connected. never rejects:
707
+ // a transition participant that never connects simply never resolves its
708
+ // transition, and a still-stale instance re-fires into a new one.
709
+ function waitForInstanceConnected(instanceToWatch: ZeroInstance): Promise<boolean> {
710
+ return new Promise((resolve) => {
711
+ let unsubscribe: (() => void) | undefined
712
+ let done = false
713
+ const check = () => {
714
+ if (done) return
715
+ let name: string | undefined
716
+ try {
717
+ name = instanceToWatch.connection?.state?.current?.name
718
+ } catch {
719
+ name = undefined
720
+ }
721
+ if (name === 'connected') {
722
+ done = true
723
+ try {
724
+ unsubscribe?.()
725
+ } catch {}
726
+ resolve(true)
727
+ }
728
+ }
729
+ check()
730
+ if (done) return
731
+ try {
732
+ unsubscribe = instanceToWatch.connection.state.subscribe(check)
733
+ } catch {
734
+ done = true
735
+ resolve(false)
736
+ }
737
+ })
738
+ }
739
+
740
+ // the group transition's connected gate for the provider path: remint()
741
+ // bumps the mounted provider, the rotate publishes a replacement instance,
742
+ // and the transition resolves only once that replacement is connected.
743
+ async function awaitReconnectedAfterRemint(): Promise<boolean> {
744
+ // the pre-remint instance, captured synchronously when remint() ran:
745
+ // reading zeroRuntime.zero here would race the rotate (an already-new
746
+ // instance must not wait for a second rotation that never comes, and a
747
+ // drop-remint's transient null must wait through instead of resolving).
748
+ const previous = remintPreviousInstance
749
+ let current = zeroRuntime.zero
750
+ if (current === previous || current === null) {
751
+ // the rotate publishes asynchronously after the bump; the re-check runs
752
+ // synchronously inside the executor so a publish racing this wait still
753
+ // resolves instead of dangling the waiter.
754
+ current = await new Promise<ZeroInstance | null>((resolve) => {
755
+ const latest = zeroRuntime.zero
756
+ if (latest !== previous && latest !== null) {
757
+ resolve(latest)
758
+ return
759
+ }
760
+ zeroRuntime.readyWaiters.add(resolve as (instance: ZeroInstance) => void)
761
+ })
762
+ }
763
+ if (!current) return false
764
+ return waitForInstanceConnected(current)
765
+ }
766
+
692
767
  // in-place re-mint: drop the current instance's local state then reconstruct a
693
768
  // fresh client WITHOUT a page reload — the native-safe recovery path (a reload
694
769
  // may never land on prod native, wedging the module latch). the mounted
@@ -702,6 +777,7 @@ export function createZeroClientInternal<
702
777
  const remintControl: { bump: (() => void) | null } = { bump: null }
703
778
  let lastRemintAt = 0
704
779
  let remintAttempts = 0
780
+ let remintPreviousInstance: ZeroInstance | null = null
705
781
 
706
782
  function unpublishZeroInstance(instanceToInvalidate: ZeroInstance): boolean {
707
783
  if (zeroRuntime.zero !== instanceToInvalidate) return false
@@ -752,13 +828,22 @@ export function createZeroClientInternal<
752
828
  // no mounted provider to reconstruct through — bail BEFORE the guard so an
753
829
  // unmounted call doesn't burn an attempt or start the cooldown.
754
830
  if (!remintControl.bump) return false
755
- const now = Date.now()
756
- const sinceLast = now - lastRemintAt
757
- if (lastRemintAt > 0 && sinceLast < REMINT_GUARD_MS) return false
758
- if (sinceLast > REMINT_ATTEMPT_RESET_MS) remintAttempts = 0
759
- if (remintAttempts >= REMINT_MAX_ATTEMPTS) return false
760
- lastRemintAt = now
761
- remintAttempts += 1
831
+ // capture the outgoing instance synchronously for the transition's
832
+ // connected gate (see awaitReconnectedAfterRemint).
833
+ remintPreviousInstance = zeroRuntime.zero
834
+ // a group transition in flight owns the retry policy (one remint per
835
+ // provider per transition, terminal-on-failure): its remints bypass the
836
+ // cooldown and the attempt budget untouched, so rapid legitimate
837
+ // transitions all land while lone storms stay guarded.
838
+ if (!isGroupTransitionOpen()) {
839
+ const now = Date.now()
840
+ const sinceLast = now - lastRemintAt
841
+ if (lastRemintAt > 0 && sinceLast < REMINT_GUARD_MS) return false
842
+ if (sinceLast > REMINT_ATTEMPT_RESET_MS) remintAttempts = 0
843
+ if (remintAttempts >= REMINT_MAX_ATTEMPTS) return false
844
+ lastRemintAt = now
845
+ remintAttempts += 1
846
+ }
762
847
 
763
848
  const { dropLocalState = true } = opts
764
849
  if (dropLocalState && zeroRuntime.zero) {
@@ -1234,6 +1319,11 @@ export function createZeroClientInternal<
1234
1319
  // a headless host has no provider state to bump and no page it owns to
1235
1320
  // reload. replace the disabled instance directly, then republish the
1236
1321
  // stable module facade and move the connection watcher with it.
1322
+ // the transition resolves once the replacement connects.
1323
+ awaitReconnected: async () => {
1324
+ if (closed) return true
1325
+ return waitForInstanceConnected(activeInstance)
1326
+ },
1237
1327
  recoverInPlace: async () => {
1238
1328
  // an update can arrive after the host intentionally closed this
1239
1329
  // connection during preview turnover. there is no live client left
@@ -139,6 +139,37 @@ export const relations = defineRelations(schema, (r) => ({
139
139
  )
140
140
  })
141
141
 
142
+ test('preserves schemas owned by drizzle-zero configuration', async () => {
143
+ const dataDir = join(testDir, 'src/data')
144
+ const schemaPath = join(dataDir, 'generated/schema.ts')
145
+ const externalSchema = `import { createSchema, table, string } from '@rocicorp/zero'
146
+ export const schema = createSchema({
147
+ tables: [table('post').columns({ id: string() }).primaryKey('id')],
148
+ })
149
+ `
150
+ writeFileSync(join(dataDir, 'post.ts'), `export const posts = () => zql.post`)
151
+ writeFileSync(
152
+ join(testDir, 'src/database/schema.ts'),
153
+ `export * from './schema-public'`
154
+ )
155
+ writeFileSync(
156
+ join(testDir, 'src/database/schema-public.ts'),
157
+ `
158
+ import { pgTable, text } from 'drizzle-orm/pg-core'
159
+ export const post = pgTable('post', { id: text('id').primaryKey() })
160
+ `
161
+ )
162
+ writeFileSync(
163
+ join(testDir, 'src/database/drizzle-zero.config.ts'),
164
+ `export default {}`
165
+ )
166
+ writeFileSync(schemaPath, externalSchema)
167
+
168
+ await generate({ dir: dataDir, silent: true })
169
+
170
+ expect(readFileSync(schemaPath, 'utf8')).toBe(externalSchema)
171
+ })
172
+
142
173
  test('generates every crud slot for a default table registration', async () => {
143
174
  writeFileSync(
144
175
  join(testDir, 'post/mutations.ts'),