on-zero 0.12.21 → 0.13.0

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 (203) hide show
  1. package/README.md +3 -0
  2. package/dist/cjs/cli.native.js.map +1 -1
  3. package/dist/cjs/combineZeroClients.native.js.map +1 -1
  4. package/dist/cjs/config.native.js.map +1 -1
  5. package/dist/cjs/createPermissions.native.js.map +1 -1
  6. package/dist/cjs/createSchemaFromDrizzle.native.js.map +1 -1
  7. package/dist/cjs/createUseQuery.native.js.map +1 -1
  8. package/dist/cjs/createUseQueryDirect.native.js.map +1 -1
  9. package/dist/cjs/createZeroClient.authData.test.native.js.map +1 -1
  10. package/dist/cjs/createZeroClient.cjs +24 -6
  11. package/dist/cjs/createZeroClient.connection.test.native.js.map +1 -1
  12. package/dist/cjs/createZeroClient.headless.test.cjs +55 -0
  13. package/dist/cjs/createZeroClient.headless.test.native.js +64 -0
  14. package/dist/cjs/createZeroClient.headless.test.native.js.map +1 -1
  15. package/dist/cjs/createZeroClient.native.js +22 -4
  16. package/dist/cjs/createZeroClient.native.js.map +1 -1
  17. package/dist/cjs/createZeroClient.recovery.test.native.js.map +1 -1
  18. package/dist/cjs/generate-helpers.cjs +17 -0
  19. package/dist/cjs/generate-helpers.native.js +23 -0
  20. package/dist/cjs/generate-helpers.native.js.map +1 -1
  21. package/dist/cjs/generate-helpers.test.native.js.map +1 -1
  22. package/dist/cjs/generate-layout.cjs +47 -4
  23. package/dist/cjs/generate-layout.native.js +191 -81
  24. package/dist/cjs/generate-layout.native.js.map +1 -1
  25. package/dist/cjs/generate-lite.cjs +25 -5
  26. package/dist/cjs/generate-lite.native.js +205 -163
  27. package/dist/cjs/generate-lite.native.js.map +1 -1
  28. package/dist/cjs/generate-lite.test.cjs +58 -0
  29. package/dist/cjs/generate-lite.test.native.js +59 -0
  30. package/dist/cjs/generate-lite.test.native.js.map +1 -1
  31. package/dist/cjs/generate.cjs +9 -1
  32. package/dist/cjs/generate.native.js +13 -1
  33. package/dist/cjs/generate.native.js.map +1 -1
  34. package/dist/cjs/generate.test.cjs +16 -0
  35. package/dist/cjs/generate.test.native.js +16 -0
  36. package/dist/cjs/generate.test.native.js.map +1 -1
  37. package/dist/cjs/helpers/NullToOptional.native.js.map +1 -1
  38. package/dist/cjs/helpers/asyncContext.hostStorage.test.native.js.map +1 -1
  39. package/dist/cjs/helpers/batchQuery.native.js.map +1 -1
  40. package/dist/cjs/helpers/clearZeroClientData.native.js.map +1 -1
  41. package/dist/cjs/helpers/createMutators.cjs +3 -3
  42. package/dist/cjs/helpers/createMutators.native.js +3 -3
  43. package/dist/cjs/helpers/createMutators.native.js.map +1 -1
  44. package/dist/cjs/helpers/createMutators.test.cjs +6 -6
  45. package/dist/cjs/helpers/createMutators.test.native.js +6 -6
  46. package/dist/cjs/helpers/createMutators.test.native.js.map +1 -1
  47. package/dist/cjs/helpers/ellipsis.native.js.map +1 -1
  48. package/dist/cjs/helpers/emitter.native.js.map +1 -1
  49. package/dist/cjs/helpers/ensure.native.js.map +1 -1
  50. package/dist/cjs/helpers/ensureLoggedIn.native.js.map +1 -1
  51. package/dist/cjs/helpers/errors.native.js.map +1 -1
  52. package/dist/cjs/helpers/getAuth.native.js.map +1 -1
  53. package/dist/cjs/helpers/getCurrentComponentStack.native.js.map +1 -1
  54. package/dist/cjs/helpers/globalValue.native.js.map +1 -1
  55. package/dist/cjs/helpers/hostStorageScope.native.js.map +1 -1
  56. package/dist/cjs/helpers/mapObject.native.js.map +1 -1
  57. package/dist/cjs/helpers/mutationLifecycle.native.js.map +1 -1
  58. package/dist/cjs/helpers/mutationLifecycle.test.native.js.map +1 -1
  59. package/dist/cjs/helpers/mutatorContext.native.js.map +1 -1
  60. package/dist/cjs/helpers/mutatorContext.test.native.js.map +1 -1
  61. package/dist/cjs/helpers/prettyFormatZeroQuery.native.js.map +1 -1
  62. package/dist/cjs/helpers/queryContext.native.js.map +1 -1
  63. package/dist/cjs/helpers/recoverZeroClient.cjs +27 -3
  64. package/dist/cjs/helpers/recoverZeroClient.native.js +28 -4
  65. package/dist/cjs/helpers/recoverZeroClient.native.js.map +1 -1
  66. package/dist/cjs/helpers/recoverZeroClient.test.cjs +32 -2
  67. package/dist/cjs/helpers/recoverZeroClient.test.native.js +38 -2
  68. package/dist/cjs/helpers/recoverZeroClient.test.native.js.map +1 -1
  69. package/dist/cjs/helpers/showZeroClientError.native.js.map +1 -1
  70. package/dist/cjs/helpers/sleep.native.js.map +1 -1
  71. package/dist/cjs/helpers/time.native.js.map +1 -1
  72. package/dist/cjs/helpers/tuple.native.js.map +1 -1
  73. package/dist/cjs/helpers/useMutation.cjs +27 -2
  74. package/dist/cjs/helpers/useMutation.native.js +71 -6
  75. package/dist/cjs/helpers/useMutation.native.js.map +1 -1
  76. package/dist/cjs/helpers/useMutation.test.cjs +16 -0
  77. package/dist/cjs/helpers/useMutation.test.native.js +18 -0
  78. package/dist/cjs/helpers/useMutation.test.native.js.map +1 -1
  79. package/dist/cjs/helpers/useZeroDebug.native.js.map +1 -1
  80. package/dist/cjs/index.native.js.map +1 -1
  81. package/dist/cjs/instanceRegistry.native.js.map +1 -1
  82. package/dist/cjs/modelRegistry.native.js.map +1 -1
  83. package/dist/cjs/multi.cjs +1 -1
  84. package/dist/cjs/multi.native.js +1 -1
  85. package/dist/cjs/multi.native.js.map +1 -1
  86. package/dist/cjs/multiInstance.test.native.js.map +1 -1
  87. package/dist/cjs/multiInstanceNested.test.native.js.map +1 -1
  88. package/dist/cjs/multiPartition.test.native.js.map +1 -1
  89. package/dist/cjs/mutations.native.js.map +1 -1
  90. package/dist/cjs/mutations.test.native.js.map +1 -1
  91. package/dist/cjs/provideZero.hydration.test.native.js.map +1 -1
  92. package/dist/cjs/queryRegistry.native.js.map +1 -1
  93. package/dist/cjs/resolveQuery.native.js.map +1 -1
  94. package/dist/cjs/run.native.js.map +1 -1
  95. package/dist/cjs/server.native.js.map +1 -1
  96. package/dist/cjs/server.test.native.js.map +1 -1
  97. package/dist/cjs/serverWhere.native.js.map +1 -1
  98. package/dist/cjs/serverWhere.test.native.js.map +1 -1
  99. package/dist/cjs/state.native.js.map +1 -1
  100. package/dist/cjs/streaming.native.js.map +1 -1
  101. package/dist/cjs/testSetup.native.js.map +1 -1
  102. package/dist/cjs/types.native.js.map +1 -1
  103. package/dist/cjs/usePermission.test.native.js.map +1 -1
  104. package/dist/cjs/useQuery.empty.test.native.js.map +1 -1
  105. package/dist/cjs/useQuery.ssr.test.native.js.map +1 -1
  106. package/dist/cjs/useStreamingField.loop.test.native.js.map +1 -1
  107. package/dist/cjs/useStreamingField.native.js.map +1 -1
  108. package/dist/cjs/vite-plugin.native.js.map +1 -1
  109. package/dist/cjs/where.native.js.map +1 -1
  110. package/dist/cjs/zeroRunner.native.js.map +1 -1
  111. package/dist/cjs/zql.native.js.map +1 -1
  112. package/dist/esm/createZeroClient.headless.test.mjs +55 -0
  113. package/dist/esm/createZeroClient.headless.test.mjs.map +1 -1
  114. package/dist/esm/createZeroClient.headless.test.native.js +64 -0
  115. package/dist/esm/createZeroClient.headless.test.native.js.map +1 -1
  116. package/dist/esm/createZeroClient.mjs +24 -6
  117. package/dist/esm/createZeroClient.mjs.map +1 -1
  118. package/dist/esm/createZeroClient.native.js +22 -4
  119. package/dist/esm/createZeroClient.native.js.map +1 -1
  120. package/dist/esm/generate-helpers.mjs +17 -1
  121. package/dist/esm/generate-helpers.mjs.map +1 -1
  122. package/dist/esm/generate-helpers.native.js +23 -1
  123. package/dist/esm/generate-helpers.native.js.map +1 -1
  124. package/dist/esm/generate-layout.mjs +47 -4
  125. package/dist/esm/generate-layout.mjs.map +1 -1
  126. package/dist/esm/generate-layout.native.js +191 -81
  127. package/dist/esm/generate-layout.native.js.map +1 -1
  128. package/dist/esm/generate-lite.mjs +26 -6
  129. package/dist/esm/generate-lite.mjs.map +1 -1
  130. package/dist/esm/generate-lite.native.js +206 -164
  131. package/dist/esm/generate-lite.native.js.map +1 -1
  132. package/dist/esm/generate-lite.test.mjs +58 -0
  133. package/dist/esm/generate-lite.test.mjs.map +1 -1
  134. package/dist/esm/generate-lite.test.native.js +59 -0
  135. package/dist/esm/generate-lite.test.native.js.map +1 -1
  136. package/dist/esm/generate.mjs +10 -2
  137. package/dist/esm/generate.mjs.map +1 -1
  138. package/dist/esm/generate.native.js +14 -2
  139. package/dist/esm/generate.native.js.map +1 -1
  140. package/dist/esm/generate.test.mjs +16 -0
  141. package/dist/esm/generate.test.mjs.map +1 -1
  142. package/dist/esm/generate.test.native.js +16 -0
  143. package/dist/esm/generate.test.native.js.map +1 -1
  144. package/dist/esm/helpers/createMutators.mjs +3 -3
  145. package/dist/esm/helpers/createMutators.mjs.map +1 -1
  146. package/dist/esm/helpers/createMutators.native.js +3 -3
  147. package/dist/esm/helpers/createMutators.native.js.map +1 -1
  148. package/dist/esm/helpers/createMutators.test.mjs +5 -5
  149. package/dist/esm/helpers/createMutators.test.mjs.map +1 -1
  150. package/dist/esm/helpers/createMutators.test.native.js +5 -5
  151. package/dist/esm/helpers/createMutators.test.native.js.map +1 -1
  152. package/dist/esm/helpers/recoverZeroClient.mjs +27 -3
  153. package/dist/esm/helpers/recoverZeroClient.mjs.map +1 -1
  154. package/dist/esm/helpers/recoverZeroClient.native.js +28 -4
  155. package/dist/esm/helpers/recoverZeroClient.native.js.map +1 -1
  156. package/dist/esm/helpers/recoverZeroClient.test.mjs +32 -2
  157. package/dist/esm/helpers/recoverZeroClient.test.mjs.map +1 -1
  158. package/dist/esm/helpers/recoverZeroClient.test.native.js +38 -2
  159. package/dist/esm/helpers/recoverZeroClient.test.native.js.map +1 -1
  160. package/dist/esm/helpers/useMutation.mjs +27 -3
  161. package/dist/esm/helpers/useMutation.mjs.map +1 -1
  162. package/dist/esm/helpers/useMutation.native.js +71 -7
  163. package/dist/esm/helpers/useMutation.native.js.map +1 -1
  164. package/dist/esm/helpers/useMutation.test.mjs +16 -0
  165. package/dist/esm/helpers/useMutation.test.mjs.map +1 -1
  166. package/dist/esm/helpers/useMutation.test.native.js +18 -0
  167. package/dist/esm/helpers/useMutation.test.native.js.map +1 -1
  168. package/dist/esm/multi.mjs +1 -1
  169. package/dist/esm/multi.mjs.map +1 -1
  170. package/dist/esm/multi.native.js +1 -1
  171. package/dist/esm/multi.native.js.map +1 -1
  172. package/package.json +2 -2
  173. package/src/createZeroClient.headless.test.tsx +58 -0
  174. package/src/createZeroClient.tsx +28 -7
  175. package/src/generate-helpers.ts +29 -0
  176. package/src/generate-layout.ts +80 -11
  177. package/src/generate-lite.test.ts +56 -0
  178. package/src/generate-lite.ts +38 -9
  179. package/src/generate.test.ts +19 -0
  180. package/src/generate.ts +19 -2
  181. package/src/helpers/createMutators.test.ts +5 -5
  182. package/src/helpers/createMutators.ts +7 -5
  183. package/src/helpers/recoverZeroClient.test.ts +34 -5
  184. package/src/helpers/recoverZeroClient.ts +38 -8
  185. package/src/helpers/useMutation.test.ts +15 -0
  186. package/src/helpers/useMutation.tsx +41 -2
  187. package/src/multi.ts +3 -3
  188. package/types/createZeroClient.d.ts +3 -3
  189. package/types/createZeroClient.d.ts.map +1 -1
  190. package/types/generate-helpers.d.ts +4 -0
  191. package/types/generate-helpers.d.ts.map +1 -1
  192. package/types/generate-layout.d.ts +2 -0
  193. package/types/generate-layout.d.ts.map +1 -1
  194. package/types/generate-lite.d.ts.map +1 -1
  195. package/types/generate.d.ts.map +1 -1
  196. package/types/helpers/createMutators.d.ts +3 -3
  197. package/types/helpers/createMutators.d.ts.map +1 -1
  198. package/types/helpers/recoverZeroClient.d.ts +2 -1
  199. package/types/helpers/recoverZeroClient.d.ts.map +1 -1
  200. package/types/helpers/useMutation.d.ts +1 -0
  201. package/types/helpers/useMutation.d.ts.map +1 -1
  202. package/types/multi.d.ts +2 -2
  203. package/types/multi.d.ts.map +1 -1
