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.
- package/dist/cjs/createZeroSQLiteServer.cjs +319 -0
- package/dist/cjs/createZeroSQLiteServer.native.js +440 -0
- package/dist/cjs/createZeroSQLiteServer.native.js.map +1 -0
- package/dist/cjs/createZeroSQLiteServer.test.cjs +429 -0
- package/dist/cjs/createZeroSQLiteServer.test.native.js +466 -0
- package/dist/cjs/createZeroSQLiteServer.test.native.js.map +1 -0
- package/dist/cjs/createZeroServer.cjs +20 -234
- package/dist/cjs/createZeroServer.native.js +24 -250
- package/dist/cjs/createZeroServer.native.js.map +1 -1
- package/dist/cjs/createZeroServer.test.cjs +35 -1
- package/dist/cjs/createZeroServer.test.native.js +38 -1
- package/dist/cjs/createZeroServer.test.native.js.map +1 -1
- package/dist/cjs/createZeroServerCore.cjs +362 -0
- package/dist/cjs/createZeroServerCore.native.js +389 -0
- package/dist/cjs/createZeroServerCore.native.js.map +1 -0
- package/dist/cjs/generate-lite.cjs +1 -0
- package/dist/cjs/generate-lite.native.js +1 -0
- package/dist/cjs/generate-lite.native.js.map +1 -1
- package/dist/cjs/generate-lite.test.cjs +5 -1
- package/dist/cjs/generate-lite.test.native.js +5 -1
- package/dist/cjs/generate-lite.test.native.js.map +1 -1
- package/dist/cjs/generate.cjs +20 -11
- package/dist/cjs/generate.native.js +29 -21
- package/dist/cjs/generate.native.js.map +1 -1
- package/dist/cjs/generate.test.cjs +110 -0
- package/dist/cjs/generate.test.native.js +110 -0
- package/dist/cjs/generate.test.native.js.map +1 -1
- package/dist/cjs/httpPull/transport.test.cjs +44 -0
- package/dist/cjs/httpPull/transport.test.native.js +53 -0
- package/dist/cjs/httpPull/transport.test.native.js.map +1 -1
- package/dist/cjs/httpPullTransport.cjs +4 -1
- package/dist/cjs/httpPullTransport.native.js +5 -2
- package/dist/cjs/httpPullTransport.native.js.map +1 -1
- package/dist/cjs/server.cjs +2 -1
- package/dist/cjs/syncHost.cjs +164 -0
- package/dist/cjs/syncHost.native.js +371 -0
- package/dist/cjs/syncHost.native.js.map +1 -0
- package/dist/cjs/syncHost.test.cjs +229 -0
- package/dist/cjs/syncHost.test.native.js +278 -0
- package/dist/cjs/syncHost.test.native.js.map +1 -0
- package/dist/esm/createZeroSQLiteServer.mjs +293 -0
- package/dist/esm/createZeroSQLiteServer.mjs.map +1 -0
- package/dist/esm/createZeroSQLiteServer.native.js +411 -0
- package/dist/esm/createZeroSQLiteServer.native.js.map +1 -0
- package/dist/esm/createZeroSQLiteServer.test.mjs +430 -0
- package/dist/esm/createZeroSQLiteServer.test.mjs.map +1 -0
- package/dist/esm/createZeroSQLiteServer.test.native.js +464 -0
- package/dist/esm/createZeroSQLiteServer.test.native.js.map +1 -0
- package/dist/esm/createZeroServer.mjs +17 -231
- package/dist/esm/createZeroServer.mjs.map +1 -1
- package/dist/esm/createZeroServer.native.js +21 -247
- package/dist/esm/createZeroServer.native.js.map +1 -1
- package/dist/esm/createZeroServer.test.mjs +35 -1
- package/dist/esm/createZeroServer.test.mjs.map +1 -1
- package/dist/esm/createZeroServer.test.native.js +38 -1
- package/dist/esm/createZeroServer.test.native.js.map +1 -1
- package/dist/esm/createZeroServerCore.mjs +336 -0
- package/dist/esm/createZeroServerCore.mjs.map +1 -0
- package/dist/esm/createZeroServerCore.native.js +360 -0
- package/dist/esm/createZeroServerCore.native.js.map +1 -0
- package/dist/esm/generate-lite.mjs +1 -0
- package/dist/esm/generate-lite.mjs.map +1 -1
- package/dist/esm/generate-lite.native.js +1 -0
- package/dist/esm/generate-lite.native.js.map +1 -1
- package/dist/esm/generate-lite.test.mjs +5 -1
- package/dist/esm/generate-lite.test.mjs.map +1 -1
- package/dist/esm/generate-lite.test.native.js +5 -1
- package/dist/esm/generate-lite.test.native.js.map +1 -1
- package/dist/esm/generate.mjs +20 -11
- package/dist/esm/generate.mjs.map +1 -1
- package/dist/esm/generate.native.js +29 -21
- package/dist/esm/generate.native.js.map +1 -1
- package/dist/esm/generate.test.mjs +87 -1
- package/dist/esm/generate.test.mjs.map +1 -1
- package/dist/esm/generate.test.native.js +87 -1
- package/dist/esm/generate.test.native.js.map +1 -1
- package/dist/esm/httpPull/transport.test.mjs +44 -0
- package/dist/esm/httpPull/transport.test.mjs.map +1 -1
- package/dist/esm/httpPull/transport.test.native.js +53 -0
- package/dist/esm/httpPull/transport.test.native.js.map +1 -1
- package/dist/esm/httpPullTransport.mjs +4 -1
- package/dist/esm/httpPullTransport.mjs.map +1 -1
- package/dist/esm/httpPullTransport.native.js +5 -2
- package/dist/esm/httpPullTransport.native.js.map +1 -1
- package/dist/esm/server.mjs +1 -0
- package/dist/esm/server.mjs.map +1 -1
- package/dist/esm/syncHost.mjs +138 -0
- package/dist/esm/syncHost.mjs.map +1 -0
- package/dist/esm/syncHost.native.js +342 -0
- package/dist/esm/syncHost.native.js.map +1 -0
- package/dist/esm/syncHost.test.mjs +206 -0
- package/dist/esm/syncHost.test.mjs.map +1 -0
- package/dist/esm/syncHost.test.native.js +252 -0
- package/dist/esm/syncHost.test.native.js.map +1 -0
- package/package.json +19 -3
- package/readme.md +26 -0
- package/src/createZeroSQLiteServer.test.ts +461 -0
- package/src/createZeroSQLiteServer.ts +523 -0
- package/src/createZeroServer.test.ts +39 -1
- package/src/createZeroServer.ts +35 -428
- package/src/createZeroServerCore.ts +557 -0
- package/src/generate-lite.test.ts +4 -0
- package/src/generate-lite.ts +1 -0
- package/src/generate.test.ts +87 -1
- package/src/generate.ts +29 -20
- package/src/httpPull/transport.test.ts +47 -0
- package/src/httpPullTransport.ts +15 -1
- package/src/server.ts +1 -0
- package/src/syncHost.test.ts +216 -0
- package/src/syncHost.ts +255 -0
- package/test-fixtures/server-return-types.ts +50 -0
- package/test-fixtures/tsconfig.json +13 -0
- package/types/createZeroSQLiteServer.d.ts +37 -0
- package/types/createZeroSQLiteServer.d.ts.map +1 -0
- package/types/createZeroSQLiteServer.test.d.ts +2 -0
- package/types/createZeroSQLiteServer.test.d.ts.map +1 -0
- package/types/createZeroServer.d.ts +11 -293
- package/types/createZeroServer.d.ts.map +1 -1
- package/types/createZeroServerCore.d.ts +310 -0
- package/types/createZeroServerCore.d.ts.map +1 -0
- package/types/generate-lite.d.ts.map +1 -1
- package/types/generate.d.ts +2 -1
- package/types/generate.d.ts.map +1 -1
- package/types/httpPullTransport.d.ts +2 -0
- package/types/httpPullTransport.d.ts.map +1 -1
- package/types/server.d.ts +1 -0
- package/types/server.d.ts.map +1 -1
- package/types/syncHost.d.ts +51 -0
- package/types/syncHost.d.ts.map +1 -0
- package/types/syncHost.test.d.ts +2 -0
- 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
|
-
|
|
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
|
|
734
|
+
return `{ sourceField: [${sf}], destSchema: ${hop.destSchema}Table, destField: [${df}] }`
|
|
720
735
|
})
|
|
721
736
|
.join(', ')
|
|
722
|
-
return ` ${relName}:
|
|
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(
|
|
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(
|
|
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,6 +134,53 @@ describe('zero-http transport', () => {
|
|
|
134
134
|
)
|
|
135
135
|
})
|
|
136
136
|
|
|
137
|
+
test('pullOrigin and pushOrigin route sync through the authoritative application server', async () => {
|
|
138
|
+
const requests: RequestRecord[] = []
|
|
139
|
+
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
140
|
+
const request = recordRequest(input, init)
|
|
141
|
+
requests.push(request)
|
|
142
|
+
if (request.path.endsWith('/pull')) {
|
|
143
|
+
return jsonResponse({ cookie: request.body.cookie, unchanged: true })
|
|
144
|
+
}
|
|
145
|
+
const mutation = request.body.mutations[0]
|
|
146
|
+
return jsonResponse({
|
|
147
|
+
pushResponse: {
|
|
148
|
+
mutations: [
|
|
149
|
+
{
|
|
150
|
+
id: { clientID: mutation.clientID, id: mutation.id },
|
|
151
|
+
result: {},
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
const transport = installHttpPullTransport({
|
|
158
|
+
origin: ORIGIN,
|
|
159
|
+
pullOrigin: 'https://app.local/zero-http',
|
|
160
|
+
pushOrigin: 'https://app.local/zero-http',
|
|
161
|
+
fetch,
|
|
162
|
+
})
|
|
163
|
+
transports.push(transport)
|
|
164
|
+
const zero = createZero()
|
|
165
|
+
|
|
166
|
+
await eventually(() =>
|
|
167
|
+
expect(requests.some((request) => request.path.endsWith('/pull'))).toBe(true),
|
|
168
|
+
)
|
|
169
|
+
const mutation = zero.mutate.project.create({
|
|
170
|
+
id: 'p1',
|
|
171
|
+
ownerId: 'u1',
|
|
172
|
+
name: 'created',
|
|
173
|
+
})
|
|
174
|
+
await mutation.client
|
|
175
|
+
await mutation.server
|
|
176
|
+
|
|
177
|
+
const push = requests.find((request) => request.path.endsWith('/push'))
|
|
178
|
+
expect(push?.url).toBe('https://app.local/zero-http/push')
|
|
179
|
+
expect(requests.find((request) => request.path.endsWith('/pull'))?.url).toBe(
|
|
180
|
+
'https://app.local/zero-http/pull',
|
|
181
|
+
)
|
|
182
|
+
})
|
|
183
|
+
|
|
137
184
|
test('updateAuth frame updates bearer headers for later requests', async () => {
|
|
138
185
|
const requests: RequestRecord[] = []
|
|
139
186
|
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
package/src/httpPullTransport.ts
CHANGED
|
@@ -60,6 +60,8 @@ type PullResponse =
|
|
|
60
60
|
type TransportState = {
|
|
61
61
|
readonly origin: URL
|
|
62
62
|
readonly originString: string
|
|
63
|
+
readonly pullOriginString: string
|
|
64
|
+
readonly pushOriginString: string
|
|
63
65
|
readonly fetch: typeof fetch
|
|
64
66
|
readonly nativeWebSocket: WebSocketConstructor | undefined
|
|
65
67
|
readonly sockets: Set<ZeroHttpSocket>
|
|
@@ -84,6 +86,10 @@ export type HttpPullTransport = {
|
|
|
84
86
|
|
|
85
87
|
export type HttpPullTransportOptions = {
|
|
86
88
|
origin: string
|
|
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
|
|
92
|
+
pushOrigin?: string
|
|
87
93
|
fetch?: typeof fetch
|
|
88
94
|
// when set, every open connection also pulls on this interval so
|
|
89
95
|
// server-initiated changes arrive without a client-side trigger
|
|
@@ -116,6 +122,12 @@ export function installHttpPullTransport(
|
|
|
116
122
|
const state: TransportState = {
|
|
117
123
|
origin: new URL(opts.origin),
|
|
118
124
|
originString: trimTrailingSlash(new URL(opts.origin).toString()),
|
|
125
|
+
pullOriginString: trimTrailingSlash(
|
|
126
|
+
new URL(opts.pullOrigin ?? opts.origin).toString(),
|
|
127
|
+
),
|
|
128
|
+
pushOriginString: trimTrailingSlash(
|
|
129
|
+
new URL(opts.pushOrigin ?? opts.origin).toString(),
|
|
130
|
+
),
|
|
119
131
|
// the transport invokes this as `state.fetch(...)` — without binding,
|
|
120
132
|
// window.fetch sees `state` as its receiver and browsers throw
|
|
121
133
|
// "Illegal invocation" (node's fetch doesn't care, so tests can't catch it)
|
|
@@ -532,7 +544,9 @@ class ZeroHttpSocket {
|
|
|
532
544
|
}
|
|
533
545
|
|
|
534
546
|
private async postJSON(path: '/pull' | '/push', body: unknown) {
|
|
535
|
-
const
|
|
547
|
+
const base =
|
|
548
|
+
path === '/push' ? this.state.pushOriginString : this.state.pullOriginString
|
|
549
|
+
const response = await this.state.fetch(`${base}${path}`, {
|
|
536
550
|
method: 'POST',
|
|
537
551
|
headers: {
|
|
538
552
|
authorization: this.authToken ? `Bearer ${this.authToken}` : '',
|
package/src/server.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/syncHost.ts
ADDED
|
@@ -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
|
+
}
|