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.
Files changed (120) hide show
  1. package/dist/cjs/createUseQuery.cjs +2 -1
  2. package/dist/cjs/createUseQuery.native.js +2 -1
  3. package/dist/cjs/createUseQuery.native.js.map +1 -1
  4. package/dist/cjs/createUseQueryDirect.cjs +2 -1
  5. package/dist/cjs/createUseQueryDirect.native.js +2 -1
  6. package/dist/cjs/createUseQueryDirect.native.js.map +1 -1
  7. package/dist/cjs/createZeroClient.cjs +75 -9
  8. package/dist/cjs/createZeroClient.native.js +151 -81
  9. package/dist/cjs/createZeroClient.native.js.map +1 -1
  10. package/dist/cjs/createZeroClient.recovery.test.cjs +351 -10
  11. package/dist/cjs/createZeroClient.recovery.test.native.js +412 -12
  12. package/dist/cjs/createZeroClient.recovery.test.native.js.map +1 -1
  13. package/dist/cjs/generate.cjs +16 -11
  14. package/dist/cjs/generate.native.js +16 -11
  15. package/dist/cjs/generate.native.js.map +1 -1
  16. package/dist/cjs/generate.test.cjs +22 -0
  17. package/dist/cjs/generate.test.native.js +22 -0
  18. package/dist/cjs/generate.test.native.js.map +1 -1
  19. package/dist/cjs/helpers/recoverZeroClient.cjs +542 -52
  20. package/dist/cjs/helpers/recoverZeroClient.native.js +721 -59
  21. package/dist/cjs/helpers/recoverZeroClient.native.js.map +1 -1
  22. package/dist/cjs/helpers/recoverZeroClient.test.cjs +318 -0
  23. package/dist/cjs/helpers/recoverZeroClient.test.native.js +361 -0
  24. package/dist/cjs/helpers/recoverZeroClient.test.native.js.map +1 -1
  25. package/dist/cjs/kvStore.cjs +30 -0
  26. package/dist/cjs/kvStore.native.js +34 -0
  27. package/dist/cjs/kvStore.native.js.map +1 -0
  28. package/dist/cjs/queryJson.cjs +58 -0
  29. package/dist/cjs/queryJson.native.js +86 -0
  30. package/dist/cjs/queryJson.native.js.map +1 -0
  31. package/dist/cjs/resolveQuery.cjs +2 -0
  32. package/dist/cjs/resolveQuery.native.js +2 -0
  33. package/dist/cjs/resolveQuery.native.js.map +1 -1
  34. package/dist/cjs/useQuery.empty.test.cjs +11 -1
  35. package/dist/cjs/useQuery.empty.test.native.js +23 -1
  36. package/dist/cjs/useQuery.empty.test.native.js.map +1 -1
  37. package/dist/cjs/useQuery.ssr.test.cjs +22 -1
  38. package/dist/cjs/useQuery.ssr.test.native.js +30 -1
  39. package/dist/cjs/useQuery.ssr.test.native.js.map +1 -1
  40. package/dist/esm/createUseQuery.mjs +2 -1
  41. package/dist/esm/createUseQuery.mjs.map +1 -1
  42. package/dist/esm/createUseQuery.native.js +2 -1
  43. package/dist/esm/createUseQuery.native.js.map +1 -1
  44. package/dist/esm/createUseQueryDirect.mjs +2 -1
  45. package/dist/esm/createUseQueryDirect.mjs.map +1 -1
  46. package/dist/esm/createUseQueryDirect.native.js +2 -1
  47. package/dist/esm/createUseQueryDirect.native.js.map +1 -1
  48. package/dist/esm/createZeroClient.mjs +76 -10
  49. package/dist/esm/createZeroClient.mjs.map +1 -1
  50. package/dist/esm/createZeroClient.native.js +152 -82
  51. package/dist/esm/createZeroClient.native.js.map +1 -1
  52. package/dist/esm/createZeroClient.recovery.test.mjs +352 -11
  53. package/dist/esm/createZeroClient.recovery.test.mjs.map +1 -1
  54. package/dist/esm/createZeroClient.recovery.test.native.js +413 -13
  55. package/dist/esm/createZeroClient.recovery.test.native.js.map +1 -1
  56. package/dist/esm/generate.mjs +16 -11
  57. package/dist/esm/generate.mjs.map +1 -1
  58. package/dist/esm/generate.native.js +16 -11
  59. package/dist/esm/generate.native.js.map +1 -1
  60. package/dist/esm/generate.test.mjs +22 -0
  61. package/dist/esm/generate.test.mjs.map +1 -1
  62. package/dist/esm/generate.test.native.js +22 -0
  63. package/dist/esm/generate.test.native.js.map +1 -1
  64. package/dist/esm/helpers/recoverZeroClient.mjs +542 -54
  65. package/dist/esm/helpers/recoverZeroClient.mjs.map +1 -1
  66. package/dist/esm/helpers/recoverZeroClient.native.js +721 -61
  67. package/dist/esm/helpers/recoverZeroClient.native.js.map +1 -1
  68. package/dist/esm/helpers/recoverZeroClient.test.mjs +319 -1
  69. package/dist/esm/helpers/recoverZeroClient.test.mjs.map +1 -1
  70. package/dist/esm/helpers/recoverZeroClient.test.native.js +362 -1
  71. package/dist/esm/helpers/recoverZeroClient.test.native.js.map +1 -1
  72. package/dist/esm/kvStore.mjs +5 -0
  73. package/dist/esm/kvStore.mjs.map +1 -0
  74. package/dist/esm/kvStore.native.js +6 -0
  75. package/dist/esm/kvStore.native.js.map +1 -0
  76. package/dist/esm/queryJson.mjs +32 -0
  77. package/dist/esm/queryJson.mjs.map +1 -0
  78. package/dist/esm/queryJson.native.js +57 -0
  79. package/dist/esm/queryJson.native.js.map +1 -0
  80. package/dist/esm/resolveQuery.mjs +2 -0
  81. package/dist/esm/resolveQuery.mjs.map +1 -1
  82. package/dist/esm/resolveQuery.native.js +2 -0
  83. package/dist/esm/resolveQuery.native.js.map +1 -1
  84. package/dist/esm/useQuery.empty.test.mjs +12 -2
  85. package/dist/esm/useQuery.empty.test.mjs.map +1 -1
  86. package/dist/esm/useQuery.empty.test.native.js +24 -2
  87. package/dist/esm/useQuery.empty.test.native.js.map +1 -1
  88. package/dist/esm/useQuery.ssr.test.mjs +23 -2
  89. package/dist/esm/useQuery.ssr.test.mjs.map +1 -1
  90. package/dist/esm/useQuery.ssr.test.native.js +31 -2
  91. package/dist/esm/useQuery.ssr.test.native.js.map +1 -1
  92. package/package.json +11 -3
  93. package/src/createUseQuery.tsx +2 -1
  94. package/src/createUseQueryDirect.tsx +2 -1
  95. package/src/createZeroClient.recovery.test.tsx +370 -7
  96. package/src/createZeroClient.tsx +100 -10
  97. package/src/generate.test.ts +31 -0
  98. package/src/generate.ts +22 -10
  99. package/src/helpers/recoverZeroClient.test.ts +323 -0
  100. package/src/helpers/recoverZeroClient.ts +896 -78
  101. package/src/kvStore.native.ts +7 -0
  102. package/src/kvStore.ts +7 -0
  103. package/src/queryJson.ts +46 -0
  104. package/src/resolveQuery.ts +2 -0
  105. package/src/useQuery.empty.test.tsx +16 -2
  106. package/src/useQuery.ssr.test.ts +23 -2
  107. package/types/createUseQuery.d.ts.map +1 -1
  108. package/types/createUseQueryDirect.d.ts.map +1 -1
  109. package/types/createZeroClient.d.ts.map +1 -1
  110. package/types/createZeroClient.recovery.test.d.ts.map +1 -1
  111. package/types/generate.d.ts.map +1 -1
  112. package/types/helpers/recoverZeroClient.d.ts +6 -0
  113. package/types/helpers/recoverZeroClient.d.ts.map +1 -1
  114. package/types/kvStore.d.ts +2 -0
  115. package/types/kvStore.d.ts.map +1 -0
  116. package/types/kvStore.native.d.ts +3 -0
  117. package/types/kvStore.native.d.ts.map +1 -0
  118. package/types/queryJson.d.ts +3 -0
  119. package/types/queryJson.d.ts.map +1 -0
  120. 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 = '7'
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 needsSqliteZeroSchema = existsSync(databaseSchemaPath)
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 (needsSqliteZeroSchema) {
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
- if (writeFileIfChanged(resolve(generatedDir, 'schema.ts'), sqliteSchema)) {
1079
- filesChanged++
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
+ })