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.
- package/dist/cjs/createZeroClient.cjs +14 -1
- package/dist/cjs/createZeroClient.native.js +15 -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 +588 -0
- package/dist/cjs/httpPull/transport.test.native.js +677 -0
- package/dist/cjs/httpPull/transport.test.native.js.map +1 -0
- package/dist/cjs/httpPullTransport.cjs +447 -0
- package/dist/cjs/httpPullTransport.native.js +710 -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/esm/createZeroClient.mjs +14 -1
- package/dist/esm/createZeroClient.mjs.map +1 -1
- package/dist/esm/createZeroClient.native.js +15 -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 +589 -0
- package/dist/esm/httpPull/transport.test.mjs.map +1 -0
- package/dist/esm/httpPull/transport.test.native.js +675 -0
- package/dist/esm/httpPull/transport.test.native.js.map +1 -0
- package/dist/esm/httpPullTransport.mjs +421 -0
- package/dist/esm/httpPullTransport.mjs.map +1 -0
- package/dist/esm/httpPullTransport.native.js +681 -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/package.json +2 -2
- package/src/createZeroClient.tsx +22 -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 +603 -0
- package/src/httpPullTransport.ts +587 -0
- package/src/index.ts +1 -0
- 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,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
|
+
}
|