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.
- package/README.md +3 -0
- package/dist/cjs/cli.native.js.map +1 -1
- package/dist/cjs/combineZeroClients.native.js.map +1 -1
- package/dist/cjs/config.native.js.map +1 -1
- package/dist/cjs/createPermissions.native.js.map +1 -1
- package/dist/cjs/createSchemaFromDrizzle.native.js.map +1 -1
- package/dist/cjs/createUseQuery.native.js.map +1 -1
- package/dist/cjs/createUseQueryDirect.native.js.map +1 -1
- package/dist/cjs/createZeroClient.authData.test.native.js.map +1 -1
- package/dist/cjs/createZeroClient.cjs +24 -6
- package/dist/cjs/createZeroClient.connection.test.native.js.map +1 -1
- package/dist/cjs/createZeroClient.headless.test.cjs +55 -0
- package/dist/cjs/createZeroClient.headless.test.native.js +64 -0
- package/dist/cjs/createZeroClient.headless.test.native.js.map +1 -1
- package/dist/cjs/createZeroClient.native.js +22 -4
- package/dist/cjs/createZeroClient.native.js.map +1 -1
- package/dist/cjs/createZeroClient.recovery.test.native.js.map +1 -1
- package/dist/cjs/generate-helpers.cjs +17 -0
- package/dist/cjs/generate-helpers.native.js +23 -0
- package/dist/cjs/generate-helpers.native.js.map +1 -1
- package/dist/cjs/generate-helpers.test.native.js.map +1 -1
- package/dist/cjs/generate-layout.cjs +47 -4
- package/dist/cjs/generate-layout.native.js +191 -81
- package/dist/cjs/generate-layout.native.js.map +1 -1
- package/dist/cjs/generate-lite.cjs +25 -5
- package/dist/cjs/generate-lite.native.js +205 -163
- package/dist/cjs/generate-lite.native.js.map +1 -1
- package/dist/cjs/generate-lite.test.cjs +58 -0
- package/dist/cjs/generate-lite.test.native.js +59 -0
- package/dist/cjs/generate-lite.test.native.js.map +1 -1
- package/dist/cjs/generate.cjs +9 -1
- package/dist/cjs/generate.native.js +13 -1
- package/dist/cjs/generate.native.js.map +1 -1
- package/dist/cjs/generate.test.cjs +16 -0
- package/dist/cjs/generate.test.native.js +16 -0
- package/dist/cjs/generate.test.native.js.map +1 -1
- package/dist/cjs/helpers/NullToOptional.native.js.map +1 -1
- package/dist/cjs/helpers/asyncContext.hostStorage.test.native.js.map +1 -1
- package/dist/cjs/helpers/batchQuery.native.js.map +1 -1
- package/dist/cjs/helpers/clearZeroClientData.native.js.map +1 -1
- package/dist/cjs/helpers/createMutators.cjs +3 -3
- package/dist/cjs/helpers/createMutators.native.js +3 -3
- package/dist/cjs/helpers/createMutators.native.js.map +1 -1
- package/dist/cjs/helpers/createMutators.test.cjs +6 -6
- package/dist/cjs/helpers/createMutators.test.native.js +6 -6
- package/dist/cjs/helpers/createMutators.test.native.js.map +1 -1
- package/dist/cjs/helpers/ellipsis.native.js.map +1 -1
- package/dist/cjs/helpers/emitter.native.js.map +1 -1
- package/dist/cjs/helpers/ensure.native.js.map +1 -1
- package/dist/cjs/helpers/ensureLoggedIn.native.js.map +1 -1
- package/dist/cjs/helpers/errors.native.js.map +1 -1
- package/dist/cjs/helpers/getAuth.native.js.map +1 -1
- package/dist/cjs/helpers/getCurrentComponentStack.native.js.map +1 -1
- package/dist/cjs/helpers/globalValue.native.js.map +1 -1
- package/dist/cjs/helpers/hostStorageScope.native.js.map +1 -1
- package/dist/cjs/helpers/mapObject.native.js.map +1 -1
- package/dist/cjs/helpers/mutationLifecycle.native.js.map +1 -1
- package/dist/cjs/helpers/mutationLifecycle.test.native.js.map +1 -1
- package/dist/cjs/helpers/mutatorContext.native.js.map +1 -1
- package/dist/cjs/helpers/mutatorContext.test.native.js.map +1 -1
- package/dist/cjs/helpers/prettyFormatZeroQuery.native.js.map +1 -1
- package/dist/cjs/helpers/queryContext.native.js.map +1 -1
- package/dist/cjs/helpers/recoverZeroClient.cjs +27 -3
- package/dist/cjs/helpers/recoverZeroClient.native.js +28 -4
- package/dist/cjs/helpers/recoverZeroClient.native.js.map +1 -1
- package/dist/cjs/helpers/recoverZeroClient.test.cjs +32 -2
- package/dist/cjs/helpers/recoverZeroClient.test.native.js +38 -2
- package/dist/cjs/helpers/recoverZeroClient.test.native.js.map +1 -1
- package/dist/cjs/helpers/showZeroClientError.native.js.map +1 -1
- package/dist/cjs/helpers/sleep.native.js.map +1 -1
- package/dist/cjs/helpers/time.native.js.map +1 -1
- package/dist/cjs/helpers/tuple.native.js.map +1 -1
- package/dist/cjs/helpers/useMutation.cjs +27 -2
- package/dist/cjs/helpers/useMutation.native.js +71 -6
- package/dist/cjs/helpers/useMutation.native.js.map +1 -1
- package/dist/cjs/helpers/useMutation.test.cjs +16 -0
- package/dist/cjs/helpers/useMutation.test.native.js +18 -0
- package/dist/cjs/helpers/useMutation.test.native.js.map +1 -1
- package/dist/cjs/helpers/useZeroDebug.native.js.map +1 -1
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/instanceRegistry.native.js.map +1 -1
- package/dist/cjs/modelRegistry.native.js.map +1 -1
- package/dist/cjs/multi.cjs +1 -1
- package/dist/cjs/multi.native.js +1 -1
- package/dist/cjs/multi.native.js.map +1 -1
- package/dist/cjs/multiInstance.test.native.js.map +1 -1
- package/dist/cjs/multiInstanceNested.test.native.js.map +1 -1
- package/dist/cjs/multiPartition.test.native.js.map +1 -1
- package/dist/cjs/mutations.native.js.map +1 -1
- package/dist/cjs/mutations.test.native.js.map +1 -1
- package/dist/cjs/provideZero.hydration.test.native.js.map +1 -1
- package/dist/cjs/queryRegistry.native.js.map +1 -1
- package/dist/cjs/resolveQuery.native.js.map +1 -1
- package/dist/cjs/run.native.js.map +1 -1
- package/dist/cjs/server.native.js.map +1 -1
- package/dist/cjs/server.test.native.js.map +1 -1
- package/dist/cjs/serverWhere.native.js.map +1 -1
- package/dist/cjs/serverWhere.test.native.js.map +1 -1
- package/dist/cjs/state.native.js.map +1 -1
- package/dist/cjs/streaming.native.js.map +1 -1
- package/dist/cjs/testSetup.native.js.map +1 -1
- package/dist/cjs/types.native.js.map +1 -1
- package/dist/cjs/usePermission.test.native.js.map +1 -1
- package/dist/cjs/useQuery.empty.test.native.js.map +1 -1
- package/dist/cjs/useQuery.ssr.test.native.js.map +1 -1
- package/dist/cjs/useStreamingField.loop.test.native.js.map +1 -1
- package/dist/cjs/useStreamingField.native.js.map +1 -1
- package/dist/cjs/vite-plugin.native.js.map +1 -1
- package/dist/cjs/where.native.js.map +1 -1
- package/dist/cjs/zeroRunner.native.js.map +1 -1
- package/dist/cjs/zql.native.js.map +1 -1
- package/dist/esm/createZeroClient.headless.test.mjs +55 -0
- package/dist/esm/createZeroClient.headless.test.mjs.map +1 -1
- package/dist/esm/createZeroClient.headless.test.native.js +64 -0
- package/dist/esm/createZeroClient.headless.test.native.js.map +1 -1
- package/dist/esm/createZeroClient.mjs +24 -6
- package/dist/esm/createZeroClient.mjs.map +1 -1
- package/dist/esm/createZeroClient.native.js +22 -4
- package/dist/esm/createZeroClient.native.js.map +1 -1
- package/dist/esm/generate-helpers.mjs +17 -1
- package/dist/esm/generate-helpers.mjs.map +1 -1
- package/dist/esm/generate-helpers.native.js +23 -1
- package/dist/esm/generate-helpers.native.js.map +1 -1
- package/dist/esm/generate-layout.mjs +47 -4
- package/dist/esm/generate-layout.mjs.map +1 -1
- package/dist/esm/generate-layout.native.js +191 -81
- package/dist/esm/generate-layout.native.js.map +1 -1
- package/dist/esm/generate-lite.mjs +26 -6
- package/dist/esm/generate-lite.mjs.map +1 -1
- package/dist/esm/generate-lite.native.js +206 -164
- package/dist/esm/generate-lite.native.js.map +1 -1
- package/dist/esm/generate-lite.test.mjs +58 -0
- package/dist/esm/generate-lite.test.mjs.map +1 -1
- package/dist/esm/generate-lite.test.native.js +59 -0
- package/dist/esm/generate-lite.test.native.js.map +1 -1
- package/dist/esm/generate.mjs +10 -2
- package/dist/esm/generate.mjs.map +1 -1
- package/dist/esm/generate.native.js +14 -2
- package/dist/esm/generate.native.js.map +1 -1
- package/dist/esm/generate.test.mjs +16 -0
- package/dist/esm/generate.test.mjs.map +1 -1
- package/dist/esm/generate.test.native.js +16 -0
- package/dist/esm/generate.test.native.js.map +1 -1
- package/dist/esm/helpers/createMutators.mjs +3 -3
- package/dist/esm/helpers/createMutators.mjs.map +1 -1
- package/dist/esm/helpers/createMutators.native.js +3 -3
- package/dist/esm/helpers/createMutators.native.js.map +1 -1
- package/dist/esm/helpers/createMutators.test.mjs +5 -5
- package/dist/esm/helpers/createMutators.test.mjs.map +1 -1
- package/dist/esm/helpers/createMutators.test.native.js +5 -5
- package/dist/esm/helpers/createMutators.test.native.js.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.mjs +27 -3
- package/dist/esm/helpers/recoverZeroClient.mjs.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.native.js +28 -4
- package/dist/esm/helpers/recoverZeroClient.native.js.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.test.mjs +32 -2
- package/dist/esm/helpers/recoverZeroClient.test.mjs.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.test.native.js +38 -2
- package/dist/esm/helpers/recoverZeroClient.test.native.js.map +1 -1
- package/dist/esm/helpers/useMutation.mjs +27 -3
- package/dist/esm/helpers/useMutation.mjs.map +1 -1
- package/dist/esm/helpers/useMutation.native.js +71 -7
- package/dist/esm/helpers/useMutation.native.js.map +1 -1
- package/dist/esm/helpers/useMutation.test.mjs +16 -0
- package/dist/esm/helpers/useMutation.test.mjs.map +1 -1
- package/dist/esm/helpers/useMutation.test.native.js +18 -0
- package/dist/esm/helpers/useMutation.test.native.js.map +1 -1
- package/dist/esm/multi.mjs +1 -1
- package/dist/esm/multi.mjs.map +1 -1
- package/dist/esm/multi.native.js +1 -1
- package/dist/esm/multi.native.js.map +1 -1
- package/package.json +2 -2
- package/src/createZeroClient.headless.test.tsx +58 -0
- package/src/createZeroClient.tsx +28 -7
- package/src/generate-helpers.ts +29 -0
- package/src/generate-layout.ts +80 -11
- package/src/generate-lite.test.ts +56 -0
- package/src/generate-lite.ts +38 -9
- package/src/generate.test.ts +19 -0
- package/src/generate.ts +19 -2
- package/src/helpers/createMutators.test.ts +5 -5
- package/src/helpers/createMutators.ts +7 -5
- package/src/helpers/recoverZeroClient.test.ts +34 -5
- package/src/helpers/recoverZeroClient.ts +38 -8
- package/src/helpers/useMutation.test.ts +15 -0
- package/src/helpers/useMutation.tsx +41 -2
- package/src/multi.ts +3 -3
- package/types/createZeroClient.d.ts +3 -3
- package/types/createZeroClient.d.ts.map +1 -1
- package/types/generate-helpers.d.ts +4 -0
- package/types/generate-helpers.d.ts.map +1 -1
- package/types/generate-layout.d.ts +2 -0
- package/types/generate-layout.d.ts.map +1 -1
- package/types/generate-lite.d.ts.map +1 -1
- package/types/generate.d.ts.map +1 -1
- package/types/helpers/createMutators.d.ts +3 -3
- package/types/helpers/createMutators.d.ts.map +1 -1
- package/types/helpers/recoverZeroClient.d.ts +2 -1
- package/types/helpers/recoverZeroClient.d.ts.map +1 -1
- package/types/helpers/useMutation.d.ts +1 -0
- package/types/helpers/useMutation.d.ts.map +1 -1
- package/types/multi.d.ts +2 -2
- package/types/multi.d.ts.map +1 -1
package/src/generate-layout.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
.
|
|
820
|
-
.
|
|
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' } } })`,
|
package/src/generate-lite.ts
CHANGED
|
@@ -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
|
-
//
|
|
38
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/generate.test.ts
CHANGED
|
@@ -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 = '
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
125
|
+
aggregates,
|
|
126
126
|
models: {
|
|
127
127
|
comment: {
|
|
128
128
|
mutate: {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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' &&
|
|
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
|
|
244
|
-
//
|
|
245
|
-
|
|
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)
|
|
196
|
-
|
|
197
|
-
|
|
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(() =>
|
|
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
|
|
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()
|