on-zero 0.4.26 → 0.4.28

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 (123) hide show
  1. package/dist/cjs/createZeroClient.cjs +14 -1
  2. package/dist/cjs/createZeroClient.native.js +15 -1
  3. package/dist/cjs/createZeroClient.native.js.map +1 -1
  4. package/dist/cjs/httpPull/auth.test.cjs +197 -0
  5. package/dist/cjs/httpPull/auth.test.native.js +279 -0
  6. package/dist/cjs/httpPull/auth.test.native.js.map +1 -0
  7. package/dist/cjs/httpPull/churn.test.cjs +132 -0
  8. package/dist/cjs/httpPull/churn.test.native.js +155 -0
  9. package/dist/cjs/httpPull/churn.test.native.js.map +1 -0
  10. package/dist/cjs/httpPull/fixtureSchema.cjs +76 -0
  11. package/dist/cjs/httpPull/fixtureSchema.native.js +82 -0
  12. package/dist/cjs/httpPull/fixtureSchema.native.js.map +1 -0
  13. package/dist/cjs/httpPull/fixtureServer.cjs +340 -0
  14. package/dist/cjs/httpPull/fixtureServer.native.js +534 -0
  15. package/dist/cjs/httpPull/fixtureServer.native.js.map +1 -0
  16. package/dist/cjs/httpPull/integration.test.cjs +53 -0
  17. package/dist/cjs/httpPull/integration.test.native.js +60 -0
  18. package/dist/cjs/httpPull/integration.test.native.js.map +1 -0
  19. package/dist/cjs/httpPull/rebase.test.cjs +360 -0
  20. package/dist/cjs/httpPull/rebase.test.native.js +420 -0
  21. package/dist/cjs/httpPull/rebase.test.native.js.map +1 -0
  22. package/dist/cjs/httpPull/relations.test.cjs +107 -0
  23. package/dist/cjs/httpPull/relations.test.native.js +119 -0
  24. package/dist/cjs/httpPull/relations.test.native.js.map +1 -0
  25. package/dist/cjs/httpPull/testHarness.cjs +100 -0
  26. package/dist/cjs/httpPull/testHarness.native.js +112 -0
  27. package/dist/cjs/httpPull/testHarness.native.js.map +1 -0
  28. package/dist/cjs/httpPull/transport.test.cjs +588 -0
  29. package/dist/cjs/httpPull/transport.test.native.js +677 -0
  30. package/dist/cjs/httpPull/transport.test.native.js.map +1 -0
  31. package/dist/cjs/httpPullTransport.cjs +447 -0
  32. package/dist/cjs/httpPullTransport.native.js +710 -0
  33. package/dist/cjs/httpPullTransport.native.js.map +1 -0
  34. package/dist/cjs/index.cjs +1 -0
  35. package/dist/cjs/index.native.js +1 -0
  36. package/dist/cjs/index.native.js.map +1 -1
  37. package/dist/esm/createZeroClient.mjs +14 -1
  38. package/dist/esm/createZeroClient.mjs.map +1 -1
  39. package/dist/esm/createZeroClient.native.js +15 -1
  40. package/dist/esm/createZeroClient.native.js.map +1 -1
  41. package/dist/esm/httpPull/auth.test.mjs +198 -0
  42. package/dist/esm/httpPull/auth.test.mjs.map +1 -0
  43. package/dist/esm/httpPull/auth.test.native.js +277 -0
  44. package/dist/esm/httpPull/auth.test.native.js.map +1 -0
  45. package/dist/esm/httpPull/churn.test.mjs +133 -0
  46. package/dist/esm/httpPull/churn.test.mjs.map +1 -0
  47. package/dist/esm/httpPull/churn.test.native.js +153 -0
  48. package/dist/esm/httpPull/churn.test.native.js.map +1 -0
  49. package/dist/esm/httpPull/fixtureSchema.mjs +50 -0
  50. package/dist/esm/httpPull/fixtureSchema.mjs.map +1 -0
  51. package/dist/esm/httpPull/fixtureSchema.native.js +53 -0
  52. package/dist/esm/httpPull/fixtureSchema.native.js.map +1 -0
  53. package/dist/esm/httpPull/fixtureServer.mjs +315 -0
  54. package/dist/esm/httpPull/fixtureServer.mjs.map +1 -0
  55. package/dist/esm/httpPull/fixtureServer.native.js +506 -0
  56. package/dist/esm/httpPull/fixtureServer.native.js.map +1 -0
  57. package/dist/esm/httpPull/integration.test.mjs +54 -0
  58. package/dist/esm/httpPull/integration.test.mjs.map +1 -0
  59. package/dist/esm/httpPull/integration.test.native.js +58 -0
  60. package/dist/esm/httpPull/integration.test.native.js.map +1 -0
  61. package/dist/esm/httpPull/rebase.test.mjs +361 -0
  62. package/dist/esm/httpPull/rebase.test.mjs.map +1 -0
  63. package/dist/esm/httpPull/rebase.test.native.js +418 -0
  64. package/dist/esm/httpPull/rebase.test.native.js.map +1 -0
  65. package/dist/esm/httpPull/relations.test.mjs +108 -0
  66. package/dist/esm/httpPull/relations.test.mjs.map +1 -0
  67. package/dist/esm/httpPull/relations.test.native.js +117 -0
  68. package/dist/esm/httpPull/relations.test.native.js.map +1 -0
  69. package/dist/esm/httpPull/testHarness.mjs +72 -0
  70. package/dist/esm/httpPull/testHarness.mjs.map +1 -0
  71. package/dist/esm/httpPull/testHarness.native.js +81 -0
  72. package/dist/esm/httpPull/testHarness.native.js.map +1 -0
  73. package/dist/esm/httpPull/transport.test.mjs +589 -0
  74. package/dist/esm/httpPull/transport.test.mjs.map +1 -0
  75. package/dist/esm/httpPull/transport.test.native.js +675 -0
  76. package/dist/esm/httpPull/transport.test.native.js.map +1 -0
  77. package/dist/esm/httpPullTransport.mjs +421 -0
  78. package/dist/esm/httpPullTransport.mjs.map +1 -0
  79. package/dist/esm/httpPullTransport.native.js +681 -0
  80. package/dist/esm/httpPullTransport.native.js.map +1 -0
  81. package/dist/esm/index.js +1 -0
  82. package/dist/esm/index.js.map +1 -1
  83. package/dist/esm/index.mjs +1 -0
  84. package/dist/esm/index.mjs.map +1 -1
  85. package/dist/esm/index.native.js +1 -0
  86. package/dist/esm/index.native.js.map +1 -1
  87. package/package.json +2 -2
  88. package/src/createZeroClient.tsx +22 -0
  89. package/src/httpPull/auth.test.ts +208 -0
  90. package/src/httpPull/churn.test.ts +147 -0
  91. package/src/httpPull/fixtureSchema.ts +82 -0
  92. package/src/httpPull/fixtureServer.ts +391 -0
  93. package/src/httpPull/integration.test.ts +57 -0
  94. package/src/httpPull/rebase.test.ts +368 -0
  95. package/src/httpPull/relations.test.ts +135 -0
  96. package/src/httpPull/testHarness.ts +95 -0
  97. package/src/httpPull/transport.test.ts +603 -0
  98. package/src/httpPullTransport.ts +587 -0
  99. package/src/index.ts +1 -0
  100. package/types/createZeroClient.d.ts +3 -1
  101. package/types/createZeroClient.d.ts.map +1 -1
  102. package/types/httpPull/auth.test.d.ts +2 -0
  103. package/types/httpPull/auth.test.d.ts.map +1 -0
  104. package/types/httpPull/churn.test.d.ts +2 -0
  105. package/types/httpPull/churn.test.d.ts.map +1 -0
  106. package/types/httpPull/fixtureSchema.d.ts +111 -0
  107. package/types/httpPull/fixtureSchema.d.ts.map +1 -0
  108. package/types/httpPull/fixtureServer.d.ts +14 -0
  109. package/types/httpPull/fixtureServer.d.ts.map +1 -0
  110. package/types/httpPull/integration.test.d.ts +2 -0
  111. package/types/httpPull/integration.test.d.ts.map +1 -0
  112. package/types/httpPull/rebase.test.d.ts +2 -0
  113. package/types/httpPull/rebase.test.d.ts.map +1 -0
  114. package/types/httpPull/relations.test.d.ts +2 -0
  115. package/types/httpPull/relations.test.d.ts.map +1 -0
  116. package/types/httpPull/testHarness.d.ts +32 -0
  117. package/types/httpPull/testHarness.d.ts.map +1 -0
  118. package/types/httpPull/transport.test.d.ts +2 -0
  119. package/types/httpPull/transport.test.d.ts.map +1 -0
  120. package/types/httpPullTransport.d.ts +13 -0
  121. package/types/httpPullTransport.d.ts.map +1 -0
  122. package/types/index.d.ts +1 -0
  123. package/types/index.d.ts.map +1 -1
