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.
Files changed (93) hide show
  1. package/dist/cjs/combineZeroClients.cjs +101 -0
  2. package/dist/cjs/combineZeroClients.native.js +150 -0
  3. package/dist/cjs/combineZeroClients.native.js.map +1 -0
  4. package/dist/cjs/createUseQuery.cjs +92 -4
  5. package/dist/cjs/createUseQuery.native.js +130 -4
  6. package/dist/cjs/createUseQuery.native.js.map +1 -1
  7. package/dist/cjs/createZeroClient.cjs +41 -13
  8. package/dist/cjs/createZeroClient.native.js +75 -43
  9. package/dist/cjs/createZeroClient.native.js.map +1 -1
  10. package/dist/cjs/index.cjs +1 -0
  11. package/dist/cjs/index.native.js +1 -0
  12. package/dist/cjs/index.native.js.map +1 -1
  13. package/dist/cjs/instanceRegistry.cjs +65 -0
  14. package/dist/cjs/instanceRegistry.native.js +111 -0
  15. package/dist/cjs/instanceRegistry.native.js.map +1 -0
  16. package/dist/cjs/multiInstance.test.cjs +322 -0
  17. package/dist/cjs/multiInstance.test.native.js +387 -0
  18. package/dist/cjs/multiInstance.test.native.js.map +1 -0
  19. package/dist/cjs/multiInstanceNested.test.cjs +206 -0
  20. package/dist/cjs/multiInstanceNested.test.native.js +254 -0
  21. package/dist/cjs/multiInstanceNested.test.native.js.map +1 -0
  22. package/dist/cjs/run.cjs +5 -5
  23. package/dist/cjs/run.native.js +6 -5
  24. package/dist/cjs/run.native.js.map +1 -1
  25. package/dist/cjs/zeroRunner.cjs +4 -1
  26. package/dist/cjs/zeroRunner.native.js +4 -1
  27. package/dist/cjs/zeroRunner.native.js.map +1 -1
  28. package/dist/esm/combineZeroClients.mjs +76 -0
  29. package/dist/esm/combineZeroClients.mjs.map +1 -0
  30. package/dist/esm/combineZeroClients.native.js +122 -0
  31. package/dist/esm/combineZeroClients.native.js.map +1 -0
  32. package/dist/esm/createUseQuery.mjs +92 -5
  33. package/dist/esm/createUseQuery.mjs.map +1 -1
  34. package/dist/esm/createUseQuery.native.js +130 -5
  35. package/dist/esm/createUseQuery.native.js.map +1 -1
  36. package/dist/esm/createZeroClient.mjs +42 -14
  37. package/dist/esm/createZeroClient.mjs.map +1 -1
  38. package/dist/esm/createZeroClient.native.js +76 -44
  39. package/dist/esm/createZeroClient.native.js.map +1 -1
  40. package/dist/esm/index.js +1 -0
  41. package/dist/esm/index.js.map +1 -1
  42. package/dist/esm/index.mjs +1 -0
  43. package/dist/esm/index.mjs.map +1 -1
  44. package/dist/esm/index.native.js +1 -0
  45. package/dist/esm/index.native.js.map +1 -1
  46. package/dist/esm/instanceRegistry.mjs +38 -0
  47. package/dist/esm/instanceRegistry.mjs.map +1 -0
  48. package/dist/esm/instanceRegistry.native.js +81 -0
  49. package/dist/esm/instanceRegistry.native.js.map +1 -0
  50. package/dist/esm/multiInstance.test.mjs +323 -0
  51. package/dist/esm/multiInstance.test.mjs.map +1 -0
  52. package/dist/esm/multiInstance.test.native.js +385 -0
  53. package/dist/esm/multiInstance.test.native.js.map +1 -0
  54. package/dist/esm/multiInstanceNested.test.mjs +207 -0
  55. package/dist/esm/multiInstanceNested.test.mjs.map +1 -0
  56. package/dist/esm/multiInstanceNested.test.native.js +252 -0
  57. package/dist/esm/multiInstanceNested.test.native.js.map +1 -0
  58. package/dist/esm/run.mjs +5 -5
  59. package/dist/esm/run.mjs.map +1 -1
  60. package/dist/esm/run.native.js +6 -5
  61. package/dist/esm/run.native.js.map +1 -1
  62. package/dist/esm/zeroRunner.mjs +4 -1
  63. package/dist/esm/zeroRunner.mjs.map +1 -1
  64. package/dist/esm/zeroRunner.native.js +4 -1
  65. package/dist/esm/zeroRunner.native.js.map +1 -1
  66. package/package.json +5 -3
  67. package/readme.md +59 -0
  68. package/src/combineZeroClients.tsx +186 -0
  69. package/src/createUseQuery.tsx +175 -12
  70. package/src/createZeroClient.tsx +107 -42
  71. package/src/index.ts +1 -0
  72. package/src/instanceRegistry.ts +75 -0
  73. package/src/multiInstance.test.tsx +284 -0
  74. package/src/multiInstanceNested.test.tsx +205 -0
  75. package/src/run.ts +7 -6
  76. package/src/zeroRunner.ts +7 -1
  77. package/types/combineZeroClients.d.ts +38 -0
  78. package/types/combineZeroClients.d.ts.map +1 -0
  79. package/types/createUseQuery.d.ts +15 -0
  80. package/types/createUseQuery.d.ts.map +1 -1
  81. package/types/createZeroClient.d.ts +7 -2
  82. package/types/createZeroClient.d.ts.map +1 -1
  83. package/types/index.d.ts +1 -0
  84. package/types/index.d.ts.map +1 -1
  85. package/types/instanceRegistry.d.ts +15 -0
  86. package/types/instanceRegistry.d.ts.map +1 -0
  87. package/types/multiInstance.test.d.ts +2 -0
  88. package/types/multiInstance.test.d.ts.map +1 -0
  89. package/types/multiInstanceNested.test.d.ts +5 -0
  90. package/types/multiInstanceNested.test.d.ts.map +1 -0
  91. package/types/run.d.ts.map +1 -1
  92. package/types/zeroRunner.d.ts +3 -1
  93. package/types/zeroRunner.d.ts.map +1 -1