@@ -8,6 +8,8 @@ export type DataNamespace = {
8
8
  instance: string
9
9
  queryPath: string | null
10
10
  modelPath: string | null
11
+ aggregatePath: string | null
12
+ table: string | null
11
13
  sourcePaths: string[]
12
14
  }
13
15
 
@@ -327,6 +329,59 @@ function namespaceExportKinds(
327
329
  return { model: false, query }
328
330
  }
329
331
 
332
+ function mutationTable(
333
+ ts: typeof import('typescript'),
334
+ path: string,
335
+ namespace: string
336
+ ): string {
337
+ const source = ts.createSourceFile(
338
+ path,
339
+ readFileSync(path, 'utf8'),
340
+ ts.ScriptTarget.Latest,
341
+ true
342
+ )
343
+ let schemaTable: string | null = null
344
+
345
+ for (const statement of source.statements) {
346
+ if (!ts.isVariableStatement(statement)) continue
347
+ if (
348
+ !statement.modifiers?.some(
349
+ (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword
350
+ )
351
+ ) {
352
+ continue
353
+ }
354
+ for (const declaration of statement.declarationList.declarations) {
355
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue
356
+ if (
357
+ declaration.name.text === 'mutate' &&
358
+ ts.isCallExpression(declaration.initializer)
359
+ ) {
360
+ const firstArgument = declaration.initializer.arguments[0]
361
+ if (firstArgument && ts.isStringLiteralLike(firstArgument)) {
362
+ return firstArgument.text
363
+ }
364
+ }
365
+ if (declaration.name.text !== 'schema') continue
366
+ const visit = (node: ts.Node) => {
367
+ if (
368
+ ts.isCallExpression(node) &&
369
+ node.expression.getText(source) === 'table' &&
370
+ node.arguments[0] &&
371
+ ts.isStringLiteralLike(node.arguments[0])
372
+ ) {
373
+ schemaTable = node.arguments[0].text
374
+ return
375
+ }
376
+ ts.forEachChild(node, visit)
377
+ }
378
+ visit(declaration.initializer)
379
+ }
380
+ }
381
+
382
+ return schemaTable ?? namespace
383
+ }
384
+
330
385
  function discoverNamespaces(
331
386
  ts: typeof import('typescript'),
332
387
  baseDir: string,
@@ -350,6 +405,8 @@ function discoverNamespaces(
350
405
  // a query-only file is not a model, matching the folder layout where a
351
406
  // missing mutations.ts leaves modelPath null
352
407
  modelPath: kinds.model ? path : null,
408
+ aggregatePath: null,
409
+ table: kinds.model ? mutationTable(ts, path, name) : null,
353
410
  sourcePaths: [path],
354
411
  })
355
412
  continue
@@ -360,9 +417,11 @@ function discoverNamespaces(
360
417
 
361
418
  const queryPath = resolve(folder, 'queries.ts')
362
419
  const modelPath = resolve(folder, 'mutations.ts')
420
+ const aggregatePath = resolve(folder, 'aggregates.ts')
363
421
  const hasQueries = existsSync(queryPath)
364
422
  const hasMutations = existsSync(modelPath)
365
- if (!hasQueries && !hasMutations) {
423
+ const hasAggregates = existsSync(aggregatePath)
424
+ if (!hasQueries && !hasMutations && !hasAggregates) {
366
425
  if (
367
426
  ['models', 'mutations', 'queries'].includes(entry.name) &&
368
427
  readdirSync(folder).some(isSourceFile)
@@ -379,9 +438,13 @@ function discoverNamespaces(
379
438
  instance: instance.name,
380
439
  queryPath: hasQueries ? queryPath : null,
381
440
  modelPath: hasMutations ? modelPath : null,
382
- sourcePaths: [hasQueries && queryPath, hasMutations && modelPath].filter(
383
- (path): path is string => Boolean(path)
384
- ),
441
+ aggregatePath: hasAggregates ? aggregatePath : null,
442
+ table: hasMutations ? mutationTable(ts, modelPath, entry.name) : null,
443
+ sourcePaths: [
444
+ hasQueries && queryPath,
445
+ hasMutations && modelPath,
446
+ hasAggregates && aggregatePath,
447
+ ].filter((path): path is string => Boolean(path)),
385
448
  })
386
449
  }
387
450
  return namespaces
@@ -803,11 +866,17 @@ export function discoverDataLayout(
803
866
  }
804
867
  namespaceOwners.set(namespace.name, namespace.instance)
805
868
  }
806
- const tableOwners = new Map(
807
- namespaces
808
- .filter((namespace) => namespace.modelPath !== null)
809
- .map((namespace) => [namespace.name, namespace.instance])
810
- )
869
+ const tableOwners = new Map<string, string>()
870
+ for (const namespace of namespaces) {
871
+ if (namespace.table === null) continue
872
+ const owner = tableOwners.get(namespace.table)
873
+ if (owner && owner !== namespace.instance) {
874
+ throw new Error(
875
+ `[on-zero] table '${namespace.table}' is claimed by instances '${owner}' and '${namespace.instance}'`
876
+ )
877
+ }
878
+ tableOwners.set(namespace.table, namespace.instance)
879
+ }
811
880
 
812
881
  const metadata = metadataPaths(baseDir)
813
882
  const relations = relationTargets(ts, metadata)
@@ -816,8 +885,8 @@ export function discoverDataLayout(
816
885
  for (const instance of instances) {
817
886
  const tables = new Set(
818
887
  instance.namespaces
819
- .filter((namespace) => namespace.modelPath !== null)
820
- .map((namespace) => namespace.name)
888
+ .map((namespace) => namespace.table)
889
+ .filter((table): table is string => table !== null)
821
890
  )
822
891
  const syncTables = new Set(tables)
823
892
  for (const namespace of instance.namespaces) {
@@ -118,6 +118,46 @@ describe('generateLite', () => {
118
118
  expect(result.instances[0]?.syncTables).toEqual([])
119
119
  })
120
120
 
121
+ test('compiles every namespace aggregates.ts into one generated set', () => {
122
+ const files = {
123
+ [`${DIR}/post/queries.ts`]: '// queries',
124
+ [`${DIR}/post/aggregates.ts`]: '// declarations only',
125
+ [`${DIR}/order/aggregates.ts`]: '// declarations only',
126
+ }
127
+ const result = generateLite({
128
+ files,
129
+ dir: DIR,
130
+ parse: makeParse({
131
+ [`${DIR}/post/queries.ts`]: { mutations: [], queries: [] },
132
+ }),
133
+ })
134
+
135
+ const generated = result.files['aggregates.ts']
136
+ expect(generated).toBeDefined()
137
+ // a folder holding only aggregates.ts is still a namespace, and the
138
+ // aggregate module is never parsed — it is imported, not read.
139
+ expect(generated).toContain(
140
+ `import { aggregates as orderAggregates } from '../order/aggregates'`
141
+ )
142
+ expect(generated).toContain(
143
+ `import { aggregates as postAggregates } from '../post/aggregates'`
144
+ )
145
+ expect(generated).toContain(
146
+ 'mergeAggregateDefinitions(orderAggregates, postAggregates)'
147
+ )
148
+ })
149
+
150
+ test('omits the generated aggregate set when no namespace declares one', () => {
151
+ const files = { [`${DIR}/post/queries.ts`]: '// queries' }
152
+ const result = generateLite({
153
+ files,
154
+ dir: DIR,
155
+ parse: makeParse({ [`${DIR}/post/queries.ts`]: { mutations: [], queries: [] } }),
156
+ })
157
+
158
+ expect(result.files['aggregates.ts']).toBeUndefined()
159
+ })
160
+
121
161
  test('derives fileless support tables through parsed mutation helpers', () => {
122
162
  const files = {
123
163
  [`${DIR}/post.ts`]: '// namespace',
@@ -166,6 +206,22 @@ describe('generateLite', () => {
166
206
  expect(context.instances?.default?.supportTables).toEqual(['audit', 'settings'])
167
207
  })
168
208
 
209
+ test('derives mutation membership from the targeted table', () => {
210
+ const path = `${DIR}/mint/mutations.ts`
211
+ const result = generateLite({
212
+ files: { [path]: '// namespace' },
213
+ dir: DIR,
214
+ parse: makeParse({
215
+ [path]: {
216
+ mutations: [{ modelName: 'expense', handlers: [], schema: null }],
217
+ queries: [],
218
+ },
219
+ }),
220
+ })
221
+
222
+ expect(result.instances[0]?.syncTables).toEqual(['expense'])
223
+ })
224
+
169
225
  test('includes a fileless support table in every lite instance that uses it', () => {
170
226
  const files = {
171
227
  [`${DIR}/on-zero.config.ts`]: `export default defineConfig({ instances: { control: { dir: './planes/control', supportTables: ['manual'] }, project: { scope: 'projectId' } } })`,
@@ -15,6 +15,7 @@
15
15
  // contexts without pulling in ~10mb of typescript.
16
16
 
17
17
  import {
18
+ generateAggregatesFile,
18
19
  generateGroupedQueriesFile,
19
20
  generateInstancesFile,
20
21
  generateModelsFile,
@@ -34,9 +35,8 @@ import type { ModelMutations, SchemaColumn } from './generate-helpers'
34
35
  // minimal ast info about a namespace mutation module (e.g. `post/mutations.ts`)
35
36
  export type LiteMutationExport = {
36
37
  // the first arg to `mutations('NAME', ...)` — string literal from the source.
37
- // not currently emitted anywhere in the output; the model file basename is
38
- // used for the top-level key in syncedMutations instead. kept for parity with
39
- // what callers naturally extract, and to leave room for future use.
38
+ // this targets membership at the named table while the model file basename
39
+ // remains the top-level key in syncedMutations.
40
40
  modelName: string
41
41
  // handlers = keys of the last object literal arg to `mutations(...)`
42
42
  handlers: Array<{
@@ -218,6 +218,7 @@ type LiteNamespace = {
218
218
  instance: string
219
219
  queryPath: string | null
220
220
  modelPath: string | null
221
+ aggregatePath: string | null
221
222
  }
222
223
 
223
224
  type LiteInstance = {
@@ -381,6 +382,7 @@ function discoverLiteLayout(
381
382
  instance: instance.name,
382
383
  queryPath: path,
383
384
  modelPath: path,
385
+ aggregatePath: null,
384
386
  })
385
387
  }
386
388
 
@@ -396,7 +398,8 @@ function discoverLiteLayout(
396
398
  if (folder === `${baseDir}/generated` || instanceDirs.has(folder)) continue
397
399
  const queries = `${folder}/queries.ts`
398
400
  const mutations = `${folder}/mutations.ts`
399
- if (!(queries in files) && !(mutations in files)) {
401
+ const aggregates = `${folder}/aggregates.ts`
402
+ if (!(queries in files) && !(mutations in files) && !(aggregates in files)) {
400
403
  const oldName = baseName(folder)
401
404
  if (
402
405
  ['models', 'mutations', 'queries'].includes(oldName) &&
@@ -414,6 +417,7 @@ function discoverLiteLayout(
414
417
  instance: instance.name,
415
418
  queryPath: queries in files ? queries : null,
416
419
  modelPath: mutations in files ? mutations : null,
420
+ aggregatePath: aggregates in files ? aggregates : null,
417
421
  })
418
422
  }
419
423
  }
@@ -464,6 +468,7 @@ export function generateLite(opts: LiteGenerateOptions): LiteGenerateResult {
464
468
  // parse each model file and build ModelMutations records for the emitter
465
469
  const allModelMutations: ModelMutations[] = []
466
470
  const modelNamesWithSchema: string[] = []
471
+ const modelTables = new Map<string, string>()
467
472
 
468
473
  for (const namespace of modelNamespaces) {
469
474
  const filePath = namespace.modelPath
@@ -473,6 +478,10 @@ export function generateLite(opts: LiteGenerateOptions): LiteGenerateResult {
473
478
 
474
479
  // a model file has at most one mutate export, but the lite ast is an array
475
480
  const mutationExport = parsed.mutations[0] ?? null
481
+ modelTables.set(
482
+ modelName,
483
+ mutationExport?.modelName || mutationExport?.schema?.tableName || modelName
484
+ )
476
485
 
477
486
  // extract schema info if present
478
487
  const columns: Record<string, SchemaColumn> = {}
@@ -612,9 +621,17 @@ export function generateLite(opts: LiteGenerateOptions): LiteGenerateResult {
612
621
  }
613
622
  }
614
623
 
615
- const owners = new Map(
616
- modelNamespaces.map((namespace) => [namespace.name, namespace.instance])
617
- )
624
+ const owners = new Map<string, string>()
625
+ for (const namespace of modelNamespaces) {
626
+ const table = modelTables.get(namespace.name)!
627
+ const owner = owners.get(table)
628
+ if (owner && owner !== namespace.instance) {
629
+ throw new Error(
630
+ `[on-zero] table '${table}' is claimed by instances '${owner}' and '${namespace.instance}'`
631
+ )
632
+ }
633
+ owners.set(table, namespace.instance)
634
+ }
618
635
  const relatedOwners = new Map<string, string>()
619
636
  const directTables = new Map<string, string[]>()
620
637
  const syncTables = new Map<string, string[]>()
@@ -622,7 +639,7 @@ export function generateLite(opts: LiteGenerateOptions): LiteGenerateResult {
622
639
  const direct = new Set(
623
640
  instance.namespaces
624
641
  .filter((namespace) => namespace.modelPath !== null)
625
- .map((namespace) => namespace.name)
642
+ .map((namespace) => modelTables.get(namespace.name)!)
626
643
  )
627
644
  const synced = new Set(direct)
628
645
  for (const namespace of instance.namespaces) {
@@ -730,7 +747,6 @@ export function generateLite(opts: LiteGenerateOptions): LiteGenerateResult {
730
747
  importPath: `../${relativePath(baseDir, namespace.modelPath).replace(/\.ts$/, '')}`,
731
748
  }))
732
749
  )
733
-
734
750
  if (modelNamesWithSchema.length > 0) {
735
751
  out['types.ts'] = generateTypesFile(modelNamesWithSchema)
736
752
  out['tables.ts'] = generateTablesFile(
@@ -767,6 +783,19 @@ export function generateLite(opts: LiteGenerateOptions): LiteGenerateResult {
767
783
  out['syncedMutations.ts'] = generateSyncedMutationsFile(allModelMutations)
768
784
  }
769
785
 
786
+ const aggregateNamespaces = namespaces.filter(
787
+ (namespace): namespace is LiteNamespace & { aggregatePath: string } =>
788
+ namespace.aggregatePath !== null
789
+ )
790
+ if (aggregateNamespaces.length > 0) {
791
+ out['aggregates.ts'] = generateAggregatesFile(
792
+ aggregateNamespaces.map((namespace) => ({
793
+ name: namespace.name,
794
+ importPath: `../${relativePath(baseDir, namespace.aggregatePath).replace(/\.ts$/, '')}`,
795
+ }))
796
+ )
797
+ }
798
+
770
799
  // count mutations the same way `generate()` does: 3 per crud model plus
771
800
  // non-crud custom mutations (the crud-ops set is excluded when hasCRUD).
772
801
  let mutationCount = 0
@@ -200,6 +200,25 @@ export async function writeAudit(tx: Transaction) {
200
200
  })
201
201
  })
202
202
 
203
+ test('derives mutation membership from the targeted table', async () => {
204
+ writeFileSync(
205
+ join(dataDir(), 'mint/mutations.ts'),
206
+ `export const mutate = mutations('expense', permissions, { save: async () => {} })`
207
+ )
208
+
209
+ await expect(deriveDataMembership({ dir: dataDir() })).resolves.toEqual({
210
+ instances: {
211
+ default: {
212
+ tables: ['expense'],
213
+ syncTables: ['expense'],
214
+ supportTables: [],
215
+ scope: null,
216
+ },
217
+ },
218
+ allTables: ['expense'],
219
+ })
220
+ })
221
+
203
222
  test('includes a fileless support table in every instance that uses it', async () => {
204
223
  writeFileSync(
205
224
  join(dataDir(), 'control/account.ts'),
package/src/generate.ts CHANGED
@@ -4,6 +4,7 @@ import { basename, dirname, resolve } from 'node:path'
4
4
 
5
5
  import {
6
6
  formatObjectKey,
7
+ generateAggregatesFile,
7
8
  generateGroupedQueriesFile,
8
9
  generateInstancesFile,
9
10
  generateModelsFile,
@@ -22,7 +23,7 @@ import type { ExtractedMutation, ModelMutations, SchemaColumn } from './generate
22
23
  import type { DataLayout } from './generate-layout'
23
24
 
24
25
  const hash = (s: string) => createHash('sha256').update(s).digest('hex')
25
- const GENERATOR_CACHE_VERSION = '5'
26
+ const GENERATOR_CACHE_VERSION = '6'
26
27
 
27
28
  const isGeneratorSourceFile = (name: string) =>
28
29
  name.endsWith('.ts') &&
@@ -1028,7 +1029,6 @@ export async function generate(options: GenerateOptions): Promise<GenerateResult
1028
1029
  name: namespace.name,
1029
1030
  importPath: namespaceImportPath(baseDir, namespace.modelPath),
1030
1031
  }))
1031
-
1032
1032
  const writeResults = [
1033
1033
  writeFileIfChanged(
1034
1034
  resolve(generatedDir, 'models.ts'),
@@ -1196,6 +1196,23 @@ export async function generate(options: GenerateOptions): Promise<GenerateResult
1196
1196
  )
1197
1197
  if (instancesChanged) filesChanged++
1198
1198
 
1199
+ const aggregateNamespaces = layout.namespaces.filter(
1200
+ (namespace): namespace is typeof namespace & { aggregatePath: string } =>
1201
+ namespace.aggregatePath !== null
1202
+ )
1203
+ if (aggregateNamespaces.length > 0) {
1204
+ const aggregatesChanged = writeFileIfChanged(
1205
+ resolve(generatedDir, 'aggregates.ts'),
1206
+ generateAggregatesFile(
1207
+ aggregateNamespaces.map((namespace) => ({
1208
+ name: namespace.name,
1209
+ importPath: namespaceImportPath(baseDir, namespace.aggregatePath),
1210
+ }))
1211
+ )
1212
+ )
1213
+ if (aggregatesChanged) filesChanged++
1214
+ }
1215
+
1199
1216
  // generate mutation validators from model files
1200
1217
  const allModelMutations: ModelMutations[] = []
1201
1218
 
@@ -1,5 +1,5 @@
1
1
  import { createBuilder, createSchema, number, string, table } from '@rocicorp/zero'
2
- import { count, defineRollups } from 'orez-lite/rollup'
2
+ import { count, defineAggregates } from 'orez-lite/aggregate'
3
3
  import { afterEach, describe, expect, test, vi } from 'vitest'
4
4
 
5
5
  import { setSchema } from '../state'
@@ -53,7 +53,7 @@ describe('createMutators timeout guard', () => {
53
53
  })
54
54
  })
55
55
 
56
- test('createMutators installs optimistic rollups on client transactions', async () => {
56
+ test('createMutators installs optimistic aggregates on client transactions', async () => {
57
57
  const comment = table('comment')
58
58
  .columns({ id: string(), postId: string() })
59
59
  .primaryKey('id')
@@ -62,13 +62,13 @@ test('createMutators installs optimistic rollups on client transactions', async
62
62
  .primaryKey('id')
63
63
  const schema = createSchema({ tables: [comment, post] })
64
64
  setSchema(schema, createBuilder(schema))
65
- const rollups = defineRollups(schema, {
65
+ const aggregates = defineAggregates(schema, {
66
66
  postCommentCount: {
67
67
  source: 'comment',
68
68
  target: 'post',
69
69
  mode: 'existing',
70
70
  groupBy: { postId: 'id' },
71
- aggregates: { commentCount: count() },
71
+ columns: { commentCount: count() },
72
72
  },
73
73
  })
74
74
  const rows: Record<string, Array<Record<string, unknown>>> = {
@@ -122,7 +122,7 @@ test('createMutators installs optimistic rollups on client transactions', async
122
122
  environment: 'client',
123
123
  authData: null,
124
124
  bindCan: () => async () => {},
125
- rollups,
125
+ aggregates,
126
126
  models: {
127
127
  comment: {
128
128
  mutate: {
@@ -1,4 +1,4 @@
1
- import { withOptimisticRollups } from 'orez-lite/rollup'
1
+ import { withOptimisticAggregates } from 'orez-lite/aggregate'
2
2
 
3
3
  import { getAuthData } from '../state'
4
4
  import { mapObject } from './mapObject'
@@ -13,7 +13,7 @@ import type {
13
13
  MutatorContext,
14
14
  Transaction,
15
15
  } from '../types'
16
- import type { RollupSet } from 'orez-lite/rollup'
16
+ import type { AggregateSet } from 'orez-lite/aggregate'
17
17
 
18
18
  export type ValidateMutationFn = (args: {
19
19
  authData: AuthData | null
@@ -35,7 +35,7 @@ export function createMutators<Models extends GenericModels>({
35
35
  validateMutation,
36
36
  mutationValidators,
37
37
  resolveAuthData,
38
- rollups,
38
+ aggregates,
39
39
  }: {
40
40
  environment: 'server' | 'client'
41
41
  authData: AuthData | null
@@ -48,7 +48,7 @@ export function createMutators<Models extends GenericModels>({
48
48
  /** valibot schemas keyed by model.mutationName, auto-validates args before running */
49
49
  mutationValidators?: Record<string, Record<string, any>>
50
50
  resolveAuthData?: () => AuthData | null
51
- rollups?: RollupSet
51
+ aggregates?: AggregateSet
52
52
  }): GetZeroMutators<Models> {
53
53
  const serverActions = createServerActions?.()
54
54
 
@@ -60,7 +60,9 @@ export function createMutators<Models extends GenericModels>({
60
60
  function withContext<Args extends any[]>(fn: (...args: Args) => Promise<void>) {
61
61
  return async (tx: Transaction, ...args: Args): Promise<void> => {
62
62
  const transaction =
63
- environment === 'client' && rollups ? withOptimisticRollups(tx, rollups) : tx
63
+ environment === 'client' && aggregates
64
+ ? withOptimisticAggregates(tx, aggregates)
65
+ : tx
64
66
  // on client, read authData dynamically to avoid stale closure during auth
65
67
  // transitions (ZeroProvider recreates Zero instance in useEffect, but
66
68
  // mutations can run before that)
@@ -90,6 +90,34 @@ describe('zero recovery', () => {
90
90
  expect(a.reload.mock.calls.length + b.reload.mock.calls.length).toBe(1)
91
91
  })
92
92
 
93
+ test('combined native client remints every instance when page reload is unavailable', async () => {
94
+ const a = setup()
95
+ const b = setup()
96
+ a.deps.reload = undefined
97
+ b.deps.reload = undefined
98
+ const recoverA = vi.fn(() => Promise.resolve(true))
99
+ const recoverB = vi.fn(() => Promise.resolve(true))
100
+ a.deps.recoverInPlace = recoverA
101
+ b.deps.recoverInPlace = recoverB
102
+ const originalLocation = globalThis.location
103
+ Object.defineProperty(globalThis, 'location', {
104
+ configurable: true,
105
+ value: undefined,
106
+ })
107
+ try {
108
+ makeZeroRecovery(a.deps).onClientStateNotFound()
109
+ makeZeroRecovery(b.deps).onClientStateNotFound()
110
+ await flush()
111
+ } finally {
112
+ Object.defineProperty(globalThis, 'location', {
113
+ configurable: true,
114
+ value: originalLocation,
115
+ })
116
+ }
117
+ expect(recoverA).toHaveBeenCalledTimes(1)
118
+ expect(recoverB).toHaveBeenCalledTimes(1)
119
+ })
120
+
93
121
  test('a second trigger in the same page-load adds no extra reload or fatal', async () => {
94
122
  const { deps, reload, events } = setup()
95
123
  const recovery = makeZeroRecovery(deps)
@@ -240,10 +268,9 @@ describe('zero recovery', () => {
240
268
 
241
269
  // a host with a `window` shim but no real `location` — the sootsim tenant
242
270
  // render-worker, which hides `location` for isolation. the DEFAULT reload
243
- // path (no injected `deps.reload`) must still drop stale IDB and not throw
244
- // "reading 'reload'" on undefined; it no-ops the reload and lets the host
245
- // remount. previously crashed as an unhandled rejection.
246
- test('default reload path is a safe no-op when location is absent', async () => {
271
+ // path (no injected `deps.reload`) must still drop stale IDB and reconstruct
272
+ // the client in place rather than leaving the worker on the rejected client.
273
+ test('default reload path remints in place when location is absent', async () => {
247
274
  const events: ZeroEvent[] = []
248
275
  const zeroEvents = createEmitter<ZeroEvent | null>(
249
276
  `test-recover-${emitterSeq++}`,
@@ -253,8 +280,9 @@ describe('zero recovery', () => {
253
280
  if (event) events.push(event)
254
281
  })
255
282
  const deleteLocalState = vi.fn(() => Promise.resolve())
283
+ const recoverInPlace = vi.fn(() => Promise.resolve(true))
256
284
  // no `reload` dep — exercises the default `globalThis.location?.reload?.()`.
257
- const deps: ZeroRecoveryDeps = { deleteLocalState, zeroEvents }
285
+ const deps: ZeroRecoveryDeps = { deleteLocalState, zeroEvents, recoverInPlace }
258
286
  const originalLocation = globalThis.location
259
287
  // simulate the worker: window exists (jsdom) but location is absent.
260
288
  Object.defineProperty(globalThis, 'location', {
@@ -271,6 +299,7 @@ describe('zero recovery', () => {
271
299
  })
272
300
  }
273
301
  expect(deleteLocalState).toHaveBeenCalledTimes(1)
302
+ expect(recoverInPlace).toHaveBeenCalledTimes(1)
274
303
  expect(events).toContainEqual({
275
304
  type: 'recovering',
276
305
  reasonKey: 'client-state-not-found',
@@ -80,8 +80,11 @@ export type ZeroRecoveryDeps = {
80
80
  // internal lifecycle fence. recovery invalidates queued and in-flight work
81
81
  // before the consumer can defer the reload.
82
82
  onRecovery?: (reasonKey: ZeroRecoveryReasonKey) => void
83
+ // non-DOM hosts cannot reload a page. reconstruct the mounted client in
84
+ // place after the same delete/beforeReload work completes.
85
+ recoverInPlace?: () => Promise<boolean>
83
86
  // injectable for tests; defaults to a real page reload.
84
- reload?: () => void
87
+ reload?: () => void | boolean
85
88
  }
86
89
 
87
90
  // one reload per page-load. the local-state deletes that must precede it are
@@ -94,6 +97,7 @@ let reloadScheduled = false
94
97
  let reloadInProgress = false
95
98
  let reloadLatchTimer: ReturnType<typeof setTimeout> | undefined
96
99
  const pendingDeletes: Array<() => Promise<unknown>> = []
100
+ const pendingInPlaceRecoveries: Array<() => Promise<boolean>> = []
97
101
 
98
102
  // within-a-page-load per-reason guard: real loop protection everywhere,
99
103
  // including Hermes (no storage needed). a reload wipes it, which is why the
@@ -192,15 +196,39 @@ function performReload(deps: ZeroRecoveryDeps): Promise<void> {
192
196
  // (`getBrowserGlobal('location')?.reload()`, zero.js): read location off the
193
197
  // global and optional-chain through it. a non-DOM host with a `window` shim
194
198
  // but no real `location` (the sootsim tenant render-worker hides `location`
195
- // for isolation) then drops its stale IDB and no-ops the reload — letting the
196
- // host remount — instead of throwing "reading 'reload'" on undefined.
197
- const doReload = deps.reload ?? (() => globalThis.location?.reload?.())
199
+ // for isolation) reconstructs the client in place after the same cleanup.
200
+ const doReload =
201
+ deps.reload ??
202
+ (() => {
203
+ const reload = globalThis.location?.reload
204
+ if (!reload) return false
205
+ reload.call(globalThis.location)
206
+ return true
207
+ })
208
+ let inPlaceRecoveries: Array<() => Promise<boolean>> = []
198
209
  return Promise.resolve()
199
- .then(() => Promise.allSettled(pendingDeletes.splice(0).map((run) => run())))
210
+ .then(() => {
211
+ inPlaceRecoveries = pendingInPlaceRecoveries.splice(0)
212
+ return Promise.allSettled(pendingDeletes.splice(0).map((run) => run()))
213
+ })
200
214
  .then(() => deps.beforeReload?.())
201
215
  .catch(() => {})
202
- .then(() => {
203
- doReload()
216
+ .then(async () => {
217
+ const reloadStarted = doReload()
218
+ if (reloadStarted !== false) return
219
+ try {
220
+ const results = await Promise.all(inPlaceRecoveries.map((recover) => recover()))
221
+ if (inPlaceRecoveries.length > 0 && results.some((recovered) => !recovered)) {
222
+ console.error('[on-zero] recovery could not reload or reconstruct the client')
223
+ }
224
+ } catch (error) {
225
+ console.error('[on-zero] in-place recovery failed', error)
226
+ } finally {
227
+ // a page reload tears down this module, but an in-place recovery keeps
228
+ // it alive. reopen the latch so a later, distinct failure can recover.
229
+ reloadInProgress = false
230
+ reloadScheduled = false
231
+ }
204
232
  })
205
233
  }
206
234
 
@@ -218,6 +246,7 @@ function recover(
218
246
  if (dropLocalState) {
219
247
  pendingDeletes.push(() => Promise.resolve().then(deps.deleteLocalState))
220
248
  }
249
+ if (deps.recoverInPlace) pendingInPlaceRecoveries.push(deps.recoverInPlace)
221
250
  // only ONE reload per page-load; a later trigger just contributes its delete.
222
251
  if (reloadScheduled) return
223
252
  if (!recoveryGuardOpen(reasonKey, deps.guardStorage)) {
@@ -461,7 +490,7 @@ export function isRecoverableZeroStalePokeMessage(message: string): boolean {
461
490
  )
462
491
  }
463
492
 
464
- // test-only: the reload latch + pending deletes + in-memory guard are in-memory
493
+ // test-only: the reload latch + pending work + in-memory guard are in-memory
465
494
  // (a real page reload clears them); tests simulate that reset between successive
466
495
  // "page loads". the injectable/sessionStorage guard is NOT cleared here — like a
467
496
  // real reload, it survives, which is what catches an immediate re-fire.
@@ -473,6 +502,7 @@ export function resetRecoveryStateForTests() {
473
502
  reloadLatchTimer = undefined
474
503
  }
475
504
  pendingDeletes.length = 0
505
+ pendingInPlaceRecoveries.length = 0
476
506
  lastStoreClosedAtMs = 0
477
507
  inMemoryGuard.clear()
478
508
  }
@@ -95,6 +95,21 @@ describe('observeMutation', () => {
95
95
  await expect(observeMutation(result)).resolves.toBeUndefined()
96
96
  })
97
97
 
98
+ test('observing the same result twice emits globally once', async () => {
99
+ const { result, client, server } = fakeResult()
100
+ const global: MutationError[] = []
101
+ const dispose = onMutationError((e) => global.push(e))
102
+
103
+ const first = observeMutation(result)
104
+ const second = observeMutation(result)
105
+ client.resolve(SUCCESS)
106
+ server.resolve(appError('denied'))
107
+ await Promise.all([first, second])
108
+
109
+ expect(global).toHaveLength(1)
110
+ dispose()
111
+ })
112
+
98
113
  test('with no listener registered it falls back to console.error in dev', async () => {
99
114
  const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
100
115
  const { result, client, server } = fakeResult()