on-zero 0.6.12 → 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 +5 -4
  29. package/dist/cjs/httpPull/transport.test.native.js +6 -5
  30. package/dist/cjs/httpPull/transport.test.native.js.map +1 -1
  31. package/dist/cjs/httpPullTransport.cjs +2 -1
  32. package/dist/cjs/httpPullTransport.native.js +3 -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 +5 -4
  78. package/dist/esm/httpPull/transport.test.mjs.map +1 -1
  79. package/dist/esm/httpPull/transport.test.native.js +6 -5
  80. package/dist/esm/httpPull/transport.test.native.js.map +1 -1
  81. package/dist/esm/httpPullTransport.mjs +2 -1
  82. package/dist/esm/httpPullTransport.mjs.map +1 -1
  83. package/dist/esm/httpPullTransport.native.js +3 -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 +6 -5
  107. package/src/httpPullTransport.ts +9 -4
  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 +1 -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
package/src/generate.ts CHANGED
@@ -18,6 +18,12 @@ import type { ExtractedMutation, ModelMutations, SchemaColumn } from './generate
18
18
 
19
19
  const hash = (s: string) => createHash('sha256').update(s).digest('hex')
20
20
 
21
+ const isGeneratorSourceFile = (name: string) =>
22
+ name.endsWith('.ts') &&
23
+ !name.endsWith('.d.ts') &&
24
+ !name.endsWith('.test.ts') &&
25
+ !name.endsWith('.spec.ts')
26
+
21
27
  // hash every .ts input the generator reads (all of baseDir except the generated
22
28
  // output dir + node_modules) so a dev-boot regen can be skipped when nothing
23
29
  // changed. the expensive part of generate() — loading the typescript compiler
@@ -36,11 +42,7 @@ function hashInputTree(baseDir: string, generatedDir: string): string {
36
42
  if (entry.isDirectory()) {
37
43
  if (entry.name === 'node_modules' || full === generatedDir) continue
38
44
  walk(full)
39
- } else if (
40
- entry.isFile() &&
41
- entry.name.endsWith('.ts') &&
42
- !entry.name.endsWith('.d.ts')
43
- ) {
45
+ } else if (entry.isFile() && isGeneratorSourceFile(entry.name)) {
44
46
  parts.push(`${full}\0${readFileSync(full, 'utf-8')}`)
45
47
  }
46
48
  }
@@ -614,8 +616,9 @@ function tsTypeToValibot(
614
616
 
615
617
  type SchemaTable = {
616
618
  name: string
619
+ serverName?: string
617
620
  columns: Record<string, SchemaColumn>
618
- primaryKey: string[]
621
+ primaryKey: readonly string[]
619
622
  }
620
623
 
621
624
  type SchemaRelationHop = {
@@ -685,7 +688,10 @@ export function generateDrizzleSchemaFile(schema: DrizzleZeroSchema): string {
685
688
  .join('\n')
686
689
  const pkArgs = t.primaryKey.map((k) => `'${k}'`).join(', ')
687
690
 
688
- lines.push(`const ${tableName}Table = table('${t.name}')`)
691
+ const tableSource = t.serverName
692
+ ? `table(${JSON.stringify(t.name)}).from(${JSON.stringify(t.serverName)})`
693
+ : `table(${JSON.stringify(t.name)})`
694
+ lines.push(`const ${tableName}Table = ${tableSource}`)
689
695
  lines.push(` .columns({`)
690
696
  lines.push(colEntries)
691
697
  lines.push(` })`)
@@ -711,15 +717,24 @@ export function generateDrizzleSchemaFile(schema: DrizzleZeroSchema): string {
711
717
  return ` ${relName}: ${fn}({\n sourceField: [${sf}],\n destSchema: ${hop.destSchema}Table,\n destField: [${df}],\n })`
712
718
  }
713
719
  // many-to-many (2 hops)
720
+ if (hops.length !== 2) {
721
+ throw new Error(
722
+ `Relationship ${tableName}.${relName} must have one or two hops`,
723
+ )
724
+ }
725
+ const cardinality = hops[0]!.cardinality
726
+ if (hops.some((hop) => hop.cardinality !== cardinality)) {
727
+ throw new Error(`Relationship ${tableName}.${relName} must use one cardinality`)
728
+ }
729
+ const fn = cardinality === 'one' ? 'one' : 'many'
714
730
  const hopCode = hops
715
731
  .map((hop) => {
716
- const fn = hop.cardinality === 'one' ? 'one' : 'many'
717
732
  const sf = hop.sourceField.map((f) => `'${f}'`).join(', ')
718
733
  const df = hop.destField.map((f) => `'${f}'`).join(', ')
719
- return `${fn}({ sourceField: [${sf}], destSchema: ${hop.destSchema}Table, destField: [${df}] })`
734
+ return `{ sourceField: [${sf}], destSchema: ${hop.destSchema}Table, destField: [${df}] }`
720
735
  })
721
736
  .join(', ')
722
- return ` ${relName}: [${hopCode}]`
737
+ return ` ${relName}: ${fn}(${hopCode})`
723
738
  })
724
739
  .join(',\n')
725
740
 
@@ -813,9 +828,7 @@ export async function generate(options: GenerateOptions): Promise<GenerateResult
813
828
  }