@@ -0,0 +1,75 @@
1
+ // registry mapping query/mutator namespaces to the zero client instance that
2
+ // owns them. enables multiple createZeroClient instances on one page (e.g. a
3
+ // control-plane instance + a per-project instance): the ambient run() and the
4
+ // combineZeroClients facade dispatch each query/mutation to the owning
5
+ // instance by its registered namespace instead of whichever mounted last.
6
+ //
7
+ // stored via globalValue so dual-loaded module copies (cjs/esm) share one map,
8
+ // matching the package's other cross-module registries.
9
+
10
+ import { globalValue } from '@take-out/helpers'
11
+
12
+ import { getQueryName } from './queryRegistry'
13
+
14
+ import type { ZeroRunner } from './zeroRunner'
15
+ import type { AnyQueryRegistry } from '@rocicorp/zero'
16
+
17
+ export type ZeroClientInstance = {
18
+ name: string
19
+ customQueries: AnyQueryRegistry
20
+ // set when the instance's provider mounts (client); stays null on the
21
+ // server, where the ambient transaction runner serves every instance
22
+ runner: ZeroRunner | null
23
+ }
24
+
25
+ const getInstancesByNamespace = () =>
26
+ globalValue<Map<string, ZeroClientInstance>>(
27
+ 'on-zero:instances-by-namespace',
28
+ () => new Map(),
29
+ )
30
+
31
+ export function registerClientInstance({
32
+ name,
33
+ namespaces,
34
+ customQueries,
35
+ }: {
36
+ name: string
37
+ namespaces: string[]
38
+ customQueries: AnyQueryRegistry
39
+ }): ZeroClientInstance {
40
+ const instancesByNamespace = getInstancesByNamespace()
41
+
42
+ // re-creating an instance under the same name (hmr) replaces its claims
43
+ for (const [namespace, owner] of instancesByNamespace) {
44
+ if (owner.name === name) {
45
+ instancesByNamespace.delete(namespace)
46
+ }
47
+ }
48
+
49
+ const instance: ZeroClientInstance = { name, customQueries, runner: null }
50
+
51
+ for (const namespace of namespaces) {
52
+ const existing = instancesByNamespace.get(namespace)
53
+ if (existing) {
54
+ throw new Error(
55
+ `[on-zero] namespace '${namespace}' is already claimed by zero client instance '${existing.name}' ` +
56
+ `(while creating instance '${name}'). Each query/mutator namespace must belong to exactly one createZeroClient instance.`,
57
+ )
58
+ }
59
+ instancesByNamespace.set(namespace, instance)
60
+ }
61
+
62
+ return instance
63
+ }
64
+
65
+ export function getInstanceForNamespace(
66
+ namespace: string,
67
+ ): ZeroClientInstance | undefined {
68
+ return getInstancesByNamespace().get(namespace)
69
+ }
70
+
71
+ export function getInstanceForQueryFn(fn: Function): ZeroClientInstance | undefined {
72
+ const queryName = getQueryName(fn)
73
+ if (!queryName) return undefined
74
+ return getInstanceForNamespace(queryName.split('.', 1)[0])
75
+ }
@@ -0,0 +1,284 @@
1
+ import { createSchema, string, table } from '@rocicorp/zero'
2
+ import { createEmitter } from '@take-out/helpers'
3
+ import { describe, expect, test, vi } from 'vitest'
4
+
5
+ import { combineZeroClients } from './combineZeroClients'
6
+ import { createZeroClient } from './createZeroClient'
7
+ import { getInstanceForNamespace, registerClientInstance } from './instanceRegistry'
8
+ import { registerQuery } from './queryRegistry'
9
+ import { run } from './run'
10
+ import { setRunner, type ZeroRunner } from './zeroRunner'
11
+
12
+ import type { ZeroEvent } from './types'
13
+ import type { AnyQueryRegistry, Query } from '@rocicorp/zero'
14
+ import type { ReactNode } from 'react'
15
+
16
+ const userTable = table('user').columns({ id: string(), name: string() }).primaryKey('id')
17
+ const taskTable = table('task')
18
+ .columns({ id: string(), title: string() })
19
+ .primaryKey('id')
20
+ const schema = createSchema({ tables: [userTable, taskTable] })
21
+
22
+ // query fns are never invoked in these tests (runners are mocked, and
23
+ // defineQueries resolves to lazy QueryRequests), so the body is a stub
24
+ const makeQueryFn = () => (args: { id: string }) =>
25
+ args as unknown as Query<'user', typeof schema>
26
+
27
+ // each test uses unique instance names + namespaces — the instance registry
28
+ // is module-global and shared across tests in this file
29
+ function makeClient(instanceName: string, namespace: string) {
30
+ const byId = makeQueryFn()
31
+ return {
32
+ byId,
33
+ client: createZeroClient({
34
+ schema,
35
+ models: {},
36
+ groupedQueries: { [namespace]: { byId } },
37
+ instanceName,
38
+ }),
39
+ }
40
+ }
41
+
42
+ // a structural stand-in for a createZeroClient return value, registered in
43
+ // the instance registry so the facade can resolve namespace ownership.
44
+ // real clients can't be used for mutate/useQuery dispatch tests because
45
+ // their zero proxy requires a mounted ZeroProvider.
46
+ function fakeClient(name: string, namespaces: string[], zeroStub: unknown) {
47
+ registerClientInstance({
48
+ name,
49
+ namespaces,
50
+ // boundary stub: dispatch tests never resolve queries through it
51
+ customQueries: {} as AnyQueryRegistry,
52
+ })
53
+ return {
54
+ instanceName: name,
55
+ useQuery: vi.fn(() => `${name}-useQuery`),
56
+ useQueryDirect: vi.fn(() => `${name}-useQueryDirect`),
57
+ usePermission: vi.fn(() => `${name}-usePermission`),
58
+ usePermissionDirect: vi.fn(() => `${name}-usePermissionDirect`),
59
+ zero: zeroStub,
60
+ preload: vi.fn(() => `${name}-preload`),
61
+ getQuery: vi.fn(() => `${name}-getQuery`),
62
+ zeroEvents: createEmitter<ZeroEvent | null>(`zero:test-${name}`, null),
63
+ ControlQueries: ({ children }: { children: ReactNode }) => children,
64
+ }
65
+ }
66
+
67
+ describe('multi-instance namespace dispatch', () => {
68
+ test('run() dispatches named queries to the owning instance runner', async () => {
69
+ const control = makeClient('run-control', 'runUser')
70
+ const project = makeClient('run-project', 'runTask')
71
+
72
+ // simulate each instance's provider mount (SetZeroInstance)
73
+ const controlRunner = vi.fn(async (..._args: unknown[]) => ({ from: 'control' }))
74
+ const projectRunner = vi.fn(async (..._args: unknown[]) => ({ from: 'project' }))
75
+ getInstanceForNamespace('runUser')!.runner = controlRunner as ZeroRunner
76
+ getInstanceForNamespace('runTask')!.runner = projectRunner as ZeroRunner
77
+
78
+ // the last mount also claims the ambient runner — owned namespaces must
79
+ // not use it (this is the "second mount steals run()" bug)
80
+ const ambientRunner = vi.fn(async (..._args: unknown[]) => ({ from: 'ambient' }))
81
+ setRunner(ambientRunner as ZeroRunner)
82
+
83
+ await expect(run(control.byId, { id: '1' })).resolves.toEqual({ from: 'control' })
84
+ await expect(run(project.byId, { id: '2' })).resolves.toEqual({ from: 'project' })
85
+ expect(ambientRunner).not.toHaveBeenCalled()
86
+
87
+ // the request resolved through the owning instance's own query registry
88
+ const request = controlRunner.mock.calls[0]![0] as { query: { queryName: string } }
89
+ expect(request.query.queryName).toBe('runUser.byId')
90
+ })
91
+
92
+ test('a claimed namespace with an unmounted instance uses the ambient runner (server path)', async () => {
93
+ const { byId } = makeClient('srv-instance', 'srvThing')
94
+ const ambient = vi.fn(async (..._args: unknown[]) => ({ from: 'ambient' }))
95
+ setRunner(ambient as ZeroRunner)
96
+
97
+ await expect(run(byId, { id: '1' })).resolves.toEqual({ from: 'ambient' })
98
+ expect(ambient).toHaveBeenCalledTimes(1)
99
+ })
100
+
101
+ test('duplicate namespace claim throws at create time', () => {
102
+ makeClient('dup-a', 'dupNs')
103
+ expect(() => makeClient('dup-b', 'dupNs')).toThrow(/already claimed/)
104
+ })
105
+
106
+ test('re-creating an instance under the same name re-claims without throwing (hmr)', () => {
107
+ makeClient('hmr', 'hmrNs')
108
+ expect(() => makeClient('hmr', 'hmrNs')).not.toThrow()
109
+ expect(getInstanceForNamespace('hmrNs')?.name).toBe('hmr')
110
+ })
111
+
112
+ test('model namespaces are claimed too', () => {
113
+ const models = { mdlThing: { mutate: { insert: async () => {} } } }
114
+ createZeroClient({
115
+ schema,
116
+ models,
117
+ groupedQueries: {},
118
+ instanceName: 'mdl-a',
119
+ })
120
+ expect(getInstanceForNamespace('mdlThing')?.name).toBe('mdl-a')
121
+ expect(() =>
122
+ createZeroClient({
123
+ schema,
124
+ models,
125
+ groupedQueries: {},
126
+ instanceName: 'mdl-b',
127
+ }),
128
+ ).toThrow(/already claimed/)
129
+ })
130
+ })
131
+
132
+ describe('multi-instance isolation', () => {
133
+ test('each instance has an isolated zeroEvents emitter', () => {
134
+ const { client: a } = makeClient('emit-a', 'emitA')
135
+ const { client: b } = makeClient('emit-b', 'emitB')
136
+
137
+ expect(a.zeroEvents).not.toBe(b.zeroEvents)
138
+ expect(a.zeroEvents.options?.name).toBe('zero:emit-a')
139
+ expect(b.zeroEvents.options?.name).toBe('zero:emit-b')
140
+
141
+ const seenByB: Array<ZeroEvent | null> = []
142
+ b.zeroEvents.listen((event) => seenByB.push(event))
143
+ a.zeroEvents.emit({ type: 'error', message: 'a-only' })
144
+
145
+ expect(seenByB).toEqual([])
146
+ expect(a.zeroEvents.value).toEqual({ type: 'error', message: 'a-only' })
147
+ expect(b.zeroEvents.value).toBe(null)
148
+ })
149
+
150
+ test('the default instance keeps the legacy emitter name', () => {
151
+ const { client } = makeClient('default', 'defaultNs')
152
+ expect(client.zeroEvents.options?.name).toBe('zero')
153
+ })
154
+
155
+ test('each instance has its own unmounted zero proxy', () => {
156
+ const { client: a } = makeClient('proxy-a', 'proxyA')
157
+ const { client: b } = makeClient('proxy-b', 'proxyB')
158
+ // identity check without handing the proxy to expect() — vitest's
159
+ // thenable detection would access .then and trip the unmounted throw
160
+ expect(a.zero === (b.zero as unknown)).toBe(false)
161
+ // neither provider is mounted; each proxy throws its own error rather
162
+ // than resolving against some shared/global instance
163
+ expect(() => a.zero.clientID).toThrow(/not initialized/)
164
+ expect(() => b.zero.clientID).toThrow(/not initialized/)
165
+ })
166
+ })
167
+
168
+ describe('combineZeroClients facade', () => {
169
+ test('zero.mutate dispatches by model namespace, rest forwards to primary', () => {
170
+ const insertSpy = vi.fn()
171
+ const updateSpy = vi.fn()
172
+ const control = fakeClient('fz-control', ['fzUser'], {
173
+ mutate: { fzUser: { insert: insertSpy } },
174
+ userID: 'primary-user',
175
+ })
176
+ const project = fakeClient('fz-project', ['fzTask'], {
177
+ mutate: { fzTask: { update: updateSpy } },
178
+ userID: 'project-user',
179
+ })
180
+
181
+ const combined = combineZeroClients(control, project)
182
+ const zero = combined.zero as Record<string, any>
183
+
184
+ zero.mutate.fzUser.insert({ id: '1' })
185
+ zero.mutate.fzTask.update({ id: '2' })
186
+
187
+ expect(insertSpy).toHaveBeenCalledWith({ id: '1' })
188
+ expect(updateSpy).toHaveBeenCalledWith({ id: '2' })
189
+ expect(zero.userID).toBe('primary-user')
190
+ // unclaimed namespaces fall back to the primary instance
191
+ expect(zero.mutate.unknownNs).toBe(undefined)
192
+ })
193
+
194
+ test('useQuery/preload/getQuery/usePermission dispatch by namespace', () => {
195
+ const control = fakeClient('fq-control', ['fqUser'], {})
196
+ const project = fakeClient('fq-project', ['fqTask'], {})
197
+ const userQuery = makeQueryFn()
198
+ const taskQuery = makeQueryFn()
199
+ registerQuery(userQuery, 'fqUser.byId')
200
+ registerQuery(taskQuery, 'fqTask.byId')
201
+
202
+ // project is last → inner: its hooks ride the upstream context path;
203
+ // control is outer → its hooks go through the context-free direct path
204
+ const combined = combineZeroClients(control, project)
205
+ const useQuery = combined.useQuery as (...args: any[]) => any
206
+ const preload = combined.preload as (...args: any[]) => any
207
+ const getQuery = combined.getQuery as (...args: any[]) => any
208
+ const usePermission = combined.usePermission as (...args: any[]) => any
209
+
210
+ useQuery(userQuery, { id: '1' })
211
+ expect(control.useQueryDirect).toHaveBeenCalledWith(userQuery, { id: '1' })
212
+ expect(control.useQuery).not.toHaveBeenCalled()
213
+ expect(project.useQuery).not.toHaveBeenCalled()
214
+
215
+ useQuery(taskQuery, { id: '2' })
216
+ expect(project.useQuery).toHaveBeenCalledWith(taskQuery, { id: '2' })
217
+ expect(project.useQueryDirect).not.toHaveBeenCalled()
218
+
219
+ preload(taskQuery, { id: '3' })
220
+ expect(project.preload).toHaveBeenCalledWith(taskQuery, { id: '3' })
221
+ expect(control.preload).not.toHaveBeenCalled()
222
+
223
+ getQuery(userQuery, { id: '4' })
224
+ expect(control.getQuery).toHaveBeenCalledWith(userQuery, { id: '4' })
225
+ expect(project.getQuery).not.toHaveBeenCalled()
226
+
227
+ // unregistered query fns fall back to the primary instance (outer → direct)
228
+ const anonymous = makeQueryFn()
229
+ useQuery(anonymous, { id: '5' })
230
+ expect(control.useQueryDirect).toHaveBeenCalledWith(anonymous, { id: '5' })
231
+
232
+ // usePermission dispatches by table-named model namespace and follows the
233
+ // same context vs direct split: inner table → context, outer table → direct
234
+ usePermission('fqTask', 'row-1')
235
+ expect(project.usePermission).toHaveBeenCalledWith('fqTask', 'row-1')
236
+ expect(project.usePermissionDirect).not.toHaveBeenCalled()
237
+
238
+ usePermission('fqUser', 'row-2')
239
+ expect(control.usePermissionDirect).toHaveBeenCalledWith('fqUser', 'row-2')
240
+ expect(control.usePermission).not.toHaveBeenCalled()
241
+ })
242
+
243
+ test('the inner option overrides which client uses the context path', () => {
244
+ const a = fakeClient('inner-a', ['innerA'], {})
245
+ const b = fakeClient('inner-b', ['innerB'], {})
246
+ const aQuery = makeQueryFn()
247
+ const bQuery = makeQueryFn()
248
+ registerQuery(aQuery, 'innerA.byId')
249
+ registerQuery(bQuery, 'innerB.byId')
250
+
251
+ // a is FIRST (primary) but declared inner → context path; b becomes direct
252
+ const combined = combineZeroClients(a, b, { inner: 'inner-a' })
253
+ const useQuery = combined.useQuery as (...args: any[]) => any
254
+
255
+ useQuery(aQuery, { id: '1' })
256
+ expect(a.useQuery).toHaveBeenCalledWith(aQuery, { id: '1' })
257
+ expect(a.useQueryDirect).not.toHaveBeenCalled()
258
+
259
+ useQuery(bQuery, { id: '2' })
260
+ expect(b.useQueryDirect).toHaveBeenCalledWith(bQuery, { id: '2' })
261
+ expect(b.useQuery).not.toHaveBeenCalled()
262
+
263
+ expect(() => combineZeroClients(a, b, { inner: 'nope' })).toThrow(
264
+ /not one of the passed clients/,
265
+ )
266
+ })
267
+
268
+ test('combined zeroEvents relays every instance', () => {
269
+ const { client: a } = makeClient('relay-a', 'relayA')
270
+ const { client: b } = makeClient('relay-b', 'relayB')
271
+
272
+ const combined = combineZeroClients(a, b)
273
+ const seen: Array<ZeroEvent | null> = []
274
+ combined.zeroEvents.listen((event) => seen.push(event))
275
+
276
+ a.zeroEvents.emit({ type: 'error', message: 'from-a' })
277
+ b.zeroEvents.emit({ type: 'error', message: 'from-b' })
278
+
279
+ expect(seen.map((event) => event?.message)).toEqual(['from-a', 'from-b'])
280
+ // the source emitters stay independent of each other
281
+ expect(a.zeroEvents.value?.message).toBe('from-a')
282
+ expect(b.zeroEvents.value?.message).toBe('from-b')
283
+ })
284
+ })
@@ -0,0 +1,205 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // real nested-provider tests for combineZeroClients: zero-react's useQuery
4
+ // resolves its zero instance from the NEAREST ZeroProvider context, so under
5
+ // nested providers an outer instance's queries must NOT ride the context
6
+ // path — they must materialize directly on the owning instance.
7
+
8
+ import { createSchema, string, table } from '@rocicorp/zero'
9
+ import { act } from 'react'
10
+ import { createRoot, type Root } from 'react-dom/client'
11
+ import { afterEach, beforeEach, expect, test } from 'vitest'
12
+
13
+ import { combineZeroClients } from './combineZeroClients'
14
+ import { createZeroClient } from './createZeroClient'
15
+ import { zql } from './zql'
16
+
17
+ import type { MutatorContext } from './types'
18
+ import type { ReactNode } from 'react'
19
+
20
+ declare global {
21
+ // eslint-disable-next-line no-var
22
+ var IS_REACT_ACT_ENVIRONMENT: boolean | undefined
23
+ }
24
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
25
+
26
+ const userTable = table('user').columns({ id: string(), name: string() }).primaryKey('id')
27
+ const taskTable = table('task')
28
+ .columns({ id: string(), title: string() })
29
+ .primaryKey('id')
30
+ const schema = createSchema({ tables: [userTable, taskTable] })
31
+
32
+ type UserRow = { id: string; name: string }
33
+
34
+ // control = OUTER/primary instance — owns the 'user' model namespace and the
35
+ // 'ctlUser' query namespace
36
+ const controlModels = {
37
+ user: {
38
+ mutate: {
39
+ seed: async (ctx: MutatorContext, row?: UserRow) => {
40
+ await (ctx.tx.mutate as any).user.upsert(row)
41
+ },
42
+ },
43
+ },
44
+ }
45
+ const ctlUserById = (args: { id: string }) => (zql as any).user.where('id', args.id).one()
46
+
47
+ const control = createZeroClient({
48
+ schema,
49
+ models: controlModels,
50
+ groupedQueries: { ctlUser: { byId: ctlUserById } },
51
+ instanceName: 'nested-control',
52
+ })
53
+
54
+ // project = INNER instance — its own store. its seed mutator writes the same
55
+ // user TABLE (tables are physical, only namespaces are claims) so the two
56
+ // stores can hold conflicting rows for the same id.
57
+ const projectModels = {
58
+ prjSeed: {
59
+ mutate: {
60
+ seedUser: async (ctx: MutatorContext, row?: UserRow) => {
61
+ await (ctx.tx.mutate as any).user.upsert(row)
62
+ },
63
+ },
64
+ },
65
+ }
66
+ const prjTaskById = (args: { id: string }) => (zql as any).task.where('id', args.id).one()
67
+
68
+ const project = createZeroClient({
69
+ schema,
70
+ models: projectModels,
71
+ groupedQueries: { prjTask: { byId: prjTaskById } },
72
+ instanceName: 'nested-project',
73
+ })
74
+
75
+ // default contract: last argument = inner provider
76
+ const combined = combineZeroClients(control, project)
77
+
78
+ let root: Root
79
+ let container: HTMLElement
80
+
81
+ beforeEach(() => {
82
+ resetProbe()
83
+ container = document.createElement('div')
84
+ document.body.appendChild(container)
85
+ root = createRoot(container)
86
+ })
87
+
88
+ afterEach(async () => {
89
+ await act(async () => root.unmount())
90
+ container.remove()
91
+ })
92
+
93
+ const render = (ui: ReactNode) => act(async () => root.render(ui))
94
+
95
+ async function waitFor(condition: () => boolean, what: string) {
96
+ for (let i = 0; i < 200; i++) {
97
+ if (condition()) return
98
+ await act(async () => {
99
+ await new Promise((resolve) => setTimeout(resolve, 5))
100
+ })
101
+ }
102
+ throw new Error(`timed out waiting for ${what}`)
103
+ }
104
+
105
+ const probe: { data: UserRow | undefined; renders: number } = {
106
+ data: undefined,
107
+ renders: 0,
108
+ }
109
+
110
+ // reset via helper so the assignments don't narrow probe.data to undefined
111
+ // in test scope
112
+ function resetProbe() {
113
+ probe.data = undefined
114
+ probe.renders = 0
115
+ }
116
+
117
+ function ControlUserProbe({ id }: { id: string }) {
118
+ const [data] = (combined.useQuery as any)(ctlUserById, { id })
119
+ probe.data = data ?? undefined
120
+ probe.renders++
121
+ return null
122
+ }
123
+
124
+ const seedControl = async (row: UserRow) => {
125
+ await act(async () => {
126
+ await (control.zero.mutate as any).user.seed(row).client
127
+ })
128
+ }
129
+
130
+ const seedProject = async (row: UserRow) => {
131
+ await act(async () => {
132
+ await (project.zero.mutate as any).prjSeed.seedUser(row).client
133
+ })
134
+ }
135
+
136
+ test('outer-instance query reads the OUTER store under nested providers', async () => {
137
+ await render(
138
+ <control.ProvideZero server={null} userID="t1-ctl">
139
+ <project.ProvideZero server={null} userID="t1-prj">
140
+ <ControlUserProbe id="u1" />
141
+ </project.ProvideZero>
142
+ </control.ProvideZero>,
143
+ )
144
+ await waitFor(() => probe.renders > 0, 'probe mount')
145
+
146
+ // same row id, conflicting values per store — whichever store the view
147
+ // materialized against decides the rendered name
148
+ await seedControl({ id: 'u1', name: 'from-control' })
149
+ await seedProject({ id: 'u1', name: 'from-project' })
150
+
151
+ await waitFor(() => probe.data !== undefined, 'control row visible')
152
+ expect(probe.data?.name).toBe('from-control')
153
+ })
154
+
155
+ test('inner provider unmount/remount does not break outer-instance subscriptions', async () => {
156
+ const App = ({ showInner }: { showInner: boolean }) => (
157
+ <control.ProvideZero server={null} userID="t2-ctl">
158
+ {showInner ? (
159
+ <project.ProvideZero server={null} userID="t2-prj">
160
+ <ControlUserProbe id="u2" />
161
+ </project.ProvideZero>
162
+ ) : (
163
+ <ControlUserProbe id="u2" />
164
+ )}
165
+ </control.ProvideZero>
166
+ )
167
+
168
+ await render(<App showInner />)
169
+ await waitFor(() => probe.renders > 0, 'probe mount')
170
+
171
+ await seedControl({ id: 'u2', name: 'v1' })
172
+ await waitFor(() => probe.data?.name === 'v1', 'v1 visible')
173
+
174
+ // unmount the inner provider (probe remounts outside it)
175
+ await render(<App showInner={false} />)
176
+ await seedControl({ id: 'u2', name: 'v2' })
177
+ await waitFor(() => probe.data?.name === 'v2', 'v2 visible after inner unmount')
178
+
179
+ // remount the inner provider
180
+ await render(<App showInner />)
181
+ await seedControl({ id: 'u2', name: 'v3' })
182
+ await waitFor(() => probe.data?.name === 'v3', 'v3 visible after inner remount')
183
+ })
184
+
185
+ test('direct-path views re-materialize when the owning instance rotates', async () => {
186
+ const App = ({ userID }: { userID: string }) => (
187
+ <control.ProvideZero server={null} userID={userID}>
188
+ <ControlUserProbe id="u3" />
189
+ </control.ProvideZero>
190
+ )
191
+
192
+ await render(<App userID="rot-1" />)
193
+ await waitFor(() => probe.renders > 0, 'probe mount')
194
+
195
+ await seedControl({ id: 'u3', name: 'before-rotation' })
196
+ await waitFor(() => probe.data?.name === 'before-rotation', 'pre-rotation row')
197
+
198
+ // identity change rotates the mounted zero instance (new client group,
199
+ // fresh store) — the same mechanism a zeroInstanceGeneration bump uses
200
+ await render(<App userID="rot-2" />)
201
+ await waitFor(() => probe.data === undefined, 'view reset to the new empty store')
202
+
203
+ await seedControl({ id: 'u3', name: 'after-rotation' })
204
+ await waitFor(() => probe.data?.name === 'after-rotation', 'post-rotation row')
205
+ })
package/src/run.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { getInstanceForQueryFn } from './instanceRegistry'
1
2
  import { resolveQuery, type PlainQueryFn } from './resolveQuery'