@@ -0,0 +1,368 @@
1
+ import { afterEach, expect, test } from 'vitest'
2
+
3
+ import {
4
+ eventually,
5
+ startZeroHttpHarness,
6
+ waitForComplete,
7
+ type ZeroHttpHarness,
8
+ } from './testHarness'
9
+
10
+ let harness: ZeroHttpHarness | undefined
11
+
12
+ afterEach(async () => {
13
+ await harness?.close()
14
+ harness = undefined
15
+ })
16
+
17
+ test('ack-then-pull keeps optimistic rows visible until the authoritative snapshot lands', async () => {
18
+ const postPushPull = deferred<void>()
19
+ const postPushPullStarted = deferred<void>()
20
+ let shouldHoldPostPushPull = false
21
+
22
+ harness = await startZeroHttpHarness({
23
+ seed: {
24
+ user: [{ id: 'u1', name: 'ada' }],
25
+ project: [{ id: 'p1', ownerId: 'u1', name: 'first' }],
26
+ member: [],
27
+ },
28
+ interceptFetch: (next) => async (input, init) => {
29
+ const path = new URL(String(input)).pathname
30
+ if (path === '/push') {
31
+ const response = await next(input, init)
32
+ shouldHoldPostPushPull = true
33
+ return response
34
+ }
35
+ if (path === '/pull' && shouldHoldPostPushPull) {
36
+ shouldHoldPostPushPull = false
37
+ postPushPullStarted.resolve()
38
+ await postPushPull.promise
39
+ }
40
+ return next(input, init)
41
+ },
42
+ })
43
+ const zero = harness.createZero('u1')
44
+ const view = zero.query.project.materialize()
45
+ await waitForComplete<any[]>(view)
46
+ const emissions = recordEmissions(view)
47
+
48
+ const mutation = zero.mutate.project.create({
49
+ id: 'p2',
50
+ ownerId: 'u1',
51
+ name: 'second',
52
+ })
53
+ await mutation.client
54
+ await eventually(() => expect(projectIDs(view.data)).toContain('p2'))
55
+ await mutation.server
56
+ await postPushPullStarted.promise
57
+
58
+ expect(projectIDs(view.data)).toContain('p2')
59
+ postPushPull.resolve()
60
+ await harness.transport.pull()
61
+ await eventually(() => expect(projectIDs(view.data).sort()).toEqual(['p1', 'p2']))
62
+
63
+ expectNeverDisappearsAfterFirstSeen(emissions, 'p2')
64
+ expect(harness.server.rows('project').sort(byID)).toEqual([
65
+ { id: 'p1', ownerId: 'u1', name: 'first' },
66
+ { id: 'p2', ownerId: 'u1', name: 'second' },
67
+ ])
68
+ emissions.cleanup()
69
+ view.destroy()
70
+ })
71
+
72
+ test('pull-then-ack rebases optimistic rows over a newer snapshot', async () => {
73
+ const pushGate = deferred<void>()
74
+ const pushStarted = deferred<any>()
75
+
76
+ harness = await startZeroHttpHarness({
77
+ seed: {
78
+ user: [{ id: 'u1', name: 'ada' }],
79
+ project: [{ id: 'p1', ownerId: 'u1', name: 'first' }],
80
+ member: [],
81
+ },
82
+ interceptFetch: (next) => async (input, init) => {
83
+ const path = new URL(String(input)).pathname
84
+ if (path === '/push') {
85
+ const body = JSON.parse(String(init?.body))
86
+ pushStarted.resolve(body)
87
+ await pushGate.promise
88
+ }
89
+ return next(input, init)
90
+ },
91
+ })
92
+ const zero = harness.createZero('u1')
93
+ const view = zero.query.project.materialize()
94
+ await waitForComplete<any[]>(view)
95
+ const emissions = recordEmissions(view)
96
+
97
+ const mutation = zero.mutate.project.create({
98
+ id: 'p2',
99
+ ownerId: 'u1',
100
+ name: 'optimistic',
101
+ })
102
+ await mutation.client
103
+ await eventually(() => expect(projectIDs(view.data)).toContain('p2'))
104
+
105
+ const heldPush = await pushStarted.promise
106
+ await rawPush(harness, {
107
+ clientGroupID: heldPush.clientGroupID,
108
+ clientID: 'server-side',
109
+ id: 1,
110
+ name: 'project|create',
111
+ args: { id: 'p-server', ownerId: 'u1', name: 'server change' },
112
+ })
113
+ await harness.transport.pull()
114
+
115
+ await eventually(() =>
116
+ expect(projectIDs(view.data).sort()).toEqual(['p-server', 'p1', 'p2']),
117
+ )
118
+ expect(
119
+ harness.server
120
+ .rows('project')
121
+ .map((row) => row.id)
122
+ .sort(),
123
+ ).toEqual(['p-server', 'p1'])
124
+
125
+ pushGate.resolve()
126
+ await mutation.server
127
+ await harness.transport.pull()
128
+ await eventually(() =>
129
+ expect(projectIDs(view.data).sort()).toEqual(['p-server', 'p1', 'p2']),
130
+ )
131
+
132
+ expectNeverDisappearsAfterFirstSeen(emissions, 'p2')
133
+ expect(harness.server.rows('project').sort(byID)).toEqual([
134
+ { id: 'p-server', ownerId: 'u1', name: 'server change' },
135
+ { id: 'p1', ownerId: 'u1', name: 'first' },
136
+ { id: 'p2', ownerId: 'u1', name: 'optimistic' },
137
+ ])
138
+ emissions.cleanup()
139
+ view.destroy()
140
+ })
141
+
142
+ test('app-error rollback advances LMID and reverts optimistic state after pull', async () => {
143
+ const pushGate = deferred<void>()
144
+ const pushStarted = deferred<void>()
145
+ let clientGroupID = ''
146
+
147
+ harness = await startZeroHttpHarness({
148
+ seed: {
149
+ user: [
150
+ { id: 'u1', name: 'ada' },
151
+ { id: 'u2', name: 'ben' },
152
+ ],
153
+ project: [{ id: 'p1', ownerId: 'u2', name: 'shared' }],
154
+ member: [{ id: 'm1', projectId: 'p1', userId: 'u1' }],
155
+ },
156
+ interceptFetch: (next) => async (input, init) => {
157
+ if (new URL(String(input)).pathname === '/push') {
158
+ const body = JSON.parse(String(init?.body))
159
+ clientGroupID = body.clientGroupID
160
+ pushStarted.resolve()
161
+ await pushGate.promise
162
+ }
163
+ return next(input, init)
164
+ },
165
+ })
166
+ const zero = harness.createZero('u1')
167
+ const view = zero.query.project.materialize()
168
+ await waitForComplete<any[]>(view)
169
+ const emissions = recordEmissions(view)
170
+
171
+ const mutation = zero.mutate.project.rename({ id: 'p1', name: 'stolen' })
172
+ await mutation.client
173
+ await pushStarted.promise
174
+ expect(projectName(view.data, 'p1')).toBe('stolen')
175
+ expect(harness.server.rows('project')).toEqual([
176
+ { id: 'p1', ownerId: 'u2', name: 'shared' },
177
+ ])
178
+
179
+ pushGate.resolve()
180
+ await expect(mutation.server).resolves.toMatchObject({
181
+ type: 'error',
182
+ error: {
183
+ type: 'app',
184
+ details: 'forbidden',
185
+ },
186
+ })
187
+
188
+ await harness.transport.pull()
189
+ await eventually(() => expect(projectName(view.data, 'p1')).toBe('shared'))
190
+ expect(emissions.values.map((rows) => projectName(rows, 'p1'))).toContain('stolen')
191
+ expect(emissions.values.at(-1)?.[0]).toEqual({
192
+ id: 'p1',
193
+ ownerId: 'u2',
194
+ name: 'shared',
195
+ })
196
+ expect(harness.server.rows('project')).toEqual([
197
+ { id: 'p1', ownerId: 'u2', name: 'shared' },
198
+ ])
199
+
200
+ const pull = await rawPull(harness, 'u1', { clientGroupID })
201
+ expect(Object.values(pull.lastMutationIDChanges)).toContain(1)
202
+ emissions.cleanup()
203
+ view.destroy()
204
+ })
205
+
206
+ test('app-error rollback removes phantom optimistic create after pull', async () => {
207
+ const pushGate = deferred<void>()
208
+ const pushStarted = deferred<void>()
209
+ let clientGroupID = ''
210
+
211
+ harness = await startZeroHttpHarness({
212
+ seed: {
213
+ user: [
214
+ { id: 'u1', name: 'ada' },
215
+ { id: 'u2', name: 'ben' },
216
+ ],
217
+ project: [],
218
+ member: [],
219
+ },
220
+ interceptFetch: (next) => async (input, init) => {
221
+ if (new URL(String(input)).pathname === '/push') {
222
+ const body = JSON.parse(String(init?.body))
223
+ clientGroupID = body.clientGroupID
224
+ pushStarted.resolve()
225
+ await pushGate.promise
226
+ }
227
+ return next(input, init)
228
+ },
229
+ })
230
+ const zero = harness.createZero('u1')
231
+ const view = zero.query.project.materialize()
232
+ await waitForComplete<any[]>(view)
233
+ const emissions = recordEmissions(view)
234
+
235
+ const mutation = zero.mutate.project.create({
236
+ id: 'p-phantom',
237
+ ownerId: 'u2',
238
+ name: 'forbidden',
239
+ })
240
+ await mutation.client
241
+ await pushStarted.promise
242
+ await eventually(() => expect(projectIDs(view.data)).toContain('p-phantom'))
243
+ expect(harness.server.rows('project')).toEqual([])
244
+
245
+ pushGate.resolve()
246
+ await expect(mutation.server).resolves.toMatchObject({
247
+ type: 'error',
248
+ error: {
249
+ type: 'app',
250
+ details: 'forbidden',
251
+ },
252
+ })
253
+
254
+ await harness.transport.pull()
255
+ await eventually(() => expect(projectIDs(view.data)).not.toContain('p-phantom'))
256
+ expect(emissions.values.some((rows) => projectIDs(rows).includes('p-phantom'))).toBe(
257
+ true,
258
+ )
259
+ expect(emissions.values.at(-1)).toEqual([])
260
+ expect(harness.server.rows('project')).toEqual([])
261
+
262
+ const pull = await rawPull(harness, 'u1', { clientGroupID })
263
+ expect(Object.values(pull.lastMutationIDChanges)).toContain(1)
264
+ emissions.cleanup()
265
+ view.destroy()
266
+ })
267
+
268
+ async function rawPush(
269
+ harness: ZeroHttpHarness,
270
+ mutation: {
271
+ clientGroupID: string
272
+ clientID: string
273
+ id: number
274
+ name: string
275
+ args: Record<string, string>
276
+ },
277
+ ) {
278
+ const response = await fetch(`${harness.server.url}/push`, {
279
+ method: 'POST',
280
+ headers: {
281
+ authorization: 'Bearer token-u1',
282
+ 'content-type': 'application/json',
283
+ },
284
+ body: JSON.stringify({
285
+ timestamp: Date.now(),
286
+ clientGroupID: mutation.clientGroupID,
287
+ pushVersion: 1,
288
+ requestID: `raw-${mutation.clientID}-${mutation.id}`,
289
+ mutations: [
290
+ {
291
+ type: 'custom',
292
+ name: mutation.name,
293
+ id: mutation.id,
294
+ clientID: mutation.clientID,
295
+ args: [mutation.args],
296
+ },
297
+ ],
298
+ }),
299
+ })
300
+ expect(response.status).toBe(200)
301
+ return response.json()
302
+ }
303
+
304
+ async function rawPull(
305
+ harness: ZeroHttpHarness,
306
+ userID: string,
307
+ body: { clientGroupID: string },
308
+ ) {
309
+ const response = await fetch(`${harness.server.url}/pull`, {
310
+ method: 'POST',
311
+ headers: {
312
+ authorization: `Bearer token-${userID}`,
313
+ 'content-type': 'application/json',
314
+ },
315
+ body: JSON.stringify({
316
+ clientID: 'raw-pull',
317
+ clientGroupID: body.clientGroupID,
318
+ cookie: null,
319
+ }),
320
+ })
321
+ expect(response.status).toBe(200)
322
+ return response.json() as Promise<{
323
+ lastMutationIDChanges: Record<string, number>
324
+ }>
325
+ }
326
+
327
+ function recordEmissions(view: {
328
+ addListener(listener: (data: any) => void): () => void
329
+ }) {
330
+ const values: any[][] = []
331
+ const cleanup = view.addListener((data) => values.push(snapshot(data)))
332
+ return { values, cleanup }
333
+ }
334
+
335
+ function snapshot(rows: any[]) {
336
+ return JSON.parse(JSON.stringify(rows)) as any[]
337
+ }
338
+
339
+ function projectIDs(rows: any[]) {
340
+ return rows.map((row) => row.id)
341
+ }
342
+
343
+ function projectName(rows: any[], id: string) {
344
+ return rows.find((row) => row.id === id)?.name
345
+ }
346
+
347
+ function expectNeverDisappearsAfterFirstSeen(emissions: { values: any[][] }, id: string) {
348
+ let seen = false
349
+ for (const rows of emissions.values) {
350
+ if (projectIDs(rows).includes(id)) seen = true
351
+ if (seen) expect(projectIDs(rows)).toContain(id)
352
+ }
353
+ expect(seen).toBe(true)
354
+ }
355
+
356
+ function byID(a: Record<string, string>, b: Record<string, string>) {
357
+ return a.id.localeCompare(b.id)
358
+ }
359
+
360
+ function deferred<T>() {
361
+ let resolve!: (value: T | PromiseLike<T>) => void
362
+ let reject!: (error: unknown) => void
363
+ const promise = new Promise<T>((res, rej) => {
364
+ resolve = res
365
+ reject = rej
366
+ })
367
+ return { promise, resolve, reject }
368
+ }
@@ -0,0 +1,135 @@
1
+ import { afterEach, expect, test } from 'vitest'
2
+
3
+ import {
4
+ eventually,
5
+ sleep,
6
+ startZeroHttpHarness,
7
+ waitForComplete,
8
+ type ZeroHttpHarness,
9
+ } from './testHarness'
10
+
11
+ let harness: ZeroHttpHarness | undefined
12
+
13
+ type MemberRow = { id: string; projectId: string; userId: string }
14
+ type ProjectWithMembers = {
15
+ id: string
16
+ ownerId: string
17
+ name: string
18
+ members: MemberRow[]
19
+ }
20
+
21
+ afterEach(async () => {
22
+ await harness?.close()
23
+ harness = undefined
24
+ })
25
+
26
+ test('related project members appear, update, and vanish with visibility', async () => {
27
+ harness = await startZeroHttpHarness({
28
+ seed: {
29
+ user: [
30
+ { id: 'u1', name: 'ada' },
31
+ { id: 'u2', name: 'ben' },
32
+ ],
33
+ project: [],
34
+ member: [],
35
+ },
36
+ })
37
+ const u1 = harness.createZero('u1')
38
+ const u2 = harness.createZero('u2')
39
+ const u1Projects = u1.query.project.related('members').materialize()
40
+ const emissions: ProjectWithMembers[][] = []
41
+ const stopCapture = captureProjectEmissions(u1Projects, emissions)
42
+
43
+ try {
44
+ await expect(waitForComplete<ProjectWithMembers[]>(u1Projects)).resolves.toEqual([])
45
+
46
+ const created = u2.mutate.project.create({
47
+ id: 'p-shared',
48
+ ownerId: 'u2',
49
+ name: 'shared',
50
+ })
51
+ await created.client
52
+ await created.server
53
+
54
+ const added = u2.mutate.member.add({
55
+ id: 'm-u1-shared',
56
+ projectId: 'p-shared',
57
+ userId: 'u1',
58
+ })
59
+ await added.client
60
+ await added.server
61
+
62
+ await harness.transport.pull()
63
+ await eventually(() => {
64
+ expect(normalizeProjects(u1Projects.data as ProjectWithMembers[])).toEqual([
65
+ {
66
+ id: 'p-shared',
67
+ ownerId: 'u2',
68
+ name: 'shared',
69
+ members: [{ id: 'm-u1-shared', projectId: 'p-shared', userId: 'u1' }],
70
+ },
71
+ ])
72
+ })
73
+
74
+ const renamed = u2.mutate.project.rename({
75
+ id: 'p-shared',
76
+ name: 'renamed shared',
77
+ })
78
+ await renamed.client
79
+ await renamed.server
80
+
81
+ await harness.transport.pull()
82
+ await eventually(() => {
83
+ expect(normalizeProjects(u1Projects.data as ProjectWithMembers[])).toEqual([
84
+ {
85
+ id: 'p-shared',
86
+ ownerId: 'u2',
87
+ name: 'renamed shared',
88
+ members: [{ id: 'm-u1-shared', projectId: 'p-shared', userId: 'u1' }],
89
+ },
90
+ ])
91
+ })
92
+
93
+ const removed = u2.mutate.member.remove({ id: 'm-u1-shared' })
94
+ await removed.client
95
+ await removed.server
96
+
97
+ await harness.transport.pull()
98
+ await eventually(() => {
99
+ expect(normalizeProjects(u1Projects.data as ProjectWithMembers[])).toEqual([])
100
+ expect(emissions.at(-1)?.some((project) => project.id === 'p-shared')).toBe(false)
101
+ })
102
+
103
+ const emissionCountAfterRevocation = emissions.length
104
+ await harness.transport.pull()
105
+ await sleep(50)
106
+ expect(emissions.length).toBe(emissionCountAfterRevocation)
107
+ } finally {
108
+ stopCapture()
109
+ u1Projects.destroy()
110
+ }
111
+ })
112
+
113
+ function captureProjectEmissions(
114
+ view: {
115
+ addListener(listener: (data: any, resultType: string) => void): () => void
116
+ },
117
+ emissions: ProjectWithMembers[][],
118
+ ) {
119
+ return view.addListener((data) => {
120
+ emissions.push(normalizeProjects(data))
121
+ })
122
+ }
123
+
124
+ function normalizeProjects(projects: ProjectWithMembers[]) {
125
+ return clone(projects)
126
+ .map((project) => ({
127
+ ...project,
128
+ members: [...project.members].sort((a, b) => a.id.localeCompare(b.id)),
129
+ }))
130
+ .sort((a, b) => a.id.localeCompare(b.id))
131
+ }
132
+
133
+ function clone<T>(value: T): T {
134
+ return JSON.parse(JSON.stringify(value)) as T
135
+ }
@@ -0,0 +1,95 @@
1
+ import { Zero } from '@rocicorp/zero'
2
+
3
+ import { zeroHttpFixtureMutators, zeroHttpFixtureSchema } from './fixtureSchema'
4
+ import { startZeroHttpServer, type Row } from './fixtureServer'
5
+ import { installHttpPullTransport } from '../httpPullTransport'
6
+
7
+ let storageID = 0
8
+
9
+ export type FixtureZero = Zero<
10
+ typeof zeroHttpFixtureSchema,
11
+ typeof zeroHttpFixtureMutators
12
+ >
13
+
14
+ export type ZeroHttpHarness = Awaited<ReturnType<typeof startZeroHttpHarness>>
15
+
16
+ export async function startZeroHttpHarness(opts?: {
17
+ seed?: { user?: Row[]; project?: Row[]; member?: Row[] }
18
+ interceptFetch?: (next: typeof fetch) => typeof fetch
19
+ }) {
20
+ const server = await startZeroHttpServer({ seed: opts?.seed })
21
+ const baseFetch: typeof fetch = (input, init) => globalThis.fetch(input, init)
22
+ const transportFetch = opts?.interceptFetch ? opts.interceptFetch(baseFetch) : baseFetch
23
+ const transport = installHttpPullTransport({
24
+ origin: server.url,
25
+ fetch: transportFetch,
26
+ })
27
+ const clients: Array<{ close(): Promise<unknown> }> = []
28
+
29
+ return {
30
+ server,
31
+ transport,
32
+ createZero(
33
+ userID: string,
34
+ createOpts?: { storageKey?: string; pingTimeoutMs?: number },
35
+ ): FixtureZero {
36
+ const zero = new Zero({
37
+ server: server.url,
38
+ userID,
39
+ auth: `token-${userID}`,
40
+ schema: zeroHttpFixtureSchema,
41
+ kvStore: 'mem' as const,
42
+ storageKey: createOpts?.storageKey ?? `zero-http-harness-${++storageID}`,
43
+ mutators: zeroHttpFixtureMutators,
44
+ pingTimeoutMs: createOpts?.pingTimeoutMs,
45
+ })
46
+ clients.push(zero)
47
+ return zero
48
+ },
49
+ async close() {
50
+ await transport.pull().catch(() => {})
51
+ while (clients.length) await clients.pop()?.close()
52
+ transport.uninstall()
53
+ await server.close()
54
+ },
55
+ }
56
+ }
57
+
58
+ // the view's listener data is Immutable<T>; results are deep-cloned to a
59
+ // plain mutable T for assertions, so the listener param is typed loosely
60
+ export function waitForComplete<T>(view: {
61
+ addListener(listener: (data: any, resultType: string) => void): () => void
62
+ }): Promise<T> {
63
+ return new Promise<T>((resolve, reject) => {
64
+ const timeout = setTimeout(
65
+ () => reject(new Error('timed out waiting for complete query')),
66
+ 5_000,
67
+ )
68
+ let cleanup = () => {}
69
+ cleanup = view.addListener((data, resultType) => {
70
+ if (resultType !== 'complete') return
71
+ clearTimeout(timeout)
72
+ cleanup()
73
+ resolve(JSON.parse(JSON.stringify(data)) as T)
74
+ })
75
+ })
76
+ }
77
+
78
+ export async function eventually(assertion: () => void | Promise<void>, timeout = 3_000) {
79
+ const started = Date.now()
80
+ let lastError: unknown
81
+ while (Date.now() - started < timeout) {
82
+ try {
83
+ await assertion()
84
+ return
85
+ } catch (error) {
86
+ lastError = error
87
+ await sleep(10)
88
+ }
89
+ }
90
+ throw lastError
91
+ }
92
+
93
+ export function sleep(ms: number) {
94
+ return new Promise((resolve) => setTimeout(resolve, ms))
95
+ }