on-zero 0.16.19 → 0.16.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/createUseQuery.cjs +2 -1
- package/dist/cjs/createUseQuery.native.js +2 -1
- package/dist/cjs/createUseQuery.native.js.map +1 -1
- package/dist/cjs/createUseQueryDirect.cjs +2 -1
- package/dist/cjs/createUseQueryDirect.native.js +2 -1
- package/dist/cjs/createUseQueryDirect.native.js.map +1 -1
- package/dist/cjs/createZeroClient.cjs +75 -9
- package/dist/cjs/createZeroClient.native.js +151 -81
- package/dist/cjs/createZeroClient.native.js.map +1 -1
- package/dist/cjs/createZeroClient.recovery.test.cjs +351 -10
- package/dist/cjs/createZeroClient.recovery.test.native.js +412 -12
- package/dist/cjs/createZeroClient.recovery.test.native.js.map +1 -1
- package/dist/cjs/generate.cjs +16 -11
- package/dist/cjs/generate.native.js +16 -11
- package/dist/cjs/generate.native.js.map +1 -1
- package/dist/cjs/generate.test.cjs +22 -0
- package/dist/cjs/generate.test.native.js +22 -0
- package/dist/cjs/generate.test.native.js.map +1 -1
- package/dist/cjs/helpers/recoverZeroClient.cjs +542 -52
- package/dist/cjs/helpers/recoverZeroClient.native.js +721 -59
- package/dist/cjs/helpers/recoverZeroClient.native.js.map +1 -1
- package/dist/cjs/helpers/recoverZeroClient.test.cjs +318 -0
- package/dist/cjs/helpers/recoverZeroClient.test.native.js +361 -0
- package/dist/cjs/helpers/recoverZeroClient.test.native.js.map +1 -1
- package/dist/cjs/kvStore.cjs +30 -0
- package/dist/cjs/kvStore.native.js +34 -0
- package/dist/cjs/kvStore.native.js.map +1 -0
- package/dist/cjs/queryJson.cjs +58 -0
- package/dist/cjs/queryJson.native.js +86 -0
- package/dist/cjs/queryJson.native.js.map +1 -0
- package/dist/cjs/resolveQuery.cjs +2 -0
- package/dist/cjs/resolveQuery.native.js +2 -0
- package/dist/cjs/resolveQuery.native.js.map +1 -1
- package/dist/cjs/useQuery.empty.test.cjs +11 -1
- package/dist/cjs/useQuery.empty.test.native.js +23 -1
- package/dist/cjs/useQuery.empty.test.native.js.map +1 -1
- package/dist/cjs/useQuery.ssr.test.cjs +22 -1
- package/dist/cjs/useQuery.ssr.test.native.js +30 -1
- package/dist/cjs/useQuery.ssr.test.native.js.map +1 -1
- package/dist/esm/createUseQuery.mjs +2 -1
- package/dist/esm/createUseQuery.mjs.map +1 -1
- package/dist/esm/createUseQuery.native.js +2 -1
- package/dist/esm/createUseQuery.native.js.map +1 -1
- package/dist/esm/createUseQueryDirect.mjs +2 -1
- package/dist/esm/createUseQueryDirect.mjs.map +1 -1
- package/dist/esm/createUseQueryDirect.native.js +2 -1
- package/dist/esm/createUseQueryDirect.native.js.map +1 -1
- package/dist/esm/createZeroClient.mjs +76 -10
- package/dist/esm/createZeroClient.mjs.map +1 -1
- package/dist/esm/createZeroClient.native.js +152 -82
- package/dist/esm/createZeroClient.native.js.map +1 -1
- package/dist/esm/createZeroClient.recovery.test.mjs +352 -11
- package/dist/esm/createZeroClient.recovery.test.mjs.map +1 -1
- package/dist/esm/createZeroClient.recovery.test.native.js +413 -13
- package/dist/esm/createZeroClient.recovery.test.native.js.map +1 -1
- package/dist/esm/generate.mjs +16 -11
- package/dist/esm/generate.mjs.map +1 -1
- package/dist/esm/generate.native.js +16 -11
- package/dist/esm/generate.native.js.map +1 -1
- package/dist/esm/generate.test.mjs +22 -0
- package/dist/esm/generate.test.mjs.map +1 -1
- package/dist/esm/generate.test.native.js +22 -0
- package/dist/esm/generate.test.native.js.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.mjs +542 -54
- package/dist/esm/helpers/recoverZeroClient.mjs.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.native.js +721 -61
- package/dist/esm/helpers/recoverZeroClient.native.js.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.test.mjs +319 -1
- package/dist/esm/helpers/recoverZeroClient.test.mjs.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.test.native.js +362 -1
- package/dist/esm/helpers/recoverZeroClient.test.native.js.map +1 -1
- package/dist/esm/kvStore.mjs +5 -0
- package/dist/esm/kvStore.mjs.map +1 -0
- package/dist/esm/kvStore.native.js +6 -0
- package/dist/esm/kvStore.native.js.map +1 -0
- package/dist/esm/queryJson.mjs +32 -0
- package/dist/esm/queryJson.mjs.map +1 -0
- package/dist/esm/queryJson.native.js +57 -0
- package/dist/esm/queryJson.native.js.map +1 -0
- package/dist/esm/resolveQuery.mjs +2 -0
- package/dist/esm/resolveQuery.mjs.map +1 -1
- package/dist/esm/resolveQuery.native.js +2 -0
- package/dist/esm/resolveQuery.native.js.map +1 -1
- package/dist/esm/useQuery.empty.test.mjs +12 -2
- package/dist/esm/useQuery.empty.test.mjs.map +1 -1
- package/dist/esm/useQuery.empty.test.native.js +24 -2
- package/dist/esm/useQuery.empty.test.native.js.map +1 -1
- package/dist/esm/useQuery.ssr.test.mjs +23 -2
- package/dist/esm/useQuery.ssr.test.mjs.map +1 -1
- package/dist/esm/useQuery.ssr.test.native.js +31 -2
- package/dist/esm/useQuery.ssr.test.native.js.map +1 -1
- package/package.json +11 -3
- package/src/createUseQuery.tsx +2 -1
- package/src/createUseQueryDirect.tsx +2 -1
- package/src/createZeroClient.recovery.test.tsx +370 -7
- package/src/createZeroClient.tsx +100 -10
- package/src/generate.test.ts +31 -0
- package/src/generate.ts +22 -10
- package/src/helpers/recoverZeroClient.test.ts +323 -0
- package/src/helpers/recoverZeroClient.ts +896 -78
- package/src/kvStore.native.ts +7 -0
- package/src/kvStore.ts +7 -0
- package/src/queryJson.ts +46 -0
- package/src/resolveQuery.ts +2 -0
- package/src/useQuery.empty.test.tsx +16 -2
- package/src/useQuery.ssr.test.ts +23 -2
- package/types/createUseQuery.d.ts.map +1 -1
- package/types/createUseQueryDirect.d.ts.map +1 -1
- package/types/createZeroClient.d.ts.map +1 -1
- package/types/createZeroClient.recovery.test.d.ts.map +1 -1
- package/types/generate.d.ts.map +1 -1
- package/types/helpers/recoverZeroClient.d.ts +6 -0
- package/types/helpers/recoverZeroClient.d.ts.map +1 -1
- package/types/kvStore.d.ts +2 -0
- package/types/kvStore.d.ts.map +1 -0
- package/types/kvStore.native.d.ts +3 -0
- package/types/kvStore.native.d.ts.map +1 -0
- package/types/queryJson.d.ts +3 -0
- package/types/queryJson.d.ts.map +1 -0
- package/types/resolveQuery.d.ts.map +1 -1
package/src/generate.ts
CHANGED
|
@@ -40,7 +40,7 @@ import type { ExtractedMutation, ModelMutations, SchemaColumn } from './generate
|
|
|
40
40
|
import type { DataLayout } from './generate-layout'
|
|
41
41
|
|
|
42
42
|
const hash = (s: string) => createHash('sha256').update(s).digest('hex')
|
|
43
|
-
const GENERATOR_CACHE_VERSION = '
|
|
43
|
+
const GENERATOR_CACHE_VERSION = '8'
|
|
44
44
|
|
|
45
45
|
const isGeneratorSourceFile = (name: string) =>
|
|
46
46
|
name.endsWith('.ts') &&
|
|
@@ -966,9 +966,17 @@ async function generateWithProject(
|
|
|
966
966
|
// build entirely and return the cached counts. the configureServer watcher
|
|
967
967
|
// still re-runs generate on real model/query edits.
|
|
968
968
|
const databaseSchemaPath = resolve(dirname(baseDir), 'database/schema.ts')
|
|
969
|
-
const
|
|
969
|
+
const needsDrizzleSchema = existsSync(databaseSchemaPath)
|
|
970
|
+
const externalZeroConfigPath = resolve(
|
|
971
|
+
dirname(databaseSchemaPath),
|
|
972
|
+
'drizzle-zero.config.ts'
|
|
973
|
+
)
|
|
974
|
+
const externalZeroConfig = existsSync(externalZeroConfigPath)
|
|
975
|
+
? readFileSync(externalZeroConfigPath, 'utf8')
|
|
976
|
+
: null
|
|
977
|
+
const needsSqliteZeroSchema = needsDrizzleSchema && externalZeroConfig === null
|
|
970
978
|
const inputHash = hash(
|
|
971
|
-
`${hashInputTree(layout.sourceRoots, generatedDir)}\0${metadataHash}`
|
|
979
|
+
`${hashInputTree(layout.sourceRoots, generatedDir)}\0${metadataHash}\0${externalZeroConfig}`
|
|
972
980
|
)
|
|
973
981
|
if (
|
|
974
982
|
!force &&
|
|
@@ -1004,6 +1012,7 @@ async function generateWithProject(
|
|
|
1004
1012
|
}
|
|
1005
1013
|
}) &&
|
|
1006
1014
|
existsSync(resolve(generatedDir, 'models.ts')) &&
|
|
1015
|
+
(!needsDrizzleSchema || existsSync(resolve(generatedDir, 'drizzleSchema.ts'))) &&
|
|
1007
1016
|
(!needsSqliteZeroSchema || existsSync(resolve(generatedDir, 'schema.ts')))
|
|
1008
1017
|
) {
|
|
1009
1018
|
let counts: Partial<GenerateResult> = {}
|
|
@@ -1058,7 +1067,7 @@ async function generateWithProject(
|
|
|
1058
1067
|
]
|
|
1059
1068
|
|
|
1060
1069
|
let filesChanged = writeResults.filter(Boolean).length
|
|
1061
|
-
if (
|
|
1070
|
+
if (needsDrizzleSchema) {
|
|
1062
1071
|
const membership = dataMembershipFromLayout(layout)
|
|
1063
1072
|
const drizzleSchema = await generateDrizzleSchemaInputFileWithProject(
|
|
1064
1073
|
{
|
|
@@ -1068,15 +1077,18 @@ async function generateWithProject(
|
|
|
1068
1077
|
},
|
|
1069
1078
|
project
|
|
1070
1079
|
)
|
|
1071
|
-
const sqliteSchema = renderDrizzleZeroSqliteSchemaModule({
|
|
1072
|
-
importPath: './drizzleSchema',
|
|
1073
|
-
tableNames: membership.allTables,
|
|
1074
|
-
})
|
|
1075
1080
|
if (writeFileIfChanged(resolve(generatedDir, 'drizzleSchema.ts'), drizzleSchema)) {
|
|
1076
1081
|
filesChanged++
|
|
1077
1082
|
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1083
|
+
// drizzle-zero owns its output when the app declares its configuration.
|
|
1084
|
+
if (needsSqliteZeroSchema) {
|
|
1085
|
+
const sqliteSchema = renderDrizzleZeroSqliteSchemaModule({
|
|
1086
|
+
importPath: './drizzleSchema',
|
|
1087
|
+
tableNames: membership.allTables,
|
|
1088
|
+
})
|
|
1089
|
+
if (writeFileIfChanged(resolve(generatedDir, 'schema.ts'), sqliteSchema)) {
|
|
1090
|
+
filesChanged++
|
|
1091
|
+
}
|
|
1080
1092
|
}
|
|
1081
1093
|
}
|
|
1082
1094
|
let queryCount = 0
|
|
@@ -6,6 +6,7 @@ import { createEmitter } from './emitter'
|
|
|
6
6
|
import {
|
|
7
7
|
classifyZeroRecoveryLog,
|
|
8
8
|
composeRecoveryLogSink,
|
|
9
|
+
isGroupTransitionOpen,
|
|
9
10
|
isRecoverableZeroStalePokeMessage,
|
|
10
11
|
makeZeroRecovery,
|
|
11
12
|
resetRecoveryStateForTests,
|
|
@@ -559,3 +560,325 @@ describe('zero recovery', () => {
|
|
|
559
560
|
expect(isRecoverableZeroStalePokeMessage('client state not found')).toBe(false)
|
|
560
561
|
})
|
|
561
562
|
})
|
|
563
|
+
|
|
564
|
+
describe('group transitions', () => {
|
|
565
|
+
function setupTransition(opts?: {
|
|
566
|
+
group?: string
|
|
567
|
+
identity?: string
|
|
568
|
+
reload?: () => void | boolean
|
|
569
|
+
remint?: () => Promise<boolean>
|
|
570
|
+
connected?: Promise<boolean>
|
|
571
|
+
}) {
|
|
572
|
+
const events: ZeroEvent[] = []
|
|
573
|
+
const zeroEvents = createEmitter<ZeroEvent | null>(
|
|
574
|
+
`test-transition-${emitterSeq++}`,
|
|
575
|
+
null
|
|
576
|
+
)
|
|
577
|
+
zeroEvents.listen((event) => {
|
|
578
|
+
if (event) events.push(event)
|
|
579
|
+
})
|
|
580
|
+
const deleteLocalState = vi.fn(() => Promise.resolve())
|
|
581
|
+
const reload = vi.fn(opts?.reload ?? (() => {}))
|
|
582
|
+
const recoverInPlace = opts?.remint ? vi.fn(opts.remint) : undefined
|
|
583
|
+
const awaitReconnected = opts?.connected ? vi.fn(() => opts.connected!) : undefined
|
|
584
|
+
const deps: ZeroRecoveryDeps = {
|
|
585
|
+
deleteLocalState,
|
|
586
|
+
zeroEvents,
|
|
587
|
+
reload,
|
|
588
|
+
recoverInPlace,
|
|
589
|
+
awaitReconnected,
|
|
590
|
+
clientIdentity: opts?.identity,
|
|
591
|
+
getGroupID: opts?.group === undefined ? undefined : () => opts.group!,
|
|
592
|
+
}
|
|
593
|
+
return { deps, deleteLocalState, reload, recoverInPlace, events }
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function fatals(events: ZeroEvent[]): Extract<ZeroEvent, { type: 'fatal' }>[] {
|
|
597
|
+
return events.filter(
|
|
598
|
+
(event): event is Extract<ZeroEvent, { type: 'fatal' }> => event.type === 'fatal'
|
|
599
|
+
)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function recoverings(events: ZeroEvent[]): ZeroEvent[] {
|
|
603
|
+
return events.filter((event) => event.type === 'recovering')
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
test('concurrent NewClientGroup signals join one transition: one reload, joins silent, no fatal, no IDB delete', async () => {
|
|
607
|
+
const a = setupTransition({ group: 'g-a' })
|
|
608
|
+
const b = setupTransition({ group: 'g-b' })
|
|
609
|
+
// both instances (and their recovery wrappers) exist before either fires,
|
|
610
|
+
// exactly like two mounted providers meeting one registry change.
|
|
611
|
+
const recoveryA = makeZeroRecovery(a.deps)
|
|
612
|
+
const recoveryB = makeZeroRecovery(b.deps)
|
|
613
|
+
recoveryA.onUpdateNeeded({ type: UpdateNeededReasonType.NewClientGroup })
|
|
614
|
+
recoveryB.onUpdateNeeded({ type: UpdateNeededReasonType.NewClientGroup })
|
|
615
|
+
await flush()
|
|
616
|
+
// one transition: one reload, one recovering (the join stays silent).
|
|
617
|
+
expect(a.reload.mock.calls.length + b.reload.mock.calls.length).toBe(1)
|
|
618
|
+
expect(recoverings(a.events)).toHaveLength(1)
|
|
619
|
+
expect(recoverings(b.events)).toHaveLength(0)
|
|
620
|
+
expect(fatals([...a.events, ...b.events])).toEqual([])
|
|
621
|
+
// NewClientGroup never deletes IndexedDB (sibling-tab safe).
|
|
622
|
+
expect(a.deleteLocalState).not.toHaveBeenCalled()
|
|
623
|
+
expect(b.deleteLocalState).not.toHaveBeenCalled()
|
|
624
|
+
expect(isGroupTransitionOpen()).toBe(false)
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
test('a second change with a newer group starts a fresh transition after a reload', async () => {
|
|
628
|
+
const a = setupTransition({ group: 'g1' })
|
|
629
|
+
makeZeroRecovery(a.deps).onUpdateNeeded({
|
|
630
|
+
type: UpdateNeededReasonType.NewClientGroup,
|
|
631
|
+
})
|
|
632
|
+
await flush()
|
|
633
|
+
expect(a.reload).toHaveBeenCalledTimes(1)
|
|
634
|
+
|
|
635
|
+
resetRecoveryStateForTests() // simulate the page reload clearing in-memory state
|
|
636
|
+
const b = setupTransition({ group: 'g2' })
|
|
637
|
+
makeZeroRecovery(b.deps).onUpdateNeeded({
|
|
638
|
+
type: UpdateNeededReasonType.NewClientGroup,
|
|
639
|
+
})
|
|
640
|
+
await flush()
|
|
641
|
+
// newer code verifiably landed: a fresh transition, not a fatal.
|
|
642
|
+
expect(b.reload).toHaveBeenCalledTimes(1)
|
|
643
|
+
expect(recoverings(b.events)).toHaveLength(1)
|
|
644
|
+
expect(fatals(b.events)).toEqual([])
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
test('an unchanged group after a reload goes terminal once, then silent', async () => {
|
|
648
|
+
const a = setupTransition({ group: 'g1' })
|
|
649
|
+
makeZeroRecovery(a.deps).onUpdateNeeded({
|
|
650
|
+
type: UpdateNeededReasonType.NewClientGroup,
|
|
651
|
+
})
|
|
652
|
+
await flush()
|
|
653
|
+
expect(a.reload).toHaveBeenCalledTimes(1)
|
|
654
|
+
|
|
655
|
+
resetRecoveryStateForTests()
|
|
656
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
657
|
+
try {
|
|
658
|
+
const b = setupTransition({ group: 'g1' })
|
|
659
|
+
const recovery = makeZeroRecovery(b.deps)
|
|
660
|
+
recovery.onUpdateNeeded({ type: UpdateNeededReasonType.NewClientGroup })
|
|
661
|
+
await flush()
|
|
662
|
+
// the reload did not change the group: one terminal error with the IDs.
|
|
663
|
+
expect(b.reload).not.toHaveBeenCalled()
|
|
664
|
+
expect(fatals(b.events)).toHaveLength(1)
|
|
665
|
+
expect(fatals(b.events)[0]).toMatchObject({
|
|
666
|
+
reasonKey: 'NewClientGroup',
|
|
667
|
+
reason: expect.stringContaining('g1'),
|
|
668
|
+
})
|
|
669
|
+
expect(consoleError).toHaveBeenCalledTimes(1)
|
|
670
|
+
|
|
671
|
+
// re-fires stay silent: never a loop, never a cascade.
|
|
672
|
+
recovery.onUpdateNeeded({ type: UpdateNeededReasonType.NewClientGroup })
|
|
673
|
+
const c = setupTransition({ group: 'g1' })
|
|
674
|
+
makeZeroRecovery(c.deps).onUpdateNeeded({
|
|
675
|
+
type: UpdateNeededReasonType.NewClientGroup,
|
|
676
|
+
})
|
|
677
|
+
await flush()
|
|
678
|
+
expect(fatals([...b.events, ...c.events])).toHaveLength(1)
|
|
679
|
+
expect(consoleError).toHaveBeenCalledTimes(1)
|
|
680
|
+
expect(b.reload).not.toHaveBeenCalled()
|
|
681
|
+
} finally {
|
|
682
|
+
consoleError.mockRestore()
|
|
683
|
+
}
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
test('a reminted instance re-firing with an unchanged group fails terminal once', async () => {
|
|
687
|
+
let releaseConnected: (value: boolean) => void = () => {}
|
|
688
|
+
const connected = new Promise<boolean>((resolve) => {
|
|
689
|
+
releaseConnected = resolve
|
|
690
|
+
})
|
|
691
|
+
const a = setupTransition({
|
|
692
|
+
group: 'g1',
|
|
693
|
+
reload: () => false,
|
|
694
|
+
remint: () => Promise.resolve(true),
|
|
695
|
+
connected,
|
|
696
|
+
})
|
|
697
|
+
makeZeroRecovery(a.deps).onUpdateNeeded({
|
|
698
|
+
type: UpdateNeededReasonType.SchemaVersionNotSupported,
|
|
699
|
+
})
|
|
700
|
+
await flush()
|
|
701
|
+
expect(a.recoverInPlace).toHaveBeenCalledTimes(1)
|
|
702
|
+
expect(isGroupTransitionOpen()).toBe(true)
|
|
703
|
+
|
|
704
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
705
|
+
try {
|
|
706
|
+
// the replacement instance (built during the transition, same group
|
|
707
|
+
// because the new code is genuinely incompatible) fires: terminal.
|
|
708
|
+
const b = setupTransition({ group: 'g1' })
|
|
709
|
+
const recovery = makeZeroRecovery(b.deps)
|
|
710
|
+
recovery.onUpdateNeeded({
|
|
711
|
+
type: UpdateNeededReasonType.SchemaVersionNotSupported,
|
|
712
|
+
})
|
|
713
|
+
await flush()
|
|
714
|
+
expect(fatals(b.events)).toHaveLength(1)
|
|
715
|
+
expect(fatals(b.events)[0]?.reason).toContain('g1')
|
|
716
|
+
expect(consoleError).toHaveBeenCalledTimes(1)
|
|
717
|
+
expect(a.recoverInPlace).toHaveBeenCalledTimes(1)
|
|
718
|
+
expect(isGroupTransitionOpen()).toBe(false)
|
|
719
|
+
|
|
720
|
+
// the failed transition releases its connected wait (no wedged latch)
|
|
721
|
+
// and every later re-fire stays silent.
|
|
722
|
+
releaseConnected(true)
|
|
723
|
+
recovery.onUpdateNeeded({
|
|
724
|
+
type: UpdateNeededReasonType.SchemaVersionNotSupported,
|
|
725
|
+
})
|
|
726
|
+
makeZeroRecovery(a.deps).onUpdateNeeded({
|
|
727
|
+
type: UpdateNeededReasonType.SchemaVersionNotSupported,
|
|
728
|
+
})
|
|
729
|
+
await flush()
|
|
730
|
+
expect(fatals([...a.events, ...b.events])).toHaveLength(1)
|
|
731
|
+
expect(consoleError).toHaveBeenCalledTimes(1)
|
|
732
|
+
} finally {
|
|
733
|
+
consoleError.mockRestore()
|
|
734
|
+
}
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
test('a reminted instance with a changed group supersedes into a fresh transition', async () => {
|
|
738
|
+
const a = setupTransition({
|
|
739
|
+
group: 'g1',
|
|
740
|
+
reload: () => false,
|
|
741
|
+
remint: () => Promise.resolve(true),
|
|
742
|
+
connected: new Promise<boolean>(() => {}),
|
|
743
|
+
})
|
|
744
|
+
makeZeroRecovery(a.deps).onUpdateNeeded({
|
|
745
|
+
type: UpdateNeededReasonType.NewClientGroup,
|
|
746
|
+
})
|
|
747
|
+
await flush()
|
|
748
|
+
expect(isGroupTransitionOpen()).toBe(true)
|
|
749
|
+
|
|
750
|
+
// an overlapping second edit: the replacement already runs newer code,
|
|
751
|
+
// so this is a new legitimate transition, not a failure.
|
|
752
|
+
const b = setupTransition({
|
|
753
|
+
group: 'g2',
|
|
754
|
+
reload: () => false,
|
|
755
|
+
remint: () => Promise.resolve(true),
|
|
756
|
+
})
|
|
757
|
+
makeZeroRecovery(b.deps).onUpdateNeeded({
|
|
758
|
+
type: UpdateNeededReasonType.NewClientGroup,
|
|
759
|
+
})
|
|
760
|
+
await flush()
|
|
761
|
+
expect(recoverings(b.events)).toHaveLength(1)
|
|
762
|
+
expect(fatals([...a.events, ...b.events])).toEqual([])
|
|
763
|
+
expect(b.recoverInPlace).toHaveBeenCalledTimes(1)
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
test('unreadable groups allow one supersede, then go terminal', async () => {
|
|
767
|
+
const a = setupTransition({
|
|
768
|
+
reload: () => false,
|
|
769
|
+
remint: () => Promise.resolve(true),
|
|
770
|
+
connected: new Promise<boolean>(() => {}),
|
|
771
|
+
})
|
|
772
|
+
makeZeroRecovery(a.deps).onUpdateNeeded({
|
|
773
|
+
type: UpdateNeededReasonType.NewClientGroup,
|
|
774
|
+
})
|
|
775
|
+
await flush()
|
|
776
|
+
|
|
777
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
778
|
+
try {
|
|
779
|
+
// the superseding transition must stay open (blocked on its own
|
|
780
|
+
// connected wait) so the next unknown signal is judged against it.
|
|
781
|
+
const b = setupTransition({
|
|
782
|
+
reload: () => false,
|
|
783
|
+
remint: () => Promise.resolve(true),
|
|
784
|
+
connected: new Promise<boolean>(() => {}),
|
|
785
|
+
})
|
|
786
|
+
makeZeroRecovery(b.deps).onUpdateNeeded({
|
|
787
|
+
type: UpdateNeededReasonType.NewClientGroup,
|
|
788
|
+
})
|
|
789
|
+
await flush()
|
|
790
|
+
// first unknown post-transition signal: allowed as a fresh transition.
|
|
791
|
+
expect(recoverings(b.events)).toHaveLength(1)
|
|
792
|
+
expect(fatals(b.events)).toEqual([])
|
|
793
|
+
|
|
794
|
+
const c = setupTransition({ reload: () => false })
|
|
795
|
+
makeZeroRecovery(c.deps).onUpdateNeeded({
|
|
796
|
+
type: UpdateNeededReasonType.NewClientGroup,
|
|
797
|
+
})
|
|
798
|
+
await flush()
|
|
799
|
+
// second one in the window: terminal once, then silent.
|
|
800
|
+
expect(fatals(c.events)).toHaveLength(1)
|
|
801
|
+
expect(consoleError).toHaveBeenCalledTimes(1)
|
|
802
|
+
makeZeroRecovery(c.deps).onUpdateNeeded({
|
|
803
|
+
type: UpdateNeededReasonType.NewClientGroup,
|
|
804
|
+
})
|
|
805
|
+
await flush()
|
|
806
|
+
expect(fatals(c.events)).toHaveLength(1)
|
|
807
|
+
expect(consoleError).toHaveBeenCalledTimes(1)
|
|
808
|
+
} finally {
|
|
809
|
+
consoleError.mockRestore()
|
|
810
|
+
}
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
test('SchemaVersionNotSupported drops the store inside its transition', async () => {
|
|
814
|
+
const a = setupTransition({
|
|
815
|
+
group: 'g1',
|
|
816
|
+
reload: () => false,
|
|
817
|
+
remint: () => Promise.resolve(true),
|
|
818
|
+
})
|
|
819
|
+
makeZeroRecovery(a.deps).onUpdateNeeded({
|
|
820
|
+
type: UpdateNeededReasonType.SchemaVersionNotSupported,
|
|
821
|
+
})
|
|
822
|
+
await flush()
|
|
823
|
+
expect(a.deleteLocalState).toHaveBeenCalledTimes(1)
|
|
824
|
+
expect(a.recoverInPlace).toHaveBeenCalledTimes(1)
|
|
825
|
+
expect(fatals(a.events)).toEqual([])
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
test('different identities ride separate transitions but share the one reload', async () => {
|
|
829
|
+
const a = setupTransition({ group: 'g-a', identity: 'user-a' })
|
|
830
|
+
const b = setupTransition({ group: 'g-b', identity: 'user-b' })
|
|
831
|
+
const recoveryA = makeZeroRecovery(a.deps)
|
|
832
|
+
const recoveryB = makeZeroRecovery(b.deps)
|
|
833
|
+
recoveryA.onUpdateNeeded({ type: UpdateNeededReasonType.NewClientGroup })
|
|
834
|
+
recoveryB.onUpdateNeeded({ type: UpdateNeededReasonType.NewClientGroup })
|
|
835
|
+
await flush()
|
|
836
|
+
// one transition per shared identity, but still a single real reload.
|
|
837
|
+
expect(recoverings(a.events)).toHaveLength(1)
|
|
838
|
+
expect(recoverings(b.events)).toHaveLength(1)
|
|
839
|
+
expect(a.reload.mock.calls.length + b.reload.mock.calls.length).toBe(1)
|
|
840
|
+
expect(fatals([...a.events, ...b.events])).toEqual([])
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
test('a legacy bare-timestamp marker goes terminal once (legacy parity)', async () => {
|
|
844
|
+
const key = `on-zero-recover-${globalThis.location?.href}-NewClientGroup`
|
|
845
|
+
window.sessionStorage.setItem(key, String(Date.now()))
|
|
846
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
847
|
+
try {
|
|
848
|
+
const a = setupTransition({ group: 'g1' })
|
|
849
|
+
const recovery = makeZeroRecovery(a.deps)
|
|
850
|
+
recovery.onUpdateNeeded({ type: UpdateNeededReasonType.NewClientGroup })
|
|
851
|
+
await flush()
|
|
852
|
+
expect(a.reload).not.toHaveBeenCalled()
|
|
853
|
+
expect(fatals(a.events)).toHaveLength(1)
|
|
854
|
+
expect(consoleError).toHaveBeenCalledTimes(1)
|
|
855
|
+
recovery.onUpdateNeeded({ type: UpdateNeededReasonType.NewClientGroup })
|
|
856
|
+
await flush()
|
|
857
|
+
expect(fatals(a.events)).toHaveLength(1)
|
|
858
|
+
expect(consoleError).toHaveBeenCalledTimes(1)
|
|
859
|
+
} finally {
|
|
860
|
+
consoleError.mockRestore()
|
|
861
|
+
}
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
test('a participant that cannot remint skips without failing the transition', async () => {
|
|
865
|
+
const a = setupTransition({
|
|
866
|
+
group: 'g1',
|
|
867
|
+
reload: () => false,
|
|
868
|
+
remint: () => Promise.resolve(false),
|
|
869
|
+
})
|
|
870
|
+
makeZeroRecovery(a.deps).onUpdateNeeded({
|
|
871
|
+
type: UpdateNeededReasonType.NewClientGroup,
|
|
872
|
+
})
|
|
873
|
+
await flush()
|
|
874
|
+
// false means "nothing to reconstruct" (unmounted): the transition
|
|
875
|
+
// resolves, and a later signal starts a fresh one instead of fataling.
|
|
876
|
+
expect(isGroupTransitionOpen()).toBe(false)
|
|
877
|
+
makeZeroRecovery(a.deps).onUpdateNeeded({
|
|
878
|
+
type: UpdateNeededReasonType.NewClientGroup,
|
|
879
|
+
})
|
|
880
|
+
await flush()
|
|
881
|
+
expect(recoverings(a.events)).toHaveLength(2)
|
|
882
|
+
expect(fatals(a.events)).toEqual([])
|
|
883
|
+
})
|
|
884
|
+
})
|