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.
Files changed (140) hide show
  1. package/dist/cjs/createUseQuery.cjs +28 -16
  2. package/dist/cjs/createUseQuery.native.js +42 -30
  3. package/dist/cjs/createUseQuery.native.js.map +1 -1
  4. package/dist/cjs/createZeroClient.cjs +13 -1
  5. package/dist/cjs/createZeroClient.native.js +13 -1
  6. package/dist/cjs/createZeroClient.native.js.map +1 -1
  7. package/dist/cjs/httpPull/auth.test.cjs +197 -0
  8. package/dist/cjs/httpPull/auth.test.native.js +279 -0
  9. package/dist/cjs/httpPull/auth.test.native.js.map +1 -0
  10. package/dist/cjs/httpPull/churn.test.cjs +132 -0
  11. package/dist/cjs/httpPull/churn.test.native.js +155 -0
  12. package/dist/cjs/httpPull/churn.test.native.js.map +1 -0
  13. package/dist/cjs/httpPull/fixtureSchema.cjs +76 -0
  14. package/dist/cjs/httpPull/fixtureSchema.native.js +82 -0
  15. package/dist/cjs/httpPull/fixtureSchema.native.js.map +1 -0
  16. package/dist/cjs/httpPull/fixtureServer.cjs +340 -0
  17. package/dist/cjs/httpPull/fixtureServer.native.js +534 -0
  18. package/dist/cjs/httpPull/fixtureServer.native.js.map +1 -0
  19. package/dist/cjs/httpPull/integration.test.cjs +53 -0
  20. package/dist/cjs/httpPull/integration.test.native.js +60 -0
  21. package/dist/cjs/httpPull/integration.test.native.js.map +1 -0
  22. package/dist/cjs/httpPull/rebase.test.cjs +360 -0
  23. package/dist/cjs/httpPull/rebase.test.native.js +420 -0
  24. package/dist/cjs/httpPull/rebase.test.native.js.map +1 -0
  25. package/dist/cjs/httpPull/relations.test.cjs +107 -0
  26. package/dist/cjs/httpPull/relations.test.native.js +119 -0
  27. package/dist/cjs/httpPull/relations.test.native.js.map +1 -0
  28. package/dist/cjs/httpPull/testHarness.cjs +100 -0
  29. package/dist/cjs/httpPull/testHarness.native.js +112 -0
  30. package/dist/cjs/httpPull/testHarness.native.js.map +1 -0
  31. package/dist/cjs/httpPull/transport.test.cjs +568 -0
  32. package/dist/cjs/httpPull/transport.test.native.js +655 -0
  33. package/dist/cjs/httpPull/transport.test.native.js.map +1 -0
  34. package/dist/cjs/httpPullTransport.cjs +432 -0
  35. package/dist/cjs/httpPullTransport.native.js +695 -0
  36. package/dist/cjs/httpPullTransport.native.js.map +1 -0
  37. package/dist/cjs/index.cjs +1 -0
  38. package/dist/cjs/index.native.js +1 -0
  39. package/dist/cjs/index.native.js.map +1 -1
  40. package/dist/cjs/multiInstanceNested.test.cjs +26 -0
  41. package/dist/cjs/multiInstanceNested.test.native.js +34 -0
  42. package/dist/cjs/multiInstanceNested.test.native.js.map +1 -1
  43. package/dist/esm/createUseQuery.mjs +28 -16
  44. package/dist/esm/createUseQuery.mjs.map +1 -1
  45. package/dist/esm/createUseQuery.native.js +42 -30
  46. package/dist/esm/createUseQuery.native.js.map +1 -1
  47. package/dist/esm/createZeroClient.mjs +13 -1
  48. package/dist/esm/createZeroClient.mjs.map +1 -1
  49. package/dist/esm/createZeroClient.native.js +13 -1
  50. package/dist/esm/createZeroClient.native.js.map +1 -1
  51. package/dist/esm/httpPull/auth.test.mjs +198 -0
  52. package/dist/esm/httpPull/auth.test.mjs.map +1 -0
  53. package/dist/esm/httpPull/auth.test.native.js +277 -0
  54. package/dist/esm/httpPull/auth.test.native.js.map +1 -0
  55. package/dist/esm/httpPull/churn.test.mjs +133 -0
  56. package/dist/esm/httpPull/churn.test.mjs.map +1 -0
  57. package/dist/esm/httpPull/churn.test.native.js +153 -0
  58. package/dist/esm/httpPull/churn.test.native.js.map +1 -0
  59. package/dist/esm/httpPull/fixtureSchema.mjs +50 -0
  60. package/dist/esm/httpPull/fixtureSchema.mjs.map +1 -0
  61. package/dist/esm/httpPull/fixtureSchema.native.js +53 -0
  62. package/dist/esm/httpPull/fixtureSchema.native.js.map +1 -0
  63. package/dist/esm/httpPull/fixtureServer.mjs +315 -0
  64. package/dist/esm/httpPull/fixtureServer.mjs.map +1 -0
  65. package/dist/esm/httpPull/fixtureServer.native.js +506 -0
  66. package/dist/esm/httpPull/fixtureServer.native.js.map +1 -0
  67. package/dist/esm/httpPull/integration.test.mjs +54 -0
  68. package/dist/esm/httpPull/integration.test.mjs.map +1 -0
  69. package/dist/esm/httpPull/integration.test.native.js +58 -0
  70. package/dist/esm/httpPull/integration.test.native.js.map +1 -0
  71. package/dist/esm/httpPull/rebase.test.mjs +361 -0
  72. package/dist/esm/httpPull/rebase.test.mjs.map +1 -0
  73. package/dist/esm/httpPull/rebase.test.native.js +418 -0
  74. package/dist/esm/httpPull/rebase.test.native.js.map +1 -0
  75. package/dist/esm/httpPull/relations.test.mjs +108 -0
  76. package/dist/esm/httpPull/relations.test.mjs.map +1 -0
  77. package/dist/esm/httpPull/relations.test.native.js +117 -0
  78. package/dist/esm/httpPull/relations.test.native.js.map +1 -0
  79. package/dist/esm/httpPull/testHarness.mjs +72 -0
  80. package/dist/esm/httpPull/testHarness.mjs.map +1 -0
  81. package/dist/esm/httpPull/testHarness.native.js +81 -0
  82. package/dist/esm/httpPull/testHarness.native.js.map +1 -0
  83. package/dist/esm/httpPull/transport.test.mjs +569 -0
  84. package/dist/esm/httpPull/transport.test.mjs.map +1 -0
  85. package/dist/esm/httpPull/transport.test.native.js +653 -0
  86. package/dist/esm/httpPull/transport.test.native.js.map +1 -0
  87. package/dist/esm/httpPullTransport.mjs +406 -0
  88. package/dist/esm/httpPullTransport.mjs.map +1 -0
  89. package/dist/esm/httpPullTransport.native.js +666 -0
  90. package/dist/esm/httpPullTransport.native.js.map +1 -0
  91. package/dist/esm/index.js +1 -0
  92. package/dist/esm/index.js.map +1 -1
  93. package/dist/esm/index.mjs +1 -0
  94. package/dist/esm/index.mjs.map +1 -1
  95. package/dist/esm/index.native.js +1 -0
  96. package/dist/esm/index.native.js.map +1 -1
  97. package/dist/esm/multiInstanceNested.test.mjs +27 -1
  98. package/dist/esm/multiInstanceNested.test.mjs.map +1 -1
  99. package/dist/esm/multiInstanceNested.test.native.js +35 -1
  100. package/dist/esm/multiInstanceNested.test.native.js.map +1 -1
  101. package/package.json +2 -2
  102. package/src/createUseQuery.tsx +40 -22
  103. package/src/createZeroClient.tsx +19 -0
  104. package/src/httpPull/auth.test.ts +208 -0
  105. package/src/httpPull/churn.test.ts +147 -0
  106. package/src/httpPull/fixtureSchema.ts +82 -0
  107. package/src/httpPull/fixtureServer.ts +391 -0
  108. package/src/httpPull/integration.test.ts +57 -0
  109. package/src/httpPull/rebase.test.ts +368 -0
  110. package/src/httpPull/relations.test.ts +135 -0
  111. package/src/httpPull/testHarness.ts +95 -0
  112. package/src/httpPull/transport.test.ts +577 -0
  113. package/src/httpPullTransport.ts +559 -0
  114. package/src/index.ts +1 -0
  115. package/src/multiInstanceNested.test.tsx +25 -1
  116. package/types/createUseQuery.d.ts.map +1 -1
  117. package/types/createZeroClient.d.ts +3 -1
  118. package/types/createZeroClient.d.ts.map +1 -1
  119. package/types/httpPull/auth.test.d.ts +2 -0
  120. package/types/httpPull/auth.test.d.ts.map +1 -0
  121. package/types/httpPull/churn.test.d.ts +2 -0
  122. package/types/httpPull/churn.test.d.ts.map +1 -0
  123. package/types/httpPull/fixtureSchema.d.ts +111 -0
  124. package/types/httpPull/fixtureSchema.d.ts.map +1 -0
  125. package/types/httpPull/fixtureServer.d.ts +14 -0
  126. package/types/httpPull/fixtureServer.d.ts.map +1 -0
  127. package/types/httpPull/integration.test.d.ts +2 -0
  128. package/types/httpPull/integration.test.d.ts.map +1 -0
  129. package/types/httpPull/rebase.test.d.ts +2 -0
  130. package/types/httpPull/rebase.test.d.ts.map +1 -0
  131. package/types/httpPull/relations.test.d.ts +2 -0
  132. package/types/httpPull/relations.test.d.ts.map +1 -0
  133. package/types/httpPull/testHarness.d.ts +32 -0
  134. package/types/httpPull/testHarness.d.ts.map +1 -0
  135. package/types/httpPull/transport.test.d.ts +2 -0
  136. package/types/httpPull/transport.test.d.ts.map +1 -0
  137. package/types/httpPullTransport.d.ts +13 -0
  138. package/types/httpPullTransport.d.ts.map +1 -0
  139. package/types/index.d.ts +1 -0
  140. 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
+ }