on-zero 0.7.2-1784530701923 → 0.7.2

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 (260) hide show
  1. package/dist/cjs/asyncTasks.cjs +51 -0
  2. package/dist/cjs/asyncTasks.native.js +84 -0
  3. package/dist/cjs/asyncTasks.native.js.map +1 -0
  4. package/dist/cjs/createZeroDirectServer.cjs +269 -0
  5. package/dist/cjs/createZeroDirectServer.native.js +287 -0
  6. package/dist/cjs/createZeroDirectServer.native.js.map +1 -0
  7. package/dist/cjs/createZeroSQLiteServer.cjs +50 -0
  8. package/dist/cjs/createZeroSQLiteServer.native.js +58 -0
  9. package/dist/cjs/createZeroSQLiteServer.native.js.map +1 -0
  10. package/dist/cjs/createZeroSQLiteServer.test.cjs +595 -0
  11. package/dist/cjs/createZeroSQLiteServer.test.native.js +647 -0
  12. package/dist/cjs/createZeroSQLiteServer.test.native.js.map +1 -0
  13. package/dist/cjs/createZeroServer.cjs +82 -0
  14. package/dist/cjs/createZeroServer.native.js +93 -0
  15. package/dist/cjs/createZeroServer.native.js.map +1 -0
  16. package/dist/cjs/createZeroServer.test.cjs +378 -0
  17. package/dist/cjs/createZeroServer.test.native.js +448 -0
  18. package/dist/cjs/createZeroServer.test.native.js.map +1 -0
  19. package/dist/cjs/createZeroServerCore.cjs +211 -0
  20. package/dist/cjs/createZeroServerCore.native.js +355 -0
  21. package/dist/cjs/createZeroServerCore.native.js.map +1 -0
  22. package/dist/cjs/helpers/createMutators.cjs +3 -4
  23. package/dist/cjs/helpers/createMutators.native.js +3 -6
  24. package/dist/cjs/helpers/createMutators.native.js.map +1 -1
  25. package/dist/cjs/httpPull/auth.test.cjs +197 -0
  26. package/dist/cjs/httpPull/auth.test.native.js +279 -0
  27. package/dist/cjs/httpPull/auth.test.native.js.map +1 -0
  28. package/dist/cjs/httpPull/churn.test.cjs +132 -0
  29. package/dist/cjs/httpPull/churn.test.native.js +155 -0
  30. package/dist/cjs/httpPull/churn.test.native.js.map +1 -0
  31. package/dist/cjs/httpPull/completeAck.test.cjs +69 -0
  32. package/dist/cjs/httpPull/completeAck.test.native.js +76 -0
  33. package/dist/cjs/httpPull/completeAck.test.native.js.map +1 -0
  34. package/dist/cjs/httpPull/fixtureServer.cjs +340 -0
  35. package/dist/cjs/httpPull/fixtureServer.native.js +534 -0
  36. package/dist/cjs/httpPull/fixtureServer.native.js.map +1 -0
  37. package/dist/cjs/httpPull/integration.test.cjs +53 -0
  38. package/dist/cjs/httpPull/integration.test.native.js +60 -0
  39. package/dist/cjs/httpPull/integration.test.native.js.map +1 -0
  40. package/dist/cjs/httpPull/rebase.test.cjs +360 -0
  41. package/dist/cjs/httpPull/rebase.test.native.js +420 -0
  42. package/dist/cjs/httpPull/rebase.test.native.js.map +1 -0
  43. package/dist/cjs/httpPull/relations.test.cjs +107 -0
  44. package/dist/cjs/httpPull/relations.test.native.js +119 -0
  45. package/dist/cjs/httpPull/relations.test.native.js.map +1 -0
  46. package/dist/cjs/httpPull/testHarness.cjs +100 -0
  47. package/dist/cjs/httpPull/testHarness.native.js +112 -0
  48. package/dist/cjs/httpPull/testHarness.native.js.map +1 -0
  49. package/dist/cjs/index.cjs +1 -0
  50. package/dist/cjs/index.native.js +1 -0
  51. package/dist/cjs/index.native.js.map +1 -1
  52. package/dist/cjs/run.cjs +1 -1
  53. package/dist/cjs/run.native.js +1 -1
  54. package/dist/cjs/server.cjs +22 -0
  55. package/dist/cjs/server.native.js +4 -0
  56. package/dist/cjs/server.native.js.map +1 -0
  57. package/dist/cjs/sqliteRuntime.cjs +318 -0
  58. package/dist/cjs/sqliteRuntime.native.js +438 -0
  59. package/dist/cjs/sqliteRuntime.native.js.map +1 -0
  60. package/dist/cjs/state.cjs +1 -1
  61. package/dist/cjs/state.native.js +1 -1
  62. package/dist/cjs/syncHost.cjs +179 -0
  63. package/dist/cjs/syncHost.native.js +388 -0
  64. package/dist/cjs/syncHost.native.js.map +1 -0
  65. package/dist/cjs/syncHost.test.cjs +240 -0
  66. package/dist/cjs/syncHost.test.native.js +290 -0
  67. package/dist/cjs/syncHost.test.native.js.map +1 -0
  68. package/dist/cjs/zeroRunner.cjs +1 -1
  69. package/dist/cjs/zeroRunner.native.js +1 -1
  70. package/dist/esm/asyncTasks.mjs +26 -0
  71. package/dist/esm/asyncTasks.mjs.map +1 -0
  72. package/dist/esm/asyncTasks.native.js +56 -0
  73. package/dist/esm/asyncTasks.native.js.map +1 -0
  74. package/dist/esm/createZeroDirectServer.mjs +241 -0
  75. package/dist/esm/createZeroDirectServer.mjs.map +1 -0
  76. package/dist/esm/createZeroDirectServer.native.js +256 -0
  77. package/dist/esm/createZeroDirectServer.native.js.map +1 -0
  78. package/dist/esm/createZeroSQLiteServer.mjs +24 -0
  79. package/dist/esm/createZeroSQLiteServer.mjs.map +1 -0
  80. package/dist/esm/createZeroSQLiteServer.native.js +29 -0
  81. package/dist/esm/createZeroSQLiteServer.native.js.map +1 -0
  82. package/dist/esm/createZeroSQLiteServer.test.mjs +596 -0
  83. package/dist/esm/createZeroSQLiteServer.test.mjs.map +1 -0
  84. package/dist/esm/createZeroSQLiteServer.test.native.js +645 -0
  85. package/dist/esm/createZeroSQLiteServer.test.native.js.map +1 -0
  86. package/dist/esm/createZeroServer.mjs +57 -0
  87. package/dist/esm/createZeroServer.mjs.map +1 -0
  88. package/dist/esm/createZeroServer.native.js +65 -0
  89. package/dist/esm/createZeroServer.native.js.map +1 -0
  90. package/dist/esm/createZeroServer.test.mjs +379 -0
  91. package/dist/esm/createZeroServer.test.mjs.map +1 -0
  92. package/dist/esm/createZeroServer.test.native.js +446 -0
  93. package/dist/esm/createZeroServer.test.native.js.map +1 -0
  94. package/dist/esm/createZeroServerCore.mjs +186 -0
  95. package/dist/esm/createZeroServerCore.mjs.map +1 -0
  96. package/dist/esm/createZeroServerCore.native.js +327 -0
  97. package/dist/esm/createZeroServerCore.native.js.map +1 -0
  98. package/dist/esm/helpers/createMutators.mjs +3 -4
  99. package/dist/esm/helpers/createMutators.mjs.map +1 -1
  100. package/dist/esm/helpers/createMutators.native.js +3 -6
  101. package/dist/esm/helpers/createMutators.native.js.map +1 -1
  102. package/dist/esm/httpPull/auth.test.mjs +198 -0
  103. package/dist/esm/httpPull/auth.test.mjs.map +1 -0
  104. package/dist/esm/httpPull/auth.test.native.js +277 -0
  105. package/dist/esm/httpPull/auth.test.native.js.map +1 -0
  106. package/dist/esm/httpPull/churn.test.mjs +133 -0
  107. package/dist/esm/httpPull/churn.test.mjs.map +1 -0
  108. package/dist/esm/httpPull/churn.test.native.js +153 -0
  109. package/dist/esm/httpPull/churn.test.native.js.map +1 -0
  110. package/dist/esm/httpPull/completeAck.test.mjs +70 -0
  111. package/dist/esm/httpPull/completeAck.test.mjs.map +1 -0
  112. package/dist/esm/httpPull/completeAck.test.native.js +74 -0
  113. package/dist/esm/httpPull/completeAck.test.native.js.map +1 -0
  114. package/dist/esm/httpPull/fixtureServer.mjs +315 -0
  115. package/dist/esm/httpPull/fixtureServer.mjs.map +1 -0
  116. package/dist/esm/httpPull/fixtureServer.native.js +506 -0
  117. package/dist/esm/httpPull/fixtureServer.native.js.map +1 -0
  118. package/dist/esm/httpPull/integration.test.mjs +54 -0
  119. package/dist/esm/httpPull/integration.test.mjs.map +1 -0
  120. package/dist/esm/httpPull/integration.test.native.js +58 -0
  121. package/dist/esm/httpPull/integration.test.native.js.map +1 -0
  122. package/dist/esm/httpPull/rebase.test.mjs +361 -0
  123. package/dist/esm/httpPull/rebase.test.mjs.map +1 -0
  124. package/dist/esm/httpPull/rebase.test.native.js +418 -0
  125. package/dist/esm/httpPull/rebase.test.native.js.map +1 -0
  126. package/dist/esm/httpPull/relations.test.mjs +108 -0
  127. package/dist/esm/httpPull/relations.test.mjs.map +1 -0
  128. package/dist/esm/httpPull/relations.test.native.js +117 -0
  129. package/dist/esm/httpPull/relations.test.native.js.map +1 -0
  130. package/dist/esm/httpPull/testHarness.mjs +72 -0
  131. package/dist/esm/httpPull/testHarness.mjs.map +1 -0
  132. package/dist/esm/httpPull/testHarness.native.js +81 -0
  133. package/dist/esm/httpPull/testHarness.native.js.map +1 -0
  134. package/dist/esm/index.js +1 -0
  135. package/dist/esm/index.js.map +1 -1
  136. package/dist/esm/index.mjs +1 -0
  137. package/dist/esm/index.mjs.map +1 -1
  138. package/dist/esm/index.native.js +1 -0
  139. package/dist/esm/index.native.js.map +1 -1
  140. package/dist/esm/run.mjs +1 -1
  141. package/dist/esm/run.mjs.map +1 -1
  142. package/dist/esm/run.native.js +1 -1
  143. package/dist/esm/server.mjs +4 -0
  144. package/dist/esm/server.mjs.map +1 -0
  145. package/dist/esm/server.native.js +2 -0
  146. package/dist/esm/server.native.js.map +1 -0
  147. package/dist/esm/sqliteRuntime.mjs +289 -0
  148. package/dist/esm/sqliteRuntime.mjs.map +1 -0
  149. package/dist/esm/sqliteRuntime.native.js +406 -0
  150. package/dist/esm/sqliteRuntime.native.js.map +1 -0
  151. package/dist/esm/state.mjs +1 -1
  152. package/dist/esm/state.native.js +1 -1
  153. package/dist/esm/syncHost.mjs +153 -0
  154. package/dist/esm/syncHost.mjs.map +1 -0
  155. package/dist/esm/syncHost.native.js +359 -0
  156. package/dist/esm/syncHost.native.js.map +1 -0
  157. package/dist/esm/syncHost.test.mjs +217 -0
  158. package/dist/esm/syncHost.test.mjs.map +1 -0
  159. package/dist/esm/syncHost.test.native.js +264 -0
  160. package/dist/esm/syncHost.test.native.js.map +1 -0
  161. package/dist/esm/zeroRunner.mjs +1 -1
  162. package/dist/esm/zeroRunner.mjs.map +1 -1
  163. package/dist/esm/zeroRunner.native.js +1 -1
  164. package/package.json +23 -8
  165. package/readme.md +65 -40
  166. package/src/asyncTasks.ts +36 -0
  167. package/src/createZeroClient.tsx +1 -1
  168. package/src/createZeroDirectServer.ts +411 -0
  169. package/src/createZeroSQLiteServer.test.ts +614 -0
  170. package/src/createZeroSQLiteServer.ts +56 -0
  171. package/src/createZeroServer.test.ts +421 -0
  172. package/src/createZeroServer.ts +105 -0
  173. package/src/createZeroServerCore.ts +296 -0
  174. package/src/helpers/createMutators.ts +2 -6
  175. package/src/httpPull/auth.test.ts +208 -0
  176. package/src/httpPull/churn.test.ts +147 -0
  177. package/src/httpPull/completeAck.test.ts +80 -0
  178. package/src/httpPull/fixtureServer.ts +391 -0
  179. package/src/httpPull/integration.test.ts +57 -0
  180. package/src/httpPull/rebase.test.ts +368 -0
  181. package/src/httpPull/relations.test.ts +135 -0
  182. package/src/httpPull/testHarness.ts +95 -0
  183. package/src/index.ts +1 -0
  184. package/src/run.ts +1 -1
  185. package/src/server.native.ts +3 -0
  186. package/src/server.ts +4 -0
  187. package/src/sqliteRuntime.ts +512 -0
  188. package/src/state.ts +1 -1
  189. package/src/syncHost.test.ts +223 -0
  190. package/src/syncHost.ts +294 -0
  191. package/src/types.ts +5 -0
  192. package/src/zeroRunner.ts +1 -1
  193. package/test-fixtures/server-return-types.ts +58 -21
  194. package/types/asyncTasks.d.ts +11 -0
  195. package/types/asyncTasks.d.ts.map +1 -0
  196. package/types/createZeroDirectServer.d.ts +80 -0
  197. package/types/createZeroDirectServer.d.ts.map +1 -0
  198. package/types/createZeroSQLiteServer.d.ts +12 -0
  199. package/types/createZeroSQLiteServer.d.ts.map +1 -0
  200. package/types/createZeroSQLiteServer.test.d.ts +2 -0
  201. package/types/createZeroSQLiteServer.test.d.ts.map +1 -0
  202. package/types/createZeroServer.d.ts +18 -0
  203. package/types/createZeroServer.d.ts.map +1 -0
  204. package/types/createZeroServer.test.d.ts +2 -0
  205. package/types/createZeroServer.test.d.ts.map +1 -0
  206. package/types/createZeroServerCore.d.ts +256 -0
  207. package/types/createZeroServerCore.d.ts.map +1 -0
  208. package/types/helpers/createMutators.d.ts +1 -2
  209. package/types/helpers/createMutators.d.ts.map +1 -1
  210. package/types/httpPull/auth.test.d.ts +2 -0
  211. package/types/httpPull/auth.test.d.ts.map +1 -0
  212. package/types/httpPull/churn.test.d.ts +2 -0
  213. package/types/httpPull/churn.test.d.ts.map +1 -0
  214. package/types/httpPull/completeAck.test.d.ts +2 -0
  215. package/types/httpPull/completeAck.test.d.ts.map +1 -0
  216. package/types/httpPull/fixtureSchema.d.ts +1 -1
  217. package/types/httpPull/fixtureServer.d.ts +14 -0
  218. package/types/httpPull/fixtureServer.d.ts.map +1 -0
  219. package/types/httpPull/integration.test.d.ts +2 -0
  220. package/types/httpPull/integration.test.d.ts.map +1 -0
  221. package/types/httpPull/rebase.test.d.ts +2 -0
  222. package/types/httpPull/rebase.test.d.ts.map +1 -0
  223. package/types/httpPull/relations.test.d.ts +2 -0
  224. package/types/httpPull/relations.test.d.ts.map +1 -0
  225. package/types/httpPull/testHarness.d.ts +32 -0
  226. package/types/httpPull/testHarness.d.ts.map +1 -0
  227. package/types/index.d.ts +1 -0
  228. package/types/index.d.ts.map +1 -1
  229. package/types/server.d.ts +5 -0
  230. package/types/server.d.ts.map +1 -0
  231. package/types/server.native.d.ts +1 -0
  232. package/types/server.native.d.ts.map +1 -0
  233. package/types/sqliteRuntime.d.ts +71 -0
  234. package/types/sqliteRuntime.d.ts.map +1 -0
  235. package/types/syncHost.d.ts +42 -0
  236. package/types/syncHost.d.ts.map +1 -0
  237. package/types/syncHost.test.d.ts +2 -0
  238. package/types/syncHost.test.d.ts.map +1 -0
  239. package/types/types.d.ts +4 -0
  240. package/types/types.d.ts.map +1 -1
  241. package/dist/cjs/orez.cjs +0 -227
  242. package/dist/cjs/orez.native.js +0 -314
  243. package/dist/cjs/orez.native.js.map +0 -1
  244. package/dist/cjs/orez.test.cjs +0 -211
  245. package/dist/cjs/orez.test.native.js +0 -226
  246. package/dist/cjs/orez.test.native.js.map +0 -1
  247. package/dist/esm/orez.mjs +0 -202
  248. package/dist/esm/orez.mjs.map +0 -1
  249. package/dist/esm/orez.native.js +0 -286
  250. package/dist/esm/orez.native.js.map +0 -1
  251. package/dist/esm/orez.test.mjs +0 -188
  252. package/dist/esm/orez.test.mjs.map +0 -1
  253. package/dist/esm/orez.test.native.js +0 -200
  254. package/dist/esm/orez.test.native.js.map +0 -1
  255. package/src/orez.test.ts +0 -195
  256. package/src/orez.ts +0 -331
  257. package/types/orez.d.ts +0 -53
  258. package/types/orez.d.ts.map +0 -1
  259. package/types/orez.test.d.ts +0 -2
  260. package/types/orez.test.d.ts.map +0 -1
