on-zero 0.4.23 → 0.4.24
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.cjs +101 -0
- package/dist/cjs/combineZeroClients.native.js +150 -0
- package/dist/cjs/combineZeroClients.native.js.map +1 -0
- package/dist/cjs/createUseQuery.cjs +92 -4
- package/dist/cjs/createUseQuery.native.js +130 -4
- package/dist/cjs/createUseQuery.native.js.map +1 -1
- package/dist/cjs/createZeroClient.cjs +41 -13
- package/dist/cjs/createZeroClient.native.js +75 -43
- package/dist/cjs/createZeroClient.native.js.map +1 -1
- package/dist/cjs/index.cjs +1 -0
- package/dist/cjs/index.native.js +1 -0
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/instanceRegistry.cjs +65 -0
- package/dist/cjs/instanceRegistry.native.js +111 -0
- package/dist/cjs/instanceRegistry.native.js.map +1 -0
- package/dist/cjs/multiInstance.test.cjs +322 -0
- package/dist/cjs/multiInstance.test.native.js +387 -0
- package/dist/cjs/multiInstance.test.native.js.map +1 -0
- package/dist/cjs/multiInstanceNested.test.cjs +206 -0
- package/dist/cjs/multiInstanceNested.test.native.js +254 -0
- package/dist/cjs/multiInstanceNested.test.native.js.map +1 -0
- package/dist/cjs/run.cjs +5 -5
- package/dist/cjs/run.native.js +6 -5
- package/dist/cjs/run.native.js.map +1 -1
- package/dist/cjs/zeroRunner.cjs +4 -1
- package/dist/cjs/zeroRunner.native.js +4 -1
- package/dist/cjs/zeroRunner.native.js.map +1 -1
- package/dist/esm/combineZeroClients.mjs +76 -0
- package/dist/esm/combineZeroClients.mjs.map +1 -0
- package/dist/esm/combineZeroClients.native.js +122 -0
- package/dist/esm/combineZeroClients.native.js.map +1 -0
- package/dist/esm/createUseQuery.mjs +92 -5
- package/dist/esm/createUseQuery.mjs.map +1 -1
- package/dist/esm/createUseQuery.native.js +130 -5
- package/dist/esm/createUseQuery.native.js.map +1 -1
- package/dist/esm/createZeroClient.mjs +42 -14
- package/dist/esm/createZeroClient.mjs.map +1 -1
- package/dist/esm/createZeroClient.native.js +76 -44
- package/dist/esm/createZeroClient.native.js.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/index.mjs +1 -0
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +1 -0
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/instanceRegistry.mjs +38 -0
- package/dist/esm/instanceRegistry.mjs.map +1 -0
- package/dist/esm/instanceRegistry.native.js +81 -0
- package/dist/esm/instanceRegistry.native.js.map +1 -0
- package/dist/esm/multiInstance.test.mjs +323 -0
- package/dist/esm/multiInstance.test.mjs.map +1 -0
- package/dist/esm/multiInstance.test.native.js +385 -0
- package/dist/esm/multiInstance.test.native.js.map +1 -0
- package/dist/esm/multiInstanceNested.test.mjs +207 -0
- package/dist/esm/multiInstanceNested.test.mjs.map +1 -0
- package/dist/esm/multiInstanceNested.test.native.js +252 -0
- package/dist/esm/multiInstanceNested.test.native.js.map +1 -0
- package/dist/esm/run.mjs +5 -5
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/run.native.js +6 -5
- package/dist/esm/run.native.js.map +1 -1
- 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 +5 -3
- package/readme.md +59 -0
- package/src/combineZeroClients.tsx +186 -0
- package/src/createUseQuery.tsx +175 -12
- package/src/createZeroClient.tsx +107 -42
- package/src/index.ts +1 -0
- package/src/instanceRegistry.ts +75 -0
- package/src/multiInstance.test.tsx +284 -0
- package/src/multiInstanceNested.test.tsx +205 -0
- package/src/run.ts +7 -6
- package/src/zeroRunner.ts +7 -1
- package/types/combineZeroClients.d.ts +38 -0
- package/types/combineZeroClients.d.ts.map +1 -0
- package/types/createUseQuery.d.ts +15 -0
- package/types/createUseQuery.d.ts.map +1 -1
- package/types/createZeroClient.d.ts +7 -2
- package/types/createZeroClient.d.ts.map +1 -1
- package/types/index.d.ts +1 -0
- package/types/index.d.ts.map +1 -1
- package/types/instanceRegistry.d.ts +15 -0
- package/types/instanceRegistry.d.ts.map +1 -0
- package/types/multiInstance.test.d.ts +2 -0
- package/types/multiInstance.test.d.ts.map +1 -0
- package/types/multiInstanceNested.test.d.ts +5 -0
- package/types/multiInstanceNested.test.d.ts.map +1 -0
- package/types/run.d.ts.map +1 -1
- package/types/zeroRunner.d.ts +3 -1
- package/types/zeroRunner.d.ts.map +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createEmitter, type Emitter } from '@take-out/helpers'
|
|
2
|
+
|
|
3
|
+
import { getInstanceForNamespace, getInstanceForQueryFn } from './instanceRegistry'
|
|
4
|
+
import { run } from './run'
|
|
5
|
+
|
|
6
|
+
import type { ZeroEvent } from './types'
|
|
7
|
+
import type { ReactNode } from 'react'
|
|
8
|
+
|
|
9
|
+
// combines multiple createZeroClient instances into one consumer surface:
|
|
10
|
+
// useQuery/run/preload/getQuery dispatch to the instance that claimed the
|
|
11
|
+
// query fn's namespace, zero.mutate.<namespace> dispatches by model
|
|
12
|
+
// namespace, and everything unclaimed (plus non-mutate zero access like
|
|
13
|
+
// userID/clientID) goes to the FIRST client — the primary. consumers render
|
|
14
|
+
// each client's ProvideZero themselves; this facade does not use react
|
|
15
|
+
// context, matching the existing global-zero-import style.
|
|
16
|
+
//
|
|
17
|
+
// PROVIDER NESTING CONTRACT: zero-react's useQuery resolves its instance from
|
|
18
|
+
// the NEAREST ZeroProvider context, so only ONE instance — the one whose
|
|
19
|
+
// provider is mounted INNERMOST — may use the upstream context path. by
|
|
20
|
+
// default that is the LAST client passed here (override via the `inner`
|
|
21
|
+
// option); the first client is primary and its provider is expected OUTER.
|
|
22
|
+
// the inner instance's queries ride zero-react's useQuery unchanged, keeping
|
|
23
|
+
// viewStore dedup + ttl — give that slot to the instance owning the bulk of
|
|
24
|
+
// the subscriptions. every other instance's useQuery/usePermission route
|
|
25
|
+
// through the context-free direct hooks (useQueryDirect), which materialize
|
|
26
|
+
// on the owning instance but skip view dedup — keep those instances on
|
|
27
|
+
// bounded, tiny queries.
|
|
28
|
+
|
|
29
|
+
type ControlQueriesProps = {
|
|
30
|
+
children: ReactNode
|
|
31
|
+
action?: 'enable' | 'disable'
|
|
32
|
+
whenDisabled?: 'empty' | 'last-value'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// the minimal structural surface the facade dispatches over — the const
|
|
36
|
+
// generic captures each client's real types, this only constrains shape
|
|
37
|
+
type CombinableZeroClient = {
|
|
38
|
+
instanceName: string
|
|
39
|
+
useQuery: (...args: any[]) => any
|
|
40
|
+
useQueryDirect: (...args: any[]) => any
|
|
41
|
+
usePermission: (...args: any[]) => any
|
|
42
|
+
usePermissionDirect: (...args: any[]) => any
|
|
43
|
+
zero: any
|
|
44
|
+
preload: (...args: any[]) => any
|
|
45
|
+
getQuery: (...args: any[]) => any
|
|
46
|
+
zeroEvents: Emitter<ZeroEvent | null>
|
|
47
|
+
ControlQueries: (props: ControlQueriesProps) => ReactNode
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type CombineZeroClientsOptions = {
|
|
51
|
+
// instanceName of the client whose ProvideZero is mounted INNERMOST.
|
|
52
|
+
// defaults to the last client passed.
|
|
53
|
+
inner?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (
|
|
57
|
+
x: infer I,
|
|
58
|
+
) => void
|
|
59
|
+
? I
|
|
60
|
+
: never
|
|
61
|
+
|
|
62
|
+
export type CombinedZeroClients<Clients extends readonly CombinableZeroClient[]> = {
|
|
63
|
+
useQuery: UnionToIntersection<Clients[number]['useQuery']>
|
|
64
|
+
usePermission: UnionToIntersection<Clients[number]['usePermission']>
|
|
65
|
+
zero: UnionToIntersection<Clients[number]['zero']>
|
|
66
|
+
preload: UnionToIntersection<Clients[number]['preload']>
|
|
67
|
+
getQuery: UnionToIntersection<Clients[number]['getQuery']>
|
|
68
|
+
run: typeof run
|
|
69
|
+
zeroEvents: Emitter<ZeroEvent | null>
|
|
70
|
+
ControlQueries: (props: ControlQueriesProps) => ReactNode
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function combineZeroClients<
|
|
74
|
+
const Clients extends readonly [CombinableZeroClient, ...CombinableZeroClient[]],
|
|
75
|
+
>(
|
|
76
|
+
...clientsAndOptions: [...Clients] | [...Clients, CombineZeroClientsOptions]
|
|
77
|
+
): CombinedZeroClients<Clients> {
|
|
78
|
+
const last = clientsAndOptions[clientsAndOptions.length - 1]
|
|
79
|
+
const hasOptions = typeof (last as { instanceName?: unknown }).instanceName !== 'string'
|
|
80
|
+
// boundary narrow: everything before a trailing options object is a client
|
|
81
|
+
const clients = (hasOptions
|
|
82
|
+
? clientsAndOptions.slice(0, -1)
|
|
83
|
+
: clientsAndOptions) as unknown as Clients
|
|
84
|
+
const options: CombineZeroClientsOptions = hasOptions
|
|
85
|
+
? (last as CombineZeroClientsOptions)
|
|
86
|
+
: {}
|
|
87
|
+
|
|
88
|
+
const primary = clients[0]
|
|
89
|
+
const innerName = options.inner ?? clients[clients.length - 1]!.instanceName
|
|
90
|
+
const clientsByName = new Map(clients.map((client) => [client.instanceName, client]))
|
|
91
|
+
|
|
92
|
+
if (!clientsByName.has(innerName)) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`[on-zero] combineZeroClients inner instance '${innerName}' is not one of the passed clients`,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const ownerOfNamespace = (namespace: string): CombinableZeroClient => {
|
|
99
|
+
const owner = getInstanceForNamespace(namespace)
|
|
100
|
+
return (owner && clientsByName.get(owner.name)) || primary
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const ownerOfQueryFn = (fn: Function): CombinableZeroClient => {
|
|
104
|
+
const owner = getInstanceForQueryFn(fn)
|
|
105
|
+
return (owner && clientsByName.get(owner.name)) || primary
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// hooks: a given call site always passes the same query fn / table, so the
|
|
109
|
+
// dispatched hook target is stable across renders (no conditional hooks).
|
|
110
|
+
// only the inner instance may use the upstream context path — its provider
|
|
111
|
+
// is the nearest one, so useZero() resolves to it. everyone else goes
|
|
112
|
+
// through the context-free direct hooks.
|
|
113
|
+
function useQuery(...args: any[]) {
|
|
114
|
+
const owner = ownerOfQueryFn(args[0])
|
|
115
|
+
return owner.instanceName === innerName
|
|
116
|
+
? owner.useQuery(...args)
|
|
117
|
+
: owner.useQueryDirect(...args)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function usePermission(...args: any[]) {
|
|
121
|
+
// model namespaces are table-named, so the table arg picks the owner;
|
|
122
|
+
// permission checks follow the same context vs direct path as the
|
|
123
|
+
// table's other queries
|
|
124
|
+
const owner = ownerOfNamespace(String(args[0]))
|
|
125
|
+
return owner.instanceName === innerName
|
|
126
|
+
? owner.usePermission(...args)
|
|
127
|
+
: owner.usePermissionDirect(...args)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const mutate = new Proxy({} as never, {
|
|
131
|
+
get(_, key) {
|
|
132
|
+
if (typeof key !== 'string') return undefined
|
|
133
|
+
return ownerOfNamespace(key).zero.mutate[key]
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const zero = new Proxy({} as never, {
|
|
138
|
+
get(_, key) {
|
|
139
|
+
if (key === 'mutate') {
|
|
140
|
+
return mutate
|
|
141
|
+
}
|
|
142
|
+
// non-mutate access (userID, clientID, close, …) forwards to primary
|
|
143
|
+
return primary.zero[key]
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
function preload(...args: any[]) {
|
|
148
|
+
return ownerOfQueryFn(args[0]).preload(...args)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getQuery(...args: any[]) {
|
|
152
|
+
return ownerOfQueryFn(args[0]).getQuery(...args)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// one events stream relaying every instance's emitter
|
|
156
|
+
const zeroEvents = createEmitter<ZeroEvent | null>(
|
|
157
|
+
`zero:combined(${clients.map((client) => client.instanceName).join('+')})`,
|
|
158
|
+
null,
|
|
159
|
+
)
|
|
160
|
+
for (const client of clients) {
|
|
161
|
+
client.zeroEvents.listen((event) => zeroEvents.emit(event))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const ControlQueries = ({ children, ...props }: ControlQueriesProps) =>
|
|
165
|
+
clients.reduceRight(
|
|
166
|
+
(inner: ReactNode, client) => (
|
|
167
|
+
<client.ControlQueries {...props}>{inner}</client.ControlQueries>
|
|
168
|
+
),
|
|
169
|
+
children,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
const combined = {
|
|
173
|
+
useQuery,
|
|
174
|
+
usePermission,
|
|
175
|
+
zero,
|
|
176
|
+
preload,
|
|
177
|
+
getQuery,
|
|
178
|
+
run,
|
|
179
|
+
zeroEvents,
|
|
180
|
+
ControlQueries,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// boundary assertion: the dispatching wrappers are untyped internally; the
|
|
184
|
+
// combined type re-applies each client's real surface
|
|
185
|
+
return combined as unknown as CombinedZeroClients<Clients>
|
|
186
|
+
}
|
package/src/createUseQuery.tsx
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { useQuery as zeroUseQuery } from '@rocicorp/zero/react'
|
|
2
|
-
import {
|
|
2
|
+
import { useEmitterValue, type Emitter } from '@take-out/helpers'
|
|
3
|
+
import {
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useSyncExternalStore,
|
|
9
|
+
type Context,
|
|
10
|
+
} from 'react'
|
|
3
11
|
|
|
4
12
|
import { useZeroDebug } from './helpers/useZeroDebug'
|
|
5
13
|
import { resolveQuery, type PlainQueryFn } from './resolveQuery'
|
|
@@ -41,6 +49,21 @@ export type UseQueryHook<Schema extends ZeroSchema> = {
|
|
|
41
49
|
|
|
42
50
|
const EMPTY_RESPONSE = [null, { type: 'unknown' }] as never
|
|
43
51
|
|
|
52
|
+
// determine if useQuery-style args are (fn, params, options) or (fn, options)
|
|
53
|
+
function parseUseQueryArgs(paramsOrOptions: any, optionsArg: any) {
|
|
54
|
+
const hasParams =
|
|
55
|
+
optionsArg !== undefined ||
|
|
56
|
+
(paramsOrOptions &&
|
|
57
|
+
typeof paramsOrOptions === 'object' &&
|
|
58
|
+
!('enabled' in paramsOrOptions) &&
|
|
59
|
+
!('ttl' in paramsOrOptions))
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
params: hasParams ? paramsOrOptions : undefined,
|
|
63
|
+
options: hasParams ? optionsArg : paramsOrOptions,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
44
67
|
export function createUseQuery<Schema extends ZeroSchema>({
|
|
45
68
|
DisabledContext,
|
|
46
69
|
customQueries,
|
|
@@ -54,17 +77,7 @@ export function createUseQuery<Schema extends ZeroSchema>({
|
|
|
54
77
|
const [fn, paramsOrOptions, optionsArg] = args
|
|
55
78
|
|
|
56
79
|
const { queryRequest, options } = useMemo(() => {
|
|
57
|
-
|
|
58
|
-
const hasParams =
|
|
59
|
-
optionsArg !== undefined ||
|
|
60
|
-
(paramsOrOptions &&
|
|
61
|
-
typeof paramsOrOptions === 'object' &&
|
|
62
|
-
!('enabled' in paramsOrOptions) &&
|
|
63
|
-
!('ttl' in paramsOrOptions))
|
|
64
|
-
|
|
65
|
-
const params = hasParams ? paramsOrOptions : undefined
|
|
66
|
-
const opts = hasParams ? optionsArg : paramsOrOptions
|
|
67
|
-
|
|
80
|
+
const { params, options: opts } = parseUseQueryArgs(paramsOrOptions, optionsArg)
|
|
68
81
|
const queryRequest = resolveQuery({ customQueries, fn, params })
|
|
69
82
|
|
|
70
83
|
return { queryRequest, options: opts }
|
|
@@ -92,3 +105,153 @@ export function createUseQuery<Schema extends ZeroSchema>({
|
|
|
92
105
|
|
|
93
106
|
return useQuery as UseQueryHook<Schema>
|
|
94
107
|
}
|
|
108
|
+
|
|
109
|
+
// --- direct (context-free) query path -------------------------------------
|
|
110
|
+
|
|
111
|
+
// zero-react's useQuery resolves its zero instance via useZero() → react
|
|
112
|
+
// context, i.e. the NEAREST mounted ZeroProvider. with multiple client
|
|
113
|
+
// instances nested on one page, every context-path query would materialize
|
|
114
|
+
// against the innermost provider's instance regardless of which instance owns
|
|
115
|
+
// the query. this hook is the context-free counterpart: it materializes the
|
|
116
|
+
// resolved query directly on the owning instance's mounted zero.
|
|
117
|
+
//
|
|
118
|
+
// tradeoffs vs the context path (acceptable for the bounded, tiny queries the
|
|
119
|
+
// non-inner instances own — see combineZeroClients):
|
|
120
|
+
// - no cross-component view dedup (zero-react's viewStore): each hook
|
|
121
|
+
// instance materializes its own view.
|
|
122
|
+
// - no strict-mode double-render protection: a discarded render's view is
|
|
123
|
+
// not destroyed until the owning zero closes.
|
|
124
|
+
|
|
125
|
+
const DISABLED_SUBSCRIBE = () => () => {}
|
|
126
|
+
// upstream zero-react returns [undefined, {type:'unknown'}] when disabled
|
|
127
|
+
const DISABLED_SNAPSHOT: readonly [undefined, { type: 'unknown' }] = [
|
|
128
|
+
undefined,
|
|
129
|
+
{ type: 'unknown' },
|
|
130
|
+
]
|
|
131
|
+
const getDisabledSnapshot = () => DISABLED_SNAPSHOT
|
|
132
|
+
|
|
133
|
+
type DirectView = {
|
|
134
|
+
subscribe: (notify: () => void) => () => void
|
|
135
|
+
getSnapshot: () => readonly [unknown, { type: string }]
|
|
136
|
+
destroy: () => void
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// method syntax for bivariant param checks — the real Zero['materialize']
|
|
140
|
+
// generic signature must remain assignable to this structural slice. ttl is
|
|
141
|
+
// untyped pass-through here, matching the context path (zeroUseQuery's
|
|
142
|
+
// options are equally unchecked against upstream's TTL type).
|
|
143
|
+
type MaterializableZero = {
|
|
144
|
+
materialize(
|
|
145
|
+
query: any,
|
|
146
|
+
options?: { ttl?: any },
|
|
147
|
+
): {
|
|
148
|
+
addListener(cb: (data: any, resultType: string) => void): void
|
|
149
|
+
destroy(): void
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function createDirectView(
|
|
154
|
+
zero: MaterializableZero,
|
|
155
|
+
queryRequest: unknown,
|
|
156
|
+
ttl: UseQueryOptions['ttl'],
|
|
157
|
+
): DirectView {
|
|
158
|
+
let snapshot: readonly [unknown, { type: string }] = DISABLED_SNAPSHOT
|
|
159
|
+
const listeners = new Set<() => void>()
|
|
160
|
+
|
|
161
|
+
// zero.materialize accepts a QueryRequest (it applies the instance context
|
|
162
|
+
// itself) — the same public api the upstream ViewWrapper uses
|
|
163
|
+
const view = zero.materialize(queryRequest, ttl === undefined ? undefined : { ttl })
|
|
164
|
+
|
|
165
|
+
// addListener fires immediately with current data, so the snapshot is
|
|
166
|
+
// populated before react's first getSnapshot. data must be cloned: the
|
|
167
|
+
// underlying view mutates it in place, and useSyncExternalStore requires
|
|
168
|
+
// immutable snapshots to detect changes.
|
|
169
|
+
view.addListener((data, resultType) => {
|
|
170
|
+
snapshot = [
|
|
171
|
+
data === undefined ? undefined : structuredClone(data),
|
|
172
|
+
{ type: resultType },
|
|
173
|
+
]
|
|
174
|
+
for (const listener of listeners) {
|
|
175
|
+
listener()
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
subscribe: (notify) => {
|
|
181
|
+
listeners.add(notify)
|
|
182
|
+
return () => listeners.delete(notify)
|
|
183
|
+
},
|
|
184
|
+
getSnapshot: () => snapshot,
|
|
185
|
+
destroy: () => view.destroy(),
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function createUseQueryDirect<Schema extends ZeroSchema>({
|
|
190
|
+
DisabledContext,
|
|
191
|
+
customQueries,
|
|
192
|
+
getZero,
|
|
193
|
+
zeroVersion,
|
|
194
|
+
}: {
|
|
195
|
+
DisabledContext: Context<QueryControlMode>
|
|
196
|
+
customQueries: AnyQueryRegistry
|
|
197
|
+
// the owning instance's mounted zero (null before its provider mounts)
|
|
198
|
+
getZero: () => MaterializableZero | null
|
|
199
|
+
// bumps after commit whenever the mounted zero changes (mount, rotation)
|
|
200
|
+
zeroVersion: Emitter<number>
|
|
201
|
+
}): UseQueryHook<Schema> {
|
|
202
|
+
function useQueryDirect(...args: any[]): any {
|
|
203
|
+
const disableMode = useContext(DisabledContext)
|
|
204
|
+
const lastRef = useRef<any>(EMPTY_RESPONSE)
|
|
205
|
+
const [fn, paramsOrOptions, optionsArg] = args
|
|
206
|
+
|
|
207
|
+
const version = useEmitterValue(zeroVersion)
|
|
208
|
+
const { params, options } = parseUseQueryArgs(paramsOrOptions, optionsArg)
|
|
209
|
+
|
|
210
|
+
let enabled = true
|
|
211
|
+
let ttl: UseQueryOptions['ttl']
|
|
212
|
+
if (typeof options === 'boolean') {
|
|
213
|
+
enabled = options
|
|
214
|
+
} else if (options) {
|
|
215
|
+
enabled = options.enabled !== false
|
|
216
|
+
ttl = options.ttl
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// key the view by VALUE (serialized params), not params object identity —
|
|
220
|
+
// there is no view dedup on this path, so an unstable inline params object
|
|
221
|
+
// must not re-materialize every render
|
|
222
|
+
const paramsKey = params === undefined ? '' : JSON.stringify(params)
|
|
223
|
+
|
|
224
|
+
const view = useMemo((): DirectView | null => {
|
|
225
|
+
const zero = getZero()
|
|
226
|
+
if (!enabled || !zero) return null
|
|
227
|
+
const queryRequest = resolveQuery({ customQueries, fn, params })
|
|
228
|
+
return createDirectView(zero, queryRequest, ttl)
|
|
229
|
+
// params is keyed by paramsKey; version re-materializes on a new zero
|
|
230
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
231
|
+
}, [fn, paramsKey, enabled, ttl, version])
|
|
232
|
+
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
if (!view) return
|
|
235
|
+
return () => view.destroy()
|
|
236
|
+
}, [view])
|
|
237
|
+
|
|
238
|
+
const out = useSyncExternalStore(
|
|
239
|
+
view ? view.subscribe : DISABLED_SUBSCRIBE,
|
|
240
|
+
view ? view.getSnapshot : getDisabledSnapshot,
|
|
241
|
+
view ? view.getSnapshot : getDisabledSnapshot,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if (!disableMode) {
|
|
245
|
+
lastRef.current = out
|
|
246
|
+
return out
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (disableMode === 'last-value') {
|
|
250
|
+
return lastRef.current
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return EMPTY_RESPONSE
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return useQueryDirect as UseQueryHook<Schema>
|
|
257
|
+
}
|
package/src/createZeroClient.tsx
CHANGED
|
@@ -13,7 +13,13 @@ import {
|
|
|
13
13
|
} from 'react'
|
|
14
14
|
|
|
15
15
|
import { createPermissions } from './createPermissions'
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
createUseQuery,
|
|
18
|
+
createUseQueryDirect,
|
|
19
|
+
type QueryControlMode,
|
|
20
|
+
type UseQueryHook,
|
|
21
|
+
} from './createUseQuery'
|
|
22
|
+
import { registerClientInstance } from './instanceRegistry'
|
|
17
23
|
import { createMutators } from './helpers/createMutators'
|
|
18
24
|
import { getAuth } from './helpers/getAuth'
|
|
19
25
|
import { getAllMutationsPermissions, getMutationsPermissions } from './modelRegistry'
|
|
@@ -22,7 +28,7 @@ import { resolveQuery, type PlainQueryFn } from './resolveQuery'
|
|
|
22
28
|
import { setCustomQueries } from './run'
|
|
23
29
|
import { getEnvironment, setAuthData, setEnvironment, setSchema } from './state'
|
|
24
30
|
import { getRawWhere, setEvaluatingPermission } from './where'
|
|
25
|
-
import { setRunner } from './zeroRunner'
|
|
31
|
+
import { setRunner, type ZeroRunner } from './zeroRunner'
|
|
26
32
|
import { zql } from './zql'
|
|
27
33
|
|
|
28
34
|
import type { AuthData, GenericModels, GetZeroMutators, ZeroEvent } from './types'
|
|
@@ -63,11 +69,16 @@ export function createZeroClient<
|
|
|
63
69
|
models,
|
|
64
70
|
groupedQueries,
|
|
65
71
|
permissionStrategy = 'optimistic',
|
|
72
|
+
instanceName = 'default',
|
|
66
73
|
}: {
|
|
67
74
|
schema: Schema
|
|
68
75
|
models: Models
|
|
69
76
|
groupedQueries: GroupedQueries
|
|
70
77
|
permissionStrategy?: PermissionStrategy
|
|
78
|
+
// names this client instance so multiple instances can coexist on one page.
|
|
79
|
+
// each query/mutator namespace is claimed by exactly one instance, and the
|
|
80
|
+
// ambient run() + the combineZeroClients facade dispatch by that claim.
|
|
81
|
+
instanceName?: string
|
|
71
82
|
}) {
|
|
72
83
|
type ZeroMutators = GetZeroMutators<Models>
|
|
73
84
|
type ZeroInstance = Zero<Schema, ZeroMutators>
|
|
@@ -177,6 +188,15 @@ export function createZeroClient<
|
|
|
177
188
|
// create the single shared CustomQuery registry
|
|
178
189
|
const customQueries = defineQueries(wrappedNamespaces)
|
|
179
190
|
|
|
191
|
+
// claim this instance's query/mutator namespaces so the ambient run() and
|
|
192
|
+
// the combineZeroClients facade dispatch to the owning instance. the
|
|
193
|
+
// auto-generated 'permission' namespace stays unclaimed (per-instance).
|
|
194
|
+
const instance = registerClientInstance({
|
|
195
|
+
name: instanceName,
|
|
196
|
+
namespaces: Object.keys({ ...groupedQueries, ...models }),
|
|
197
|
+
customQueries,
|
|
198
|
+
})
|
|
199
|
+
|
|
180
200
|
// register for global run() helper
|
|
181
201
|
setCustomQueries(customQueries)
|
|
182
202
|
|
|
@@ -197,7 +217,20 @@ export function createZeroClient<
|
|
|
197
217
|
},
|
|
198
218
|
})
|
|
199
219
|
|
|
200
|
-
|
|
220
|
+
// emitter names are global keys (dev hmr cache) — scope them per instance
|
|
221
|
+
// so two instances never share cached values. the default name stays
|
|
222
|
+
// unchanged for single-instance back-compat.
|
|
223
|
+
const emitterScope = instanceName === 'default' ? '' : `:${instanceName}`
|
|
224
|
+
|
|
225
|
+
const zeroEvents = createEmitter<ZeroEvent | null>(`zero${emitterScope}`, null)
|
|
226
|
+
|
|
227
|
+
// bumped after commit whenever the mounted zero instance changes (first
|
|
228
|
+
// mount, identity change, client-state-not-found rotation) — the direct
|
|
229
|
+
// query path re-materializes its views against the new instance
|
|
230
|
+
const zeroInstanceVersion = createEmitter<number>(
|
|
231
|
+
`zero-instance-version${emitterScope}`,
|
|
232
|
+
0,
|
|
233
|
+
)
|
|
201
234
|
|
|
202
235
|
const AuthDataContext = createContext<AuthData>({} as AuthData)
|
|
203
236
|
|
|
@@ -206,50 +239,66 @@ export function createZeroClient<
|
|
|
206
239
|
customQueries,
|
|
207
240
|
})
|
|
208
241
|
|
|
242
|
+
// context-free counterpart: materializes directly on THIS instance's
|
|
243
|
+
// mounted zero instead of the nearest ZeroProvider context. used by
|
|
244
|
+
// combineZeroClients for instances whose provider is not innermost.
|
|
245
|
+
const useQueryDirect = createUseQueryDirect<Schema>({
|
|
246
|
+
DisabledContext,
|
|
247
|
+
customQueries,
|
|
248
|
+
getZero: () => latestZeroInstance,
|
|
249
|
+
zeroVersion: zeroInstanceVersion,
|
|
250
|
+
})
|
|
251
|
+
|
|
209
252
|
// permission check uses a per-model synced query so server is authoritative
|
|
210
|
-
// permissionStrategy controls client behavior before server responds
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
253
|
+
// permissionStrategy controls client behavior before server responds.
|
|
254
|
+
// built over a query hook so the facade can route permission checks down
|
|
255
|
+
// the same context vs direct path as the table's other queries.
|
|
256
|
+
const createUsePermission = (useQueryImpl: UseQueryHook<Schema>) =>
|
|
257
|
+
function usePermission(
|
|
258
|
+
table: TableName | (string & {}),
|
|
259
|
+
objOrId: string | Partial<Row<any>> | undefined,
|
|
260
|
+
enabled = typeof objOrId !== 'undefined',
|
|
261
|
+
debug = false,
|
|
262
|
+
): boolean | null {
|
|
263
|
+
const disableMode = useContext(DisabledContext)
|
|
264
|
+
const lastRef = useRef<boolean | null>(null)
|
|
265
|
+
const tableStr = table as string
|
|
266
|
+
const checkFn = permissionCheckFns[tableStr]
|
|
267
|
+
|
|
268
|
+
// include auth user ID in query args so zero-cache creates per-user
|
|
269
|
+
// permission views (prevents dedup across different auth contexts)
|
|
270
|
+
const auth = getAuth()
|
|
271
|
+
const _uid = auth?.id || 'anon'
|
|
272
|
+
|
|
273
|
+
const [data, status] = useQueryImpl(
|
|
274
|
+
checkFn as any,
|
|
275
|
+
{ objOrId: objOrId as any, _uid },
|
|
276
|
+
{ enabled: Boolean(!disableMode && enabled && objOrId && checkFn) },
|
|
277
|
+
)
|
|
232
278
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
279
|
+
if (debug) {
|
|
280
|
+
console.info(`usePermission()`, { table, objOrId, data, status })
|
|
281
|
+
}
|
|
236
282
|
|
|
237
|
-
|
|
283
|
+
if (!objOrId) return false
|
|
238
284
|
|
|
239
|
-
|
|
240
|
-
|
|
285
|
+
// null while loading, then server's authoritative answer
|
|
286
|
+
const result = status.type === 'unknown' ? null : Boolean(data)
|
|
241
287
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
288
|
+
if (!disableMode) {
|
|
289
|
+
lastRef.current = result
|
|
290
|
+
return result
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (disableMode === 'last-value') {
|
|
294
|
+
return lastRef.current
|
|
295
|
+
}
|
|
246
296
|
|
|
247
|
-
|
|
248
|
-
return lastRef.current
|
|
297
|
+
return null
|
|
249
298
|
}
|
|
250
299
|
|
|
251
|
-
|
|
252
|
-
|
|
300
|
+
const usePermission = createUsePermission(useQuery)
|
|
301
|
+
const usePermissionDirect = createUsePermission(useQueryDirect)
|
|
253
302
|
|
|
254
303
|
// the zero instance lives OUTSIDE the react lifecycle. react destroys and
|
|
255
304
|
// re-fires the effects of a committed tree on any suspense hide/reveal, and
|
|
@@ -272,7 +321,10 @@ export function createZeroClient<
|
|
|
272
321
|
// callbacks coalesces into ONE rotation, re-armed when the fresh instance
|
|
273
322
|
// is created — without it a new instance that immediately re-fires the
|
|
274
323
|
// callback rotation-loops forever.
|
|
275
|
-
const zeroInstanceGeneration = createEmitter<number>(
|
|
324
|
+
const zeroInstanceGeneration = createEmitter<number>(
|
|
325
|
+
`zero-instance-generation${emitterScope}`,
|
|
326
|
+
0,
|
|
327
|
+
)
|
|
276
328
|
let zeroRotationPending = false
|
|
277
329
|
|
|
278
330
|
const ProvideZero = ({
|
|
@@ -420,10 +472,20 @@ export function createZeroClient<
|
|
|
420
472
|
// we'll need to add that soon
|
|
421
473
|
if (zeroInstance !== latestZeroInstance) {
|
|
422
474
|
latestZeroInstance = zeroInstance
|
|
423
|
-
|
|
424
|
-
|
|
475
|
+
const runner: ZeroRunner = (query, options) =>
|
|
476
|
+
zeroInstance.run(query as any, options)
|
|
477
|
+
// the instance-keyed runner is what run() dispatches owned namespaces
|
|
478
|
+
// to; the global runner stays as the ambient fallback (inline zql)
|
|
479
|
+
instance.runner = runner
|
|
480
|
+
setRunner(runner)
|
|
425
481
|
}
|
|
426
482
|
|
|
483
|
+
// notify direct-path views after commit (emitting during render would
|
|
484
|
+
// setState other components mid-render)
|
|
485
|
+
useEffect(() => {
|
|
486
|
+
zeroInstanceVersion.emit(zeroInstanceVersion.value + 1)
|
|
487
|
+
}, [zeroInstance])
|
|
488
|
+
|
|
427
489
|
return null
|
|
428
490
|
}
|
|
429
491
|
|
|
@@ -506,11 +568,14 @@ export function createZeroClient<
|
|
|
506
568
|
}
|
|
507
569
|
|
|
508
570
|
return {
|
|
571
|
+
instanceName,
|
|
509
572
|
zeroEvents,
|
|
510
573
|
ProvideZero,
|
|
511
574
|
ControlQueries,
|
|
512
575
|
useQuery,
|
|
576
|
+
useQueryDirect,
|
|
513
577
|
usePermission,
|
|
578
|
+
usePermissionDirect,
|
|
514
579
|
zero,
|
|
515
580
|
preload,
|
|
516
581
|
getQuery,
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from './helpers/useMutation'
|
|
|
8
8
|
export { ensureAuth, getAuth } from './helpers/getAuth'
|
|
9
9
|
export { setAuthData, setEnvironment } from './state'
|
|
10
10
|
|
|
11
|
+
export * from './combineZeroClients'
|
|
11
12
|
export * from './createZeroClient'
|
|
12
13
|
export * from './createUseQuery'
|
|
13
14
|
export * from './resolveQuery'
|