on-zero 0.6.11 → 0.6.13

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 (131) hide show
  1. package/dist/cjs/createZeroSQLiteServer.cjs +319 -0
  2. package/dist/cjs/createZeroSQLiteServer.native.js +440 -0
  3. package/dist/cjs/createZeroSQLiteServer.native.js.map +1 -0
  4. package/dist/cjs/createZeroSQLiteServer.test.cjs +429 -0
  5. package/dist/cjs/createZeroSQLiteServer.test.native.js +466 -0
  6. package/dist/cjs/createZeroSQLiteServer.test.native.js.map +1 -0
  7. package/dist/cjs/createZeroServer.cjs +20 -234
  8. package/dist/cjs/createZeroServer.native.js +24 -250
  9. package/dist/cjs/createZeroServer.native.js.map +1 -1
  10. package/dist/cjs/createZeroServer.test.cjs +35 -1
  11. package/dist/cjs/createZeroServer.test.native.js +38 -1
  12. package/dist/cjs/createZeroServer.test.native.js.map +1 -1
  13. package/dist/cjs/createZeroServerCore.cjs +362 -0
  14. package/dist/cjs/createZeroServerCore.native.js +389 -0
  15. package/dist/cjs/createZeroServerCore.native.js.map +1 -0
  16. package/dist/cjs/generate-lite.cjs +1 -0
  17. package/dist/cjs/generate-lite.native.js +1 -0
  18. package/dist/cjs/generate-lite.native.js.map +1 -1
  19. package/dist/cjs/generate-lite.test.cjs +5 -1
  20. package/dist/cjs/generate-lite.test.native.js +5 -1
  21. package/dist/cjs/generate-lite.test.native.js.map +1 -1
  22. package/dist/cjs/generate.cjs +20 -11
  23. package/dist/cjs/generate.native.js +29 -21
  24. package/dist/cjs/generate.native.js.map +1 -1
  25. package/dist/cjs/generate.test.cjs +110 -0
  26. package/dist/cjs/generate.test.native.js +110 -0
  27. package/dist/cjs/generate.test.native.js.map +1 -1
  28. package/dist/cjs/httpPull/transport.test.cjs +44 -0
  29. package/dist/cjs/httpPull/transport.test.native.js +53 -0
  30. package/dist/cjs/httpPull/transport.test.native.js.map +1 -1
  31. package/dist/cjs/httpPullTransport.cjs +4 -1
  32. package/dist/cjs/httpPullTransport.native.js +5 -2
  33. package/dist/cjs/httpPullTransport.native.js.map +1 -1
  34. package/dist/cjs/server.cjs +2 -1
  35. package/dist/cjs/syncHost.cjs +164 -0
  36. package/dist/cjs/syncHost.native.js +371 -0
  37. package/dist/cjs/syncHost.native.js.map +1 -0
  38. package/dist/cjs/syncHost.test.cjs +229 -0
  39. package/dist/cjs/syncHost.test.native.js +278 -0
  40. package/dist/cjs/syncHost.test.native.js.map +1 -0
  41. package/dist/esm/createZeroSQLiteServer.mjs +293 -0
  42. package/dist/esm/createZeroSQLiteServer.mjs.map +1 -0
  43. package/dist/esm/createZeroSQLiteServer.native.js +411 -0
  44. package/dist/esm/createZeroSQLiteServer.native.js.map +1 -0
  45. package/dist/esm/createZeroSQLiteServer.test.mjs +430 -0
  46. package/dist/esm/createZeroSQLiteServer.test.mjs.map +1 -0
  47. package/dist/esm/createZeroSQLiteServer.test.native.js +464 -0
  48. package/dist/esm/createZeroSQLiteServer.test.native.js.map +1 -0
  49. package/dist/esm/createZeroServer.mjs +17 -231
  50. package/dist/esm/createZeroServer.mjs.map +1 -1
  51. package/dist/esm/createZeroServer.native.js +21 -247
  52. package/dist/esm/createZeroServer.native.js.map +1 -1
  53. package/dist/esm/createZeroServer.test.mjs +35 -1
  54. package/dist/esm/createZeroServer.test.mjs.map +1 -1
  55. package/dist/esm/createZeroServer.test.native.js +38 -1
  56. package/dist/esm/createZeroServer.test.native.js.map +1 -1
  57. package/dist/esm/createZeroServerCore.mjs +336 -0
  58. package/dist/esm/createZeroServerCore.mjs.map +1 -0
  59. package/dist/esm/createZeroServerCore.native.js +360 -0
  60. package/dist/esm/createZeroServerCore.native.js.map +1 -0
  61. package/dist/esm/generate-lite.mjs +1 -0
  62. package/dist/esm/generate-lite.mjs.map +1 -1
  63. package/dist/esm/generate-lite.native.js +1 -0
  64. package/dist/esm/generate-lite.native.js.map +1 -1
  65. package/dist/esm/generate-lite.test.mjs +5 -1
  66. package/dist/esm/generate-lite.test.mjs.map +1 -1
  67. package/dist/esm/generate-lite.test.native.js +5 -1
  68. package/dist/esm/generate-lite.test.native.js.map +1 -1
  69. package/dist/esm/generate.mjs +20 -11
  70. package/dist/esm/generate.mjs.map +1 -1
  71. package/dist/esm/generate.native.js +29 -21
  72. package/dist/esm/generate.native.js.map +1 -1
  73. package/dist/esm/generate.test.mjs +87 -1
  74. package/dist/esm/generate.test.mjs.map +1 -1
  75. package/dist/esm/generate.test.native.js +87 -1
  76. package/dist/esm/generate.test.native.js.map +1 -1
  77. package/dist/esm/httpPull/transport.test.mjs +44 -0
  78. package/dist/esm/httpPull/transport.test.mjs.map +1 -1
  79. package/dist/esm/httpPull/transport.test.native.js +53 -0
  80. package/dist/esm/httpPull/transport.test.native.js.map +1 -1
  81. package/dist/esm/httpPullTransport.mjs +4 -1
  82. package/dist/esm/httpPullTransport.mjs.map +1 -1
  83. package/dist/esm/httpPullTransport.native.js +5 -2
  84. package/dist/esm/httpPullTransport.native.js.map +1 -1
  85. package/dist/esm/server.mjs +1 -0
  86. package/dist/esm/server.mjs.map +1 -1
  87. package/dist/esm/syncHost.mjs +138 -0
  88. package/dist/esm/syncHost.mjs.map +1 -0
  89. package/dist/esm/syncHost.native.js +342 -0
  90. package/dist/esm/syncHost.native.js.map +1 -0
  91. package/dist/esm/syncHost.test.mjs +206 -0
  92. package/dist/esm/syncHost.test.mjs.map +1 -0
  93. package/dist/esm/syncHost.test.native.js +252 -0
  94. package/dist/esm/syncHost.test.native.js.map +1 -0
  95. package/package.json +19 -3
  96. package/readme.md +26 -0
  97. package/src/createZeroSQLiteServer.test.ts +461 -0
  98. package/src/createZeroSQLiteServer.ts +523 -0
  99. package/src/createZeroServer.test.ts +39 -1
  100. package/src/createZeroServer.ts +35 -428
  101. package/src/createZeroServerCore.ts +557 -0
  102. package/src/generate-lite.test.ts +4 -0
  103. package/src/generate-lite.ts +1 -0
  104. package/src/generate.test.ts +87 -1
  105. package/src/generate.ts +29 -20
  106. package/src/httpPull/transport.test.ts +47 -0
  107. package/src/httpPullTransport.ts +15 -1
  108. package/src/server.ts +1 -0
  109. package/src/syncHost.test.ts +216 -0
  110. package/src/syncHost.ts +255 -0
  111. package/test-fixtures/server-return-types.ts +50 -0
  112. package/test-fixtures/tsconfig.json +13 -0
  113. package/types/createZeroSQLiteServer.d.ts +37 -0
  114. package/types/createZeroSQLiteServer.d.ts.map +1 -0
  115. package/types/createZeroSQLiteServer.test.d.ts +2 -0
  116. package/types/createZeroSQLiteServer.test.d.ts.map +1 -0
  117. package/types/createZeroServer.d.ts +11 -293
  118. package/types/createZeroServer.d.ts.map +1 -1
  119. package/types/createZeroServerCore.d.ts +310 -0
  120. package/types/createZeroServerCore.d.ts.map +1 -0
  121. package/types/generate-lite.d.ts.map +1 -1
  122. package/types/generate.d.ts +2 -1
  123. package/types/generate.d.ts.map +1 -1
  124. package/types/httpPullTransport.d.ts +2 -0
  125. package/types/httpPullTransport.d.ts.map +1 -1
  126. package/types/server.d.ts +1 -0
  127. package/types/server.d.ts.map +1 -1
  128. package/types/syncHost.d.ts +51 -0
  129. package/types/syncHost.d.ts.map +1 -0
  130. package/types/syncHost.test.d.ts +2 -0
  131. package/types/syncHost.test.d.ts.map +1 -0
