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.
- package/dist/cjs/createUseQuery.cjs +2 -1
- package/dist/cjs/createUseQuery.native.js +2 -1
- package/dist/cjs/createUseQuery.native.js.map +1 -1
- package/dist/cjs/createUseQueryDirect.cjs +2 -1
- package/dist/cjs/createUseQueryDirect.native.js +2 -1
- package/dist/cjs/createUseQueryDirect.native.js.map +1 -1
- package/dist/cjs/createZeroClient.cjs +75 -9
- package/dist/cjs/createZeroClient.native.js +151 -81
- package/dist/cjs/createZeroClient.native.js.map +1 -1
- package/dist/cjs/createZeroClient.recovery.test.cjs +351 -10
- package/dist/cjs/createZeroClient.recovery.test.native.js +412 -12
- package/dist/cjs/createZeroClient.recovery.test.native.js.map +1 -1
- package/dist/cjs/generate.cjs +16 -11
- package/dist/cjs/generate.native.js +16 -11
- package/dist/cjs/generate.native.js.map +1 -1
- package/dist/cjs/generate.test.cjs +22 -0
- package/dist/cjs/generate.test.native.js +22 -0
- package/dist/cjs/generate.test.native.js.map +1 -1
- package/dist/cjs/helpers/recoverZeroClient.cjs +542 -52
- package/dist/cjs/helpers/recoverZeroClient.native.js +721 -59
- package/dist/cjs/helpers/recoverZeroClient.native.js.map +1 -1
- package/dist/cjs/helpers/recoverZeroClient.test.cjs +318 -0
- package/dist/cjs/helpers/recoverZeroClient.test.native.js +361 -0
- package/dist/cjs/helpers/recoverZeroClient.test.native.js.map +1 -1
- package/dist/cjs/kvStore.cjs +30 -0
- package/dist/cjs/kvStore.native.js +34 -0
- package/dist/cjs/kvStore.native.js.map +1 -0
- package/dist/cjs/queryJson.cjs +58 -0
- package/dist/cjs/queryJson.native.js +86 -0
- package/dist/cjs/queryJson.native.js.map +1 -0
- package/dist/cjs/resolveQuery.cjs +2 -0
- package/dist/cjs/resolveQuery.native.js +2 -0
- package/dist/cjs/resolveQuery.native.js.map +1 -1
- package/dist/cjs/useQuery.empty.test.cjs +11 -1
- package/dist/cjs/useQuery.empty.test.native.js +23 -1
- package/dist/cjs/useQuery.empty.test.native.js.map +1 -1
- package/dist/cjs/useQuery.ssr.test.cjs +22 -1
- package/dist/cjs/useQuery.ssr.test.native.js +30 -1
- package/dist/cjs/useQuery.ssr.test.native.js.map +1 -1
- package/dist/esm/createUseQuery.mjs +2 -1
- package/dist/esm/createUseQuery.mjs.map +1 -1
- package/dist/esm/createUseQuery.native.js +2 -1
- package/dist/esm/createUseQuery.native.js.map +1 -1
- package/dist/esm/createUseQueryDirect.mjs +2 -1
- package/dist/esm/createUseQueryDirect.mjs.map +1 -1
- package/dist/esm/createUseQueryDirect.native.js +2 -1
- package/dist/esm/createUseQueryDirect.native.js.map +1 -1
- package/dist/esm/createZeroClient.mjs +76 -10
- package/dist/esm/createZeroClient.mjs.map +1 -1
- package/dist/esm/createZeroClient.native.js +152 -82
- package/dist/esm/createZeroClient.native.js.map +1 -1
- package/dist/esm/createZeroClient.recovery.test.mjs +352 -11
- package/dist/esm/createZeroClient.recovery.test.mjs.map +1 -1
- package/dist/esm/createZeroClient.recovery.test.native.js +413 -13
- package/dist/esm/createZeroClient.recovery.test.native.js.map +1 -1
- package/dist/esm/generate.mjs +16 -11
- package/dist/esm/generate.mjs.map +1 -1
- package/dist/esm/generate.native.js +16 -11
- package/dist/esm/generate.native.js.map +1 -1
- package/dist/esm/generate.test.mjs +22 -0
- package/dist/esm/generate.test.mjs.map +1 -1
- package/dist/esm/generate.test.native.js +22 -0
- package/dist/esm/generate.test.native.js.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.mjs +542 -54
- package/dist/esm/helpers/recoverZeroClient.mjs.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.native.js +721 -61
- package/dist/esm/helpers/recoverZeroClient.native.js.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.test.mjs +319 -1
- package/dist/esm/helpers/recoverZeroClient.test.mjs.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.test.native.js +362 -1
- package/dist/esm/helpers/recoverZeroClient.test.native.js.map +1 -1
- package/dist/esm/kvStore.mjs +5 -0
- package/dist/esm/kvStore.mjs.map +1 -0
- package/dist/esm/kvStore.native.js +6 -0
- package/dist/esm/kvStore.native.js.map +1 -0
- package/dist/esm/queryJson.mjs +32 -0
- package/dist/esm/queryJson.mjs.map +1 -0
- package/dist/esm/queryJson.native.js +57 -0
- package/dist/esm/queryJson.native.js.map +1 -0
- package/dist/esm/resolveQuery.mjs +2 -0
- package/dist/esm/resolveQuery.mjs.map +1 -1
- package/dist/esm/resolveQuery.native.js +2 -0
- package/dist/esm/resolveQuery.native.js.map +1 -1
- package/dist/esm/useQuery.empty.test.mjs +12 -2
- package/dist/esm/useQuery.empty.test.mjs.map +1 -1
- package/dist/esm/useQuery.empty.test.native.js +24 -2
- package/dist/esm/useQuery.empty.test.native.js.map +1 -1
- package/dist/esm/useQuery.ssr.test.mjs +23 -2
- package/dist/esm/useQuery.ssr.test.mjs.map +1 -1
- package/dist/esm/useQuery.ssr.test.native.js +31 -2
- package/dist/esm/useQuery.ssr.test.native.js.map +1 -1
- package/package.json +11 -3
- package/src/createUseQuery.tsx +2 -1
- package/src/createUseQueryDirect.tsx +2 -1
- package/src/createZeroClient.recovery.test.tsx +370 -7
- package/src/createZeroClient.tsx +100 -10
- package/src/generate.test.ts +31 -0
- package/src/generate.ts +22 -10
- package/src/helpers/recoverZeroClient.test.ts +323 -0
- package/src/helpers/recoverZeroClient.ts +896 -78
- package/src/kvStore.native.ts +7 -0
- package/src/kvStore.ts +7 -0
- package/src/queryJson.ts +46 -0
- package/src/resolveQuery.ts +2 -0
- package/src/useQuery.empty.test.tsx +16 -2
- package/src/useQuery.ssr.test.ts +23 -2
- package/types/createUseQuery.d.ts.map +1 -1
- package/types/createUseQueryDirect.d.ts.map +1 -1
- package/types/createZeroClient.d.ts.map +1 -1
- package/types/createZeroClient.recovery.test.d.ts.map +1 -1
- package/types/generate.d.ts.map +1 -1
- package/types/helpers/recoverZeroClient.d.ts +6 -0
- package/types/helpers/recoverZeroClient.d.ts.map +1 -1
- package/types/kvStore.d.ts +2 -0
- package/types/kvStore.d.ts.map +1 -0
- package/types/kvStore.native.d.ts +3 -0
- package/types/kvStore.native.d.ts.map +1 -0
- package/types/queryJson.d.ts +3 -0
- package/types/queryJson.d.ts.map +1 -0
- 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:
|
|
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
|
-
|
|
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 {
|
|
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
|
+
})
|
package/src/createZeroClient.tsx
CHANGED
|
@@ -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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
package/src/generate.test.ts
CHANGED
|
@@ -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'),
|