814
829
  }
815
830
 
816
- const allModelFiles = readdirSync(modelsDir)
817
- .filter((f) => f.endsWith('.ts'))
818
- .sort()
831
+ const allModelFiles = readdirSync(modelsDir).filter(isGeneratorSourceFile).sort()
819
832
 
820
833
  const filesWithSchema = allModelFiles.filter((f) =>
821
834
  readFileSync(resolve(modelsDir, f), 'utf-8').includes('export const schema = table('),
@@ -867,7 +880,7 @@ export async function generate(options: GenerateOptions): Promise<GenerateResult
867
880
 
868
881
  // generate query files if queries directory exists
869
882
  if (existsSync(queriesDir)) {
870
- const queryFiles = readdirSync(queriesDir).filter((f) => f.endsWith('.ts'))
883
+ const queryFiles = readdirSync(queriesDir).filter(isGeneratorSourceFile)
871
884
 
872
885
  const allQueries: Array<{
873
886
  name: string
@@ -881,7 +894,7 @@ export async function generate(options: GenerateOptions): Promise<GenerateResult
881
894
  const getQueryResolver = () => {
882
895
  if (!queryResolver) {
883
896
  const allFiles = readdirSync(queriesDir)
884
- .filter((f) => f.endsWith('.ts'))
897
+ .filter(isGeneratorSourceFile)
885
898
  .map((f) => ({
886
899
  path: resolve(queriesDir, f),
887
900
  content: readFileSync(resolve(queriesDir, f), 'utf-8'),
@@ -1037,11 +1050,7 @@ export async function generate(options: GenerateOptions): Promise<GenerateResult
1037
1050
  const fullPath = resolve(dir, entry.name)
1038
1051
  if (entry.isDirectory() && entry.name !== 'node_modules') {
1039
1052
  results.push(...collectTsFiles(fullPath))
1040
- } else if (
1041
- entry.isFile() &&
1042
- entry.name.endsWith('.ts') &&
1043
- !entry.name.endsWith('.d.ts')
1044
- ) {
1053
+ } else if (entry.isFile() && isGeneratorSourceFile(entry.name)) {
1045
1054
  results.push({ path: fullPath, content: readFileSync(fullPath, 'utf-8') })
1046
1055
  }
1047
1056
  }
@@ -134,12 +134,12 @@ describe('zero-http transport', () => {
134
134
  )
135
135
  })
136
136
 
137
- test('pushOrigin routes mutations through the authoritative application server', async () => {
137
+ test('pullOrigin and pushOrigin route sync through the authoritative application server', async () => {
138
138
  const requests: RequestRecord[] = []
139
139
  const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
140
140
  const request = recordRequest(input, init)
141
141
  requests.push(request)
142
- if (request.path === '/pull') {
142
+ if (request.path.endsWith('/pull')) {
143
143
  return jsonResponse({ cookie: request.body.cookie, unchanged: true })
144
144
  }
145
145
  const mutation = request.body.mutations[0]
@@ -156,6 +156,7 @@ describe('zero-http transport', () => {
156
156
  })
157
157
  const transport = installHttpPullTransport({
158
158
  origin: ORIGIN,
159
+ pullOrigin: 'https://app.local/zero-http',
159
160
  pushOrigin: 'https://app.local/zero-http',
160
161
  fetch,
161
162
  })
@@ -163,7 +164,7 @@ describe('zero-http transport', () => {
163
164
  const zero = createZero()
164
165
 
165
166
  await eventually(() =>
166
- expect(requests.some((request) => request.path === '/pull')).toBe(true),
167
+ expect(requests.some((request) => request.path.endsWith('/pull'))).toBe(true),
167
168
  )
168
169
  const mutation = zero.mutate.project.create({
169
170
  id: 'p1',
@@ -175,8 +176,8 @@ describe('zero-http transport', () => {
175
176
 
176
177
  const push = requests.find((request) => request.path.endsWith('/push'))
177
178
  expect(push?.url).toBe('https://app.local/zero-http/push')
178
- expect(requests.find((request) => request.path === '/pull')?.url).toBe(
179
- 'https://zero-http.local/pull',
179
+ expect(requests.find((request) => request.path.endsWith('/pull'))?.url).toBe(
180
+ 'https://app.local/zero-http/pull',
180
181
  )
181
182
  })
182
183
 
@@ -60,6 +60,7 @@ type PullResponse =
60
60
  type TransportState = {
61
61
  readonly origin: URL
62
62
  readonly originString: string
63
+ readonly pullOriginString: string
63
64
  readonly pushOriginString: string
64
65
  readonly fetch: typeof fetch
65
66
  readonly nativeWebSocket: WebSocketConstructor | undefined
@@ -85,9 +86,9 @@ export type HttpPullTransport = {
85
86
 
86
87
  export type HttpPullTransportOptions = {
87
88
  origin: string
88
- // optional authoritative mutation endpoint base. reads and wake stay on
89
- // origin, while push POSTs to <pushOrigin>/push. this supports a native read
90
- // host paired with an application server that owns custom mutator execution.
89
+ // optional authoritative sync endpoint bases. websocket interception and
90
+ // wake stay on origin, while pull/push POST to their configured bases.
91
+ pullOrigin?: string
91
92
  pushOrigin?: string
92
93
  fetch?: typeof fetch
93
94
  // when set, every open connection also pulls on this interval so
@@ -121,6 +122,9 @@ export function installHttpPullTransport(
121
122
  const state: TransportState = {
122
123
  origin: new URL(opts.origin),
123
124
  originString: trimTrailingSlash(new URL(opts.origin).toString()),
125
+ pullOriginString: trimTrailingSlash(
126
+ new URL(opts.pullOrigin ?? opts.origin).toString(),
127
+ ),
124
128
  pushOriginString: trimTrailingSlash(
125
129
  new URL(opts.pushOrigin ?? opts.origin).toString(),
126
130
  ),
@@ -540,7 +544,8 @@ class ZeroHttpSocket {
540
544
  }
541
545
 
542
546
  private async postJSON(path: '/pull' | '/push', body: unknown) {
543
- const base = path === '/push' ? this.state.pushOriginString : this.state.originString
547
+ const base =
548
+ path === '/push' ? this.state.pushOriginString : this.state.pullOriginString
544
549
  const response = await this.state.fetch(`${base}${path}`, {
545
550
  method: 'POST',
546
551
  headers: {
package/src/server.ts CHANGED
@@ -1 +1,2 @@
1
1
  export * from './createZeroServer'
2
+ export * from './createZeroSQLiteServer'
@@ -0,0 +1,216 @@
1
+ import { DatabaseSync, type SQLInputValue } from 'node:sqlite'
2
+
3
+ import { defineQueries, defineQuery } from '@rocicorp/zero'
4
+ import * as v from 'valibot'
5
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest'
6
+
7
+ import { getScopedAuthData } from './helpers/mutatorContext'
8
+ import { zeroHttpFixtureSchema } from './httpPull/fixtureSchema'
9
+ import { createZeroSyncHostAdapter } from './syncHost'
10
+ import { zql } from './zql'
11
+
12
+ import type { SQLiteQueryFormat } from './createZeroSQLiteServer'
13
+ import type {
14
+ SyncHostApplicationTransaction,
15
+ SyncHostMutatorContext,
16
+ SyncHostTransactionExecutor,
17
+ } from './syncHost'
18
+
19
+ const queries = defineQueries({
20
+ project: {
21
+ byOwner: defineQuery(v.object({ ownerId: v.string() }), ({ args }) =>
22
+ zql.project.where('ownerId', args.ownerId),
23
+ ),
24
+ },
25
+ })
26
+
27
+ describe('createZeroSyncHostAdapter', () => {
28
+ let db: DatabaseSync
29
+ let executor: SyncHostTransactionExecutor
30
+ let transaction: SyncHostApplicationTransaction
31
+ let lastQueryFormat: SQLiteQueryFormat | undefined
32
+
33
+ beforeEach(() => {
34
+ db = new DatabaseSync(':memory:')
35
+ db.exec(
36
+ 'CREATE TABLE project (id TEXT PRIMARY KEY, ownerId TEXT NOT NULL, name TEXT NOT NULL)',
37
+ )
38
+ executor = {
39
+ exec(sql, params = []) {
40
+ db.prepare(sql).run(...(params as SQLInputValue[]))
41
+ },
42
+ query<Row extends Record<string, unknown>>(sql, params = []) {
43
+ return db.prepare(sql).all(...(params as SQLInputValue[])) as Row[]
44
+ },
45
+ queryAst<Result>(_ast, format) {
46
+ lastQueryFormat = format
47
+ const rows = db.prepare('SELECT * FROM project ORDER BY id').all()
48
+ return (format.singular ? rows[0] : rows) as Result
49
+ },
50
+ }
51
+ transaction = async (work) => {
52
+ const effects: Array<() => void | Promise<void>> = []
53
+ db.exec('BEGIN')
54
+ try {
55
+ const value = await work(executor, {
56
+ defer(effect) {
57
+ effects.push(effect)
58
+ },
59
+ })
60
+ db.exec('COMMIT')
61
+ for (const effect of effects) await effect()
62
+ return value
63
+ } catch (error) {
64
+ db.exec('ROLLBACK')
65
+ throw error
66
+ }
67
+ }
68
+ })
69
+
70
+ afterEach(() => {
71
+ db.close()
72
+ })
73
+
74
+ test('adapts pipe-named mutators, auth, validation, and deferred effects', async () => {
75
+ let effectAuthID: string | undefined
76
+ let validatedAuthID: string | undefined
77
+ const adapter = createZeroSyncHostAdapter({
78
+ schema: zeroHttpFixtureSchema,
79
+ models: {
80
+ project: {
81
+ mutate: {
82
+ create: async ({ authData, server, tx }, value: Record<string, unknown>) => {
83
+ expect(authData?.id).toBe('u1')
84
+ await tx.mutate.project.insert(value as never)
85
+ server?.asyncTasks.push(async () => {
86
+ effectAuthID = getScopedAuthData()?.id as string
87
+ })
88
+ },
89
+ },
90
+ },
91
+ },
92
+ createServerActions: () => ({}),
93
+ queries,
94
+ transaction,
95
+ validateMutation({ authData }) {
96
+ validatedAuthID = authData?.id as string
97
+ },
98
+ })
99
+
100
+ expect(Object.keys(adapter.mutators)).toEqual([
101
+ 'project|create',
102
+ '_zero_cleanupResults',
103
+ ])
104
+ await transaction((tx, context) =>
105
+ adapter.mutators['project|create']!(
106
+ tx,
107
+ { id: 'p1', ownerId: 'u1', name: 'first' },
108
+ mutationContext(context),
109
+ ),
110
+ )
111
+
112
+ expect(db.prepare('SELECT * FROM project').all()).toEqual([
113
+ { id: 'p1', ownerId: 'u1', name: 'first' },
114
+ ])
115
+ expect(validatedAuthID).toBe('u1')
116
+ expect(effectAuthID).toBe('u1')
117
+ })
118
+
119
+ test('returns a structural application error and drops rolled-back effects', async () => {
120
+ let effectRuns = 0
121
+ const adapter = createZeroSyncHostAdapter({
122
+ schema: zeroHttpFixtureSchema,
123
+ models: {
124
+ project: {
125
+ mutate: {
126
+ reject: async ({ server, tx }, value: Record<string, unknown>) => {
127
+ await tx.mutate.project.insert(value as never)
128
+ server?.asyncTasks.push(async () => {
129
+ effectRuns++
130
+ })
131
+ throw new Error('denied')
132
+ },
133
+ },
134
+ },
135
+ },
136
+ createServerActions: () => ({}),
137
+ transaction,
138
+ })
139
+
140
+ const error = await transaction((tx, context) =>
141
+ adapter.mutators['project|reject']!(
142
+ tx,
143
+ { id: 'p1', ownerId: 'u1', name: 'must roll back' },
144
+ mutationContext(context),
145
+ ),
146
+ ).catch((caught) => caught)
147
+
148
+ expect(error).toMatchObject({
149
+ name: 'MutationApplicationError',
150
+ message: 'denied',
151
+ details: 'denied',
152
+ })
153
+ expect(db.prepare('SELECT * FROM project').all()).toEqual([])
154
+ expect(effectRuns).toBe(0)
155
+ })
156
+
157
+ test('uses the host queryAst compiler surface for direct server queries', async () => {
158
+ db.prepare('INSERT INTO project (id, ownerId, name) VALUES (?, ?, ?)').run(
159
+ 'p1',
160
+ 'u1',
161
+ 'first',
162
+ )
163
+ const adapter = createZeroSyncHostAdapter({
164
+ schema: zeroHttpFixtureSchema,
165
+ models: {},
166
+ createServerActions: () => ({}),
167
+ transaction,
168
+ })
169
+
170
+ await expect(adapter.server.query((q) => q.project.one())).resolves.toEqual({
171
+ id: 'p1',
172
+ ownerId: 'u1',
173
+ name: 'first',
174
+ })
175
+ expect(lastQueryFormat).toEqual({ relationships: {}, singular: true })
176
+ })
177
+
178
+ test('resolves auth-sensitive named queries and runs query validation', async () => {
179
+ let validation: unknown
180
+ const adapter = createZeroSyncHostAdapter({
181
+ schema: zeroHttpFixtureSchema,
182
+ models: {},
183
+ createServerActions: () => ({}),
184
+ queries,
185
+ transaction,
186
+ validateQuery(args) {
187
+ validation = args
188
+ },
189
+ })
190
+
191
+ const ast = await adapter.resolveQuery('project|byOwner', [{ ownerId: 'u1' }], {
192
+ userID: 'u1',
193
+ role: 'member',
194
+ })
195
+
196
+ expect(ast).toMatchObject({ table: 'project' })
197
+ expect(JSON.stringify(ast)).toContain('ownerId')
198
+ expect(JSON.stringify(ast)).toContain('u1')
199
+ expect(validation).toEqual({
200
+ authData: { id: 'u1', role: 'member', userID: 'u1' },
201
+ queryName: 'project|byOwner',
202
+ params: { ownerId: 'u1' },
203
+ })
204
+ })
205
+ })
206
+
207
+ function mutationContext(context: {
208
+ defer(effect: () => void | Promise<void>): void
209
+ }): SyncHostMutatorContext {
210
+ return {
211
+ ...context,
212
+ claims: { userID: 'u1', role: 'member' },
213
+ clientID: 'client-a',
214
+ mutationID: '1',
215
+ }
216
+ }
@@ -0,0 +1,255 @@
1
+ import { asQueryInternals } from '@rocicorp/zero/bindings'
2
+
3
+ import { createPermissions } from './createPermissions'
4
+ import { resolveServerQuery, type ZeroServerCommonOptions } from './createZeroServerCore'
5
+ import {
6
+ createZeroSQLiteServer,
7
+ createZeroSQLiteTransaction,
8
+ type SQLiteTransactionExecutor,
9
+ type ZeroSQLiteTransactionOptions,
10
+ } from './createZeroSQLiteServer'
11
+ import { createMutators } from './helpers/createMutators'
12
+ import { runWithAuthScope } from './helpers/mutatorContext'
13
+ import { runWithQueryContext } from './helpers/queryContext'
14
+ import { setEnvironment, setSchema } from './state'
15
+
16
+ import type { AuthData, GenericModels } from './types'
17
+ import type { Schema as ZeroSchema } from '@rocicorp/zero'
18
+
19
+ export type SyncHostJsonValue =
20
+ | string
21
+ | number
22
+ | boolean
23
+ | null
24
+ | SyncHostJsonValue[]
25
+ | { [key: string]: SyncHostJsonValue }
26
+
27
+ export type SyncHostClaims = {
28
+ userID: string
29
+ [claim: string]: SyncHostJsonValue
30
+ }
31
+
32
+ export type SyncHostTransactionContext = {
33
+ defer(effect: () => void | Promise<void>): void
34
+ }
35
+
36
+ export type SyncHostTransactionExecutor = {
37
+ exec(sql: string, params?: readonly unknown[]): void | Promise<void>
38
+ query<Row extends Record<string, unknown> = Record<string, unknown>>(
39
+ sql: string,
40
+ params?: readonly unknown[],
41
+ ): Row[] | Promise<Row[]>
42
+ queryAst<Result = unknown>(
43
+ ast: SyncHostJsonValue,
44
+ format: import('./createZeroSQLiteServer').SQLiteQueryFormat,
45
+ queryName?: string,
46
+ ): Result | Promise<Result>
47
+ }
48
+
49
+ export type SyncHostMutatorContext = SyncHostTransactionContext & {
50
+ claims: SyncHostClaims
51
+ clientID?: string
52
+ mutationID?: string | number
53
+ }
54
+
55
+ export type SyncHostRegisteredMutator = (
56
+ tx: SyncHostTransactionExecutor,
57
+ args: SyncHostJsonValue,
58
+ context: SyncHostMutatorContext,
59
+ ) => void | Promise<void>
60
+
61
+ export type SyncHostApplicationTransaction = <Value>(
62
+ work: (
63
+ tx: SyncHostTransactionExecutor,
64
+ context: SyncHostTransactionContext,
65
+ ) => Value | Promise<Value>,
66
+ ) => Promise<Value>
67
+
68
+ export type CreateZeroSyncHostAdapterOptions<
69
+ Schema extends ZeroSchema,
70
+ Models extends GenericModels,
71
+ ServerActions extends Record<string, unknown>,
72
+ > = Omit<
73
+ ZeroServerCommonOptions<Schema, Models, ServerActions>,
74
+ 'defaultMutateAuthData'
75
+ > &
76
+ ZeroSQLiteTransactionOptions<Schema> & {
77
+ transaction: SyncHostApplicationTransaction
78
+ mapAuthData?: (claims: SyncHostClaims) => AuthData
79
+ defaultMutateAuthData?: Pick<AuthData, 'email' | 'id'> & Partial<AuthData>
80
+ }
81
+
82
+ export class MutationApplicationError extends Error {
83
+ constructor(
84
+ readonly details: string,
85
+ message = details,
86
+ ) {
87
+ super(message)
88
+ this.name = 'MutationApplicationError'
89
+ }
90
+ }
91
+
92
+ export function createZeroSyncHostAdapter<
93
+ Schema extends ZeroSchema,
94
+ Models extends GenericModels,
95
+ ServerActions extends Record<string, unknown>,
96
+ >({
97
+ encodeValue,
98
+ mapAuthData = defaultMapAuthData,
99
+ transaction,
100
+ ...options
101
+ }: CreateZeroSyncHostAdapterOptions<Schema, Models, ServerActions>) {
102
+ setSchema(options.schema)
103
+ setEnvironment('server')
104
+
105
+ const permissions = createPermissions({
106
+ environment: 'server',
107
+ schema: options.schema,
108
+ adminRoleMode: options.defaultAllowAdminRole ?? 'all',
109
+ })
110
+ const transactionOptions = {
111
+ encodeValue,
112
+ schema: options.schema,
113
+ }
114
+ const mutators: Record<string, SyncHostRegisteredMutator> = {}
115
+
116
+ for (const [modelName, model] of Object.entries(options.models)) {
117
+ for (const mutatorName of Object.keys(model.mutate ?? {})) {
118
+ mutators[`${modelName}|${mutatorName}`] = async (executor, args, context) => {
119
+ const authData = mapAuthData(context.claims)
120
+ const asyncTasks: Array<() => Promise<void>> = []
121
+ const modelMutators = createMutators({
122
+ asyncTasks,
123
+ authData,
124
+ can: permissions.can,
125
+ createServerActions: options.createServerActions,
126
+ environment: 'server',
127
+ models: options.models,
128
+ mutationValidators: options.mutations,
129
+ validateMutation: options.validateMutation,
130
+ }) as Record<
131
+ string,
132
+ Record<string, (tx: unknown, args: unknown) => Promise<void>>
133
+ >
134
+ const mutation = modelMutators[modelName]?.[mutatorName]
135
+ if (!mutation)
136
+ throw new Error(`unknown on-zero mutator: ${modelName}|${mutatorName}`)
137
+
138
+ const mutationID = numericMutationID(context.mutationID)
139
+ const onZeroTransaction = createZeroSQLiteTransaction(
140
+ transactionOptions,
141
+ toSQLiteExecutor(executor),
142
+ {
143
+ clientGroupID: 'orez-sync-host',
144
+ clientID: context.clientID ?? '',
145
+ mutationID,
146
+ upstreamSchema: 'orez-sync-host',
147
+ },
148
+ )
149
+ try {
150
+ await mutation(onZeroTransaction, args)
151
+ } catch (error) {
152
+ throw asMutationApplicationError(error)
153
+ }
154
+ for (const task of asyncTasks) {
155
+ context.defer(() => runWithAuthScope(authData, task))
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ // orez owns result retention and lmids. zero clients may still emit this
162
+ // protocol mutation, so accepting it advances the host ledger without
163
+ // creating a second on-zero cleanup ledger.
164
+ mutators._zero_cleanupResults = async () => {}
165
+
166
+ const direct = createZeroSQLiteServer({
167
+ ...options,
168
+ encodeValue,
169
+ transactionProvider: (work) =>
170
+ transaction((executor) => work(toSQLiteExecutor(executor))),
171
+ })
172
+
173
+ return {
174
+ mutators: Object.freeze(mutators),
175
+ async resolveQuery(
176
+ name: string,
177
+ args: readonly SyncHostJsonValue[],
178
+ claims: SyncHostClaims,
179
+ ): Promise<SyncHostJsonValue> {
180
+ if (!options.queries) {
181
+ throw new Error(
182
+ 'No queries registered with createZeroSyncHostAdapter. Pass the syncedQueries registry via the queries option.',
183
+ )
184
+ }
185
+ const authData = mapAuthData(claims)
186
+ const query = await runWithQueryContext({ authData }, () =>
187
+ resolveServerQuery({
188
+ authData,
189
+ args: args[0],
190
+ name,
191
+ permissions,
192
+ queries: options.queries!,
193
+ validateQuery: options.validateQuery,
194
+ }),
195
+ )
196
+ return asQueryInternals(query as never).ast as unknown as SyncHostJsonValue
197
+ },
198
+ server: {
199
+ mutate: direct.mutate,
200
+ query: direct.query,
201
+ transaction: direct.transaction,
202
+ },
203
+ }
204
+ }
205
+
206
+ function defaultMapAuthData(claims: SyncHostClaims): AuthData {
207
+ return {
208
+ ...claims,
209
+ id: typeof claims.id === 'string' ? claims.id : claims.userID,
210
+ } as AuthData
211
+ }
212
+
213
+ function toSQLiteExecutor(
214
+ executor: SyncHostTransactionExecutor,
215
+ ): SQLiteTransactionExecutor {
216
+ return {
217
+ exec(sql, params) {
218
+ return executor.exec(sql, params)
219
+ },
220
+ query<Row extends Record<string, unknown>>(sql: string, params?: readonly unknown[]) {
221
+ return executor.query<Row>(sql, params)
222
+ },
223
+ queryAst<Result>(ast, format, queryName) {
224
+ return executor.queryAst<Result>(
225
+ ast as unknown as SyncHostJsonValue,
226
+ format,
227
+ queryName,
228
+ )
229
+ },
230
+ }
231
+ }
232
+
233
+ function numericMutationID(value: string | number | undefined): number {
234
+ if (value === undefined) return 0
235
+ const numeric = typeof value === 'number' ? value : Number(value)
236
+ if (!Number.isSafeInteger(numeric) || numeric < 0) {
237
+ throw new TypeError(`invalid Zero mutation ID: ${value}`)
238
+ }
239
+ return numeric
240
+ }
241
+
242
+ function asMutationApplicationError(error: unknown): MutationApplicationError {
243
+ if (
244
+ typeof error === 'object' &&
245
+ error !== null &&
246
+ (error as { name?: unknown }).name === 'MutationApplicationError' &&
247
+ typeof (error as { details?: unknown }).details === 'string'
248
+ ) {
249
+ return error as MutationApplicationError
250
+ }
251
+ const message = error instanceof Error ? error.message : String(error)
252
+ const applicationError = new MutationApplicationError(message)
253
+ if (error instanceof Error && error.stack) applicationError.stack = error.stack
254
+ return applicationError
255
+ }