on-zero 0.4.25 → 0.4.27
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 +28 -16
- package/dist/cjs/createUseQuery.native.js +42 -30
- package/dist/cjs/createUseQuery.native.js.map +1 -1
- package/dist/cjs/createZeroClient.cjs +13 -1
- package/dist/cjs/createZeroClient.native.js +13 -1
- package/dist/cjs/createZeroClient.native.js.map +1 -1
- package/dist/cjs/httpPull/auth.test.cjs +197 -0
- package/dist/cjs/httpPull/auth.test.native.js +279 -0
- package/dist/cjs/httpPull/auth.test.native.js.map +1 -0
- package/dist/cjs/httpPull/churn.test.cjs +132 -0
- package/dist/cjs/httpPull/churn.test.native.js +155 -0
- package/dist/cjs/httpPull/churn.test.native.js.map +1 -0
- package/dist/cjs/httpPull/fixtureSchema.cjs +76 -0
- package/dist/cjs/httpPull/fixtureSchema.native.js +82 -0
- package/dist/cjs/httpPull/fixtureSchema.native.js.map +1 -0
- package/dist/cjs/httpPull/fixtureServer.cjs +340 -0
- package/dist/cjs/httpPull/fixtureServer.native.js +534 -0
- package/dist/cjs/httpPull/fixtureServer.native.js.map +1 -0
- package/dist/cjs/httpPull/integration.test.cjs +53 -0
- package/dist/cjs/httpPull/integration.test.native.js +60 -0
- package/dist/cjs/httpPull/integration.test.native.js.map +1 -0
- package/dist/cjs/httpPull/rebase.test.cjs +360 -0
- package/dist/cjs/httpPull/rebase.test.native.js +420 -0
- package/dist/cjs/httpPull/rebase.test.native.js.map +1 -0
- package/dist/cjs/httpPull/relations.test.cjs +107 -0
- package/dist/cjs/httpPull/relations.test.native.js +119 -0
- package/dist/cjs/httpPull/relations.test.native.js.map +1 -0
- package/dist/cjs/httpPull/testHarness.cjs +100 -0
- package/dist/cjs/httpPull/testHarness.native.js +112 -0
- package/dist/cjs/httpPull/testHarness.native.js.map +1 -0
- package/dist/cjs/httpPull/transport.test.cjs +568 -0
- package/dist/cjs/httpPull/transport.test.native.js +655 -0
- package/dist/cjs/httpPull/transport.test.native.js.map +1 -0
- package/dist/cjs/httpPullTransport.cjs +432 -0
- package/dist/cjs/httpPullTransport.native.js +695 -0
- package/dist/cjs/httpPullTransport.native.js.map +1 -0
- package/dist/cjs/index.cjs +1 -0
- package/dist/cjs/index.native.js +1 -0
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/multiInstanceNested.test.cjs +26 -0
- package/dist/cjs/multiInstanceNested.test.native.js +34 -0
- package/dist/cjs/multiInstanceNested.test.native.js.map +1 -1
- package/dist/esm/createUseQuery.mjs +28 -16
- package/dist/esm/createUseQuery.mjs.map +1 -1
- package/dist/esm/createUseQuery.native.js +42 -30
- package/dist/esm/createUseQuery.native.js.map +1 -1
- package/dist/esm/createZeroClient.mjs +13 -1
- package/dist/esm/createZeroClient.mjs.map +1 -1
- package/dist/esm/createZeroClient.native.js +13 -1
- package/dist/esm/createZeroClient.native.js.map +1 -1
- package/dist/esm/httpPull/auth.test.mjs +198 -0
- package/dist/esm/httpPull/auth.test.mjs.map +1 -0
- package/dist/esm/httpPull/auth.test.native.js +277 -0
- package/dist/esm/httpPull/auth.test.native.js.map +1 -0
- package/dist/esm/httpPull/churn.test.mjs +133 -0
- package/dist/esm/httpPull/churn.test.mjs.map +1 -0
- package/dist/esm/httpPull/churn.test.native.js +153 -0
- package/dist/esm/httpPull/churn.test.native.js.map +1 -0
- package/dist/esm/httpPull/fixtureSchema.mjs +50 -0
- package/dist/esm/httpPull/fixtureSchema.mjs.map +1 -0
- package/dist/esm/httpPull/fixtureSchema.native.js +53 -0
- package/dist/esm/httpPull/fixtureSchema.native.js.map +1 -0
- package/dist/esm/httpPull/fixtureServer.mjs +315 -0
- package/dist/esm/httpPull/fixtureServer.mjs.map +1 -0
- package/dist/esm/httpPull/fixtureServer.native.js +506 -0
- package/dist/esm/httpPull/fixtureServer.native.js.map +1 -0
- package/dist/esm/httpPull/integration.test.mjs +54 -0
- package/dist/esm/httpPull/integration.test.mjs.map +1 -0
- package/dist/esm/httpPull/integration.test.native.js +58 -0
- package/dist/esm/httpPull/integration.test.native.js.map +1 -0
- package/dist/esm/httpPull/rebase.test.mjs +361 -0
- package/dist/esm/httpPull/rebase.test.mjs.map +1 -0
- package/dist/esm/httpPull/rebase.test.native.js +418 -0
- package/dist/esm/httpPull/rebase.test.native.js.map +1 -0
- package/dist/esm/httpPull/relations.test.mjs +108 -0
- package/dist/esm/httpPull/relations.test.mjs.map +1 -0
- package/dist/esm/httpPull/relations.test.native.js +117 -0
- package/dist/esm/httpPull/relations.test.native.js.map +1 -0
- package/dist/esm/httpPull/testHarness.mjs +72 -0
- package/dist/esm/httpPull/testHarness.mjs.map +1 -0
- package/dist/esm/httpPull/testHarness.native.js +81 -0
- package/dist/esm/httpPull/testHarness.native.js.map +1 -0
- package/dist/esm/httpPull/transport.test.mjs +569 -0
- package/dist/esm/httpPull/transport.test.mjs.map +1 -0
- package/dist/esm/httpPull/transport.test.native.js +653 -0
- package/dist/esm/httpPull/transport.test.native.js.map +1 -0
- package/dist/esm/httpPullTransport.mjs +406 -0
- package/dist/esm/httpPullTransport.mjs.map +1 -0
- package/dist/esm/httpPullTransport.native.js +666 -0
- package/dist/esm/httpPullTransport.native.js.map +1 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/index.mjs +1 -0
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +1 -0
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/multiInstanceNested.test.mjs +27 -1
- package/dist/esm/multiInstanceNested.test.mjs.map +1 -1
- package/dist/esm/multiInstanceNested.test.native.js +35 -1
- package/dist/esm/multiInstanceNested.test.native.js.map +1 -1
- package/package.json +2 -2
- package/src/createUseQuery.tsx +40 -22
- package/src/createZeroClient.tsx +19 -0
- package/src/httpPull/auth.test.ts +208 -0
- package/src/httpPull/churn.test.ts +147 -0
- package/src/httpPull/fixtureSchema.ts +82 -0
- package/src/httpPull/fixtureServer.ts +391 -0
- package/src/httpPull/integration.test.ts +57 -0
- package/src/httpPull/rebase.test.ts +368 -0
- package/src/httpPull/relations.test.ts +135 -0
- package/src/httpPull/testHarness.ts +95 -0
- package/src/httpPull/transport.test.ts +577 -0
- package/src/httpPullTransport.ts +559 -0
- package/src/index.ts +1 -0
- package/src/multiInstanceNested.test.tsx +25 -1
- package/types/createUseQuery.d.ts.map +1 -1
- package/types/createZeroClient.d.ts +3 -1
- package/types/createZeroClient.d.ts.map +1 -1
- package/types/httpPull/auth.test.d.ts +2 -0
- package/types/httpPull/auth.test.d.ts.map +1 -0
- package/types/httpPull/churn.test.d.ts +2 -0
- package/types/httpPull/churn.test.d.ts.map +1 -0
- package/types/httpPull/fixtureSchema.d.ts +111 -0
- package/types/httpPull/fixtureSchema.d.ts.map +1 -0
- package/types/httpPull/fixtureServer.d.ts +14 -0
- package/types/httpPull/fixtureServer.d.ts.map +1 -0
- package/types/httpPull/integration.test.d.ts +2 -0
- package/types/httpPull/integration.test.d.ts.map +1 -0
- package/types/httpPull/rebase.test.d.ts +2 -0
- package/types/httpPull/rebase.test.d.ts.map +1 -0
- package/types/httpPull/relations.test.d.ts +2 -0
- package/types/httpPull/relations.test.d.ts.map +1 -0
- package/types/httpPull/testHarness.d.ts +32 -0
- package/types/httpPull/testHarness.d.ts.map +1 -0
- package/types/httpPull/transport.test.d.ts +2 -0
- package/types/httpPull/transport.test.d.ts.map +1 -0
- package/types/httpPullTransport.d.ts +13 -0
- package/types/httpPullTransport.d.ts.map +1 -0
- package/types/index.d.ts +1 -0
- package/types/index.d.ts.map +1 -1
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import { Zero } from '@rocicorp/zero'
|
|
2
|
+
import { afterEach, describe, expect, test, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { zeroHttpFixtureMutators, zeroHttpFixtureSchema } from './fixtureSchema'
|
|
5
|
+
import { ensureHttpPullTransport, installHttpPullTransport } from '../httpPullTransport'
|
|
6
|
+
|
|
7
|
+
const ORIGIN = 'https://zero-http.local'
|
|
8
|
+
|
|
9
|
+
type RequestRecord = {
|
|
10
|
+
url: string
|
|
11
|
+
path: string
|
|
12
|
+
headers: Record<string, string>
|
|
13
|
+
body: any
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const zeros: Zero<any, any>[] = []
|
|
17
|
+
const transports: Array<{ uninstall(): void }> = []
|
|
18
|
+
let storageID = 0
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
while (zeros.length) await zeros.pop()?.close()
|
|
22
|
+
while (transports.length) transports.pop()?.uninstall()
|
|
23
|
+
vi.useRealTimers()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('zero-http transport', () => {
|
|
27
|
+
test('connect + complete hydrates a stock Zero materialized query', async () => {
|
|
28
|
+
const requests: RequestRecord[] = []
|
|
29
|
+
let cookie = 0
|
|
30
|
+
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
31
|
+
const request = recordRequest(input, init)
|
|
32
|
+
requests.push(request)
|
|
33
|
+
expect(request.path).toBe('/pull')
|
|
34
|
+
expect(request.headers.authorization).toBe('Bearer token-u1')
|
|
35
|
+
return jsonResponse({
|
|
36
|
+
cookie: ++cookie,
|
|
37
|
+
lastMutationIDChanges: {},
|
|
38
|
+
rowsPatch: [
|
|
39
|
+
{ op: 'clear' },
|
|
40
|
+
{ op: 'put', tableName: 'user', value: { id: 'u1', name: 'ada' } },
|
|
41
|
+
{
|
|
42
|
+
op: 'put',
|
|
43
|
+
tableName: 'project',
|
|
44
|
+
value: { id: 'p1', ownerId: 'u1', name: 'control' },
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
op: 'put',
|
|
48
|
+
tableName: 'member',
|
|
49
|
+
value: { id: 'm1', projectId: 'p1', userId: 'u1' },
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
const transport = install(fetch)
|
|
55
|
+
const zero = createZero()
|
|
56
|
+
|
|
57
|
+
const view = zero.query.project.related('members').materialize()
|
|
58
|
+
const data = await waitForComplete(view)
|
|
59
|
+
view.destroy()
|
|
60
|
+
|
|
61
|
+
expect(transport.connections).toBe(1)
|
|
62
|
+
expect(data).toEqual([
|
|
63
|
+
{
|
|
64
|
+
id: 'p1',
|
|
65
|
+
ownerId: 'u1',
|
|
66
|
+
name: 'control',
|
|
67
|
+
members: [{ id: 'm1', projectId: 'p1', userId: 'u1' }],
|
|
68
|
+
},
|
|
69
|
+
])
|
|
70
|
+
expect(requests[0].body.cookie).toBeNull()
|
|
71
|
+
expect(requests[0].body.clientID).toEqual(expect.any(String))
|
|
72
|
+
expect(requests[0].body.clientGroupID).toEqual(expect.any(String))
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('push frames POST to /push, resolve server promises, and schedule a follow-up pull', async () => {
|
|
76
|
+
const requests: RequestRecord[] = []
|
|
77
|
+
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
78
|
+
const request = recordRequest(input, init)
|
|
79
|
+
requests.push(request)
|
|
80
|
+
|
|
81
|
+
if (request.path === '/pull') {
|
|
82
|
+
return jsonResponse({ cookie: request.body.cookie, unchanged: true })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
expect(request.path).toBe('/push')
|
|
86
|
+
const mutation = request.body.mutations[0]
|
|
87
|
+
return jsonResponse({
|
|
88
|
+
pushResponse: {
|
|
89
|
+
mutations: [
|
|
90
|
+
{
|
|
91
|
+
id: { clientID: mutation.clientID, id: mutation.id },
|
|
92
|
+
result: {},
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
install(fetch)
|
|
99
|
+
const zero = createZero()
|
|
100
|
+
|
|
101
|
+
await eventually(() =>
|
|
102
|
+
expect(requests.filter((request) => request.path === '/pull').length).toBe(1),
|
|
103
|
+
)
|
|
104
|
+
const pullsBeforePush = requests.filter((request) => request.path === '/pull').length
|
|
105
|
+
|
|
106
|
+
const mutation = zero.mutate.project.create({
|
|
107
|
+
id: 'p1',
|
|
108
|
+
ownerId: 'u1',
|
|
109
|
+
name: 'created',
|
|
110
|
+
})
|
|
111
|
+
await mutation.client
|
|
112
|
+
await mutation.server
|
|
113
|
+
|
|
114
|
+
const push = requests.find((request) => request.path === '/push')
|
|
115
|
+
expect(push?.headers.authorization).toBe('Bearer token-u1')
|
|
116
|
+
expect(push?.body).toMatchObject({
|
|
117
|
+
clientGroupID: expect.any(String),
|
|
118
|
+
pushVersion: 1,
|
|
119
|
+
requestID: expect.any(String),
|
|
120
|
+
mutations: [
|
|
121
|
+
{
|
|
122
|
+
type: 'custom',
|
|
123
|
+
name: 'project|create',
|
|
124
|
+
id: 1,
|
|
125
|
+
clientID: expect.any(String),
|
|
126
|
+
args: [{ id: 'p1', ownerId: 'u1', name: 'created' }],
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
})
|
|
130
|
+
await eventually(() =>
|
|
131
|
+
expect(requests.filter((request) => request.path === '/pull').length).toBe(
|
|
132
|
+
pullsBeforePush + 1,
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('updateAuth frame updates bearer headers for later requests', async () => {
|
|
138
|
+
const requests: RequestRecord[] = []
|
|
139
|
+
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
140
|
+
const request = recordRequest(input, init)
|
|
141
|
+
requests.push(request)
|
|
142
|
+
expect(request.path).toBe('/pull')
|
|
143
|
+
return jsonResponse({ cookie: request.body.cookie, unchanged: true })
|
|
144
|
+
})
|
|
145
|
+
const transport = install(fetch)
|
|
146
|
+
const { socket } = openRawSocketWithMessages({ authToken: 'token-old' })
|
|
147
|
+
|
|
148
|
+
await eventually(() => expect(requests.length).toBe(1))
|
|
149
|
+
expect(requests[0].headers.authorization).toBe('Bearer token-old')
|
|
150
|
+
|
|
151
|
+
socket.send(JSON.stringify(['updateAuth', { auth: 'token-new' }]))
|
|
152
|
+
await transport.pull()
|
|
153
|
+
|
|
154
|
+
expect(requests.at(-1)?.headers.authorization).toBe('Bearer token-new')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('push frames are serialized per socket', async () => {
|
|
158
|
+
const firstPushStarted = defer<void>()
|
|
159
|
+
const releaseFirstPush = defer<void>()
|
|
160
|
+
const pushIDs: number[] = []
|
|
161
|
+
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
162
|
+
const request = recordRequest(input, init)
|
|
163
|
+
if (request.path === '/pull') {
|
|
164
|
+
return jsonResponse({ cookie: request.body.cookie, unchanged: true })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
expect(request.path).toBe('/push')
|
|
168
|
+
const mutationID = request.body.mutations[0].id
|
|
169
|
+
pushIDs.push(mutationID)
|
|
170
|
+
if (mutationID === 1) {
|
|
171
|
+
firstPushStarted.resolve()
|
|
172
|
+
await releaseFirstPush.promise
|
|
173
|
+
}
|
|
174
|
+
return jsonResponse({
|
|
175
|
+
pushResponse: {
|
|
176
|
+
mutations: request.body.mutations.map((mutation: any) => ({
|
|
177
|
+
id: { clientID: mutation.clientID, id: mutation.id },
|
|
178
|
+
result: {},
|
|
179
|
+
})),
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
install(fetch)
|
|
184
|
+
const { messages, socket } = openRawSocketWithMessages()
|
|
185
|
+
|
|
186
|
+
await eventually(() =>
|
|
187
|
+
expect(messages.some((message) => message[0] === 'connected')).toBe(true),
|
|
188
|
+
)
|
|
189
|
+
socket.send(JSON.stringify(['push', pushBody(1)]))
|
|
190
|
+
await firstPushStarted.promise
|
|
191
|
+
socket.send(JSON.stringify(['push', pushBody(2)]))
|
|
192
|
+
await sleep(25)
|
|
193
|
+
|
|
194
|
+
expect(pushIDs).toEqual([1])
|
|
195
|
+
releaseFirstPush.resolve()
|
|
196
|
+
await eventually(() => expect(pushIDs).toEqual([1, 2]))
|
|
197
|
+
await eventually(() =>
|
|
198
|
+
expect(messages.filter((message) => message[0] === 'pushResponse')).toHaveLength(2),
|
|
199
|
+
)
|
|
200
|
+
expect(
|
|
201
|
+
messages
|
|
202
|
+
.filter((message) => message[0] === 'pushResponse')
|
|
203
|
+
.map((message) => message[1].mutations[0].id.id),
|
|
204
|
+
).toEqual([1, 2])
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('cookie discipline skips unchanged pokes, chains changed pokes, and coalesces concurrent pulls', async () => {
|
|
208
|
+
const requests: RequestRecord[] = []
|
|
209
|
+
const responses: Array<any | Promise<any>> = [
|
|
210
|
+
{
|
|
211
|
+
cookie: 1,
|
|
212
|
+
lastMutationIDChanges: {},
|
|
213
|
+
rowsPatch: [{ op: 'clear' }],
|
|
214
|
+
},
|
|
215
|
+
{ cookie: 1, unchanged: true },
|
|
216
|
+
{
|
|
217
|
+
cookie: 2,
|
|
218
|
+
lastMutationIDChanges: {},
|
|
219
|
+
rowsPatch: [
|
|
220
|
+
{ op: 'clear' },
|
|
221
|
+
{
|
|
222
|
+
op: 'put',
|
|
223
|
+
tableName: 'project',
|
|
224
|
+
value: { id: 'p2', ownerId: 'u1', name: 'second' },
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
cookie: 3,
|
|
230
|
+
lastMutationIDChanges: {},
|
|
231
|
+
rowsPatch: [
|
|
232
|
+
{ op: 'clear' },
|
|
233
|
+
{
|
|
234
|
+
op: 'put',
|
|
235
|
+
tableName: 'project',
|
|
236
|
+
value: { id: 'p3', ownerId: 'u1', name: 'third' },
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
]
|
|
241
|
+
const deferred = defer<any>()
|
|
242
|
+
responses.push(deferred.promise)
|
|
243
|
+
|
|
244
|
+
let inFlight = 0
|
|
245
|
+
let maxInFlight = 0
|
|
246
|
+
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
247
|
+
inFlight++
|
|
248
|
+
maxInFlight = Math.max(maxInFlight, inFlight)
|
|
249
|
+
try {
|
|
250
|
+
const request = recordRequest(input, init)
|
|
251
|
+
requests.push(request)
|
|
252
|
+
const response = responses.shift()
|
|
253
|
+
if (!response) throw new Error('missing canned response')
|
|
254
|
+
return jsonResponse(await response)
|
|
255
|
+
} finally {
|
|
256
|
+
inFlight--
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
const transport = install(fetch)
|
|
260
|
+
const messages = openRawSocket()
|
|
261
|
+
|
|
262
|
+
await eventually(() =>
|
|
263
|
+
expect(messages.some((message) => message[0] === 'pokeEnd')).toBe(true),
|
|
264
|
+
)
|
|
265
|
+
expect(requests[0].body.cookie).toBeNull()
|
|
266
|
+
expect(findMessage(messages, 'pokeStart')[1].baseCookie).toBeNull()
|
|
267
|
+
expect(findMessage(messages, 'pokeEnd')[1].cookie).toBe('00000000000000000001')
|
|
268
|
+
messages.length = 0
|
|
269
|
+
|
|
270
|
+
await transport.pull()
|
|
271
|
+
expect(messages.filter((message) => message[0].startsWith('poke'))).toEqual([])
|
|
272
|
+
expect(requests[1].body.cookie).toBe(1)
|
|
273
|
+
|
|
274
|
+
await transport.pull()
|
|
275
|
+
expect(findMessage(messages, 'pokeStart')[1].baseCookie).toBe('00000000000000000001')
|
|
276
|
+
expect(findMessage(messages, 'pokeEnd')[1].cookie).toBe('00000000000000000002')
|
|
277
|
+
expect(requests[2].body.cookie).toBe(1)
|
|
278
|
+
messages.length = 0
|
|
279
|
+
|
|
280
|
+
await transport.pull()
|
|
281
|
+
expect(findMessage(messages, 'pokeStart')[1].baseCookie).toBe('00000000000000000002')
|
|
282
|
+
expect(findMessage(messages, 'pokeEnd')[1].cookie).toBe('00000000000000000003')
|
|
283
|
+
expect(requests[3].body.cookie).toBe(2)
|
|
284
|
+
messages.length = 0
|
|
285
|
+
|
|
286
|
+
const concurrentA = transport.pull()
|
|
287
|
+
const concurrentB = transport.pull()
|
|
288
|
+
await eventually(() => expect(requests.length).toBe(5))
|
|
289
|
+
deferred.resolve({
|
|
290
|
+
cookie: 4,
|
|
291
|
+
lastMutationIDChanges: {},
|
|
292
|
+
rowsPatch: [{ op: 'clear' }],
|
|
293
|
+
})
|
|
294
|
+
await Promise.all([concurrentA, concurrentB])
|
|
295
|
+
|
|
296
|
+
expect(requests.length).toBe(5)
|
|
297
|
+
expect(maxInFlight).toBe(1)
|
|
298
|
+
expect(findMessage(messages, 'pokeStart')[1].baseCookie).toBe('00000000000000000003')
|
|
299
|
+
expect(findMessage(messages, 'pokeEnd')[1].cookie).toBe('00000000000000000004')
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test('unchanged pull flushes late query registration to complete', async () => {
|
|
303
|
+
const requests: RequestRecord[] = []
|
|
304
|
+
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
305
|
+
const request = recordRequest(input, init)
|
|
306
|
+
requests.push(request)
|
|
307
|
+
expect(request.path).toBe('/pull')
|
|
308
|
+
|
|
309
|
+
if (request.body.cookie === null) {
|
|
310
|
+
return jsonResponse({
|
|
311
|
+
cookie: 1,
|
|
312
|
+
lastMutationIDChanges: {},
|
|
313
|
+
rowsPatch: [
|
|
314
|
+
{ op: 'clear' },
|
|
315
|
+
{ op: 'put', tableName: 'user', value: { id: 'u1', name: 'ada' } },
|
|
316
|
+
{
|
|
317
|
+
op: 'put',
|
|
318
|
+
tableName: 'project',
|
|
319
|
+
value: { id: 'p1', ownerId: 'u1', name: 'first' },
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return jsonResponse({ cookie: request.body.cookie, unchanged: true })
|
|
326
|
+
})
|
|
327
|
+
install(fetch)
|
|
328
|
+
const zero = createZero()
|
|
329
|
+
|
|
330
|
+
const projectView = zero.query.project.materialize()
|
|
331
|
+
await waitForComplete(projectView)
|
|
332
|
+
|
|
333
|
+
const userView = zero.query.user.materialize()
|
|
334
|
+
const users = await waitForComplete<any[]>(userView)
|
|
335
|
+
|
|
336
|
+
expect(users).toEqual([{ id: 'u1', name: 'ada' }])
|
|
337
|
+
expect(requests.at(-1)?.body.cookie).toBe(1)
|
|
338
|
+
projectView.destroy()
|
|
339
|
+
userView.destroy()
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
test('ping is answered locally and the stock Zero connection survives idle ping', async () => {
|
|
343
|
+
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
344
|
+
const request = recordRequest(input, init)
|
|
345
|
+
expect(request.path).toBe('/pull')
|
|
346
|
+
return jsonResponse({ cookie: request.body.cookie, unchanged: true })
|
|
347
|
+
})
|
|
348
|
+
const transport = install(fetch)
|
|
349
|
+
const zero = createZero({ pingTimeoutMs: 10 })
|
|
350
|
+
|
|
351
|
+
await eventually(() => expect(zero.connection.state.current.name).toBe('connected'))
|
|
352
|
+
await sleep(40)
|
|
353
|
+
|
|
354
|
+
expect(zero.connection.state.current.name).toBe('connected')
|
|
355
|
+
expect(transport.connections).toBe(1)
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test('401 pull failure closes the fake socket without materializing data', async () => {
|
|
359
|
+
const requests: RequestRecord[] = []
|
|
360
|
+
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
361
|
+
const request = recordRequest(input, init)
|
|
362
|
+
requests.push(request)
|
|
363
|
+
expect(request.path).toBe('/pull')
|
|
364
|
+
return jsonResponse({ error: 'unauthorized' }, { status: 401 })
|
|
365
|
+
})
|
|
366
|
+
const transport = install(fetch)
|
|
367
|
+
const zero = createZero()
|
|
368
|
+
const view = zero.query.project.materialize()
|
|
369
|
+
const emissions: Array<{ data: any[]; resultType: string }> = []
|
|
370
|
+
const cleanup = view.addListener((data: any, resultType) => {
|
|
371
|
+
emissions.push({ data: JSON.parse(JSON.stringify(data)), resultType })
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
await eventually(() => expect(requests.length).toBeGreaterThan(0))
|
|
375
|
+
await eventually(() => expect(zero.connection.state.current.name).toBe('needs-auth'))
|
|
376
|
+
await sleep(25)
|
|
377
|
+
|
|
378
|
+
expect(transport.connections).toBe(0)
|
|
379
|
+
expect(emissions.flatMap((emission) => emission.data)).toEqual([])
|
|
380
|
+
expect(view.data).toEqual([])
|
|
381
|
+
cleanup()
|
|
382
|
+
view.destroy()
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
test('non-origin WebSockets pass through to the native implementation', () => {
|
|
386
|
+
const previous = globalThis.WebSocket
|
|
387
|
+
class NativeWebSocket {
|
|
388
|
+
static CONNECTING = 0
|
|
389
|
+
static OPEN = 1
|
|
390
|
+
static CLOSING = 2
|
|
391
|
+
static CLOSED = 3
|
|
392
|
+
constructor(
|
|
393
|
+
readonly url: string | URL,
|
|
394
|
+
readonly protocols?: string | string[],
|
|
395
|
+
) {}
|
|
396
|
+
}
|
|
397
|
+
globalThis.WebSocket = NativeWebSocket as unknown as typeof WebSocket
|
|
398
|
+
|
|
399
|
+
const transport = installHttpPullTransport({ origin: ORIGIN, fetch: vi.fn() })
|
|
400
|
+
const socket = new WebSocket('wss://elsewhere.local/socket', 'native')
|
|
401
|
+
|
|
402
|
+
expect(socket).toBeInstanceOf(NativeWebSocket)
|
|
403
|
+
expect((socket as unknown as NativeWebSocket).url).toBe(
|
|
404
|
+
'wss://elsewhere.local/socket',
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
transport.uninstall()
|
|
408
|
+
expect(globalThis.WebSocket).toBe(NativeWebSocket)
|
|
409
|
+
globalThis.WebSocket = previous
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
test('ensureHttpPullTransport installs once per origin', () => {
|
|
413
|
+
// unique origin: the ensure registry is module-global and page-lifetime
|
|
414
|
+
const origin = 'http://127.0.0.1:65501'
|
|
415
|
+
const first = ensureHttpPullTransport({ origin, fetch: vi.fn() })
|
|
416
|
+
const second = ensureHttpPullTransport({ origin, fetch: vi.fn() })
|
|
417
|
+
expect(second).toBe(first)
|
|
418
|
+
first.uninstall()
|
|
419
|
+
})
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
function install(fetch: typeof globalThis.fetch) {
|
|
423
|
+
const transport = installHttpPullTransport({ origin: ORIGIN, fetch })
|
|
424
|
+
transports.push(transport)
|
|
425
|
+
return transport
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function createZero(options: { pingTimeoutMs?: number } = {}) {
|
|
429
|
+
const zero = new Zero({
|
|
430
|
+
server: ORIGIN,
|
|
431
|
+
userID: 'u1',
|
|
432
|
+
auth: 'token-u1',
|
|
433
|
+
schema: zeroHttpFixtureSchema,
|
|
434
|
+
kvStore: 'mem',
|
|
435
|
+
storageKey: `zero-http-test-${++storageID}`,
|
|
436
|
+
mutators: zeroHttpFixtureMutators,
|
|
437
|
+
pingTimeoutMs: options.pingTimeoutMs,
|
|
438
|
+
})
|
|
439
|
+
zeros.push(zero)
|
|
440
|
+
return zero
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function recordRequest(input: RequestInfo | URL, init?: RequestInit): RequestRecord {
|
|
444
|
+
const url = new URL(String(input))
|
|
445
|
+
const headers = Object.fromEntries(
|
|
446
|
+
Object.entries((init?.headers ?? {}) as Record<string, string>).map(
|
|
447
|
+
([key, value]) => [key.toLowerCase(), value],
|
|
448
|
+
),
|
|
449
|
+
)
|
|
450
|
+
return {
|
|
451
|
+
url: url.toString(),
|
|
452
|
+
path: url.pathname,
|
|
453
|
+
headers,
|
|
454
|
+
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function jsonResponse(body: unknown, init?: ResponseInit) {
|
|
459
|
+
return new Response(JSON.stringify(body), {
|
|
460
|
+
status: init?.status ?? 200,
|
|
461
|
+
statusText: init?.statusText,
|
|
462
|
+
headers: {
|
|
463
|
+
'content-type': 'application/json',
|
|
464
|
+
...(init?.headers as Record<string, string> | undefined),
|
|
465
|
+
},
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function waitForComplete<T>(view: {
|
|
470
|
+
addListener(listener: (data: any, resultType: string) => void): () => void
|
|
471
|
+
}): Promise<T> {
|
|
472
|
+
return new Promise<T>((resolve, reject) => {
|
|
473
|
+
const timeout = setTimeout(
|
|
474
|
+
() => reject(new Error('timed out waiting for complete query')),
|
|
475
|
+
5_000,
|
|
476
|
+
)
|
|
477
|
+
let cleanup = () => {}
|
|
478
|
+
cleanup = view.addListener((data, resultType) => {
|
|
479
|
+
if (resultType !== 'complete') return
|
|
480
|
+
clearTimeout(timeout)
|
|
481
|
+
cleanup()
|
|
482
|
+
resolve(JSON.parse(JSON.stringify(data)) as T)
|
|
483
|
+
})
|
|
484
|
+
})
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function eventually(assertion: () => void | Promise<void>, timeout = 1_000) {
|
|
488
|
+
const started = Date.now()
|
|
489
|
+
let lastError: unknown
|
|
490
|
+
while (Date.now() - started < timeout) {
|
|
491
|
+
try {
|
|
492
|
+
await assertion()
|
|
493
|
+
return
|
|
494
|
+
} catch (error) {
|
|
495
|
+
lastError = error
|
|
496
|
+
await sleep(10)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
throw lastError
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function sleep(ms: number) {
|
|
503
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function openRawSocket() {
|
|
507
|
+
return openRawSocketWithMessages().messages
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function openRawSocketWithMessages(opts?: {
|
|
511
|
+
authToken?: string
|
|
512
|
+
desiredQueriesPatch?: unknown[]
|
|
513
|
+
}) {
|
|
514
|
+
const url = new URL(`${ORIGIN}/sync/v51/connect`)
|
|
515
|
+
url.protocol = 'wss:'
|
|
516
|
+
url.searchParams.set('clientID', 'c1')
|
|
517
|
+
url.searchParams.set('clientGroupID', 'cg1')
|
|
518
|
+
url.searchParams.set('userID', 'u1')
|
|
519
|
+
url.searchParams.set('baseCookie', '')
|
|
520
|
+
url.searchParams.set('lmid', '0')
|
|
521
|
+
url.searchParams.set('wsid', 'ws-test')
|
|
522
|
+
const messages: Array<[string, any]> = []
|
|
523
|
+
const socket = new WebSocket(
|
|
524
|
+
url,
|
|
525
|
+
encodeSecProtocol(
|
|
526
|
+
['initConnection', { desiredQueriesPatch: opts?.desiredQueriesPatch ?? [] }],
|
|
527
|
+
opts?.authToken ?? 'token-u1',
|
|
528
|
+
),
|
|
529
|
+
)
|
|
530
|
+
socket.addEventListener('message', (event) => {
|
|
531
|
+
messages.push(JSON.parse(String(event.data)))
|
|
532
|
+
})
|
|
533
|
+
return { messages, socket }
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function pushBody(id: number) {
|
|
537
|
+
return {
|
|
538
|
+
clientGroupID: 'cg1',
|
|
539
|
+
pushVersion: 1,
|
|
540
|
+
requestID: `push-${id}`,
|
|
541
|
+
timestamp: Date.now(),
|
|
542
|
+
mutations: [
|
|
543
|
+
{
|
|
544
|
+
type: 'custom',
|
|
545
|
+
name: 'project|create',
|
|
546
|
+
id,
|
|
547
|
+
clientID: 'c1',
|
|
548
|
+
args: [{ id: `p${id}`, ownerId: 'u1', name: `project ${id}` }],
|
|
549
|
+
},
|
|
550
|
+
],
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function findMessage(messages: Array<[string, any]>, type: string) {
|
|
555
|
+
const message = messages.find((item) => item[0] === type)
|
|
556
|
+
expect(message).toBeDefined()
|
|
557
|
+
return message as [string, any]
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function encodeSecProtocol(
|
|
561
|
+
initConnectionMessage: [string, Record<string, unknown>],
|
|
562
|
+
authToken: string,
|
|
563
|
+
) {
|
|
564
|
+
return encodeURIComponent(
|
|
565
|
+
Buffer.from(JSON.stringify({ initConnectionMessage, authToken })).toString('base64'),
|
|
566
|
+
)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function defer<T>() {
|
|
570
|
+
let resolve!: (value: T) => void
|
|
571
|
+
let reject!: (error: unknown) => void
|
|
572
|
+
const promise = new Promise<T>((res, rej) => {
|
|
573
|
+
resolve = res
|
|
574
|
+
reject = rej
|
|
575
|
+
})
|
|
576
|
+
return { promise, resolve, reject }
|
|
577
|
+
}
|