2
3
  import { getRunner } from './zeroRunner'
3
4
 
@@ -62,7 +63,6 @@ export function run(
62
63
  const hasParams = modeArg !== undefined || (paramsOrMode && paramsOrMode !== 'complete')
63
64
  const params = hasParams ? paramsOrMode : undefined
64
65
  const mode = hasParams ? modeArg : paramsOrMode
65
- const runner = getRunner()
66
66
  const options =
67
67
  mode === 'complete'
68
68
  ? ({
@@ -72,13 +72,14 @@ export function run(
72
72
 
73
73
  if (queryOrFn && queryOrFn['ast']) {
74
74
  // inline zql - on client it only resolves against cache, on server fully
75
- return runner(queryOrFn, options)
75
+ return getRunner()(queryOrFn, options)
76
76
  }
77
77
 
78
- const customQueries = getCustomQueries()
78
+ // with multiple client instances mounted, a named query executes against
79
+ // the instance that claimed its namespace, not whichever mounted last
80
+ const instance = getInstanceForQueryFn(queryOrFn)
81
+ const customQueries = instance?.customQueries ?? getCustomQueries()
79
82
  const queryRequest = resolveQuery({ customQueries, fn: queryOrFn, params })
80
83
 
81
- const out = runner(queryRequest as any, options)
82
-
83
- return out
84
+ return getRunner(instance)(queryRequest as any, options)
84
85
  }
package/src/zeroRunner.ts CHANGED
@@ -20,11 +20,17 @@ export function setRunner(r: ZeroRunner) {
20
20
  runner = r
21
21
  }
22
22
 
23
- export function getRunner(): ZeroRunner {
23
+ export function getRunner(instance?: { runner: ZeroRunner | null }): ZeroRunner {
24
24
  if (isInZeroMutation()) {
25
25
  return (q, o) => mutatorContext().tx.run(q, o)
26
26
  }
27
27
 
28
+ // a mounted instance's own runner wins; otherwise the ambient runner
29
+ // (single-instance client, or the server transaction runner)
30
+ if (instance?.runner) {
31
+ return instance.runner
32
+ }
33
+
28
34
  if (!runner) {
29
35
  throw new Error(
30
36
  'Zero runner not initialized. Ensure ProvideZero is mounted (client) or createZeroServer is called (server).',
@@ -0,0 +1,38 @@
1
+ import { type Emitter } from '@take-out/helpers';
2
+ import { run } from './run';
3
+ import type { ZeroEvent } from './types';
4
+ import type { ReactNode } from 'react';
5
+ type ControlQueriesProps = {
6
+ children: ReactNode;
7
+ action?: 'enable' | 'disable';
8
+ whenDisabled?: 'empty' | 'last-value';
9
+ };
10
+ type CombinableZeroClient = {
11
+ instanceName: string;
12
+ useQuery: (...args: any[]) => any;
13
+ useQueryDirect: (...args: any[]) => any;
14
+ usePermission: (...args: any[]) => any;
15
+ usePermissionDirect: (...args: any[]) => any;
16
+ zero: any;
17
+ preload: (...args: any[]) => any;
18
+ getQuery: (...args: any[]) => any;
19
+ zeroEvents: Emitter<ZeroEvent | null>;
20
+ ControlQueries: (props: ControlQueriesProps) => ReactNode;
21
+ };
22
+ export type CombineZeroClientsOptions = {
23
+ inner?: string;
24
+ };
25
+ type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never;
26
+ export type CombinedZeroClients<Clients extends readonly CombinableZeroClient[]> = {
27
+ useQuery: UnionToIntersection<Clients[number]['useQuery']>;
28
+ usePermission: UnionToIntersection<Clients[number]['usePermission']>;
29
+ zero: UnionToIntersection<Clients[number]['zero']>;
30
+ preload: UnionToIntersection<Clients[number]['preload']>;
31
+ getQuery: UnionToIntersection<Clients[number]['getQuery']>;
32
+ run: typeof run;
33
+ zeroEvents: Emitter<ZeroEvent | null>;
34
+ ControlQueries: (props: ControlQueriesProps) => ReactNode;
35
+ };
36
+ export declare function combineZeroClients<const Clients extends readonly [CombinableZeroClient, ...CombinableZeroClient[]]>(...clientsAndOptions: [...Clients] | [...Clients, CombineZeroClientsOptions]): CombinedZeroClients<Clients>;
37
+ export {};
38
+ //# sourceMappingURL=combineZeroClients.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"combineZeroClients.d.ts","sourceRoot":"","sources":["../src/combineZeroClients.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAA;AAG/D,OAAO,EAAE,GAAG,EAAE,MAAM,OAAO,CAAA;AAE3B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAA;AACxC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAsBtC,KAAK,mBAAmB,GAAG;IACzB,QAAQ,EAAE,SAAS,CAAA;IACnB,MAAM,CAAC,EAAE,QAAQ,GAAG,SAAS,CAAA;IAC7B,YAAY,CAAC,EAAE,OAAO,GAAG,YAAY,CAAA;CACtC,CAAA;AAID,KAAK,oBAAoB,GAAG;IAC1B,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,CAAA;IACjC,cAAc,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,CAAA;IACvC,aAAa,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,CAAA;IACtC,mBAAmB,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,CAAA;IAC5C,IAAI,EAAE,GAAG,CAAA;IACT,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,CAAA;IAChC,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,CAAA;IACjC,UAAU,EAAE,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAA;IACrC,cAAc,EAAE,CAAC,KAAK,EAAE,mBAAmB,KAAK,SAAS,CAAA;CAC1D,CAAA;AAED,MAAM,MAAM,yBAAyB,GAAG;IAGtC,KAAK,CAAC,EAAE,MAAM,CAAA;CACf,CAAA;AAED,KAAK,mBAAmB,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC,EAAE,CAAC,KAAK,IAAI,GAAG,KAAK,CAAC,SAAS,CAC7E,CAAC,EAAE,MAAM,CAAC,KACP,IAAI,GACL,CAAC,GACD,KAAK,CAAA;AAET,MAAM,MAAM,mBAAmB,CAAC,OAAO,SAAS,SAAS,oBAAoB,EAAE,IAAI;IACjF,QAAQ,EAAE,mBAAmB,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,UAAU,CAAC,CAAC,CAAA;IAC1D,aAAa,EAAE,mBAAmB,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,eAAe,CAAC,CAAC,CAAA;IACpE,IAAI,EAAE,mBAAmB,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,CAAA;IAClD,OAAO,EAAE,mBAAmB,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,CAAC,CAAA;IACxD,QAAQ,EAAE,mBAAmB,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,UAAU,CAAC,CAAC,CAAA;IAC1D,GAAG,EAAE,OAAO,GAAG,CAAA;IACf,UAAU,EAAE,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAA;IACrC,cAAc,EAAE,CAAC,KAAK,EAAE,mBAAmB,KAAK,SAAS,CAAA;CAC1D,CAAA;AAED,wBAAgB,kBAAkB,CAChC,KAAK,CAAC,OAAO,SAAS,SAAS,CAAC,oBAAoB,EAAE,GAAG,oBAAoB,EAAE,CAAC,EAEhF,GAAG,iBAAiB,EAAE,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,OAAO,EAAE,yBAAyB,CAAC,GAC3E,mBAAmB,CAAC,OAAO,CAAC,CA6G9B"}