@@ -0,0 +1,296 @@
1
+ import {
2
+ PushProcessor,
3
+ handleQueryRequest as zeroHandleQueryRequest,
4
+ } from '@rocicorp/zero/server'
5
+
6
+ import { runAsyncTaskEntries } from './asyncTasks'
7
+ import {
8
+ createAsyncTaskCollector,
9
+ createZeroDirectServerRuntime,
10
+ resolveServerQuery,
11
+ type TransactionAttemptLifecycle,
12
+ type ZeroServerCommonOptions,
13
+ type ZeroServerDatabase,
14
+ } from './createZeroDirectServer'
15
+ import { createMutators } from './helpers/createMutators'
16
+ import { runWithAuthScope } from './helpers/mutatorContext'
17
+ import { runWithQueryContext } from './helpers/queryContext'
18
+
19
+ import type { AuthData, GenericModels } from './types'
20
+ import type { Schema as ZeroSchema } from '@rocicorp/zero'
21
+
22
+ type AlreadyProcessedMutation = {
23
+ id: {
24
+ clientID: string
25
+ id: number
26
+ }
27
+ result: {
28
+ error: 'alreadyProcessed'
29
+ details?: unknown
30
+ }
31
+ }
32
+
33
+ type PushRequestBody = {
34
+ clientGroupID?: unknown
35
+ }
36
+
37
+ export type BackgroundTaskScheduler = (promise: Promise<void>) => void
38
+
39
+ export type HandleMutationRequestOptions = {
40
+ authData: AuthData | null
41
+ request: Request
42
+ scheduleBackgroundTask?: BackgroundTaskScheduler
43
+ skipAsyncTasks?: boolean
44
+ }
45
+
46
+ export type PersistAlreadyProcessedArgs = {
47
+ clientGroupID: string
48
+ mutations: AlreadyProcessedMutation[]
49
+ upstreamSchema: string
50
+ }
51
+
52
+ type ZeroServerCoreOptions<
53
+ Schema extends ZeroSchema,
54
+ Models extends GenericModels,
55
+ ServerActions extends Record<string, unknown>,
56
+ > = ZeroServerCommonOptions<Schema, Models, ServerActions> & {
57
+ createDatabase: () => ZeroServerDatabase
58
+ persistAlreadyProcessed?: (args: PersistAlreadyProcessedArgs) => Promise<void>
59
+ }
60
+
61
+ export function createZeroServerCore<
62
+ Schema extends ZeroSchema,
63
+ Models extends GenericModels,
64
+ ServerActions extends Record<string, unknown>,
65
+ >({
66
+ createDatabase,
67
+ createServerActions,
68
+ defaultAllowAdminRole = 'all',
69
+ defaultMutateAuthData,
70
+ models,
71
+ mutations: mutationValidators,
72
+ persistAlreadyProcessed,
73
+ queries,
74
+ schema,
75
+ validateMutation,
76
+ validateQuery,
77
+ }: ZeroServerCoreOptions<Schema, Models, ServerActions>) {
78
+ const { permissions, server } = createZeroDirectServerRuntime({
79
+ createDatabase,
80
+ createServerActions,
81
+ defaultAllowAdminRole,
82
+ defaultMutateAuthData,
83
+ models,
84
+ mutations: mutationValidators,
85
+ queries,
86
+ schema,
87
+ validateMutation,
88
+ validateQuery,
89
+ })
90
+
91
+ const handleMutationRequest = async ({
92
+ authData,
93
+ request,
94
+ scheduleBackgroundTask,
95
+ skipAsyncTasks,
96
+ }: HandleMutationRequestOptions) => {
97
+ const tasks = createAsyncTaskCollector()
98
+ const recoveryRequest = request.clone()
99
+ const mutators = createMutators({
100
+ enqueueTask: tasks.enqueueTask,
101
+ can: permissions.can,
102
+ createServerActions,
103
+ environment: 'server',
104
+ models,
105
+ authData,
106
+ validateMutation,
107
+ mutationValidators,
108
+ })
109
+ const processor = new PushProcessor(
110
+ withTransactionLifecycle(createDatabase(), tasks.lifecycle),
111
+ )
112
+ const response = await processor.process(mutators, request)
113
+
114
+ if (persistAlreadyProcessed) {
115
+ const parsed = await parseAlreadyProcessed(recoveryRequest, response)
116
+ if (parsed) await persistAlreadyProcessed(parsed)
117
+ }
118
+
119
+ if (!skipAsyncTasks && tasks.committed.length) {
120
+ const entries = tasks.committed.map((entry) => ({
121
+ ...entry,
122
+ task: () => runWithAuthScope(authData, entry.task),
123
+ }))
124
+ await runAsyncTaskEntries(entries, { background: scheduleBackgroundTask })
125
+ }
126
+
127
+ return {
128
+ response,
129
+ asyncTasks: tasks.committed,
130
+ }
131
+ }
132
+
133
+ const handleQueryRequest = async ({
134
+ authData,
135
+ request,
136
+ }: {
137
+ authData: AuthData | null
138
+ request: Request
139
+ }) => {
140
+ if (!queries) {
141
+ throw new Error(
142
+ 'No queries registered with createZeroServer. ' +
143
+ 'Pass the syncedQueries registry to createZeroServer via the queries option.',
144
+ )
145
+ }
146
+
147
+ const handler = (name: string, args: unknown) =>
148
+ resolveServerQuery({
149
+ authData,
150
+ name,
151
+ args,
152
+ queries,
153
+ permissions,
154
+ validateQuery,
155
+ })
156
+
157
+ const userID = typeof authData?.id === 'string' ? authData.id : undefined
158
+ const response = await runWithQueryContext(
159
+ { authData: authData || ({} as AuthData) },
160
+ () =>
161
+ // the legacy overload omits userID when auth does not identify a user.
162
+ userID === undefined
163
+ ? zeroHandleQueryRequest(handler, schema, request)
164
+ : zeroHandleQueryRequest({ handler, schema, request, userID }),
165
+ )
166
+
167
+ return { response }
168
+ }
169
+
170
+ return {
171
+ handleMutationRequest,
172
+ handleQueryRequest,
173
+ ...server,
174
+ }
175
+ }
176
+
177
+ export type ZeroServer<
178
+ Schema extends ZeroSchema,
179
+ Models extends GenericModels,
180
+ ServerActions extends Record<string, unknown>,
181
+ > = ReturnType<typeof createZeroServerCore<Schema, Models, ServerActions>>
182
+
183
+ function withTransactionLifecycle(
184
+ database: ZeroServerDatabase,
185
+ lifecycle: TransactionAttemptLifecycle,
186
+ ): ZeroServerDatabase {
187
+ return {
188
+ async transaction(callback, input) {
189
+ try {
190
+ const value = await database.transaction(async (tx, hooks) => {
191
+ lifecycle.beginAttempt()
192
+ try {
193
+ const result = await callback(tx, {
194
+ ...hooks,
195
+ async updateClientMutationID() {
196
+ const result = await hooks.updateClientMutationID()
197
+ if (input && input.mutationID < result.lastMutationID) {
198
+ throw new ExpectedDuplicateMutation(
199
+ input.clientID,
200
+ input.mutationID,
201
+ result.lastMutationID,
202
+ )
203
+ }
204
+ return result
205
+ },
206
+ })
207
+ lifecycle.completeAttempt()
208
+ return result
209
+ } catch (error) {
210
+ lifecycle.rollbackAttempt()
211
+ throw error
212
+ }
213
+ }, input)
214
+ lifecycle.commitTransaction()
215
+ return value
216
+ } catch (error) {
217
+ lifecycle.rollbackTransaction()
218
+ if (error instanceof ExpectedDuplicateMutation) {
219
+ return {
220
+ id: {
221
+ clientID: error.clientID,
222
+ id: error.mutationID,
223
+ },
224
+ result: {
225
+ error: 'alreadyProcessed',
226
+ details: error.message,
227
+ },
228
+ } as never
229
+ }
230
+ throw error
231
+ }
232
+ },
233
+ }
234
+ }
235
+
236
+ class ExpectedDuplicateMutation extends Error {
237
+ constructor(
238
+ readonly clientID: string,
239
+ readonly mutationID: number,
240
+ expectedMutationID: number | bigint,
241
+ ) {
242
+ super(
243
+ `Ignoring mutation from ${clientID} with ID ${mutationID} as it was already processed. Expected: ${expectedMutationID}`,
244
+ )
245
+ }
246
+ }
247
+
248
+ async function parseAlreadyProcessed(
249
+ request: Request,
250
+ response: unknown,
251
+ ): Promise<PersistAlreadyProcessedArgs | undefined> {
252
+ const mutations = alreadyProcessedMutations(response)
253
+ if (mutations.length === 0) return
254
+
255
+ const upstreamSchema = new URL(request.url).searchParams.get('schema')
256
+ if (!upstreamSchema) return
257
+
258
+ const body = (await request.json()) as PushRequestBody
259
+ if (typeof body.clientGroupID !== 'string') return
260
+
261
+ return {
262
+ clientGroupID: body.clientGroupID,
263
+ mutations,
264
+ upstreamSchema,
265
+ }
266
+ }
267
+
268
+ function alreadyProcessedMutations(response: unknown): AlreadyProcessedMutation[] {
269
+ if (!response || typeof response !== 'object') return []
270
+ const mutations = (response as { mutations?: unknown }).mutations
271
+ if (!Array.isArray(mutations)) return []
272
+
273
+ return mutations.filter((mutation): mutation is AlreadyProcessedMutation => {
274
+ if (!mutation || typeof mutation !== 'object') return false
275
+ const id = (mutation as { id?: unknown }).id
276
+ const result = (mutation as { result?: unknown }).result
277
+ return (
278
+ !!id &&
279
+ typeof id === 'object' &&
280
+ typeof (id as { clientID?: unknown }).clientID === 'string' &&
281
+ typeof (id as { id?: unknown }).id === 'number' &&
282
+ !!result &&
283
+ typeof result === 'object' &&
284
+ (result as { error?: unknown }).error === 'alreadyProcessed'
285
+ )
286
+ })
287
+ }
288
+
289
+ export type {
290
+ ValidateMutationArgs,
291
+ ValidateMutationFn,
292
+ ValidateQueryArgs,
293
+ ValidateQueryFn,
294
+ ZeroServerCommonOptions,
295
+ ZeroServerDatabase,
296
+ } from './createZeroDirectServer'
@@ -30,7 +30,6 @@ export function createMutators<Models extends GenericModels>({
30
30
  models,
31
31
  validateMutation,
32
32
  mutationValidators,
33
- resolveAuthData,
34
33
  }: {
35
34
  environment: 'server' | 'client'
36
35
  authData: AuthData | null
@@ -41,7 +40,6 @@ export function createMutators<Models extends GenericModels>({
41
40
  validateMutation?: ValidateMutationFn
42
41
  /** valibot schemas keyed by model.mutationName, auto-validates args before running */
43
42
  mutationValidators?: Record<string, Record<string, any>>
44
- resolveAuthData?: () => AuthData | null
45
43
  }): GetZeroMutators<Models> {
46
44
  const serverActions = createServerActions?.()
47
45
 
@@ -56,8 +54,7 @@ export function createMutators<Models extends GenericModels>({
56
54
  tx,
57
55
  // on client, read authData dynamically to avoid stale closure during auth transitions
58
56
  // (ZeroProvider recreates Zero instance in useEffect, but mutations can run before that)
59
- authData:
60
- environment === 'client' ? getAuthData() : (resolveAuthData?.() ?? authData),
57
+ authData: environment === 'client' ? getAuthData() : authData,
61
58
  environment,
62
59
  can,
63
60
  server:
@@ -171,8 +168,7 @@ export function createMutators<Models extends GenericModels>({
171
168
  // then run user-provided validation hook as escape hatch
172
169
  if (validateMutation) {
173
170
  await validateMutation({
174
- authData:
175
- environment === 'client' ? getAuthData() : (resolveAuthData?.() ?? authData),
171
+ authData: environment === 'client' ? getAuthData() : authData,
176
172
  tableName,
177
173
  mutatorName,
178
174
  args: args[1],
@@ -0,0 +1,208 @@
1
+ import { afterEach, describe, 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 UserRow = { id: string; name: string }
14
+ type MemberRow = { id: string; projectId: string; userId: string }
15
+ type ProjectWithMembers = {
16
+ id: string
17
+ ownerId: string
18
+ name: string
19
+ members: MemberRow[]
20
+ }
21
+
22
+ afterEach(async () => {
23
+ await harness?.close()
24
+ harness = undefined
25
+ })
26
+
27
+ describe('zero-http auth parity', () => {
28
+ test('real clients only emit rows visible to their auth token', async () => {
29
+ harness = await startZeroHttpHarness({
30
+ seed: {
31
+ user: [
32
+ { id: 'u1', name: 'ada' },
33
+ { id: 'u2', name: 'ben' },
34
+ ],
35
+ project: [
36
+ { id: 'p-u1', ownerId: 'u1', name: 'u1 private' },
37
+ { id: 'p-u2', ownerId: 'u2', name: 'u2 private' },
38
+ { id: 'p-shared', ownerId: 'u2', name: 'shared' },
39
+ ],
40
+ member: [
41
+ { id: 'm-u1-owner', projectId: 'p-u1', userId: 'u1' },
42
+ { id: 'm-u2-owner', projectId: 'p-u2', userId: 'u2' },
43
+ { id: 'm-shared-u1', projectId: 'p-shared', userId: 'u1' },
44
+ { id: 'm-shared-u2', projectId: 'p-shared', userId: 'u2' },
45
+ ],
46
+ },
47
+ })
48
+ const u1 = harness.createZero('u1')
49
+ const u2 = harness.createZero('u2')
50
+
51
+ const u1Users = u1.query.user.materialize()
52
+ const u1Projects = u1.query.project.related('members').materialize()
53
+ const u2Users = u2.query.user.materialize()
54
+ const u2Projects = u2.query.project.related('members').materialize()
55
+
56
+ const u1UserEmissions: UserRow[][] = []
57
+ const u1ProjectEmissions: ProjectWithMembers[][] = []
58
+ const u2UserEmissions: UserRow[][] = []
59
+ const u2ProjectEmissions: ProjectWithMembers[][] = []
60
+
61
+ const stops = [
62
+ captureRows(u1Users, u1UserEmissions, normalizeUsers),
63
+ captureRows(u1Projects, u1ProjectEmissions, normalizeProjects),
64
+ captureRows(u2Users, u2UserEmissions, normalizeUsers),
65
+ captureRows(u2Projects, u2ProjectEmissions, normalizeProjects),
66
+ ]
67
+
68
+ try {
69
+ const [u1UsersComplete, u1ProjectsComplete, u2UsersComplete, u2ProjectsComplete] =
70
+ await Promise.all([
71
+ waitForComplete<UserRow[]>(u1Users),
72
+ waitForComplete<ProjectWithMembers[]>(u1Projects),
73
+ waitForComplete<UserRow[]>(u2Users),
74
+ waitForComplete<ProjectWithMembers[]>(u2Projects),
75
+ ])
76
+
77
+ expect(normalizeUsers(u1UsersComplete)).toEqual([{ id: 'u1', name: 'ada' }])
78
+ expect(normalizeProjects(u1ProjectsComplete)).toEqual([
79
+ {
80
+ id: 'p-shared',
81
+ ownerId: 'u2',
82
+ name: 'shared',
83
+ members: [
84
+ { id: 'm-shared-u1', projectId: 'p-shared', userId: 'u1' },
85
+ { id: 'm-shared-u2', projectId: 'p-shared', userId: 'u2' },
86
+ ],
87
+ },
88
+ {
89
+ id: 'p-u1',
90
+ ownerId: 'u1',
91
+ name: 'u1 private',
92
+ members: [{ id: 'm-u1-owner', projectId: 'p-u1', userId: 'u1' }],
93
+ },
94
+ ])
95
+
96
+ expect(normalizeUsers(u2UsersComplete)).toEqual([{ id: 'u2', name: 'ben' }])
97
+ expect(normalizeProjects(u2ProjectsComplete)).toEqual([
98
+ {
99
+ id: 'p-shared',
100
+ ownerId: 'u2',
101
+ name: 'shared',
102
+ members: [
103
+ { id: 'm-shared-u1', projectId: 'p-shared', userId: 'u1' },
104
+ { id: 'm-shared-u2', projectId: 'p-shared', userId: 'u2' },
105
+ ],
106
+ },
107
+ {
108
+ id: 'p-u2',
109
+ ownerId: 'u2',
110
+ name: 'u2 private',
111
+ members: [{ id: 'm-u2-owner', projectId: 'p-u2', userId: 'u2' }],
112
+ },
113
+ ])
114
+
115
+ assertNoUserEmission(u1UserEmissions, 'u2')
116
+ assertNoProjectEmission(u1ProjectEmissions, 'p-u2', 'm-u2-owner')
117
+ assertNoUserEmission(u2UserEmissions, 'u1')
118
+ assertNoProjectEmission(u2ProjectEmissions, 'p-u1', 'm-u1-owner')
119
+
120
+ await harness.transport.pull()
121
+ await sleep(50)
122
+ assertNoUserEmission(u1UserEmissions, 'u2')
123
+ assertNoProjectEmission(u1ProjectEmissions, 'p-u2', 'm-u2-owner')
124
+ assertNoUserEmission(u2UserEmissions, 'u1')
125
+ assertNoProjectEmission(u2ProjectEmissions, 'p-u1', 'm-u1-owner')
126
+ } finally {
127
+ for (const stop of stops) stop()
128
+ u1Users.destroy()
129
+ u1Projects.destroy()
130
+ u2Users.destroy()
131
+ u2Projects.destroy()
132
+ }
133
+ })
134
+
135
+ test('unknown token 401 reaches needs-auth without materializing data', async () => {
136
+ harness = await startZeroHttpHarness({
137
+ seed: {
138
+ user: [{ id: 'u1', name: 'ada' }],
139
+ project: [{ id: 'p-u1', ownerId: 'u1', name: 'u1 private' }],
140
+ member: [{ id: 'm-u1-owner', projectId: 'p-u1', userId: 'u1' }],
141
+ },
142
+ })
143
+ const unknown = harness.createZero('missing')
144
+ const projects = unknown.query.project.related('members').materialize()
145
+ const emissions: ProjectWithMembers[][] = []
146
+ const stop = captureRows(projects, emissions, normalizeProjects)
147
+
148
+ try {
149
+ await eventually(() =>
150
+ expect(unknown.connection.state.current.name).toBe('needs-auth'),
151
+ )
152
+ await sleep(50)
153
+
154
+ expect(projects.data).toEqual([])
155
+ expect(emissions.every((emission) => emission.length === 0)).toBe(true)
156
+ } finally {
157
+ stop()
158
+ projects.destroy()
159
+ }
160
+ })
161
+ })
162
+
163
+ // listener data is Immutable<T>; normalize maps to fresh mutable rows
164
+ function captureRows<T>(
165
+ view: { addListener(listener: (data: any, resultType: string) => void): () => void },
166
+ emissions: T[],
167
+ normalize: (data: T) => T,
168
+ ) {
169
+ return view.addListener((data) => {
170
+ emissions.push(normalize(data))
171
+ })
172
+ }
173
+
174
+ function assertNoUserEmission(emissions: UserRow[][], privateUserID: string) {
175
+ for (const emission of emissions) {
176
+ expect(emission.map((row) => row.id)).not.toContain(privateUserID)
177
+ }
178
+ }
179
+
180
+ function assertNoProjectEmission(
181
+ emissions: ProjectWithMembers[][],
182
+ privateProjectID: string,
183
+ privateMemberID: string,
184
+ ) {
185
+ for (const emission of emissions) {
186
+ expect(emission.map((row) => row.id)).not.toContain(privateProjectID)
187
+ expect(
188
+ emission.flatMap((row) => row.members.map((member) => member.id)),
189
+ ).not.toContain(privateMemberID)
190
+ }
191
+ }
192
+
193
+ function normalizeUsers(users: UserRow[]) {
194
+ return clone(users).sort((a, b) => a.id.localeCompare(b.id))
195
+ }
196
+
197
+ function normalizeProjects(projects: ProjectWithMembers[]) {
198
+ return clone(projects)
199
+ .map((project) => ({
200
+ ...project,
201
+ members: [...project.members].sort((a, b) => a.id.localeCompare(b.id)),
202
+ }))
203
+ .sort((a, b) => a.id.localeCompare(b.id))
204
+ }
205
+
206
+ function clone<T>(value: T): T {
207
+ return JSON.parse(JSON.stringify(value)) as T
208
+ }
@@ -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
+ }