@@ -0,0 +1,557 @@
1
+ import { mustGetQuery } from '@rocicorp/zero'
2
+ import {
3
+ PushProcessor,
4
+ handleQueryRequest as zeroHandleQueryRequest,
5
+ } from '@rocicorp/zero/server'
6
+
7
+ import { createPermissions } from './createPermissions'
8
+ import { createMutators } from './helpers/createMutators'
9
+ import {
10
+ getScopedAuthData,
11
+ isInZeroMutation,
12
+ mutatorContext,
13
+ runWithAuthScope,
14
+ } from './helpers/mutatorContext'
15
+ import { runWithQueryContext } from './helpers/queryContext'
16
+ import { getMutationsPermissions } from './modelRegistry'
17
+ import { setCustomQueries } from './run'
18
+ import { getZQL, setEnvironment, setSchema } from './state'
19
+ import { setEvaluatingPermission } from './where'
20
+ import { setRunner } from './zeroRunner'
21
+
22
+ import type {
23
+ AdminRoleMode,
24
+ AsyncAction,
25
+ AuthData,
26
+ GenericModels,
27
+ MutatorContext,
28
+ QueryBuilder,
29
+ Transaction,
30
+ } from './types'
31
+ import type {
32
+ AnyQueryRegistry,
33
+ HumanReadable,
34
+ Query,
35
+ Schema as ZeroSchema,
36
+ } from '@rocicorp/zero'
37
+ import type { Database, TransactionProviderInput } from '@rocicorp/zero/server'
38
+
39
+ type MutateAuthData = Pick<AuthData, 'email' | 'id'> & Partial<AuthData>
40
+
41
+ type MutateOptions = {
42
+ authData?: MutateAuthData
43
+ awaitEffects?: boolean
44
+ }
45
+
46
+ type AlreadyProcessedMutation = {
47
+ id: {
48
+ clientID: string
49
+ id: number
50
+ }
51
+ result: {
52
+ error: 'alreadyProcessed'
53
+ details?: unknown
54
+ }
55
+ }
56
+
57
+ type PushRequestBody = {
58
+ clientGroupID?: unknown
59
+ }
60
+
61
+ type ServerMutate<Models extends GenericModels> = {
62
+ [Key in keyof Models]: {
63
+ [K in keyof Models[Key]['mutate']]: Models[Key]['mutate'][K] extends (
64
+ ctx: MutatorContext,
65
+ arg: infer Arg,
66
+ ) => any
67
+ ? (arg: Arg, options?: MutateOptions) => Promise<void>
68
+ : (options?: MutateOptions) => Promise<void>
69
+ }
70
+ }
71
+
72
+ export type ValidateQueryArgs = {
73
+ authData: AuthData | null
74
+ queryName: string
75
+ params: unknown
76
+ }
77
+
78
+ export type ValidateMutationArgs = {
79
+ authData: AuthData | null
80
+ mutatorName: string
81
+ tableName: string
82
+ args: unknown
83
+ }
84
+
85
+ export type ValidateQueryFn = (args: ValidateQueryArgs) => void
86
+ export type ValidateMutationFn = (args: ValidateMutationArgs) => void | Promise<void>
87
+
88
+ export type ServerQueryResolverOptions = {
89
+ authData: AuthData | null
90
+ name: string
91
+ args: unknown
92
+ queries: AnyQueryRegistry
93
+ permissions: ReturnType<typeof createPermissions>
94
+ validateQuery?: ValidateQueryFn
95
+ }
96
+
97
+ export function resolveServerQuery({
98
+ authData,
99
+ name,
100
+ args,
101
+ queries,
102
+ permissions,
103
+ validateQuery,
104
+ }: ServerQueryResolverOptions) {
105
+ if (name.startsWith('permission.')) {
106
+ const table = name.slice('permission.'.length)
107
+ const { objOrId } = args as {
108
+ objOrId: string | Record<string, any>
109
+ }
110
+ const permission = getMutationsPermissions(table)
111
+ if (!permission) {
112
+ throw new Error(`[permission] no permission defined for table: ${table}`)
113
+ }
114
+ setEvaluatingPermission(true)
115
+ try {
116
+ return (getZQL() as any)[table]
117
+ .where((eb: any) => {
118
+ return permissions.buildPermissionQuery(
119
+ authData,
120
+ eb,
121
+ permission,
122
+ objOrId,
123
+ table,
124
+ )
125
+ })
126
+ .one()
127
+ } finally {
128
+ setEvaluatingPermission(false)
129
+ }
130
+ }
131
+
132
+ if (validateQuery) {
133
+ validateQuery({ authData, queryName: name, params: args })
134
+ }
135
+
136
+ const query = (mustGetQuery as any)(queries, name)
137
+ return query.fn({ args, ctx: authData })
138
+ }
139
+
140
+ export type TransactionAttemptLifecycle = {
141
+ beginAttempt(): void
142
+ completeAttempt(): void
143
+ rollbackAttempt(): void
144
+ commitTransaction(): void
145
+ rollbackTransaction(): void
146
+ }
147
+
148
+ export type ZeroServerDatabase = Database<Transaction>
149
+
150
+ export type PersistAlreadyProcessedArgs = {
151
+ clientGroupID: string
152
+ mutations: AlreadyProcessedMutation[]
153
+ upstreamSchema: string
154
+ }
155
+
156
+ export type ZeroServerCommonOptions<
157
+ Schema extends ZeroSchema,
158
+ Models extends GenericModels,
159
+ ServerActions extends Record<string, unknown>,
160
+ > = {
161
+ schema: Schema
162
+ models: Models
163
+ createServerActions: () => ServerActions
164
+ queries?: AnyQueryRegistry
165
+ /** generated validators keyed by model and mutation name */
166
+ mutations?: Record<string, Record<string, any>>
167
+ /** synchronous query authorization hook; throw to reject */
168
+ validateQuery?: ValidateQueryFn
169
+ /** mutation authorization hook; throw to reject */
170
+ validateMutation?: ValidateMutationFn
171
+ /** controls which operations the admin role bypasses */
172
+ defaultAllowAdminRole?: AdminRoleMode
173
+ /** auth data used by direct server mutations when no scope provides it */
174
+ defaultMutateAuthData?: MutateAuthData
175
+ }
176
+
177
+ type ZeroServerCoreOptions<
178
+ Schema extends ZeroSchema,
179
+ Models extends GenericModels,
180
+ ServerActions extends Record<string, unknown>,
181
+ > = ZeroServerCommonOptions<Schema, Models, ServerActions> & {
182
+ createDatabase: () => ZeroServerDatabase
183
+ persistAlreadyProcessed?: (args: PersistAlreadyProcessedArgs) => Promise<void>
184
+ }
185
+
186
+ function createAsyncTaskCollector() {
187
+ const committed: AsyncAction[] = []
188
+ let attempt: AsyncAction[] | undefined
189
+ let completedAttempt: AsyncAction[] | undefined
190
+ const current = () => attempt ?? committed
191
+ const queue = new Proxy([] as AsyncAction[], {
192
+ deleteProperty(_target, property) {
193
+ return Reflect.deleteProperty(current(), property)
194
+ },
195
+ get(_target, property) {
196
+ const target = current()
197
+ const value = Reflect.get(target, property, target)
198
+ return typeof value === 'function' ? value.bind(target) : value
199
+ },
200
+ getOwnPropertyDescriptor(_target, property) {
201
+ return Reflect.getOwnPropertyDescriptor(current(), property)
202
+ },
203
+ has(_target, property) {
204
+ return Reflect.has(current(), property)
205
+ },
206
+ ownKeys() {
207
+ return Reflect.ownKeys(current())
208
+ },
209
+ set(_target, property, value) {
210
+ return Reflect.set(current(), property, value)
211
+ },
212
+ })
213
+
214
+ const lifecycle: TransactionAttemptLifecycle = {
215
+ beginAttempt() {
216
+ attempt = []
217
+ completedAttempt = undefined
218
+ },
219
+ completeAttempt() {
220
+ completedAttempt = attempt
221
+ attempt = undefined
222
+ },
223
+ rollbackAttempt() {
224
+ attempt = undefined
225
+ completedAttempt = undefined
226
+ },
227
+ commitTransaction() {
228
+ if (completedAttempt) committed.push(...completedAttempt)
229
+ completedAttempt = undefined
230
+ },
231
+ rollbackTransaction() {
232
+ attempt = undefined
233
+ completedAttempt = undefined
234
+ },
235
+ }
236
+
237
+ return { committed, lifecycle, queue }
238
+ }
239
+
240
+ export function createZeroServerCore<
241
+ Schema extends ZeroSchema,
242
+ Models extends GenericModels,
243
+ ServerActions extends Record<string, unknown>,
244
+ >({
245
+ createDatabase,
246
+ createServerActions,
247
+ defaultAllowAdminRole = 'all',
248
+ defaultMutateAuthData = {} as MutateAuthData,
249
+ models,
250
+ mutations: mutationValidators,
251
+ persistAlreadyProcessed,
252
+ queries,
253
+ schema,
254
+ validateMutation,
255
+ validateQuery,
256
+ }: ZeroServerCoreOptions<Schema, Models, ServerActions>) {
257
+ setSchema(schema)
258
+ setEnvironment('server')
259
+
260
+ const permissions = createPermissions<Schema>({
261
+ environment: 'server',
262
+ schema,
263
+ adminRoleMode: defaultAllowAdminRole,
264
+ })
265
+
266
+ const handleMutationRequest = async ({
267
+ authData,
268
+ request,
269
+ skipAsyncTasks,
270
+ }: {
271
+ authData: AuthData | null
272
+ request: Request
273
+ skipAsyncTasks?: boolean
274
+ }) => {
275
+ const tasks = createAsyncTaskCollector()
276
+ const recoveryRequest = request.clone()
277
+ const mutators = createMutators({
278
+ asyncTasks: tasks.queue,
279
+ can: permissions.can,
280
+ createServerActions,
281
+ environment: 'server',
282
+ models,
283
+ authData,
284
+ validateMutation,
285
+ mutationValidators,
286
+ })
287
+ const processor = new PushProcessor(
288
+ withTransactionLifecycle(createDatabase(), tasks.lifecycle),
289
+ )
290
+ const response = await processor.process(mutators, request)
291
+
292
+ if (persistAlreadyProcessed) {
293
+ const parsed = await parseAlreadyProcessed(recoveryRequest, response)
294
+ if (parsed) await persistAlreadyProcessed(parsed)
295
+ }
296
+
297
+ if (!skipAsyncTasks && tasks.committed.length) {
298
+ Promise.all(tasks.committed.map((task) => runWithAuthScope(authData, task))).catch(
299
+ (err: unknown) => {
300
+ console.error('[push] async tasks failed', err)
301
+ },
302
+ )
303
+ }
304
+
305
+ return {
306
+ response,
307
+ asyncTasks: tasks.committed,
308
+ }
309
+ }
310
+
311
+ const handleQueryRequest = async ({
312
+ authData,
313
+ request,
314
+ }: {
315
+ authData: AuthData | null
316
+ request: Request
317
+ }) => {
318
+ if (!queries) {
319
+ throw new Error(
320
+ 'No queries registered with createZeroServer. ' +
321
+ 'Pass the syncedQueries registry to createZeroServer via the queries option.',
322
+ )
323
+ }
324
+
325
+ const handler = (name: string, args: unknown) =>
326
+ resolveServerQuery({
327
+ authData,
328
+ name,
329
+ args,
330
+ queries,
331
+ permissions,
332
+ validateQuery,
333
+ })
334
+
335
+ const userID = typeof authData?.id === 'string' ? authData.id : undefined
336
+ const response = await runWithQueryContext(
337
+ { authData: authData || ({} as AuthData) },
338
+ () =>
339
+ // the legacy overload omits userID when auth does not identify a user.
340
+ userID === undefined
341
+ ? zeroHandleQueryRequest(handler, schema, request)
342
+ : zeroHandleQueryRequest({ handler, schema, request, userID }),
343
+ )
344
+
345
+ return {
346
+ response,
347
+ }
348
+ }
349
+
350
+ async function runTransaction<
351
+ CB extends (tx: Transaction) => Promise<any>,
352
+ Returns extends CB extends (tx: Transaction) => Promise<infer X> ? X : never,
353
+ >(database: ZeroServerDatabase, query: CB): Promise<Returns> {
354
+ try {
355
+ if (isInZeroMutation()) {
356
+ const { tx } = mutatorContext()
357
+ return await query(tx)
358
+ }
359
+ return (await database.transaction(query, dummyTransactionInput)) as Returns
360
+ } catch (err) {
361
+ if ((err as any)?.name !== 'PermissionError') {
362
+ console.error(`Error running transaction(): ${err}`)
363
+ }
364
+ throw err
365
+ }
366
+ }
367
+
368
+ async function transaction<
369
+ CB extends (tx: Transaction) => Promise<any>,
370
+ Returns extends CB extends (tx: Transaction) => Promise<infer X> ? X : never,
371
+ >(query: CB): Promise<Returns> {
372
+ return runTransaction(createDatabase(), query)
373
+ }
374
+
375
+ async function runMutate(
376
+ modelName: string,
377
+ mutatorName: string,
378
+ mutatorArg: unknown,
379
+ options?: MutateOptions,
380
+ ) {
381
+ let authData = options?.authData
382
+ if (!authData) {
383
+ const scoped = getScopedAuthData()
384
+ if (scoped) authData = scoped as MutateAuthData
385
+ }
386
+
387
+ const tasks = createAsyncTaskCollector()
388
+ const mutators = createMutators({
389
+ models,
390
+ environment: 'server',
391
+ asyncTasks: tasks.queue,
392
+ authData: {
393
+ ...defaultMutateAuthData,
394
+ ...authData,
395
+ },
396
+ createServerActions,
397
+ can: permissions.can,
398
+ validateMutation,
399
+ mutationValidators,
400
+ })
401
+ const modelMutators = mutators[modelName as keyof typeof mutators] as Record<
402
+ string,
403
+ (tx: Transaction, arg?: unknown) => Promise<void>
404
+ >
405
+ const mutator = modelMutators[mutatorName]
406
+
407
+ await runTransaction(
408
+ withTransactionLifecycle(createDatabase(), tasks.lifecycle),
409
+ async (tx) => {
410
+ await mutator(tx, mutatorArg)
411
+ },
412
+ )
413
+
414
+ if (tasks.committed.length) {
415
+ const resolvedAuth = authData ?? null
416
+ const promise = Promise.all(
417
+ tasks.committed.map((task) => runWithAuthScope(resolvedAuth, task)),
418
+ )
419
+ if (options?.awaitEffects) {
420
+ await promise
421
+ } else {
422
+ promise.catch((err: unknown) => {
423
+ console.error('[mutate] async tasks failed', err)
424
+ })
425
+ }
426
+ }
427
+ }
428
+
429
+ const mutate = new Proxy({} as ServerMutate<Models>, {
430
+ get(_, modelName: string) {
431
+ return new Proxy(
432
+ {},
433
+ {
434
+ get(_, mutatorName: string) {
435
+ return (arg: unknown, options?: MutateOptions) =>
436
+ runMutate(modelName, mutatorName, arg, options)
437
+ },
438
+ },
439
+ )
440
+ },
441
+ })
442
+
443
+ function query<R>(
444
+ cb: (q: QueryBuilder) => Query<any, Schema, R>,
445
+ authData?: AuthData | null,
446
+ ): Promise<HumanReadable<R>> {
447
+ const run = () =>
448
+ transaction(async (tx) => {
449
+ return tx.run(cb(getZQL()))
450
+ }) as any
451
+
452
+ if (authData !== undefined) {
453
+ return runWithQueryContext({ authData }, run)
454
+ }
455
+ return run()
456
+ }
457
+
458
+ if (queries) {
459
+ setCustomQueries(queries)
460
+ }
461
+
462
+ setRunner((queryObj) => {
463
+ return transaction(async (tx) => {
464
+ return tx.run(queryObj)
465
+ })
466
+ })
467
+
468
+ return {
469
+ handleMutationRequest,
470
+ handleQueryRequest,
471
+ transaction,
472
+ mutate,
473
+ query,
474
+ }
475
+ }
476
+
477
+ export type ZeroServer<
478
+ Schema extends ZeroSchema,
479
+ Models extends GenericModels,
480
+ ServerActions extends Record<string, unknown>,
481
+ > = ReturnType<typeof createZeroServerCore<Schema, Models, ServerActions>>
482
+
483
+ function withTransactionLifecycle(
484
+ database: ZeroServerDatabase,
485
+ lifecycle: TransactionAttemptLifecycle,
486
+ ): ZeroServerDatabase {
487
+ return {
488
+ async transaction(callback, input) {
489
+ try {
490
+ const value = await database.transaction(async (tx, hooks) => {
491
+ lifecycle.beginAttempt()
492
+ try {
493
+ const result = await callback(tx, hooks)
494
+ lifecycle.completeAttempt()
495
+ return result
496
+ } catch (error) {
497
+ lifecycle.rollbackAttempt()
498
+ throw error
499
+ }
500
+ }, input)
501
+ lifecycle.commitTransaction()
502
+ return value
503
+ } catch (error) {
504
+ lifecycle.rollbackTransaction()
505
+ throw error
506
+ }
507
+ },
508
+ }
509
+ }
510
+
511
+ async function parseAlreadyProcessed(
512
+ request: Request,
513
+ response: unknown,
514
+ ): Promise<PersistAlreadyProcessedArgs | undefined> {
515
+ const mutations = alreadyProcessedMutations(response)
516
+ if (mutations.length === 0) return
517
+
518
+ const upstreamSchema = new URL(request.url).searchParams.get('schema')
519
+ if (!upstreamSchema) return
520
+
521
+ const body = (await request.json()) as PushRequestBody
522
+ if (typeof body.clientGroupID !== 'string') return
523
+
524
+ return {
525
+ clientGroupID: body.clientGroupID,
526
+ mutations,
527
+ upstreamSchema,
528
+ }
529
+ }
530
+
531
+ function alreadyProcessedMutations(response: unknown): AlreadyProcessedMutation[] {
532
+ if (!response || typeof response !== 'object') return []
533
+ const mutations = (response as { mutations?: unknown }).mutations
534
+ if (!Array.isArray(mutations)) return []
535
+
536
+ return mutations.filter((mutation): mutation is AlreadyProcessedMutation => {
537
+ if (!mutation || typeof mutation !== 'object') return false
538
+ const id = (mutation as { id?: unknown }).id
539
+ const result = (mutation as { result?: unknown }).result
540
+ return (
541
+ !!id &&
542
+ typeof id === 'object' &&
543
+ typeof (id as { clientID?: unknown }).clientID === 'string' &&
544
+ typeof (id as { id?: unknown }).id === 'number' &&
545
+ !!result &&
546
+ typeof result === 'object' &&
547
+ (result as { error?: unknown }).error === 'alreadyProcessed'
548
+ )
549
+ })
550
+ }
551
+
552
+ const dummyTransactionInput: TransactionProviderInput = {
553
+ clientGroupID: 'unused',
554
+ clientID: 'unused',
555
+ mutationID: 42,
556
+ upstreamSchema: 'unused',
557
+ }
@@ -402,6 +402,8 @@ describe('generateLite', () => {
402
402
  [`${DIR}/models/README.md`]: 'not a model',
403
403
  [`${DIR}/models/helpers/util.ts`]: 'nested should be ignored',
404
404
  [`${DIR}/models/post.d.ts`]: 'declaration file, ignored',
405
+ [`${DIR}/models/post.test.ts`]: 'test file, ignored',
406
+ [`${DIR}/models/post.spec.ts`]: 'spec file, ignored',
405
407
  }
406
408
 
407
409
  const fixtures: Record<string, LiteParsedFile> = {
@@ -422,6 +424,8 @@ describe('generateLite', () => {
422
424
  expect(models).toContain("import * as post from '../models/post'")
423
425
  expect(models).not.toContain('util')
424
426
  expect(models).not.toContain('README')
427
+ expect(models).not.toContain('post.test')
428
+ expect(models).not.toContain('post.spec')
425
429
  })
426
430
 
427
431
  test('infers mutations/ directory when present', () => {
@@ -136,6 +136,7 @@ function listDirectTsFiles(files: Record<string, string>, dirPrefix: string): st
136
136
  if (rest.includes('/')) continue
137
137
  if (!rest.endsWith('.ts')) continue
138
138
  if (rest.endsWith('.d.ts')) continue
139
+ if (rest.endsWith('.test.ts') || rest.endsWith('.spec.ts')) continue
139
140
  out.push(path)
140
141
  }
141
142
  return out.sort()
@@ -1,10 +1,12 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
+ import { runInNewContext } from 'node:vm'
4
5
 
6
+ import * as zero from '@rocicorp/zero'
5
7
  import { afterEach, beforeEach, describe, expect, test } from 'vitest'
6
8
 
7
- import { generate } from './generate'
9
+ import { generate, generateDrizzleSchemaFile } from './generate'
8
10
 
9
11
  const testDir = join(tmpdir(), 'on-zero-test-' + Date.now())
10
12
 
@@ -45,6 +47,15 @@ export const schema = table('comment', {
45
47
  `,
46
48
  )
47
49
 
50
+ writeFileSync(
51
+ join(testDir, 'models/post.test.ts'),
52
+ `throw new Error('test files must not be generated as models')`,
53
+ )
54
+ writeFileSync(
55
+ join(testDir, 'queries/comment.spec.ts'),
56
+ `throw new Error('spec files must not be generated as queries')`,
57
+ )
58
+
48
59
  const result = await generate({ dir: testDir, silent: true })
49
60
 
50
61
  expect(result.modelCount).toBe(2)
@@ -60,6 +71,7 @@ export const schema = table('comment', {
60
71
  const modelsContent = readFileSync(join(testDir, 'generated/models.ts'), 'utf-8')
61
72
  expect(modelsContent).toContain("import * as comment from '../models/comment'")
62
73
  expect(modelsContent).toContain("import * as post from '../models/post'")
74
+ expect(modelsContent).not.toContain('post.test')
63
75
  expect(modelsContent).toContain('export const models = {')
64
76
 
65
77
  // check types.ts content
@@ -672,3 +684,77 @@ export const filteredPosts = (filter: PostFilter) => zero.query.post
672
684
  expect(content).toContain('v.boolean()')
673
685
  })
674
686
  })
687
+
688
+ describe('generateDrizzleSchemaFile', () => {
689
+ test('preserves table server names and executable two-hop relationships', () => {
690
+ const schema = {
691
+ tables: {
692
+ users: {
693
+ name: 'users',
694
+ serverName: 'user_records',
695
+ primaryKey: ['id'],
696
+ columns: {
697
+ id: { type: 'string', optional: false, customType: null },
698
+ },
699
+ },
700
+ groups: {
701
+ name: 'groups',
702
+ primaryKey: ['id'],
703
+ columns: {
704
+ id: { type: 'string', optional: false, customType: null },
705
+ },
706
+ },
707
+ memberships: {
708
+ name: 'memberships',
709
+ primaryKey: ['userId', 'groupId'],
710
+ columns: {
711
+ userId: { type: 'string', optional: false, customType: null },
712
+ groupId: { type: 'string', optional: false, customType: null },
713
+ },
714
+ },
715
+ },
716
+ relationships: {
717
+ users: {
718
+ groups: [
719
+ {
720
+ sourceField: ['id'],
721
+ destField: ['userId'],
722
+ destSchema: 'memberships',
723
+ cardinality: 'many' as const,
724
+ },
725
+ {
726
+ sourceField: ['groupId'],
727
+ destField: ['id'],
728
+ destSchema: 'groups',
729
+ cardinality: 'many' as const,
730
+ },
731
+ ],
732
+ },
733
+ },
734
+ }
735
+ const source = generateDrizzleSchemaFile(schema)
736
+ const executableSource = source
737
+ .replace(
738
+ "import { boolean, createSchema, json, number, relationships, string, table } from '@rocicorp/zero'",
739
+ '',
740
+ )
741
+ .replace('export const schema =', 'globalThis.generatedSchema =')
742
+ const context: Record<string, unknown> = { ...zero }
743
+
744
+ runInNewContext(executableSource, context)
745
+
746
+ expect(source).toContain('const usersTable = table("users").from("user_records")')
747
+ expect(context.generatedSchema).toMatchObject({
748
+ tables: {
749
+ users: {
750
+ serverName: 'user_records',
751
+ },
752
+ },
753
+ relationships: {
754
+ users: {
755
+ groups: schema.relationships.users.groups,
756
+ },
757
+ },
758
+ })
759
+ })
760
+ })