Skip to content

Commit

Permalink
feat: Add project.$waitForInitialSync() method
Browse files Browse the repository at this point in the history
Fixes Add project method to download auth + config cores #233

Rather than call this inside the `client.addProject()` method, instead I
think it is better for the API consumer to call
`project.$waitForInitialSync()` after adding a project, since this
allows the implementer to give user feedback about what is happening.
  • Loading branch information
gmaclennan committed Oct 27, 2023
1 parent 9bff9ac commit 1c0dc6b
Show file tree
Hide file tree
Showing 4 changed files with 74 additions and 7 deletions.
2 changes: 2 additions & 0 deletions src/capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ export class Capabilities {
#projectCreatorAuthCoreId
#ownDeviceId

static NO_ROLE_CAPABILITIES = NO_ROLE_CAPABILITIES

/**
*
* @param {object} opts
Expand Down
12 changes: 7 additions & 5 deletions src/mapeo-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,10 @@ export class MapeoManager extends TypedEmitter {
}

/**
* Add a project to this device. After adding a project the client should
* await `project.$waitForInitialSync()` to ensure that the device has
* downloaded their proof of project membership and the project config.
*
* @param {import('./generated/rpc.js').Invite} invite
* @returns {Promise<string>}
*/
Expand Down Expand Up @@ -410,10 +414,9 @@ export class MapeoManager extends TypedEmitter {
throw new Error(`Project with ID ${projectPublicId} already exists`)
}

// TODO: Relies on completion of https://github.com/digidem/mapeo-core-next/issues/233
// 3. Sync auth + config cores
// No awaits here - need to update table in same tick as the projectExists check

// 4. Update the project keys table
// 3. Update the project keys table
this.#saveToProjectKeysTable({
projectId,
projectPublicId,
Expand All @@ -424,9 +427,8 @@ export class MapeoManager extends TypedEmitter {
projectInfo,
})

// 5. Write device info into project
// 4. Write device info into project
const deviceInfo = await this.getDeviceInfo()

if (deviceInfo.name) {
const project = await this.getProject(projectPublicId)
await project[kSetOwnDeviceInfo]({ name: deviceInfo.name })
Expand Down
61 changes: 60 additions & 1 deletion src/mapeo-project.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const kCoreOwnership = Symbol('coreOwnership')
export const kCapabilities = Symbol('capabilities')
export const kSetOwnDeviceInfo = Symbol('kSetOwnDeviceInfo')
export const kProjectReplicate = Symbol('replicate project')
const EMPTY_PROJECT_SETTINGS = Object.freeze({})

export class MapeoProject {
#projectId
Expand Down Expand Up @@ -413,14 +414,72 @@ export class MapeoProject {
await this.#dataTypes.projectSettings.getByDocId(this.#projectId)
)
} catch {
return /** @type {EditableProjectSettings} */ ({})
return /** @type {EditableProjectSettings} */ (EMPTY_PROJECT_SETTINGS)
}
}

async $getOwnCapabilities() {
return this.#capabilities.getCapabilities(this.#deviceId)
}

/**
* Sync initial data: the `auth` cores which contain the capability messages,
* and the `config` cores which contain the project name & custom config (if
* it exists). The API consumer should await this after `client.addProject()`
* to ensure that the device is fully added to the project.
*
* @param {object} [opts]
* @param {number} [opts.timeoutMs=5000] Timeout in milliseconds for max time
* to wait between sync status updates before giving up. As long as syncing is
* happening, this will never timeout, but if more than timeoutMs passes
* without any sync activity, then this will resolve `false` e.g. data has not
* synced
* @returns
*/
async $waitForInitialSync({ timeoutMs = 5000 } = {}) {
const [capability, projectSettings] = await Promise.all([
this.$getOwnCapabilities(),
this.$getProjectSettings(),
])
const {
auth: { localState: authState },
config: { localState: configState },
} = this.#syncApi.getState()
const isCapabilitySynced = capability !== Capabilities.NO_ROLE_CAPABILITIES
const isProjectSettingsSynced = projectSettings !== EMPTY_PROJECT_SETTINGS
// Assumes every project that someone is invited to has at least one record
// in the auth store - the capability record for the invited device
const isAuthSynced = authState.want === 0 && authState.have > 0
// Assumes every project that someone is invited to has at least one record
// in the config store - defining the name of the project.
// TODO: Enforce adding a project name in the invite method
const isConfigSynced = configState.want === 0 && configState.have > 0
if (
isCapabilitySynced &&
isProjectSettingsSynced &&
isAuthSynced &&
isConfigSynced
) {
return true
}
return new Promise((resolve) => {
/** @param {import('./sync/sync-state.js').State} syncState */
const onSyncState = (syncState) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(onTimeout, timeoutMs)
if (syncState.auth.dataToSync || syncState.config.dataToSync) return
this.#syncApi.off('sync-state', onSyncState)
resolve(this.$waitForInitialSync())
}
const onTimeout = () => {
this.#syncApi.off('sync-state', onSyncState)
resolve(false)
}
let timeoutId = setTimeout(onTimeout, timeoutMs)
this.#syncApi.on('sync-state', onSyncState)
})
}

/**
* Replicate a project to a @hyperswarm/secret-stream. Invites will not
* function because the RPC channel is not connected for project replication,
Expand Down
6 changes: 5 additions & 1 deletion src/sync/namespace-sync-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { CoreSyncState } from './core-sync-state.js'
import { discoveryKey } from 'hypercore-crypto'

/**
* @typedef {Omit<import('./core-sync-state.js').DerivedState, 'coreLength'>} SyncState
* @typedef {Omit<import('./core-sync-state.js').DerivedState, 'coreLength'> & { dataToSync: boolean }} SyncState
*/

/**
Expand Down Expand Up @@ -55,6 +55,7 @@ export class NamespaceSyncState {
if (this.#cachedState) return this.#cachedState
/** @type {SyncState} */
const state = {
dataToSync: false,
localState: createState(),
remoteStates: {},
}
Expand All @@ -71,6 +72,9 @@ export class NamespaceSyncState {
}
}
}
if (state.localState.want > 0 || state.localState.wanted > 0) {
state.dataToSync = true
}
this.#cachedState = state
return state
}
Expand Down

0 comments on commit 1c0dc6b

Please sign in to comment.