on-zero 0.4.35 → 0.4.37
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/combineZeroClients.native.js.map +1 -1
- package/dist/cjs/createUseQuery.cjs +1 -96
- package/dist/cjs/createUseQuery.native.js +1 -132
- package/dist/cjs/createUseQuery.native.js.map +1 -1
- package/dist/cjs/createUseQueryDirect.cjs +228 -0
- package/dist/cjs/createUseQueryDirect.native.js +334 -0
- package/dist/cjs/createUseQueryDirect.native.js.map +1 -0
- package/dist/cjs/createZeroClient.cjs +18 -7
- package/dist/cjs/createZeroClient.native.js +18 -7
- package/dist/cjs/createZeroClient.native.js.map +1 -1
- package/dist/cjs/helpers/recoverZeroClient.test.native.js.map +1 -1
- package/dist/cjs/index.cjs +2 -2
- package/dist/cjs/index.native.js +2 -2
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/multi.cjs +38 -0
- package/dist/cjs/multi.native.js +41 -0
- package/dist/cjs/multi.native.js.map +1 -0
- package/dist/cjs/multiInstance.test.cjs +75 -0
- package/dist/cjs/multiInstance.test.native.js +111 -0
- package/dist/cjs/multiInstance.test.native.js.map +1 -1
- package/dist/cjs/multiInstanceNested.test.cjs +92 -12
- package/dist/cjs/multiInstanceNested.test.native.js +108 -12
- package/dist/cjs/multiInstanceNested.test.native.js.map +1 -1
- package/dist/cjs/run.cjs +6 -1
- package/dist/cjs/run.native.js +6 -1
- package/dist/cjs/run.native.js.map +1 -1
- package/dist/cjs/testSetup.cjs +26 -0
- package/dist/cjs/testSetup.native.js +32 -0
- package/dist/cjs/testSetup.native.js.map +1 -0
- package/dist/cjs/zeroRunner.cjs +4 -0
- package/dist/cjs/zeroRunner.native.js +4 -0
- package/dist/cjs/zeroRunner.native.js.map +1 -1
- package/dist/esm/combineZeroClients.mjs.map +1 -1
- package/dist/esm/combineZeroClients.native.js.map +1 -1
- package/dist/esm/createUseQuery.mjs +2 -97
- package/dist/esm/createUseQuery.mjs.map +1 -1
- package/dist/esm/createUseQuery.native.js +2 -133
- package/dist/esm/createUseQuery.native.js.map +1 -1
- package/dist/esm/createUseQueryDirect.mjs +203 -0
- package/dist/esm/createUseQueryDirect.mjs.map +1 -0
- package/dist/esm/createUseQueryDirect.native.js +306 -0
- package/dist/esm/createUseQueryDirect.native.js.map +1 -0
- package/dist/esm/createZeroClient.mjs +18 -8
- package/dist/esm/createZeroClient.mjs.map +1 -1
- package/dist/esm/createZeroClient.native.js +18 -8
- package/dist/esm/createZeroClient.native.js.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.test.mjs.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.test.native.js.map +1 -1
- package/dist/esm/index.js +2 -3
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/index.mjs +2 -3
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +2 -3
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/multi.mjs +12 -0
- package/dist/esm/multi.mjs.map +1 -0
- package/dist/esm/multi.native.js +12 -0
- package/dist/esm/multi.native.js.map +1 -0
- package/dist/esm/multiInstance.test.mjs +75 -0
- package/dist/esm/multiInstance.test.mjs.map +1 -1
- package/dist/esm/multiInstance.test.native.js +111 -0
- package/dist/esm/multiInstance.test.native.js.map +1 -1
- package/dist/esm/multiInstanceNested.test.mjs +85 -5
- package/dist/esm/multiInstanceNested.test.mjs.map +1 -1
- package/dist/esm/multiInstanceNested.test.native.js +101 -5
- package/dist/esm/multiInstanceNested.test.native.js.map +1 -1
- package/dist/esm/run.mjs +7 -2
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/run.native.js +7 -2
- package/dist/esm/run.native.js.map +1 -1
- package/dist/esm/testSetup.mjs +27 -0
- package/dist/esm/testSetup.mjs.map +1 -0
- package/dist/esm/testSetup.native.js +30 -0
- package/dist/esm/testSetup.native.js.map +1 -0
- package/dist/esm/zeroRunner.mjs +4 -1
- package/dist/esm/zeroRunner.mjs.map +1 -1
- package/dist/esm/zeroRunner.native.js +4 -1
- package/dist/esm/zeroRunner.native.js.map +1 -1
- package/package.json +8 -2
- package/readme.md +10 -8
- package/src/combineZeroClients.tsx +4 -6
- package/src/createUseQuery.tsx +2 -189
- package/src/createUseQueryDirect.tsx +307 -0
- package/src/createZeroClient.tsx +65 -32
- package/src/helpers/recoverZeroClient.test.ts +8 -2
- package/src/index.ts +6 -2
- package/src/multi.ts +24 -0
- package/src/multiInstance.test.tsx +69 -0
- package/src/multiInstanceNested.test.tsx +79 -4
- package/src/run.ts +10 -2
- package/src/testSetup.ts +26 -0
- package/src/zeroRunner.ts +4 -0
- package/types/combineZeroClients.d.ts.map +1 -1
- package/types/createUseQuery.d.ts +4 -15
- package/types/createUseQuery.d.ts.map +1 -1
- package/types/createUseQueryDirect.d.ts +29 -0
- package/types/createUseQueryDirect.d.ts.map +1 -0
- package/types/createZeroClient.d.ts +51 -5
- package/types/createZeroClient.d.ts.map +1 -1
- package/types/index.d.ts +1 -2
- package/types/index.d.ts.map +1 -1
- package/types/multi.d.ts +6 -0
- package/types/multi.d.ts.map +1 -0
- package/types/multiInstanceNested.test.d.ts.map +1 -1
- package/types/run.d.ts.map +1 -1
- package/types/testSetup.d.ts +1 -0
- package/types/testSetup.d.ts.map +1 -0
- package/types/zeroRunner.d.ts +3 -0
- package/types/zeroRunner.d.ts.map +1 -1
- package/vitest.config.ts +1 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addContextToQuery,
|
|
3
|
+
asQueryInternals,
|
|
4
|
+
DEFAULT_TTL_MS,
|
|
5
|
+
deepClone,
|
|
6
|
+
} from '@rocicorp/zero/bindings'
|
|
7
|
+
import { useEmitterValue, type Emitter } from '@take-out/helpers'
|
|
8
|
+
import { useContext, useMemo, useRef, useSyncExternalStore, type Context } from 'react'
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
parseUseQueryArgs,
|
|
12
|
+
type QueryControlMode,
|
|
13
|
+
type UseQueryHook,
|
|
14
|
+
type UseQueryOptions,
|
|
15
|
+
} from './createUseQuery'
|
|
16
|
+
import { resolveQuery } from './resolveQuery'
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
AnyQueryRegistry,
|
|
20
|
+
ReadonlyJSONValue,
|
|
21
|
+
Schema as ZeroSchema,
|
|
22
|
+
} from '@rocicorp/zero'
|
|
23
|
+
|
|
24
|
+
// optional multi-instance adapter. normal apps should use zero-react's native
|
|
25
|
+
// useQuery path via createZeroClient; this exists only for nested providers
|
|
26
|
+
// where a non-innermost instance cannot be selected through react context.
|
|
27
|
+
|
|
28
|
+
const EMPTY_RESPONSE = [null, { type: 'unknown' }] as never
|
|
29
|
+
const DISABLED_SUBSCRIBE = () => () => {}
|
|
30
|
+
const DISABLED_SNAPSHOT: readonly [undefined, { type: 'unknown' }] = [
|
|
31
|
+
undefined,
|
|
32
|
+
{ type: 'unknown' },
|
|
33
|
+
]
|
|
34
|
+
const getDisabledSnapshot = () => DISABLED_SNAPSHOT
|
|
35
|
+
|
|
36
|
+
type DirectSnapshot = readonly [unknown, { type: string }]
|
|
37
|
+
|
|
38
|
+
type DirectView = {
|
|
39
|
+
subscribe: (notify: () => void) => () => void
|
|
40
|
+
getSnapshot: () => DirectSnapshot
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type MaterializableZero = {
|
|
44
|
+
clientID: string
|
|
45
|
+
context: unknown
|
|
46
|
+
materialize(
|
|
47
|
+
query: any,
|
|
48
|
+
options?: { ttl?: any },
|
|
49
|
+
): {
|
|
50
|
+
addListener(
|
|
51
|
+
cb: (data: any, resultType: string, error?: DirectQueryError) => void,
|
|
52
|
+
): void
|
|
53
|
+
destroy(): void
|
|
54
|
+
updateTTL(ttl: any): void
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type CreateUseQueryDirect<Schema extends ZeroSchema> = (props: {
|
|
59
|
+
DisabledContext: Context<QueryControlMode>
|
|
60
|
+
customQueries: AnyQueryRegistry
|
|
61
|
+
getZero: () => MaterializableZero | null
|
|
62
|
+
zeroVersion: Emitter<number>
|
|
63
|
+
}) => UseQueryHook<Schema>
|
|
64
|
+
|
|
65
|
+
type DirectResultType = 'unknown' | 'complete' | 'error'
|
|
66
|
+
type DirectQueryError = {
|
|
67
|
+
error: 'app' | 'parse'
|
|
68
|
+
message?: string
|
|
69
|
+
details?: unknown
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const emptyArray: readonly unknown[] = []
|
|
73
|
+
const resultTypeUnknown = { type: 'unknown' } as const
|
|
74
|
+
const resultTypeComplete = { type: 'complete' } as const
|
|
75
|
+
const resultTypeError = { type: 'error' } as const
|
|
76
|
+
const emptySnapshotSingularUnknown: DirectSnapshot = [undefined, resultTypeUnknown]
|
|
77
|
+
const emptySnapshotSingularComplete: DirectSnapshot = [undefined, resultTypeComplete]
|
|
78
|
+
const emptySnapshotSingularError: DirectSnapshot = [undefined, resultTypeError]
|
|
79
|
+
const emptySnapshotPluralUnknown: DirectSnapshot = [emptyArray, resultTypeUnknown]
|
|
80
|
+
const emptySnapshotPluralComplete: DirectSnapshot = [emptyArray, resultTypeComplete]
|
|
81
|
+
const emptySnapshotPluralError: DirectSnapshot = [emptyArray, resultTypeError]
|
|
82
|
+
|
|
83
|
+
function getDefaultSnapshot(singular: boolean) {
|
|
84
|
+
return singular ? emptySnapshotSingularUnknown : emptySnapshotPluralUnknown
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function makeError(retry: () => void, error?: DirectQueryError) {
|
|
88
|
+
const message = error?.message ?? 'An unknown error occurred'
|
|
89
|
+
return {
|
|
90
|
+
type: 'error',
|
|
91
|
+
retry,
|
|
92
|
+
refetch: retry,
|
|
93
|
+
error: {
|
|
94
|
+
type: error?.error ?? 'app',
|
|
95
|
+
message,
|
|
96
|
+
...(error?.details ? { details: error.details } : {}),
|
|
97
|
+
},
|
|
98
|
+
} as const
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function getSnapshot(
|
|
102
|
+
singular: boolean,
|
|
103
|
+
data: unknown,
|
|
104
|
+
resultType: DirectResultType,
|
|
105
|
+
retry: () => void,
|
|
106
|
+
error?: DirectQueryError,
|
|
107
|
+
): DirectSnapshot {
|
|
108
|
+
if (singular && data === undefined) {
|
|
109
|
+
if (resultType === 'complete') return emptySnapshotSingularComplete
|
|
110
|
+
if (resultType === 'error')
|
|
111
|
+
return error ? [undefined, makeError(retry, error)] : emptySnapshotSingularError
|
|
112
|
+
return emptySnapshotSingularUnknown
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!singular && Array.isArray(data) && data.length === 0) {
|
|
116
|
+
if (resultType === 'complete') return emptySnapshotPluralComplete
|
|
117
|
+
if (resultType === 'error')
|
|
118
|
+
return error ? [emptyArray, makeError(retry, error)] : emptySnapshotPluralError
|
|
119
|
+
return emptySnapshotPluralUnknown
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (resultType === 'complete') return [data, resultTypeComplete]
|
|
123
|
+
if (resultType === 'error') return [data, makeError(retry, error)]
|
|
124
|
+
return [data, resultTypeUnknown]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
class DirectViewWrapper implements DirectView {
|
|
128
|
+
private view: ReturnType<MaterializableZero['materialize']> | undefined
|
|
129
|
+
private snapshot: DirectSnapshot
|
|
130
|
+
private readonly listeners = new Set<() => void>()
|
|
131
|
+
private destroyTimer: ReturnType<typeof setTimeout> | undefined
|
|
132
|
+
|
|
133
|
+
constructor(
|
|
134
|
+
private readonly query: any,
|
|
135
|
+
private readonly zero: MaterializableZero,
|
|
136
|
+
private ttl: UseQueryOptions['ttl'] | number,
|
|
137
|
+
private readonly singular: boolean,
|
|
138
|
+
private readonly onDematerialized: (view: DirectViewWrapper) => void,
|
|
139
|
+
) {
|
|
140
|
+
this.snapshot = getDefaultSnapshot(singular)
|
|
141
|
+
this.materializeIfNeeded()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private onData = (data: unknown, resultType: string, error?: DirectQueryError) => {
|
|
145
|
+
const cloned = data === undefined ? undefined : deepClone(data as ReadonlyJSONValue)
|
|
146
|
+
this.snapshot = getSnapshot(
|
|
147
|
+
this.singular,
|
|
148
|
+
cloned,
|
|
149
|
+
resultType as DirectResultType,
|
|
150
|
+
this.retry,
|
|
151
|
+
error,
|
|
152
|
+
)
|
|
153
|
+
for (const listener of this.listeners) {
|
|
154
|
+
listener()
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private retry = () => {
|
|
159
|
+
this.destroyView()
|
|
160
|
+
this.materializeIfNeeded()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private materializeIfNeeded() {
|
|
164
|
+
if (this.view) return
|
|
165
|
+
this.view = this.zero.materialize(this.query, { ttl: this.ttl })
|
|
166
|
+
this.view.addListener(this.onData)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private destroyView() {
|
|
170
|
+
const current = this.view
|
|
171
|
+
this.view = undefined
|
|
172
|
+
if (!current) return
|
|
173
|
+
try {
|
|
174
|
+
current.destroy()
|
|
175
|
+
} catch {
|
|
176
|
+
// the owning zero can close before react unsubscribes during rotation
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
updateTTL(ttl: UseQueryOptions['ttl'] | number) {
|
|
181
|
+
this.ttl = ttl
|
|
182
|
+
this.view?.updateTTL(ttl)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
subscribe = (notify: () => void) => {
|
|
186
|
+
this.listeners.add(notify)
|
|
187
|
+
if (this.destroyTimer !== undefined) {
|
|
188
|
+
clearTimeout(this.destroyTimer)
|
|
189
|
+
this.destroyTimer = undefined
|
|
190
|
+
}
|
|
191
|
+
this.materializeIfNeeded()
|
|
192
|
+
|
|
193
|
+
return () => {
|
|
194
|
+
this.listeners.delete(notify)
|
|
195
|
+
if (this.listeners.size === 0) {
|
|
196
|
+
this.destroyTimer = setTimeout(() => {
|
|
197
|
+
this.destroyTimer = undefined
|
|
198
|
+
if (this.listeners.size > 0) return
|
|
199
|
+
this.destroyView()
|
|
200
|
+
this.onDematerialized(this)
|
|
201
|
+
}, 10)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
getSnapshot = () => this.snapshot
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
class DirectViewStore {
|
|
210
|
+
private readonly views = new Map<string, DirectViewWrapper>()
|
|
211
|
+
|
|
212
|
+
getView(
|
|
213
|
+
zero: MaterializableZero,
|
|
214
|
+
queryRequest: unknown,
|
|
215
|
+
enabled: boolean,
|
|
216
|
+
ttl: UseQueryOptions['ttl'] | number,
|
|
217
|
+
): DirectView {
|
|
218
|
+
const query = addContextToQuery(queryRequest as any, zero.context as any)
|
|
219
|
+
const queryInternals = asQueryInternals(query)
|
|
220
|
+
|
|
221
|
+
if (!enabled) {
|
|
222
|
+
const snapshot = getDefaultSnapshot(queryInternals.format.singular)
|
|
223
|
+
return {
|
|
224
|
+
subscribe: DISABLED_SUBSCRIBE,
|
|
225
|
+
getSnapshot: () => snapshot,
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const hash = `${zero.clientID}:${queryInternals.hash()}`
|
|
230
|
+
let view = this.views.get(hash)
|
|
231
|
+
if (!view) {
|
|
232
|
+
view = new DirectViewWrapper(
|
|
233
|
+
query,
|
|
234
|
+
zero,
|
|
235
|
+
ttl,
|
|
236
|
+
queryInternals.format.singular,
|
|
237
|
+
(dematerialized) => {
|
|
238
|
+
if (this.views.get(hash) === dematerialized) {
|
|
239
|
+
this.views.delete(hash)
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
)
|
|
243
|
+
this.views.set(hash, view)
|
|
244
|
+
} else {
|
|
245
|
+
view.updateTTL(ttl)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return view
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function createUseQueryDirect<Schema extends ZeroSchema>({
|
|
253
|
+
DisabledContext,
|
|
254
|
+
customQueries,
|
|
255
|
+
getZero,
|
|
256
|
+
zeroVersion,
|
|
257
|
+
}: Parameters<CreateUseQueryDirect<Schema>>[0]): UseQueryHook<Schema> {
|
|
258
|
+
const directViewStore = new DirectViewStore()
|
|
259
|
+
|
|
260
|
+
function useQueryDirect(...args: any[]): any {
|
|
261
|
+
const disableMode = useContext(DisabledContext)
|
|
262
|
+
const lastRef = useRef<any>(EMPTY_RESPONSE)
|
|
263
|
+
const [fn, paramsOrOptions, optionsArg] = args
|
|
264
|
+
|
|
265
|
+
const version = useEmitterValue(zeroVersion)
|
|
266
|
+
const { params, options } = parseUseQueryArgs(paramsOrOptions, optionsArg)
|
|
267
|
+
|
|
268
|
+
let enabled = true
|
|
269
|
+
let ttl: UseQueryOptions['ttl'] | number = DEFAULT_TTL_MS
|
|
270
|
+
if (typeof options === 'boolean') {
|
|
271
|
+
enabled = options
|
|
272
|
+
} else if (options) {
|
|
273
|
+
enabled = options.enabled !== false
|
|
274
|
+
ttl = options.ttl ?? DEFAULT_TTL_MS
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const paramsKey = params === undefined ? '' : JSON.stringify(params)
|
|
278
|
+
|
|
279
|
+
const view = useMemo((): DirectView | null => {
|
|
280
|
+
const zero = getZero()
|
|
281
|
+
if (!zero) return null
|
|
282
|
+
const queryRequest = resolveQuery({ customQueries, fn, params })
|
|
283
|
+
return directViewStore.getView(zero, queryRequest, enabled, ttl)
|
|
284
|
+
// params is keyed by paramsKey; version re-materializes on a new zero
|
|
285
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
286
|
+
}, [fn, paramsKey, enabled, ttl, version])
|
|
287
|
+
|
|
288
|
+
const out = useSyncExternalStore(
|
|
289
|
+
view ? view.subscribe : DISABLED_SUBSCRIBE,
|
|
290
|
+
view ? view.getSnapshot : getDisabledSnapshot,
|
|
291
|
+
view ? view.getSnapshot : getDisabledSnapshot,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if (!disableMode) {
|
|
295
|
+
lastRef.current = out
|
|
296
|
+
return out
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (disableMode === 'last-value') {
|
|
300
|
+
return lastRef.current
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return EMPTY_RESPONSE
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return useQueryDirect as UseQueryHook<Schema>
|
|
307
|
+
}
|
package/src/createZeroClient.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineQueries, defineQuery, Zero as ZeroClient } from '@rocicorp/zero'
|
|
2
2
|
import { useConnectionState, useZero, ZeroProvider } from '@rocicorp/zero/react'
|
|
3
|
-
import { createEmitter } from '@take-out/helpers'
|
|
3
|
+
import { createEmitter, type Emitter } from '@take-out/helpers'
|
|
4
4
|
import {
|
|
5
5
|
createContext,
|
|
6
6
|
memo,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
useMemo,
|
|
10
10
|
useRef,
|
|
11
11
|
useState,
|
|
12
|
+
type Context,
|
|
12
13
|
type ReactNode,
|
|
13
14
|
} from 'react'
|
|
14
15
|
|
|
@@ -16,7 +17,6 @@ import { createPermissions } from './createPermissions'
|
|
|
16
17
|
import { ensureHttpPullTransport } from './httpPullTransport'
|
|
17
18
|
import {
|
|
18
19
|
createUseQuery,
|
|
19
|
-
createUseQueryDirect,
|
|
20
20
|
type QueryControlMode,
|
|
21
21
|
type UseQueryHook,
|
|
22
22
|
} from './createUseQuery'
|
|
@@ -38,7 +38,14 @@ import { setRunner, type ZeroRunner } from './zeroRunner'
|
|
|
38
38
|
import { zql } from './zql'
|
|
39
39
|
|
|
40
40
|
import type { AuthData, GenericModels, GetZeroMutators, ZeroEvent } from './types'
|
|
41
|
-
import type {
|
|
41
|
+
import type {
|
|
42
|
+
AnyQueryRegistry,
|
|
43
|
+
Query,
|
|
44
|
+
Row,
|
|
45
|
+
Zero,
|
|
46
|
+
ZeroOptions,
|
|
47
|
+
Schema as ZeroSchema,
|
|
48
|
+
} from '@rocicorp/zero'
|
|
42
49
|
|
|
43
50
|
type PreloadOptions = { ttl?: 'always' | 'never' | number | undefined }
|
|
44
51
|
|
|
@@ -50,6 +57,27 @@ export type GroupedQueries = Record<string, Record<string, (...args: any[]) => a
|
|
|
50
57
|
// - 'optimistic-allow': return true until server confirms
|
|
51
58
|
export type PermissionStrategy = 'optimistic' | 'optimistic-deny' | 'optimistic-allow'
|
|
52
59
|
|
|
60
|
+
export type CreateZeroClientOptions<
|
|
61
|
+
Schema extends ZeroSchema,
|
|
62
|
+
Models extends GenericModels,
|
|
63
|
+
> = {
|
|
64
|
+
schema: Schema
|
|
65
|
+
models: Models
|
|
66
|
+
groupedQueries: GroupedQueries
|
|
67
|
+
permissionStrategy?: PermissionStrategy
|
|
68
|
+
// names this client instance so multiple instances can coexist on one page.
|
|
69
|
+
// each query/mutator namespace is claimed by exactly one instance, and the
|
|
70
|
+
// ambient run() + the combineZeroClients facade dispatch by that claim.
|
|
71
|
+
instanceName?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type DirectQueryAdapter<Schema extends ZeroSchema> = (props: {
|
|
75
|
+
DisabledContext: Context<QueryControlMode>
|
|
76
|
+
customQueries: AnyQueryRegistry
|
|
77
|
+
getZero: () => any
|
|
78
|
+
zeroVersion: Emitter<number>
|
|
79
|
+
}) => UseQueryHook<Schema>
|
|
80
|
+
|
|
53
81
|
function getZeroProxyValue(instance: object, key: PropertyKey) {
|
|
54
82
|
const value = Reflect.get(instance, key, instance)
|
|
55
83
|
if (typeof value !== 'function') return value
|
|
@@ -67,7 +95,25 @@ function getZeroProxyValue(instance: object, key: PropertyKey) {
|
|
|
67
95
|
return bound
|
|
68
96
|
}
|
|
69
97
|
|
|
70
|
-
|
|
98
|
+
function createUnavailableDirectUseQuery<
|
|
99
|
+
Schema extends ZeroSchema,
|
|
100
|
+
>(): UseQueryHook<Schema> {
|
|
101
|
+
function useQueryDirect(): never {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`[on-zero] direct queries are optional. Import createZeroClientWithDirectQueries from 'on-zero/multi' for clients used outside the innermost ZeroProvider.`,
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return useQueryDirect as UseQueryHook<Schema>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function createZeroClient<Schema extends ZeroSchema, Models extends GenericModels>(
|
|
111
|
+
options: CreateZeroClientOptions<Schema, Models>,
|
|
112
|
+
) {
|
|
113
|
+
return createZeroClientInternal(options)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createZeroClientInternal<
|
|
71
117
|
Schema extends ZeroSchema,
|
|
72
118
|
Models extends GenericModels,
|
|
73
119
|
>({
|
|
@@ -76,15 +122,9 @@ export function createZeroClient<
|
|
|
76
122
|
groupedQueries,
|
|
77
123
|
permissionStrategy = 'optimistic',
|
|
78
124
|
instanceName = 'default',
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
groupedQueries: GroupedQueries
|
|
83
|
-
permissionStrategy?: PermissionStrategy
|
|
84
|
-
// names this client instance so multiple instances can coexist on one page.
|
|
85
|
-
// each query/mutator namespace is claimed by exactly one instance, and the
|
|
86
|
-
// ambient run() + the combineZeroClients facade dispatch by that claim.
|
|
87
|
-
instanceName?: string
|
|
125
|
+
createDirectUseQuery,
|
|
126
|
+
}: CreateZeroClientOptions<Schema, Models> & {
|
|
127
|
+
createDirectUseQuery?: DirectQueryAdapter<Schema>
|
|
88
128
|
}) {
|
|
89
129
|
type ZeroMutators = GetZeroMutators<Models>
|
|
90
130
|
type ZeroInstance = Zero<Schema, ZeroMutators>
|
|
@@ -230,13 +270,9 @@ export function createZeroClient<
|
|
|
230
270
|
|
|
231
271
|
const zeroEvents = createEmitter<ZeroEvent | null>(`zero${emitterScope}`, null)
|
|
232
272
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const zeroInstanceVersion = createEmitter<number>(
|
|
237
|
-
`zero-instance-version${emitterScope}`,
|
|
238
|
-
0,
|
|
239
|
-
)
|
|
273
|
+
const zeroInstanceVersion = createDirectUseQuery
|
|
274
|
+
? createEmitter<number>(`zero-instance-version${emitterScope}`, 0)
|
|
275
|
+
: null
|
|
240
276
|
|
|
241
277
|
const AuthDataContext = createContext<AuthData>({} as AuthData)
|
|
242
278
|
|
|
@@ -245,15 +281,14 @@ export function createZeroClient<
|
|
|
245
281
|
customQueries,
|
|
246
282
|
})
|
|
247
283
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
})
|
|
284
|
+
const useQueryDirect = createDirectUseQuery
|
|
285
|
+
? createDirectUseQuery({
|
|
286
|
+
DisabledContext,
|
|
287
|
+
customQueries,
|
|
288
|
+
getZero: () => latestZeroInstance,
|
|
289
|
+
zeroVersion: zeroInstanceVersion!,
|
|
290
|
+
})
|
|
291
|
+
: createUnavailableDirectUseQuery<Schema>()
|
|
257
292
|
|
|
258
293
|
// permission check uses a per-model synced query so server is authoritative
|
|
259
294
|
// permissionStrategy controls client behavior before server responds.
|
|
@@ -515,10 +550,8 @@ export function createZeroClient<
|
|
|
515
550
|
setRunner(runner)
|
|
516
551
|
}
|
|
517
552
|
|
|
518
|
-
// notify direct-path views after commit (emitting during render would
|
|
519
|
-
// setState other components mid-render)
|
|
520
553
|
useEffect(() => {
|
|
521
|
-
zeroInstanceVersion
|
|
554
|
+
zeroInstanceVersion?.emit(zeroInstanceVersion.value + 1)
|
|
522
555
|
}, [zeroInstance])
|
|
523
556
|
|
|
524
557
|
return null
|
|
@@ -177,7 +177,10 @@ describe('zero recovery', () => {
|
|
|
177
177
|
// remount. previously crashed as an unhandled rejection.
|
|
178
178
|
test('default reload path is a safe no-op when location is absent', async () => {
|
|
179
179
|
const events: ZeroEvent[] = []
|
|
180
|
-
const zeroEvents = createEmitter<ZeroEvent | null>(
|
|
180
|
+
const zeroEvents = createEmitter<ZeroEvent | null>(
|
|
181
|
+
`test-recover-${emitterSeq++}`,
|
|
182
|
+
null,
|
|
183
|
+
)
|
|
181
184
|
zeroEvents.listen((event) => {
|
|
182
185
|
if (event) events.push(event)
|
|
183
186
|
})
|
|
@@ -200,6 +203,9 @@ describe('zero recovery', () => {
|
|
|
200
203
|
})
|
|
201
204
|
}
|
|
202
205
|
expect(deleteLocalState).toHaveBeenCalledTimes(1)
|
|
203
|
-
expect(events).toContainEqual({
|
|
206
|
+
expect(events).toContainEqual({
|
|
207
|
+
type: 'recovering',
|
|
208
|
+
reason: 'client state not found',
|
|
209
|
+
})
|
|
204
210
|
})
|
|
205
211
|
})
|
package/src/index.ts
CHANGED
|
@@ -8,8 +8,12 @@ export * from './helpers/useMutation'
|
|
|
8
8
|
export { ensureAuth, getAuth } from './helpers/getAuth'
|
|
9
9
|
export { setAuthData, setEnvironment } from './state'
|
|
10
10
|
|
|
11
|
-
export
|
|
12
|
-
|
|
11
|
+
export {
|
|
12
|
+
createZeroClient,
|
|
13
|
+
type CreateZeroClientOptions,
|
|
14
|
+
type GroupedQueries,
|
|
15
|
+
type PermissionStrategy,
|
|
16
|
+
} from './createZeroClient'
|
|
13
17
|
export * from './httpPullTransport'
|
|
14
18
|
export * from './createUseQuery'
|
|
15
19
|
export * from './resolveQuery'
|
package/src/multi.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createZeroClientInternal,
|
|
3
|
+
type DirectQueryAdapter,
|
|
4
|
+
type CreateZeroClientOptions,
|
|
5
|
+
} from './createZeroClient'
|
|
6
|
+
import { createUseQueryDirect } from './createUseQueryDirect'
|
|
7
|
+
|
|
8
|
+
import type { GenericModels } from './types'
|
|
9
|
+
import type { Schema as ZeroSchema } from '@rocicorp/zero'
|
|
10
|
+
|
|
11
|
+
export * from './combineZeroClients'
|
|
12
|
+
|
|
13
|
+
export function createZeroClientWithDirectQueries<
|
|
14
|
+
Schema extends ZeroSchema,
|
|
15
|
+
Models extends GenericModels,
|
|
16
|
+
>(
|
|
17
|
+
options: CreateZeroClientOptions<Schema, Models>,
|
|
18
|
+
): ReturnType<typeof createZeroClientInternal<Schema, Models>> {
|
|
19
|
+
const createDirectUseQuery: DirectQueryAdapter<Schema> = createUseQueryDirect
|
|
20
|
+
return createZeroClientInternal<Schema, Models>({
|
|
21
|
+
...options,
|
|
22
|
+
createDirectUseQuery,
|
|
23
|
+
})
|
|
24
|
+
}
|
|
@@ -4,10 +4,12 @@ import { describe, expect, test, vi } from 'vitest'
|
|
|
4
4
|
|
|
5
5
|
import { combineZeroClients } from './combineZeroClients'
|
|
6
6
|
import { createZeroClient } from './createZeroClient'
|
|
7
|
+
import { runWithContext } from './helpers/mutatorContext'
|
|
7
8
|
import { getInstanceForNamespace, registerClientInstance } from './instanceRegistry'
|
|
8
9
|
import { registerQuery } from './queryRegistry'
|
|
9
10
|
import { run } from './run'
|
|
10
11
|
import { setRunner, type ZeroRunner } from './zeroRunner'
|
|
12
|
+
import { zql } from './zql'
|
|
11
13
|
|
|
12
14
|
import type { ZeroEvent } from './types'
|
|
13
15
|
import type { AnyQueryRegistry, Query } from '@rocicorp/zero'
|
|
@@ -89,6 +91,73 @@ describe('multi-instance namespace dispatch', () => {
|
|
|
89
91
|
expect(request.query.queryName).toBe('runUser.byId')
|
|
90
92
|
})
|
|
91
93
|
|
|
94
|
+
test('run() rejects named queries inside server mutation context', async () => {
|
|
95
|
+
const { byId } = makeClient('run-ctx-owner', 'runCtxThing')
|
|
96
|
+
const ownerRunner = vi.fn(async (..._args: unknown[]) => ({ from: 'owner' }))
|
|
97
|
+
getInstanceForNamespace('runCtxThing')!.runner = ownerRunner as ZeroRunner
|
|
98
|
+
const txRun = vi.fn(async (..._args: unknown[]) => ({ from: 'tx' }))
|
|
99
|
+
|
|
100
|
+
await expect(
|
|
101
|
+
runWithContext(
|
|
102
|
+
{
|
|
103
|
+
authData: null,
|
|
104
|
+
environment: 'server',
|
|
105
|
+
can: async () => {},
|
|
106
|
+
tx: { run: txRun },
|
|
107
|
+
} as any,
|
|
108
|
+
() => run(byId, { id: '1' }),
|
|
109
|
+
),
|
|
110
|
+
).rejects.toThrow(/run\(namedQuery\) cannot be used inside a Zero mutation/)
|
|
111
|
+
|
|
112
|
+
expect(ownerRunner).not.toHaveBeenCalled()
|
|
113
|
+
expect(txRun).not.toHaveBeenCalled()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('run() keeps named queries on their owning client runner when browser context leaks', async () => {
|
|
117
|
+
const { byId } = makeClient('run-client-leak-owner', 'runClientLeakThing')
|
|
118
|
+
const ownerRunner = vi.fn(async (..._args: unknown[]) => ({ from: 'owner' }))
|
|
119
|
+
getInstanceForNamespace('runClientLeakThing')!.runner = ownerRunner as ZeroRunner
|
|
120
|
+
const txRun = vi.fn(async (..._args: unknown[]) => ({ from: 'tx' }))
|
|
121
|
+
|
|
122
|
+
await expect(
|
|
123
|
+
runWithContext(
|
|
124
|
+
{
|
|
125
|
+
authData: null,
|
|
126
|
+
environment: 'client',
|
|
127
|
+
can: async () => {},
|
|
128
|
+
tx: { run: txRun },
|
|
129
|
+
} as any,
|
|
130
|
+
() => run(byId, { id: '1' }),
|
|
131
|
+
),
|
|
132
|
+
).resolves.toEqual({ from: 'owner' })
|
|
133
|
+
|
|
134
|
+
expect(ownerRunner).toHaveBeenCalledTimes(1)
|
|
135
|
+
expect(txRun).not.toHaveBeenCalled()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('run() keeps inline zql on the active transaction runner', async () => {
|
|
139
|
+
makeClient('run-inline-context', 'runInlineThing')
|
|
140
|
+
const ambientRunner = vi.fn(async (..._args: unknown[]) => ({ from: 'ambient' }))
|
|
141
|
+
setRunner(ambientRunner as ZeroRunner)
|
|
142
|
+
const txRun = vi.fn(async (..._args: unknown[]) => ({ from: 'tx' }))
|
|
143
|
+
const query = zql.user.where('id', '1')
|
|
144
|
+
|
|
145
|
+
await expect(
|
|
146
|
+
runWithContext(
|
|
147
|
+
{
|
|
148
|
+
authData: null,
|
|
149
|
+
environment: 'client',
|
|
150
|
+
can: async () => {},
|
|
151
|
+
tx: { run: txRun },
|
|
152
|
+
} as any,
|
|
153
|
+
() => run(query),
|
|
154
|
+
),
|
|
155
|
+
).resolves.toEqual({ from: 'tx' })
|
|
156
|
+
|
|
157
|
+
expect(txRun).toHaveBeenCalledWith(query, undefined)
|
|
158
|
+
expect(ambientRunner).not.toHaveBeenCalled()
|
|
159
|
+
})
|
|
160
|
+
|
|
92
161
|
test('a claimed namespace with an unmounted instance uses the ambient runner (server path)', async () => {
|
|
93
162
|
const { byId } = makeClient('srv-instance', 'srvThing')
|
|
94
163
|
const ambient = vi.fn(async (..._args: unknown[]) => ({ from: 'ambient' }))
|