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.
- 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 +5 -4
- package/dist/cjs/httpPull/transport.test.native.js +6 -5
- package/dist/cjs/httpPull/transport.test.native.js.map +1 -1
- package/dist/cjs/httpPullTransport.cjs +2 -1
- package/dist/cjs/httpPullTransport.native.js +3 -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 +5 -4
- package/dist/esm/httpPull/transport.test.mjs.map +1 -1
- package/dist/esm/httpPull/transport.test.native.js +6 -5
- package/dist/esm/httpPull/transport.test.native.js.map +1 -1
- package/dist/esm/httpPullTransport.mjs +2 -1
- package/dist/esm/httpPullTransport.mjs.map +1 -1
- package/dist/esm/httpPullTransport.native.js +3 -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 +6 -5
- package/src/httpPullTransport.ts +9 -4
- 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 +1 -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,12 +134,12 @@ describe('zero-http transport', () => {
|
|
|
134
134
|
)
|
|
135
135
|
})
|
|
136
136
|
|
|
137
|
-
test('pushOrigin
|
|
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
|
|
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
|
|
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
|
|
179
|
-
'https://zero-http
|
|
179
|
+
expect(requests.find((request) => request.path.endsWith('/pull'))?.url).toBe(
|
|
180
|
+
'https://app.local/zero-http/pull',
|
|
180
181
|
)
|
|
181
182
|
})
|
|
182
183
|
|
package/src/httpPullTransport.ts
CHANGED
|
@@ -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
|
|
89
|
-
// origin, while push
|
|
90
|
-
|
|
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 =
|
|
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
|
@@ -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
|
+
}
|