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,147 @@
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('mutation burst converges while explicit pulls churn', async () => {
18
+ let clientGroupID = ''
19
+ const pullErrors: unknown[] = []
20
+
21
+ harness = await startZeroHttpHarness({
22
+ seed: {
23
+ user: [{ id: 'u1', name: 'ada' }],
24
+ project: [],
25
+ member: [],
26
+ },
27
+ interceptFetch: (next) => async (input, init) => {
28
+ const body = init?.body ? JSON.parse(String(init.body)) : undefined
29
+ if (body?.clientGroupID) clientGroupID = body.clientGroupID
30
+ return next(input, init)
31
+ },
32
+ })
33
+ const zero = harness.createZero('u1')
34
+ const view = zero.query.project.materialize()
35
+ await waitForComplete<any[]>(view)
36
+
37
+ const timer = setInterval(() => {
38
+ void harness?.transport.pull().catch((error) => pullErrors.push(error))
39
+ }, 2)
40
+
41
+ try {
42
+ const mutations = Array.from({ length: 15 }, (_, index) =>
43
+ zero.mutate.project.create({
44
+ id: `p${index + 1}`,
45
+ ownerId: 'u1',
46
+ name: `project ${index + 1}`,
47
+ }),
48
+ )
49
+ await Promise.all(mutations.map((mutation) => mutation.client))
50
+ await Promise.all(mutations.map((mutation) => mutation.server))
51
+ await harness.transport.pull()
52
+
53
+ await eventually(() => {
54
+ expect(projectIDs(view.data)).toEqual(
55
+ Array.from({ length: 15 }, (_, index) => `p${index + 1}`).sort(),
56
+ )
57
+ })
58
+ } finally {
59
+ clearInterval(timer)
60
+ }
61
+
62
+ expect(pullErrors).toEqual([])
63
+ expect(zero.connection.state.current.name).toBe('connected')
64
+ expect(
65
+ harness.server
66
+ .rows('project')
67
+ .map((row) => row.id)
68
+ .sort(),
69
+ ).toEqual(Array.from({ length: 15 }, (_, index) => `p${index + 1}`).sort())
70
+
71
+ const pull = await rawPull(harness, clientGroupID)
72
+ expect(Object.values(pull.lastMutationIDChanges)).toContain(15)
73
+ view.destroy()
74
+ })
75
+
76
+ test('two clients sharing a client group converge without cookie fights', async () => {
77
+ const clientGroups = new Set<string>()
78
+ const clientIDs = new Set<string>()
79
+
80
+ harness = await startZeroHttpHarness({
81
+ seed: {
82
+ user: [{ id: 'u1', name: 'ada' }],
83
+ project: [{ id: 'p1', ownerId: 'u1', name: 'first' }],
84
+ member: [],
85
+ },
86
+ interceptFetch: (next) => async (input, init) => {
87
+ const body = init?.body ? JSON.parse(String(init.body)) : undefined
88
+ if (body?.clientGroupID) clientGroups.add(body.clientGroupID)
89
+ if (body?.clientID) clientIDs.add(body.clientID)
90
+ return next(input, init)
91
+ },
92
+ })
93
+
94
+ const sharedStorageKey = 'zero-http-shared-client-group'
95
+ const zeroA = harness.createZero('u1', { storageKey: sharedStorageKey })
96
+ const zeroB = harness.createZero('u1', { storageKey: sharedStorageKey })
97
+ const viewA = zeroA.query.project.materialize()
98
+ const viewB = zeroB.query.project.materialize()
99
+ await waitForComplete<any[]>(viewA)
100
+ await waitForComplete<any[]>(viewB)
101
+
102
+ expect(harness.transport.connections).toBe(2)
103
+ expect(clientGroups.size).toBe(1)
104
+ expect(clientIDs.size).toBe(2)
105
+
106
+ const mutation = zeroA.mutate.project.create({
107
+ id: 'p2',
108
+ ownerId: 'u1',
109
+ name: 'second',
110
+ })
111
+ await mutation.client
112
+ await mutation.server
113
+ await harness.transport.pull()
114
+
115
+ await eventually(() => {
116
+ expect(projectIDs(viewA.data)).toEqual(['p1', 'p2'])
117
+ expect(projectIDs(viewB.data)).toEqual(['p1', 'p2'])
118
+ })
119
+ expect(zeroA.connection.state.current.name).toBe('connected')
120
+ expect(zeroB.connection.state.current.name).toBe('connected')
121
+
122
+ viewA.destroy()
123
+ viewB.destroy()
124
+ })
125
+
126
+ async function rawPull(harness: ZeroHttpHarness, clientGroupID: string) {
127
+ const response = await fetch(`${harness.server.url}/pull`, {
128
+ method: 'POST',
129
+ headers: {
130
+ authorization: 'Bearer token-u1',
131
+ 'content-type': 'application/json',
132
+ },
133
+ body: JSON.stringify({
134
+ clientID: 'raw-pull',
135
+ clientGroupID,
136
+ cookie: null,
137
+ }),
138
+ })
139
+ expect(response.status).toBe(200)
140
+ return response.json() as Promise<{
141
+ lastMutationIDChanges: Record<string, number>
142
+ }>
143
+ }
144
+
145
+ function projectIDs(rows: any[]) {
146
+ return rows.map((row) => row.id).sort()
147
+ }
@@ -0,0 +1,82 @@
1
+ import { createSchema, relationships, string, table } from '@rocicorp/zero'
2
+
3
+ import type { Transaction } from '@rocicorp/zero'
4
+
5
+ const user = table('user')
6
+ .columns({
7
+ id: string(),
8
+ name: string(),
9
+ })
10
+ .primaryKey('id')
11
+
12
+ const project = table('project')
13
+ .columns({
14
+ id: string(),
15
+ ownerId: string(),
16
+ name: string(),
17
+ })
18
+ .primaryKey('id')
19
+
20
+ const member = table('member')
21
+ .columns({
22
+ id: string(),
23
+ projectId: string(),
24
+ userId: string(),
25
+ })
26
+ .primaryKey('id')
27
+
28
+ export const zeroHttpFixtureSchema = createSchema({
29
+ tables: [user, project, member],
30
+ relationships: [
31
+ relationships(project, ({ many }) => ({
32
+ members: many({
33
+ sourceField: ['id'],
34
+ destField: ['projectId'],
35
+ destSchema: member,
36
+ }),
37
+ })),
38
+ ],
39
+ enableLegacyQueries: true,
40
+ })
41
+
42
+ type FixtureTransaction = Transaction<typeof zeroHttpFixtureSchema>
43
+
44
+ export type ProjectCreateArgs = {
45
+ id: string
46
+ ownerId: string
47
+ name: string
48
+ }
49
+
50
+ export type ProjectRenameArgs = {
51
+ id: string
52
+ name: string
53
+ }
54
+
55
+ export type MemberAddArgs = {
56
+ id: string
57
+ projectId: string
58
+ userId: string
59
+ }
60
+
61
+ export type MemberRemoveArgs = {
62
+ id: string
63
+ }
64
+
65
+ export const zeroHttpFixtureMutators = {
66
+ project: {
67
+ create: async (tx: FixtureTransaction, args: ProjectCreateArgs) => {
68
+ await tx.mutate.project.insert(args)
69
+ },
70
+ rename: async (tx: FixtureTransaction, args: ProjectRenameArgs) => {
71
+ await tx.mutate.project.update(args)
72
+ },
73
+ },
74
+ member: {
75
+ add: async (tx: FixtureTransaction, args: MemberAddArgs) => {
76
+ await tx.mutate.member.insert(args)
77
+ },
78
+ remove: async (tx: FixtureTransaction, args: MemberRemoveArgs) => {
79
+ await tx.mutate.member.delete({ id: args.id })
80
+ },
81
+ },
82
+ }
@@ -0,0 +1,391 @@
1
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
2
+
3
+ import type { AddressInfo } from 'node:net'
4
+
5
+ export type Row = Record<string, string>
6
+
7
+ type TableName = 'user' | 'project' | 'member'
8
+ type Tables = Record<TableName, Map<string, Row>>
9
+ type ClientMutationResults = Map<string, Map<string, Map<number, MutationResult>>>
10
+
11
+ interface PullBody {
12
+ clientID: string
13
+ clientGroupID: string
14
+ cookie: number | null
15
+ }
16
+
17
+ interface PushMutation {
18
+ type: string
19
+ name: string
20
+ clientID: string
21
+ id: number
22
+ args: Row[]
23
+ }
24
+
25
+ interface PushBody {
26
+ clientGroupID: string
27
+ mutations: PushMutation[]
28
+ }
29
+
30
+ type MutationResult = Record<string, never> | { error: 'app'; details: string }
31
+
32
+ const tableNames: TableName[] = ['user', 'project', 'member']
33
+
34
+ export async function startZeroHttpServer(opts?: {
35
+ seed?: { user?: Row[]; project?: Row[]; member?: Row[] }
36
+ }): Promise<{
37
+ url: string
38
+ version(): number
39
+ rows(table: string): Row[]
40
+ close(): Promise<void>
41
+ }> {
42
+ const tables = seedTables(opts?.seed)
43
+ const lmids = new Map<string, Map<string, number>>()
44
+ const mutationResults: ClientMutationResults = new Map()
45
+ const clientGroupUsers = new Map<string, string>()
46
+ let cookie = 1
47
+
48
+ const server = createServer(async (req, res) => {
49
+ try {
50
+ if (req.method !== 'POST') {
51
+ sendJSON(res, 404, { error: 'not found' })
52
+ return
53
+ }
54
+
55
+ const path = new URL(req.url || '/', 'http://127.0.0.1').pathname
56
+ const userID = authenticate(req, tables)
57
+ if (!userID) {
58
+ sendJSON(res, 401, { error: 'unauthorized' })
59
+ return
60
+ }
61
+
62
+ if (path === '/pull') {
63
+ const body = (await readJSON(req)) as PullBody
64
+ if (!bindClientGroup(clientGroupUsers, body.clientGroupID, userID)) {
65
+ sendJSON(res, 403, { error: 'client group belongs to a different user' })
66
+ return
67
+ }
68
+ if (body.cookie === cookie) {
69
+ sendJSON(res, 200, { cookie, unchanged: true })
70
+ return
71
+ }
72
+ if (typeof body.cookie === 'number' && body.cookie > cookie) {
73
+ sendJSON(res, 409, {
74
+ error: `future cookie ${body.cookie} is ahead of server cookie ${cookie}`,
75
+ })
76
+ return
77
+ }
78
+
79
+ sendJSON(res, 200, {
80
+ cookie,
81
+ lastMutationIDChanges: lastMutationIDChanges(lmids, body.clientGroupID),
82
+ rowsPatch: [{ op: 'clear' }, ...visibleRowsPatch(tables, userID)],
83
+ })
84
+ return
85
+ }
86
+
87
+ if (path === '/push') {
88
+ const body = (await readJSON(req)) as PushBody
89
+ if (!bindClientGroup(clientGroupUsers, body.clientGroupID, userID)) {
90
+ sendJSON(res, 403, { error: 'client group belongs to a different user' })
91
+ return
92
+ }
93
+ const mutations = Array.isArray(body.mutations) ? body.mutations : []
94
+ const gap = findMutationGap(lmids, body.clientGroupID, mutations)
95
+ if (gap) {
96
+ sendJSON(res, 500, { error: gap })
97
+ return
98
+ }
99
+
100
+ const pushResults: Array<{
101
+ id: { clientID: string; id: number }
102
+ result: MutationResult
103
+ }> = []
104
+ let processedNewMutation = false
105
+
106
+ for (const mutation of mutations) {
107
+ const current = lmidFor(lmids, body.clientGroupID, mutation.clientID)
108
+ if (mutation.id <= current) {
109
+ pushResults.push({
110
+ id: { clientID: mutation.clientID, id: mutation.id },
111
+ result:
112
+ resultForMutation(
113
+ mutationResults,
114
+ body.clientGroupID,
115
+ mutation.clientID,
116
+ mutation.id,
117
+ ) || {},
118
+ })
119
+ continue
120
+ }
121
+
122
+ const result = applyMutation(tables, userID, mutation)
123
+ setLMID(lmids, body.clientGroupID, mutation.clientID, mutation.id)
124
+ setMutationResult(
125
+ mutationResults,
126
+ body.clientGroupID,
127
+ mutation.clientID,
128
+ mutation.id,
129
+ result,
130
+ )
131
+ processedNewMutation = true
132
+ pushResults.push({
133
+ id: { clientID: mutation.clientID, id: mutation.id },
134
+ result,
135
+ })
136
+ }
137
+
138
+ if (processedNewMutation) cookie += 1
139
+ sendJSON(res, 200, { pushResponse: { mutations: pushResults } })
140
+ return
141
+ }
142
+
143
+ sendJSON(res, 404, { error: 'not found' })
144
+ } catch (err) {
145
+ sendJSON(res, 500, { error: err instanceof Error ? err.message : String(err) })
146
+ }
147
+ })
148
+
149
+ await new Promise<void>((resolve, reject) => {
150
+ server.once('error', reject)
151
+ server.listen(0, '127.0.0.1', () => {
152
+ server.off('error', reject)
153
+ resolve()
154
+ })
155
+ })
156
+
157
+ const address = server.address() as AddressInfo
158
+ return {
159
+ url: `http://127.0.0.1:${address.port}`,
160
+ version: () => cookie,
161
+ rows: (table) => rowsForTable(tables, table),
162
+ close: () =>
163
+ new Promise((resolve, reject) => {
164
+ server.close((err) => (err ? reject(err) : resolve()))
165
+ }),
166
+ }
167
+ }
168
+
169
+ function seedTables(seed?: { user?: Row[]; project?: Row[]; member?: Row[] }) {
170
+ const tables: Tables = {
171
+ user: new Map(),
172
+ project: new Map(),
173
+ member: new Map(),
174
+ }
175
+ for (const table of tableNames) {
176
+ for (const row of seed?.[table] || []) {
177
+ if (typeof row.id === 'string') tables[table].set(row.id, cloneRow(row))
178
+ }
179
+ }
180
+ return tables
181
+ }
182
+
183
+ function authenticate(req: IncomingMessage, tables: Tables) {
184
+ const header = req.headers.authorization
185
+ if (!header?.startsWith('Bearer token-')) return null
186
+ const userID = header.slice('Bearer token-'.length)
187
+ return tables.user.has(userID) ? userID : null
188
+ }
189
+
190
+ function bindClientGroup(
191
+ clientGroupUsers: Map<string, string>,
192
+ clientGroupID: string,
193
+ userID: string,
194
+ ) {
195
+ const owner = clientGroupUsers.get(clientGroupID)
196
+ if (owner) return owner === userID
197
+ clientGroupUsers.set(clientGroupID, userID)
198
+ return true
199
+ }
200
+
201
+ function visibleRowsPatch(tables: Tables, userID: string) {
202
+ const visibleProjectIDs = visibleProjectIDSet(tables, userID)
203
+ const rows: Array<{ op: 'put'; tableName: TableName; value: Row }> = []
204
+ const user = tables.user.get(userID)
205
+ if (user) rows.push({ op: 'put', tableName: 'user', value: cloneRow(user) })
206
+
207
+ for (const project of tables.project.values()) {
208
+ if (visibleProjectIDs.has(project.id)) {
209
+ rows.push({ op: 'put', tableName: 'project', value: cloneRow(project) })
210
+ }
211
+ }
212
+
213
+ for (const member of tables.member.values()) {
214
+ if (visibleProjectIDs.has(member.projectId)) {
215
+ rows.push({ op: 'put', tableName: 'member', value: cloneRow(member) })
216
+ }
217
+ }
218
+ return rows
219
+ }
220
+
221
+ function visibleProjectIDSet(tables: Tables, userID: string) {
222
+ const projectIDs = new Set<string>()
223
+ for (const project of tables.project.values()) {
224
+ if (project.ownerId === userID) projectIDs.add(project.id)
225
+ }
226
+ for (const member of tables.member.values()) {
227
+ if (member.userId === userID && tables.project.has(member.projectId)) {
228
+ projectIDs.add(member.projectId)
229
+ }
230
+ }
231
+ return projectIDs
232
+ }
233
+
234
+ function findMutationGap(
235
+ lmids: Map<string, Map<string, number>>,
236
+ clientGroupID: string,
237
+ mutations: PushMutation[],
238
+ ) {
239
+ const nextLMIDs = new Map<string, number>()
240
+ for (const mutation of mutations) {
241
+ const current =
242
+ nextLMIDs.get(mutation.clientID) ?? lmidFor(lmids, clientGroupID, mutation.clientID)
243
+ if (mutation.id <= current) continue
244
+ if (mutation.id !== current + 1) {
245
+ return `mutation id gap for ${mutation.clientID}: got ${mutation.id}, expected ${
246
+ current + 1
247
+ }`
248
+ }
249
+ nextLMIDs.set(mutation.clientID, mutation.id)
250
+ }
251
+ return null
252
+ }
253
+
254
+ function applyMutation(
255
+ tables: Tables,
256
+ userID: string,
257
+ mutation: PushMutation,
258
+ ): MutationResult {
259
+ if (mutation.type !== 'custom') return appError('unsupported')
260
+ const args = mutation.args[0] || {}
261
+
262
+ if (mutation.name === 'project|create') {
263
+ if (tables.project.has(args.id)) return appError('exists')
264
+ if (args.ownerId !== userID) return appError('forbidden')
265
+ tables.project.set(args.id, {
266
+ id: args.id,
267
+ ownerId: args.ownerId,
268
+ name: args.name,
269
+ })
270
+ return {}
271
+ }
272
+
273
+ if (mutation.name === 'project|rename') {
274
+ const project = tables.project.get(args.id)
275
+ if (!project) return appError('not-found')
276
+ if (project.ownerId !== userID) return appError('forbidden')
277
+ tables.project.set(args.id, { ...project, name: args.name })
278
+ return {}
279
+ }
280
+
281
+ if (mutation.name === 'member|add') {
282
+ const project = tables.project.get(args.projectId)
283
+ if (!project) return appError('not-found')
284
+ if (project.ownerId !== userID) return appError('forbidden')
285
+ if (tables.member.has(args.id)) return appError('exists')
286
+ tables.member.set(args.id, {
287
+ id: args.id,
288
+ projectId: args.projectId,
289
+ userId: args.userId,
290
+ })
291
+ return {}
292
+ }
293
+
294
+ if (mutation.name === 'member|remove') {
295
+ const member = tables.member.get(args.id)
296
+ if (!member) return appError('not-found')
297
+ const project = tables.project.get(member.projectId)
298
+ if (!project) return appError('not-found')
299
+ if (project.ownerId !== userID) return appError('forbidden')
300
+ tables.member.delete(args.id)
301
+ return {}
302
+ }
303
+
304
+ return appError('unsupported')
305
+ }
306
+
307
+ function appError(details: string): MutationResult {
308
+ return { error: 'app', details }
309
+ }
310
+
311
+ function lastMutationIDChanges(
312
+ lmids: Map<string, Map<string, number>>,
313
+ clientGroupID: string,
314
+ ) {
315
+ return Object.fromEntries(lmids.get(clientGroupID) || [])
316
+ }
317
+
318
+ function lmidFor(
319
+ lmids: Map<string, Map<string, number>>,
320
+ clientGroupID: string,
321
+ clientID: string,
322
+ ) {
323
+ return lmids.get(clientGroupID)?.get(clientID) || 0
324
+ }
325
+
326
+ function setLMID(
327
+ lmids: Map<string, Map<string, number>>,
328
+ clientGroupID: string,
329
+ clientID: string,
330
+ id: number,
331
+ ) {
332
+ let group = lmids.get(clientGroupID)
333
+ if (!group) {
334
+ group = new Map()
335
+ lmids.set(clientGroupID, group)
336
+ }
337
+ group.set(clientID, id)
338
+ }
339
+
340
+ function resultForMutation(
341
+ results: ClientMutationResults,
342
+ clientGroupID: string,
343
+ clientID: string,
344
+ id: number,
345
+ ) {
346
+ return results.get(clientGroupID)?.get(clientID)?.get(id)
347
+ }
348
+
349
+ function setMutationResult(
350
+ results: ClientMutationResults,
351
+ clientGroupID: string,
352
+ clientID: string,
353
+ id: number,
354
+ result: MutationResult,
355
+ ) {
356
+ let group = results.get(clientGroupID)
357
+ if (!group) {
358
+ group = new Map()
359
+ results.set(clientGroupID, group)
360
+ }
361
+ let client = group.get(clientID)
362
+ if (!client) {
363
+ client = new Map()
364
+ group.set(clientID, client)
365
+ }
366
+ client.set(id, result)
367
+ }
368
+
369
+ function rowsForTable(tables: Tables, table: string): Row[] {
370
+ if (!isTableName(table)) return []
371
+ return [...tables[table].values()].map(cloneRow)
372
+ }
373
+
374
+ function isTableName(table: string): table is TableName {
375
+ return (tableNames as string[]).includes(table)
376
+ }
377
+
378
+ function cloneRow(row: Row): Row {
379
+ return { ...row }
380
+ }
381
+
382
+ async function readJSON(req: IncomingMessage): Promise<unknown> {
383
+ let body = ''
384
+ for await (const chunk of req) body += chunk
385
+ return body ? JSON.parse(body) : {}
386
+ }
387
+
388
+ function sendJSON(res: ServerResponse, status: number, body: unknown) {
389
+ res.writeHead(status, { 'content-type': 'application/json' })
390
+ res.end(JSON.stringify(body))
391
+ }
@@ -0,0 +1,57 @@
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('e2e smoke: stock zero client syncs over http against the fixture server', async () => {
18
+ harness = await startZeroHttpHarness({
19
+ seed: {
20
+ user: [{ id: 'u1', name: 'ada' }],
21
+ project: [{ id: 'p1', ownerId: 'u1', name: 'first' }],
22
+ member: [{ id: 'm1', projectId: 'p1', userId: 'u1' }],
23
+ },
24
+ })
25
+ const zero = harness.createZero('u1')
26
+
27
+ const view = zero.query.project.related('members').materialize()
28
+ const initial = await waitForComplete<any[]>(view)
29
+ expect(initial).toEqual([
30
+ {
31
+ id: 'p1',
32
+ ownerId: 'u1',
33
+ name: 'first',
34
+ members: [{ id: 'm1', projectId: 'p1', userId: 'u1' }],
35
+ },
36
+ ])
37
+
38
+ const mutation = zero.mutate.project.create({
39
+ id: 'p2',
40
+ ownerId: 'u1',
41
+ name: 'second',
42
+ })
43
+ await mutation.client
44
+ await mutation.server
45
+
46
+ await eventually(() => {
47
+ const names = view.data.map((project: any) => project.name).sort()
48
+ expect(names).toEqual(['first', 'second'])
49
+ })
50
+ expect(
51
+ harness.server
52
+ .rows('project')
53
+ .map((row) => row.id)
54
+ .sort(),
55
+ ).toEqual(['p1', 'p2'])
56
+ view.destroy()
57
+ })