Skip to content

Commit addc7da

Browse files
committed
feat: introduce initial sync hooks
1 parent 7de0925 commit addc7da

File tree

6 files changed

+509
-0
lines changed

6 files changed

+509
-0
lines changed

docs/API.md

+56
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
- [useImportProjectConfig](#useimportprojectconfig)
2121
- [useUpdateProjectSettings](#useupdateprojectsettings)
2222
- [useCreateBlob](#usecreateblob)
23+
- [useSyncState](#usesyncstate)
24+
- [useDataSyncProgress](#usedatasyncprogress)
25+
- [useStartSync](#usestartsync)
26+
- [useStopSync](#usestopsync)
2327
- [useSingleDocByDocId](#usesingledocbydocid)
2428
- [useSingleDocByVersionId](#usesingledocbyversionid)
2529
- [useManyDocs](#usemanydocs)
@@ -434,6 +438,58 @@ Parameters:
434438
* `opts.projectId`: Public project ID of project to apply to changes to.
435439

436440

441+
### useSyncState
442+
443+
Hook to subscribe to the current sync state.
444+
445+
Creates a global singleton for each project, to minimize traffic over IPC -
446+
this hook can safely be used in more than one place without attaching
447+
additional listeners across the IPC channel.
448+
449+
| Function | Type |
450+
| ---------- | ---------- |
451+
| `useSyncState` | `({ projectId, }: { projectId: string; }) => State or null` |
452+
453+
Parameters:
454+
455+
* `opts.projectId`: Project public ID
456+
457+
458+
Examples:
459+
460+
```ts
461+
function Example() {
462+
const syncState = useSyncState({ projectId });
463+
464+
if (!syncState) {
465+
// Sync information hasn't been loaded yet
466+
}
467+
468+
// Actual info about sync state is available...
469+
}
470+
```
471+
472+
473+
### useDataSyncProgress
474+
475+
Provides the progress of data sync for sync-enabled connected peers
476+
477+
| Function | Type |
478+
| ---------- | ---------- |
479+
| `useDataSyncProgress` | `({ projectId, }: { projectId: string; }) => number or null` |
480+
481+
### useStartSync
482+
483+
| Function | Type |
484+
| ---------- | ---------- |
485+
| `useStartSync` | `({ projectId }: { projectId: string; }) => { mutate: UseMutateFunction<void, Error, { autostopDataSyncAfter: number or null; } or undefined, unknown>; reset: () => void; status: "pending" or ... 2 more ... or "idle"; }` |
486+
487+
### useStopSync
488+
489+
| Function | Type |
490+
| ---------- | ---------- |
491+
| `useStopSync` | `({ projectId }: { projectId: string; }) => { mutate: UseMutateFunction<void, Error, void, unknown>; reset: () => void; status: "pending" or "error" or "success" or "idle"; }` |
492+
437493
### useSingleDocByDocId
438494

439495
Retrieve a single document from the database based on the document's document ID.

src/hooks/projects.ts

+88
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import type {
33
SvgOpts,
44
} from '@comapeo/core/dist/icon-api.js' with { 'resolution-mode': 'import' }
55
import type { BlobId } from '@comapeo/core/dist/types.js' with { 'resolution-mode': 'import' }
6+
import type { MapeoProjectApi } from '@comapeo/ipc' with { 'resolution-mode': 'import' }
67
import {
78
useMutation,
89
useQueryClient,
910
useSuspenseQuery,
1011
} from '@tanstack/react-query'
12+
import { useSyncExternalStore } from 'react'
1113

1214
import {
1315
addServerPeerMutationOptions,
@@ -23,8 +25,11 @@ import {
2325
projectMembersQueryOptions,
2426
projectSettingsQueryOptions,
2527
projectsQueryOptions,
28+
startSyncMutationOptions,
29+
stopSyncMutationOptions,
2630
updateProjectSettingsMutationOptions,
2731
} from '../lib/react-query/projects.js'
32+
import { SyncStore, type SyncState } from '../lib/sync.js'
2833
import { useClientApi } from './client.js'
2934

3035
/**
@@ -423,3 +428,86 @@ export function useCreateBlob({ projectId }: { projectId: string }) {
423428

424429
return { mutate, reset, status }
425430
}
431+
432+
const PROJECT_SYNC_STORE_MAP = new WeakMap<MapeoProjectApi, SyncStore>()
433+
434+
function useSyncStore({ projectId }: { projectId: string }) {
435+
const { data: projectApi } = useSingleProject({ projectId })
436+
437+
let syncStore = PROJECT_SYNC_STORE_MAP.get(projectApi)
438+
439+
if (!syncStore) {
440+
syncStore = new SyncStore(projectApi)
441+
PROJECT_SYNC_STORE_MAP.set(projectApi, syncStore)
442+
}
443+
444+
return syncStore
445+
}
446+
447+
/**
448+
* Hook to subscribe to the current sync state.
449+
*
450+
* Creates a global singleton for each project, to minimize traffic over IPC -
451+
* this hook can safely be used in more than one place without attaching
452+
* additional listeners across the IPC channel.
453+
*
454+
* @example
455+
* ```ts
456+
* function Example() {
457+
* const syncState = useSyncState({ projectId });
458+
*
459+
* if (!syncState) {
460+
* // Sync information hasn't been loaded yet
461+
* }
462+
*
463+
* // Actual info about sync state is available...
464+
* }
465+
* ```
466+
*
467+
* @param opts.projectId Project public ID
468+
*/
469+
export function useSyncState({
470+
projectId,
471+
}: {
472+
projectId: string
473+
}): SyncState | null {
474+
const syncStore = useSyncStore({ projectId })
475+
476+
const { subscribe, getStateSnapshot } = syncStore
477+
478+
return useSyncExternalStore(subscribe, getStateSnapshot)
479+
}
480+
481+
/**
482+
* Provides the progress of data sync for sync-enabled connected peers
483+
*
484+
* @returns `null` if no sync state events have been received. Otherwise returns a value between 0 and 1 (inclusive)
485+
*/
486+
export function useDataSyncProgress({
487+
projectId,
488+
}: {
489+
projectId: string
490+
}): number | null {
491+
const { subscribe, getDataProgressSnapshot } = useSyncStore({ projectId })
492+
return useSyncExternalStore(subscribe, getDataProgressSnapshot)
493+
}
494+
495+
export function useStartSync({ projectId }: { projectId: string }) {
496+
const { data: projectApi } = useSingleProject({ projectId })
497+
498+
const { mutate, reset, status } = useMutation(
499+
startSyncMutationOptions({ projectApi }),
500+
)
501+
502+
return { mutate, reset, status }
503+
}
504+
505+
export function useStopSync({ projectId }: { projectId: string }) {
506+
const { data: projectApi } = useSingleProject({ projectId })
507+
508+
const { mutate, reset, status } = useMutation(
509+
stopSyncMutationOptions({ projectApi }),
510+
)
511+
512+
return { mutate, reset, status }
513+
}

src/index.ts

+5
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export {
2626
useAttachmentUrl,
2727
useCreateBlob,
2828
useCreateProject,
29+
useDataSyncProgress,
2930
useDocumentCreatedBy,
3031
useIconUrl,
3132
useImportProjectConfig,
@@ -35,8 +36,12 @@ export {
3536
useProjectSettings,
3637
useSingleMember,
3738
useSingleProject,
39+
useStartSync,
40+
useStopSync,
41+
useSyncState,
3842
useUpdateProjectSettings,
3943
} from './hooks/projects.js'
44+
export { type SyncState } from './lib/sync.js'
4045
export {
4146
type WriteableDocument,
4247
type WriteableDocumentType,

src/lib/react-query/projects.ts

+32
Original file line numberDiff line numberDiff line change
@@ -413,3 +413,35 @@ export function createBlobMutationOptions({
413413
}
414414
>
415415
}
416+
417+
export function startSyncMutationOptions({
418+
projectApi,
419+
}: {
420+
projectApi: MapeoProjectApi
421+
}) {
422+
return {
423+
...baseMutationOptions(),
424+
mutationFn: async (opts) => {
425+
// Have to avoid passing `undefined` explicitly
426+
// See https://github.com/digidem/rpc-reflector/issues/21
427+
return opts ? projectApi.$sync.start(opts) : projectApi.$sync.start()
428+
},
429+
} satisfies UseMutationOptions<
430+
void,
431+
Error,
432+
{ autostopDataSyncAfter: number | null } | undefined
433+
>
434+
}
435+
436+
export function stopSyncMutationOptions({
437+
projectApi,
438+
}: {
439+
projectApi: MapeoProjectApi
440+
}) {
441+
return {
442+
...baseMutationOptions(),
443+
mutationFn: async () => {
444+
return projectApi.$sync.stop()
445+
},
446+
} satisfies UseMutationOptions<void, Error, void>
447+
}

src/lib/sync.ts

+144
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import type { MapeoProjectApi } from '@comapeo/ipc' with { 'resolution-mode': 'import' }
2+
3+
export type SyncState = Awaited<
4+
ReturnType<MapeoProjectApi['$sync']['getState']>
5+
>
6+
7+
export function getDataSyncCountForDevice(
8+
syncStateForDevice: SyncState['remoteDeviceSyncState'][string],
9+
) {
10+
const { data } = syncStateForDevice
11+
return data.want + data.wanted
12+
}
13+
14+
export class SyncStore {
15+
#project: MapeoProjectApi
16+
17+
#listeners = new Set<() => void>()
18+
#isSubscribedInternal = false
19+
#error: Error | null = null
20+
#state: SyncState | null = null
21+
22+
// Used for calculating sync progress
23+
#perDeviceMaxSyncCount = new Map<string, number>()
24+
25+
constructor(project: MapeoProjectApi) {
26+
this.#project = project
27+
}
28+
29+
subscribe = (listener: () => void) => {
30+
this.#listeners.add(listener)
31+
if (!this.#isSubscribedInternal) this.#startSubscription()
32+
return () => {
33+
this.#listeners.delete(listener)
34+
if (this.#listeners.size === 0) this.#stopSubscription()
35+
}
36+
}
37+
38+
getStateSnapshot = () => {
39+
if (this.#error) throw this.#error
40+
return this.#state
41+
}
42+
43+
getDataProgressSnapshot = () => {
44+
if (this.#state === null) {
45+
return null
46+
}
47+
48+
let currentSyncCount = 0
49+
let totalMaxSyncCount = 0
50+
let otherEnabledDevicesExist = false
51+
52+
for (const [deviceId, deviceSyncState] of Object.entries(
53+
this.#state.remoteDeviceSyncState,
54+
)) {
55+
if (deviceSyncState.data.isSyncEnabled) {
56+
otherEnabledDevicesExist = true
57+
} else {
58+
continue
59+
}
60+
61+
const existingMaxCount = this.#perDeviceMaxSyncCount.get(deviceId)
62+
63+
if (typeof existingMaxCount === 'number' && existingMaxCount > 0) {
64+
currentSyncCount = getDataSyncCountForDevice(deviceSyncState)
65+
totalMaxSyncCount += existingMaxCount
66+
}
67+
}
68+
69+
if (!otherEnabledDevicesExist) {
70+
return null
71+
}
72+
73+
if (totalMaxSyncCount === 0) {
74+
return 1
75+
}
76+
77+
const ratio = (totalMaxSyncCount - currentSyncCount) / totalMaxSyncCount
78+
79+
if (ratio <= 0) return 0
80+
if (ratio >= 1) return 1
81+
82+
return clamp(ratio, 0.01, 0.99)
83+
}
84+
85+
#notifyListeners() {
86+
for (const listener of this.#listeners) {
87+
listener()
88+
}
89+
}
90+
91+
#onSyncState = (state: SyncState) => {
92+
const dataSyncWasEnabled = this.#state
93+
? this.#state.data.isSyncEnabled
94+
: false
95+
96+
// Reset map keeping track of counts used for progress if data sync is toggled
97+
if (dataSyncWasEnabled !== state.data.isSyncEnabled) {
98+
this.#perDeviceMaxSyncCount.clear()
99+
} else {
100+
// Remove devices from #perDeviceMaxSyncCount that are no longer found in the new sync state
101+
for (const deviceId of this.#perDeviceMaxSyncCount.keys()) {
102+
if (!Object.hasOwn(state.remoteDeviceSyncState, deviceId)) {
103+
this.#perDeviceMaxSyncCount.delete(deviceId)
104+
}
105+
}
106+
}
107+
108+
for (const [deviceId, stateForDevice] of Object.entries(
109+
state.remoteDeviceSyncState,
110+
)) {
111+
const existingCount = this.#perDeviceMaxSyncCount.get(deviceId)
112+
const newCount = getDataSyncCountForDevice(stateForDevice)
113+
114+
if (existingCount === undefined || existingCount < newCount) {
115+
this.#perDeviceMaxSyncCount.set(deviceId, newCount)
116+
}
117+
}
118+
119+
this.#state = state
120+
this.#error = null
121+
this.#notifyListeners()
122+
}
123+
124+
#startSubscription = () => {
125+
this.#project.$sync.on('sync-state', this.#onSyncState)
126+
this.#isSubscribedInternal = true
127+
this.#project.$sync
128+
.getState()
129+
.then(this.#onSyncState)
130+
.catch((e) => {
131+
this.#error = e
132+
this.#notifyListeners()
133+
})
134+
}
135+
136+
#stopSubscription = () => {
137+
this.#isSubscribedInternal = false
138+
this.#project.$sync.off('sync-state', this.#onSyncState)
139+
}
140+
}
141+
142+
function clamp(value: number, min: number, max: number): number {
143+
return Math.max(min, Math.min(value, max))
144+
}

0 commit comments

Comments
 (0)