From 966a0db5c2539bc2607d5b1a248f93795d5eccc0 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 25 Apr 2024 22:13:32 +0000 Subject: [PATCH 01/19] feat: add "on backgrounded" function `MapeoManager#onBackgrounded()` should be called when the app goes into the background. It will do its best to gracefully shut down sync. Closes [#576]. [#576]: https://github.com/digidem/mapeo-core-next/issues/576 --- src/mapeo-manager.js | 28 +++++++++- src/sync/peer-sync-controller.js | 82 ++++++++++++++++++++++++++-- src/sync/sync-api.js | 18 ++++++ test-e2e/sync.js | 94 ++++++++++++++++++++++++++++++++ 4 files changed, 216 insertions(+), 6 deletions(-) diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index d70c9f814..444c4e8bd 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -41,7 +41,7 @@ import { LocalDiscovery } from './discovery/local-discovery.js' import { Roles } from './roles.js' import NoiseSecretStream from '@hyperswarm/secret-stream' import { Logger } from './logger.js' -import { kSyncState } from './sync/sync-api.js' +import { kSyncState, kPause, kResume } from './sync/sync-api.js' /** @typedef {import("@mapeo/schema").ProjectSettingsValue} ProjectValue */ /** @typedef {import('type-fest').SetNonNullable} ValidatedProjectKeys */ @@ -754,6 +754,32 @@ export class MapeoManager extends TypedEmitter { return omitPeerProtomux(this.#localPeers.peers) } + /** + * Call this when the app goes into the background. + * + * Will gracefully shut down sync. + * + * @see {@link onForegrounded} + * @returns {void} + */ + onBackgrounded() { + const projects = this.#activeProjects.values() + for (const project of projects) project.$sync[kPause]() + } + + /** + * Call this when the app goes into the foreground. + * + * Will undo the effects of `onBackgrounded`. + * + * @see {@link onBackgrounded} + * @returns {void} + */ + onForegrounded() { + const projects = this.#activeProjects.values() + for (const project of projects) project.$sync[kResume]() + } + /** * @param {string} projectPublicId */ diff --git a/src/sync/peer-sync-controller.js b/src/sync/peer-sync-controller.js index 54bd9cc48..7446a0c47 100644 --- a/src/sync/peer-sync-controller.js +++ b/src/sync/peer-sync-controller.js @@ -1,7 +1,7 @@ import mapObject from 'map-obj' import { NAMESPACES } from '../constants.js' import { Logger } from '../logger.js' -import { createMap } from '../utils.js' +import { ExhaustivenessError, createMap } from '../utils.js' /** * @typedef {import('../core-manager/index.js').Namespace} Namespace @@ -30,6 +30,8 @@ export class PeerSyncController { #prevLocalState = createNamespaceMap(null) /** @type {SyncStatus} */ #syncStatus = createNamespaceMap('unknown') + /** @type {Record} */ + #namespacePauseState = createNamespaceMap('unpaused') /** @type {Map, ReturnType>} */ #downloadingRanges = new Map() /** @type {SyncStatus} */ @@ -98,6 +100,59 @@ export class PeerSyncController { this.#updateEnabledNamespaces() } + /** + * @param {Namespace} ns + * @returns {void} + */ + #pauseNamespace(ns) { + const previousState = this.#namespacePauseState[ns] + switch (previousState) { + case 'unpaused': + case 'pausing': { + const syncStatus = this.#syncStatus[ns] + switch (syncStatus) { + case 'syncing': + case 'unknown': + this.#namespacePauseState[ns] = 'pausing' + break + case 'synced': + this.#namespacePauseState[ns] = 'paused' + break + default: + throw new ExhaustivenessError(syncStatus) + } + break + } + case 'paused': + break + default: + throw new ExhaustivenessError(previousState) + } + } + + /** + * Mark all namespaces as "paused". This will finish syncing and then close + * the namespace's cores. + * + * @see {@link resume} + * @returns {void} + */ + pause() { + for (const ns of NAMESPACES) this.#pauseNamespace(ns) + this.#updateEnabledNamespaces() + } + + /** + * Unpause syncing of all namespaces. + * + * @see {@link pause} + * @returns {void} + */ + resume() { + this.#namespacePauseState = createNamespaceMap('unpaused') + this.#updateEnabledNamespaces() + } + /** * @param {Buffer} discoveryKey */ @@ -152,6 +207,14 @@ export class PeerSyncController { }) this.#log('state %X', state) + for (const ns of NAMESPACES) { + const syncStatus = this.#syncStatus[ns] + const previousPauseState = this.#namespacePauseState[ns] + if (previousPauseState === 'pausing' && syncStatus === 'synced') { + this.#namespacePauseState[ns] = 'paused' + } + } + // Map of which namespaces have received new data since last sync change const didUpdate = mapObject(state, (ns) => { const nsDidSync = @@ -180,10 +243,7 @@ export class PeerSyncController { } this.#log('capability %o', this.#syncCapability) - // If any namespace has new data, update what is enabled - if (Object.values(didUpdate).indexOf(true) > -1) { - this.#updateEnabledNamespaces() - } + this.#updateEnabledNamespaces() } #updateEnabledNamespaces() { @@ -202,6 +262,18 @@ export class PeerSyncController { this.#disableNamespace(ns) } } else if (cap === 'allowed') { + const namespacePauseState = this.#namespacePauseState[ns] + switch (namespacePauseState) { + case 'unpaused': + case 'pausing': + break + case 'paused': + this.#disableNamespace(ns) + continue + default: + throw new ExhaustivenessError(namespacePauseState) + } + if (PRESYNC_NAMESPACES.includes(ns)) { this.#enableNamespace(ns) } else if (this.#isDataSyncEnabled) { diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index d60e1b457..bf9893ce8 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -11,6 +11,8 @@ import { keyToId } from '../utils.js' export const kHandleDiscoveryKey = Symbol('handle discovery key') export const kSyncState = Symbol('sync state') +export const kPause = Symbol('pause') +export const kResume = Symbol('resume') /** * @typedef {'initial' | 'full'} SyncType @@ -155,6 +157,22 @@ export class SyncApi extends TypedEmitter { this.emit('sync-state', this.getState()) } + /** @returns {void} */ + [kPause]() { + const peerSyncControllers = this.#peerSyncControllers.values() + for (const peerSyncController of peerSyncControllers) { + peerSyncController.pause() + } + } + + /** @returns {void} */ + [kResume]() { + const peerSyncControllers = this.#peerSyncControllers.values() + for (const peerSyncController of peerSyncControllers) { + peerSyncController.resume() + } + } + /** * @param {SyncType} type * @returns {Promise} diff --git a/test-e2e/sync.js b/test-e2e/sync.js index e4a31ae0b..835b45fab 100644 --- a/test-e2e/sync.js +++ b/test-e2e/sync.js @@ -160,6 +160,100 @@ test('start and stop sync', async function (t) { await disconnect() }) +test('gracefully shutting down sync for all projects when backgrounded', async function (t) { + const managers = await createManagers(2, t) + const [invitor, ...invitees] = managers + + const disconnect = connectPeers(managers, { discovery: false }) + t.teardown(disconnect) + + const projectGroupsAfterFirstStep = await Promise.all( + [1, 2, 3].map(async (projectNumber) => { + const projectId = await invitor.createProject({ + name: `Project ${projectNumber}`, + }) + + await invite({ invitor, invitees, projectId }) + + const projects = await Promise.all( + managers.map((m) => m.getProject(projectId)) + ) + const [invitorProject, inviteeProject] = projects + + const observation1 = await invitorProject.observation.create( + valueOf(generate('observation')[0]) + ) + + await t.exception( + () => inviteeProject.observation.getByDocId(observation1.docId), + 'before peers have started sync, doc does not sync' + ) + + inviteeProject.$sync.start() + invitorProject.$sync.start() + + await waitForSync(projects, 'full') + + return { invitorProject, inviteeProject, observation1 } + }) + ) + + invitor.onBackgrounded() + + const projectGroupsAfterSecondStep = await Promise.all( + projectGroupsAfterFirstStep.map( + async ({ invitorProject, inviteeProject, observation1 }) => { + t.ok( + await inviteeProject.observation.getByDocId(observation1.docId), + 'invitee receives doc' + ) + + const observation2 = await invitorProject.observation.create( + valueOf(generate('observation')[0]) + ) + const observation3 = await inviteeProject.observation.create( + valueOf(generate('observation')[0]) + ) + await delay(1000) + await t.exception( + () => inviteeProject.observation.getByDocId(observation2.docId), + "invitee doesn't receive second doc yet" + ) + await t.exception( + () => invitorProject.observation.getByDocId(observation3.docId), + "invitor doesn't receive third doc yet" + ) + + return { invitorProject, inviteeProject, observation2, observation3 } + } + ) + ) + + invitor.onForegrounded() + + await Promise.all( + projectGroupsAfterSecondStep.map( + async ({ + invitorProject, + inviteeProject, + observation2, + observation3, + }) => { + await waitForSync([invitorProject, inviteeProject], 'full') + + t.ok( + await inviteeProject.observation.getByDocId(observation2.docId), + 'invitee receives second doc' + ) + t.ok( + await invitorProject.observation.getByDocId(observation3.docId), + 'invitor receives third doc' + ) + } + ) + ) +}) + test('shares cores', async function (t) { const COUNT = 5 const managers = await createManagers(COUNT, t) From 9b7470d0a819df5ac2a3e4b5cd442abc6fe6371d Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 15 May 2024 18:20:30 +0000 Subject: [PATCH 02/19] Add auto-stop functionality --- src/sync/autostopper.js | 125 +++++++++++++++++++++++ src/sync/peer-sync-controller.js | 142 +++++++++----------------- src/sync/sync-api.js | 164 ++++++++++++++++++++++++++----- test-e2e/sync.js | 17 ++-- tests/sync/autostopper.js | 139 ++++++++++++++++++++++++++ 5 files changed, 460 insertions(+), 127 deletions(-) create mode 100644 src/sync/autostopper.js create mode 100644 tests/sync/autostopper.js diff --git a/src/sync/autostopper.js b/src/sync/autostopper.js new file mode 100644 index 000000000..1414fc537 --- /dev/null +++ b/src/sync/autostopper.js @@ -0,0 +1,125 @@ +import { ExhaustivenessError, assert } from '../utils.js' + +/** + * @internal + * @typedef {( + * | { type: 'incomplete' } + * | { + * type: 'done'; + * doneAt: number; + * timeoutId: null | ReturnType; + * } + * | { type: 'stopped' } + * )} AutostopperState + */ + +/** + * `Autostopper` manages auto-stop state for sync. + * + * Tell `Autostopper` the current state of sync (is it done?) and your desired + * `autoStopAfter` and it will manage the rest. + * + * Some details: + * + * - `autostopAfter` can be `0` if you want to stop sync as soon as it's done. + * + * - `autostopAfter` can be `Infinity` if you never want to auto-stop. + * + * - Changing `autostopAfter` does not restart the timer. For example, imagine + * `autostopAfter` is `100` and sync completes at time `T`. If you change + * `autostopAfter` to `200` at time `T < 100`, the timer will fire at time + * `T + 200`. + */ +export default class Autostopper { + /** @type {AutostopperState} */ + #state = { type: 'incomplete' } + #stop + #autostopAfter = Infinity + + /** + * Construct an `Autostopper`. By default, `isDone` is `false` and + * `autoStopAfter` is `Infinity`; change this by calling {@link update}. + * + * @param {() => void} stop The function to call when it's time to auto-stop. + */ + constructor(stop) { + this.#stop = stop + } + + /** @returns {boolean} */ + get #isDone() { + switch (this.#state.type) { + case 'incomplete': + return false + case 'done': + case 'stopped': + return true + default: + throw new ExhaustivenessError(this.#state) + } + } + + /** + * Update the state of the autostopper. + * + * @param {object} options + * @param {number} [options.autostopAfter] How long to wait before auto-stopping, in milliseconds. + * @param {boolean} [options.isDone] Is sync complete? + */ + update(options) { + if (typeof options.autostopAfter === 'number') { + assert( + options.autostopAfter >= 0, + 'autostopAfter must be a non-negative number' + ) + this.#autostopAfter = options.autostopAfter + } + const isDone = options.isDone ?? this.#isDone + + this.#clearTimeoutIfExists() + + if (!isDone) { + this.#state = { type: 'incomplete' } + return + } + + switch (this.#state.type) { + case 'incomplete': + this.#state = this.#doneState(Date.now()) + break + case 'done': + this.#state = this.#doneState(this.#state.doneAt) + break + case 'stopped': + break + default: + throw new ExhaustivenessError(this.#state) + } + } + + /** @returns {void} */ + #clearTimeoutIfExists() { + if (this.#state.type === 'done' && this.#state.timeoutId !== null) { + clearTimeout(this.#state.timeoutId) + } + } + + /** + * @param {number} doneAt + * @returns {AutostopperState} + */ + #doneState(doneAt) { + /** @type {AutostopperState} */ + const result = { type: 'done', doneAt, timeoutId: null } + + if (Number.isFinite(this.#autostopAfter)) { + const timeoutMs = Math.max(doneAt + this.#autostopAfter - Date.now(), 0) + result.timeoutId = setTimeout(() => { + this.#state = { type: 'stopped' } + this.#stop() + }, timeoutMs).unref() + } + + return result + } +} diff --git a/src/sync/peer-sync-controller.js b/src/sync/peer-sync-controller.js index 0b2cabec9..0cb72c1eb 100644 --- a/src/sync/peer-sync-controller.js +++ b/src/sync/peer-sync-controller.js @@ -16,6 +16,11 @@ export const DATA_NAMESPACES = NAMESPACES.filter( (ns) => !PRESYNC_NAMESPACES.includes(ns) ) +/** + * @internal + * @typedef {'none' | 'initial only' | 'all'} NamespaceGroup + */ + export class PeerSyncController { #replicatingCores = new Set() /** @type {Set} */ @@ -25,13 +30,12 @@ export class PeerSyncController { #roles /** @type {Record} */ #syncCapability = createNamespaceMap('unknown') - #isDataSyncEnabled = false + /** @type {NamespaceGroup} */ + #namespaceGroupToReplicate = 'none' /** @type {Record} */ #prevLocalState = createNamespaceMap(null) /** @type {SyncStatus} */ #syncStatus = createNamespaceMap('unknown') - /** @type {Record} */ - #namespacePauseState = createNamespaceMap('unpaused') /** @type {Map, ReturnType>} */ #downloadingRanges = new Map() /** @type {SyncStatus} */ @@ -85,75 +89,13 @@ export class PeerSyncController { return this.#syncCapability } - /** - * Enable syncing of data (in the data and blob namespaces) - */ - enableDataSync() { - this.#isDataSyncEnabled = true - this.#updateEnabledNamespaces() - } - - /** - * Disable syncing of data (in the data and blob namespaces). - * - * Syncing of metadata (auth, config and blobIndex namespaces) will continue - * in the background without user interaction. - */ - disableDataSync() { - this.#isDataSyncEnabled = false - this.#updateEnabledNamespaces() - } - - /** - * @param {Namespace} ns - * @returns {void} - */ - #pauseNamespace(ns) { - const previousState = this.#namespacePauseState[ns] - switch (previousState) { - case 'unpaused': - case 'pausing': { - const syncStatus = this.#syncStatus[ns] - switch (syncStatus) { - case 'syncing': - case 'unknown': - this.#namespacePauseState[ns] = 'pausing' - break - case 'synced': - this.#namespacePauseState[ns] = 'paused' - break - default: - throw new ExhaustivenessError(syncStatus) - } - break - } - case 'paused': - break - default: - throw new ExhaustivenessError(previousState) - } + get namespaceGroupToReplicate() { + return this.#namespaceGroupToReplicate } - /** - * Mark all namespaces as "paused". This will finish syncing and then close - * the namespace's cores. - * - * @see {@link resume} - * @returns {void} - */ - pause() { - for (const ns of NAMESPACES) this.#pauseNamespace(ns) - this.#updateEnabledNamespaces() - } - - /** - * Unpause syncing of all namespaces. - * - * @see {@link pause} - * @returns {void} - */ - resume() { - this.#namespacePauseState = createNamespaceMap('unpaused') + /** @param {NamespaceGroup} group */ + setNamespaceGroupToReplicate(group) { + this.#namespaceGroupToReplicate = group this.#updateEnabledNamespaces() } @@ -211,14 +153,6 @@ export class PeerSyncController { }) this.#log('state %X', state) - for (const ns of NAMESPACES) { - const syncStatus = this.#syncStatus[ns] - const previousPauseState = this.#namespacePauseState[ns] - if (previousPauseState === 'pausing' && syncStatus === 'synced') { - this.#namespacePauseState[ns] = 'paused' - } - } - // Map of which namespaces have received new data since last sync change const didUpdate = mapObject(state, (ns) => { const nsDidSync = @@ -250,12 +184,42 @@ export class PeerSyncController { this.#updateEnabledNamespaces() } + /** + * Enable and disable the appropriate namespaces. + * + * If replicating no namespace groups, all namespaces are disabled. + * + * If only replicating the initial namespace groups, only the initial + * namespaces are replicated, assuming the capability permits. + * + * If replicating all namespaces, everything is replicated. However, data + * namespaces are only enabled after the initial namespaces have synced. And + * again, capabilities are checked. + */ #updateEnabledNamespaces() { - // - If the sync capability is unknown, then the namespace is disabled, - // apart from the auth namespace. - // - If sync capability is allowed, the "pre-sync" namespaces are enabled, - // and if data sync is enabled, then all namespaces are enabled + /** @type {boolean} */ let isAnySyncEnabled + /** @type {boolean} */ let isDataSyncEnabled + switch (this.#namespaceGroupToReplicate) { + case 'none': + isAnySyncEnabled = isDataSyncEnabled = false + break + case 'initial only': + isAnySyncEnabled = true + isDataSyncEnabled = false + break + case 'all': + isAnySyncEnabled = isDataSyncEnabled = true + break + default: + throw new ExhaustivenessError(this.#namespaceGroupToReplicate) + } + for (const ns of NAMESPACES) { + if (!isAnySyncEnabled) { + this.#disableNamespace(ns) + continue + } + const cap = this.#syncCapability[ns] if (cap === 'blocked') { this.#disableNamespace(ns) @@ -266,21 +230,9 @@ export class PeerSyncController { this.#disableNamespace(ns) } } else if (cap === 'allowed') { - const namespacePauseState = this.#namespacePauseState[ns] - switch (namespacePauseState) { - case 'unpaused': - case 'pausing': - break - case 'paused': - this.#disableNamespace(ns) - continue - default: - throw new ExhaustivenessError(namespacePauseState) - } - if (PRESYNC_NAMESPACES.includes(ns)) { this.#enableNamespace(ns) - } else if (this.#isDataSyncEnabled) { + } else if (isDataSyncEnabled) { const arePresyncNamespacesSynced = PRESYNC_NAMESPACES.every( (ns) => this.#syncStatus[ns] === 'synced' ) diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index bf9893ce8..added362f 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -7,7 +7,8 @@ import { } from './peer-sync-controller.js' import { Logger } from '../logger.js' import { NAMESPACES } from '../constants.js' -import { keyToId } from '../utils.js' +import { ExhaustivenessError, keyToId } from '../utils.js' +import Autostopper from './autostopper.js' export const kHandleDiscoveryKey = Symbol('handle discovery key') export const kSyncState = Symbol('sync state') @@ -40,6 +41,24 @@ export const kResume = Symbol('resume') * @property {(syncState: State) => void} sync-state */ +/** + * @internal + * @typedef {object} InternalSyncStateUnpaused + * @prop {'initial only' | 'all'} enabledNamespaceGroups + * @prop {number} autostopAfter + */ + +/** + * @internal + * @typedef {object} InternalSyncStatePaused + * @prop {InternalSyncStateUnpaused} stateToReturnToAfterPause + */ + +/** + * @internal + * @typedef {InternalSyncStateUnpaused | InternalSyncStatePaused} InternalSyncState + */ + /** * @extends {TypedEmitter} */ @@ -52,11 +71,60 @@ export class SyncApi extends TypedEmitter { #pscByPeerId = new Map() /** @type {Set} */ #peerIds = new Set() - #isSyncing = false + #autostopper = new Autostopper(this.#autostop.bind(this)) + /** @type {InternalSyncState} */ + #internalSyncState = { + enabledNamespaceGroups: 'initial only', + autostopAfter: Infinity, + } /** @type {Map>} */ #pendingDiscoveryKeys = new Map() #l + /** @returns {'none' | 'initial only' | 'all'} */ + get #enabledNamespaceGroups() { + return 'enabledNamespaceGroups' in this.#internalSyncState + ? this.#internalSyncState.enabledNamespaceGroups + : 'none' + } + + /** + * @returns {boolean} + */ + get #isSyncingData() { + const enabledNamespaceGroups = this.#enabledNamespaceGroups + switch (enabledNamespaceGroups) { + case 'none': + case 'initial only': + return false + case 'all': + return true + default: + throw new ExhaustivenessError(enabledNamespaceGroups) + } + } + + #update() { + const enabledNamespaceGroups = this.#enabledNamespaceGroups + for (const peerSyncController of this.#peerSyncControllers.values()) { + peerSyncController.setNamespaceGroupToReplicate(enabledNamespaceGroups) + } + + this.#autostopper.update({ + autostopAfter: + 'stateToReturnToAfterPause' in this.#internalSyncState + ? 0 + : this.#internalSyncState.autostopAfter, + isDone: isSynced( + this[kSyncState].getState(), + this.#isSyncingData ? NAMESPACES : PRESYNC_NAMESPACES, + this.#peerSyncControllers + ), + }) + + this.emit('sync-state', this.getState()) + } + /** * * @param {object} opts @@ -79,6 +147,7 @@ export class SyncApi extends TypedEmitter { this[kSyncState].on('state', (namespaceSyncState) => { const state = this.#getState(namespaceSyncState) this.emit('sync-state', state) + this.#update() }) this.#coreManager.creatorCore.on('peer-add', this.#handlePeerAdd) @@ -127,7 +196,7 @@ export class SyncApi extends TypedEmitter { */ #getState(namespaceSyncState) { const state = reduceSyncState(namespaceSyncState) - state.data.syncing = this.#isSyncing + state.data.syncing = this.#isSyncingData return state } @@ -135,44 +204,79 @@ export class SyncApi extends TypedEmitter { * Start syncing data cores */ start() { - if (this.#isSyncing) return - this.#isSyncing = true - this.#l.log('Starting data sync') - for (const peerSyncController of this.#peerSyncControllers.values()) { - peerSyncController.enableDataSync() + this.#internalSyncState = { + enabledNamespaceGroups: 'all', + // TODO: Make this configurable. Should be trivial to add. + // See . + autostopAfter: Infinity, } - this.emit('sync-state', this.getState()) + this.#update() } /** * Stop syncing data cores (metadata cores will continue syncing in the background) */ stop() { - if (!this.#isSyncing) return - this.#isSyncing = false - this.#l.log('Stopping data sync') - for (const peerSyncController of this.#peerSyncControllers.values()) { - peerSyncController.disableDataSync() + if ('stateToReturnToAfterPause' in this.#internalSyncState) { + this.#internalSyncState = { + stateToReturnToAfterPause: { + ...this.#internalSyncState.stateToReturnToAfterPause, + enabledNamespaceGroups: 'initial only', + }, + } + } else { + this.#internalSyncState = { + ...this.#internalSyncState, + enabledNamespaceGroups: 'initial only', + } } - this.emit('sync-state', this.getState()) + this.#update() } - /** @returns {void} */ [kPause]() { - const peerSyncControllers = this.#peerSyncControllers.values() - for (const peerSyncController of peerSyncControllers) { - peerSyncController.pause() + if (!('stateToReturnToAfterPause' in this.#internalSyncState)) { + this.#internalSyncState = { + stateToReturnToAfterPause: this.#internalSyncState, + } + this.#update() } } - /** @returns {void} */ [kResume]() { - const peerSyncControllers = this.#peerSyncControllers.values() - for (const peerSyncController of peerSyncControllers) { - peerSyncController.resume() + if ('stateToReturnToAfterPause' in this.#internalSyncState) { + this.#internalSyncState = + this.#internalSyncState.stateToReturnToAfterPause + this.#update() } } + /** + * @param {'data' | 'all'} toStop + * @returns {void} + */ + #disableSync(toStop) { + /** @type {'initial only' | 'none'} */ + let enabledNamespaces + switch (toStop) { + case 'data': + enabledNamespaces = 'initial only' + break + case 'all': + enabledNamespaces = 'none' + break + default: + throw new ExhaustivenessError(toStop) + } + + this.#l.log(`Stopping ${toStop} sync`) + + for (const peerSyncController of this.#peerSyncControllers.values()) { + peerSyncController.setNamespaceGroupToReplicate(enabledNamespaces) + } + + this.emit('sync-state', this.getState()) + } + /** * @param {SyncType} type * @returns {Promise} @@ -191,6 +295,14 @@ export class SyncApi extends TypedEmitter { }) } + #autostop() { + if ('stateToReturnToAfterPause' in this.#internalSyncState) { + this.#disableSync('all') + } else { + this.#disableSync('data') + } + } + /** * Bound to `this` * @@ -224,9 +336,9 @@ export class SyncApi extends TypedEmitter { // Add peer to all core states (via namespace sync states) this[kSyncState].addPeer(peerSyncController.peerId) - if (this.#isSyncing) { - peerSyncController.enableDataSync() - } + peerSyncController.setNamespaceGroupToReplicate( + this.#enabledNamespaceGroups + ) const peerQueue = this.#pendingDiscoveryKeys.get(protomux) if (peerQueue) { diff --git a/test-e2e/sync.js b/test-e2e/sync.js index 835b45fab..7e9a4376b 100644 --- a/test-e2e/sync.js +++ b/test-e2e/sync.js @@ -1,5 +1,6 @@ // @ts-check import { test } from 'brittle' +import assert from 'node:assert/strict' import { pEvent } from 'p-event' import { setTimeout as delay } from 'timers/promises' import { excludeKeys } from 'filter-obj' @@ -161,6 +162,10 @@ test('start and stop sync', async function (t) { }) test('gracefully shutting down sync for all projects when backgrounded', async function (t) { + // NOTE: Unlike other tests in this file, this test uses `node:assert` instead + // of `t` to ease our transition away from Brittle. We can remove this comment + // when Brittle is removed. + const managers = await createManagers(2, t) const [invitor, ...invitees] = managers @@ -184,7 +189,7 @@ test('gracefully shutting down sync for all projects when backgrounded', async f valueOf(generate('observation')[0]) ) - await t.exception( + await assert.rejects( () => inviteeProject.observation.getByDocId(observation1.docId), 'before peers have started sync, doc does not sync' ) @@ -203,7 +208,7 @@ test('gracefully shutting down sync for all projects when backgrounded', async f const projectGroupsAfterSecondStep = await Promise.all( projectGroupsAfterFirstStep.map( async ({ invitorProject, inviteeProject, observation1 }) => { - t.ok( + assert( await inviteeProject.observation.getByDocId(observation1.docId), 'invitee receives doc' ) @@ -215,11 +220,11 @@ test('gracefully shutting down sync for all projects when backgrounded', async f valueOf(generate('observation')[0]) ) await delay(1000) - await t.exception( + await assert.rejects( () => inviteeProject.observation.getByDocId(observation2.docId), "invitee doesn't receive second doc yet" ) - await t.exception( + await assert.rejects( () => invitorProject.observation.getByDocId(observation3.docId), "invitor doesn't receive third doc yet" ) @@ -241,11 +246,11 @@ test('gracefully shutting down sync for all projects when backgrounded', async f }) => { await waitForSync([invitorProject, inviteeProject], 'full') - t.ok( + assert( await inviteeProject.observation.getByDocId(observation2.docId), 'invitee receives second doc' ) - t.ok( + assert( await invitorProject.observation.getByDocId(observation3.docId), 'invitor receives third doc' ) diff --git a/tests/sync/autostopper.js b/tests/sync/autostopper.js new file mode 100644 index 000000000..45a393f64 --- /dev/null +++ b/tests/sync/autostopper.js @@ -0,0 +1,139 @@ +// @ts-check +import { mock, test } from 'node:test' +import assert from 'node:assert/strict' +import fakeTimers from '@sinonjs/fake-timers' +import Autostopper from '../../src/sync/autostopper.js' + +test('Autostopper', async (t) => { + /** @type {ReturnType} */ let clock + + t.beforeEach(() => { + clock = fakeTimers.install() + }) + + t.afterEach(() => { + clock.uninstall() + }) + + await t.test('stop is not called when Autostopper is instantiated', () => { + const stop = mock.fn() + + new Autostopper(stop) + + clock.runAll() + assert.strictEqual(stop.mock.callCount(), 0, 'no timers are started') + }) + + await t.test('autostop timeout is infinite to start', () => { + const stop = mock.fn() + const autostopper = new Autostopper(stop) + + autostopper.update({ isDone: true }) + clock.runAll() + assert.strictEqual(stop.mock.callCount(), 0, 'no timers are started') + }) + + await t.test('autostop timeout can be set', () => { + const stop = mock.fn() + const autostopper = new Autostopper(stop) + + autostopper.update({ isDone: true, autostopAfter: 100 }) + + assert.strictEqual(stop.mock.callCount(), 0) + + clock.tick(99) + assert.strictEqual(stop.mock.callCount(), 0) + + clock.tick(1) + assert.strictEqual(stop.mock.callCount(), 1) + }) + + await t.test( + 'autostop timeout can be updated from ∞ to a finite time, and the some of the time is "used up"', + () => { + const stop = mock.fn() + const autostopper = new Autostopper(stop) + autostopper.update({ isDone: true, autostopAfter: Infinity }) + clock.tick(100) + + autostopper.update({ autostopAfter: 150 }) + + clock.tick(49) + assert.strictEqual(stop.mock.callCount(), 0) + + clock.tick(1) + assert.strictEqual(stop.mock.callCount(), 1) + } + ) + + await t.test( + 'autostop timeout can be updated from ∞ to a finite time, and stop runs immediately if enough time has passed', + () => { + const stop = mock.fn() + const autostopper = new Autostopper(stop) + autostopper.update({ isDone: true, autostopAfter: Infinity }) + + clock.tick(100) + + autostopper.update({ autostopAfter: 50 }) + + clock.next() + assert.strictEqual(stop.mock.callCount(), 1) + } + ) + + await t.test( + 'autostop timeout can go from a finite time to an infinite time, canceling the timeout', + () => { + const stop = mock.fn() + const autostopper = new Autostopper(stop) + autostopper.update({ isDone: true, autostopAfter: 100 }) + autostopper.update({ autostopAfter: Infinity }) + + clock.runAll() + + assert.strictEqual(stop.mock.callCount(), 0) + } + ) + + await t.test('timeout is canceled if isDone goes back to `false`', () => { + const stop = mock.fn() + const autostopper = new Autostopper(stop) + autostopper.update({ isDone: true, autostopAfter: 100 }) + autostopper.update({ isDone: false }) + + clock.runAll() + + assert.strictEqual(stop.mock.callCount(), 0) + }) + + await t.test('stop is only one once per "cycle"', () => { + const stop = mock.fn() + const autostopper = new Autostopper(stop) + autostopper.update({ isDone: true, autostopAfter: 100 }) + + clock.tick(100) + assert.strictEqual(stop.mock.callCount(), 1) + + autostopper.update({ isDone: true, autostopAfter: 999 }) + clock.runAll() + autostopper.update({ isDone: true, autostopAfter: Infinity }) + clock.runAll() + autostopper.update({ isDone: true, autostopAfter: 999 }) + clock.runAll() + assert.strictEqual(stop.mock.callCount(), 1) + + autostopper.update({ isDone: false }) + autostopper.update({ isDone: true, autostopAfter: 123 }) + clock.tick(123) + assert.strictEqual(stop.mock.callCount(), 2) + }) + + await t.test('update throws if passed an invalid autostop time', () => { + const autostopper = new Autostopper(() => {}) + + assert.throws(() => autostopper.update({ autostopAfter: -1 })) + assert.throws(() => autostopper.update({ autostopAfter: -Infinity })) + assert.throws(() => autostopper.update({ autostopAfter: NaN })) + }) +}) From 610d3590d6034af16a71a4e9872244beea0846c0 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 15 May 2024 18:43:29 +0000 Subject: [PATCH 03/19] Simplify autostop --- src/sync/sync-api.js | 37 ++++++------------------------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index added362f..9bc299b28 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -250,33 +250,6 @@ export class SyncApi extends TypedEmitter { } } - /** - * @param {'data' | 'all'} toStop - * @returns {void} - */ - #disableSync(toStop) { - /** @type {'initial only' | 'none'} */ - let enabledNamespaces - switch (toStop) { - case 'data': - enabledNamespaces = 'initial only' - break - case 'all': - enabledNamespaces = 'none' - break - default: - throw new ExhaustivenessError(toStop) - } - - this.#l.log(`Stopping ${toStop} sync`) - - for (const peerSyncController of this.#peerSyncControllers.values()) { - peerSyncController.setNamespaceGroupToReplicate(enabledNamespaces) - } - - this.emit('sync-state', this.getState()) - } - /** * @param {SyncType} type * @returns {Promise} @@ -296,10 +269,12 @@ export class SyncApi extends TypedEmitter { } #autostop() { - if ('stateToReturnToAfterPause' in this.#internalSyncState) { - this.#disableSync('all') - } else { - this.#disableSync('data') + const namespaceGroupToReplicate = + 'stateToReturnToAfterPause' in this.#internalSyncState + ? 'none' + : 'initial only' + for (const peerSyncController of this.#peerSyncControllers.values()) { + peerSyncController.setNamespaceGroupToReplicate(namespaceGroupToReplicate) } } From b8c5d7decd92d009dabdf0537ceb0f0a1ab941a7 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 16 May 2024 18:58:40 +0000 Subject: [PATCH 04/19] Rename "namespace group" to "sync enabled state" Co-Authored-By: Gregor MacLennan --- src/sync/peer-sync-controller.js | 22 ++++++++-------- src/sync/sync-api.js | 44 +++++++++++++++++--------------- 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/src/sync/peer-sync-controller.js b/src/sync/peer-sync-controller.js index 0cb72c1eb..451f46396 100644 --- a/src/sync/peer-sync-controller.js +++ b/src/sync/peer-sync-controller.js @@ -18,7 +18,7 @@ export const DATA_NAMESPACES = NAMESPACES.filter( /** * @internal - * @typedef {'none' | 'initial only' | 'all'} NamespaceGroup + * @typedef {import('./sync-api.js').SyncEnabledState} SyncEnabledState */ export class PeerSyncController { @@ -30,8 +30,8 @@ export class PeerSyncController { #roles /** @type {Record} */ #syncCapability = createNamespaceMap('unknown') - /** @type {NamespaceGroup} */ - #namespaceGroupToReplicate = 'none' + /** @type {SyncEnabledState} */ + #syncEnabledState = 'none' /** @type {Record} */ #prevLocalState = createNamespaceMap(null) /** @type {SyncStatus} */ @@ -89,13 +89,13 @@ export class PeerSyncController { return this.#syncCapability } - get namespaceGroupToReplicate() { - return this.#namespaceGroupToReplicate + get syncEnabledState() { + return this.#syncEnabledState } - /** @param {NamespaceGroup} group */ - setNamespaceGroupToReplicate(group) { - this.#namespaceGroupToReplicate = group + /** @param {SyncEnabledState} syncEnabledState */ + setSyncEnabledState(syncEnabledState) { + this.#syncEnabledState = syncEnabledState this.#updateEnabledNamespaces() } @@ -199,11 +199,11 @@ export class PeerSyncController { #updateEnabledNamespaces() { /** @type {boolean} */ let isAnySyncEnabled /** @type {boolean} */ let isDataSyncEnabled - switch (this.#namespaceGroupToReplicate) { + switch (this.#syncEnabledState) { case 'none': isAnySyncEnabled = isDataSyncEnabled = false break - case 'initial only': + case 'initial': isAnySyncEnabled = true isDataSyncEnabled = false break @@ -211,7 +211,7 @@ export class PeerSyncController { isAnySyncEnabled = isDataSyncEnabled = true break default: - throw new ExhaustivenessError(this.#namespaceGroupToReplicate) + throw new ExhaustivenessError(this.#syncEnabledState) } for (const ns of NAMESPACES) { diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index 9bc299b28..5e48a259a 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -19,6 +19,10 @@ export const kResume = Symbol('resume') * @typedef {'initial' | 'full'} SyncType */ +/** + * @typedef {'none' | 'initial' | 'all'} SyncEnabledState + */ + /** * @typedef {object} SyncTypeState * @property {number} have Number of blocks we have locally @@ -44,7 +48,7 @@ export const kResume = Symbol('resume') /** * @internal * @typedef {object} InternalSyncStateUnpaused - * @prop {'initial only' | 'all'} enabledNamespaceGroups + * @prop {'initial' | 'all'} syncEnabledState * @prop {number} autostopAfter */ @@ -74,17 +78,17 @@ export class SyncApi extends TypedEmitter { #autostopper = new Autostopper(this.#autostop.bind(this)) /** @type {InternalSyncState} */ #internalSyncState = { - enabledNamespaceGroups: 'initial only', + syncEnabledState: 'initial', autostopAfter: Infinity, } /** @type {Map>} */ #pendingDiscoveryKeys = new Map() #l - /** @returns {'none' | 'initial only' | 'all'} */ - get #enabledNamespaceGroups() { - return 'enabledNamespaceGroups' in this.#internalSyncState - ? this.#internalSyncState.enabledNamespaceGroups + /** @returns {SyncEnabledState} */ + get #syncEnabledState() { + return 'syncEnabledState' in this.#internalSyncState + ? this.#internalSyncState.syncEnabledState : 'none' } @@ -92,22 +96,22 @@ export class SyncApi extends TypedEmitter { * @returns {boolean} */ get #isSyncingData() { - const enabledNamespaceGroups = this.#enabledNamespaceGroups - switch (enabledNamespaceGroups) { + const syncEnabledState = this.#syncEnabledState + switch (syncEnabledState) { case 'none': - case 'initial only': + case 'initial': return false case 'all': return true default: - throw new ExhaustivenessError(enabledNamespaceGroups) + throw new ExhaustivenessError(syncEnabledState) } } #update() { - const enabledNamespaceGroups = this.#enabledNamespaceGroups + const syncEnabledState = this.#syncEnabledState for (const peerSyncController of this.#peerSyncControllers.values()) { - peerSyncController.setNamespaceGroupToReplicate(enabledNamespaceGroups) + peerSyncController.setSyncEnabledState(syncEnabledState) } this.#autostopper.update({ @@ -205,7 +209,7 @@ export class SyncApi extends TypedEmitter { */ start() { this.#internalSyncState = { - enabledNamespaceGroups: 'all', + syncEnabledState: 'all', // TODO: Make this configurable. Should be trivial to add. // See . autostopAfter: Infinity, @@ -221,13 +225,13 @@ export class SyncApi extends TypedEmitter { this.#internalSyncState = { stateToReturnToAfterPause: { ...this.#internalSyncState.stateToReturnToAfterPause, - enabledNamespaceGroups: 'initial only', + syncEnabledState: 'initial', }, } } else { this.#internalSyncState = { ...this.#internalSyncState, - enabledNamespaceGroups: 'initial only', + syncEnabledState: 'initial', } } this.#update() @@ -269,12 +273,12 @@ export class SyncApi extends TypedEmitter { } #autostop() { - const namespaceGroupToReplicate = + const syncEnabledState = 'stateToReturnToAfterPause' in this.#internalSyncState ? 'none' - : 'initial only' + : 'initial' for (const peerSyncController of this.#peerSyncControllers.values()) { - peerSyncController.setNamespaceGroupToReplicate(namespaceGroupToReplicate) + peerSyncController.setSyncEnabledState(syncEnabledState) } } @@ -311,9 +315,7 @@ export class SyncApi extends TypedEmitter { // Add peer to all core states (via namespace sync states) this[kSyncState].addPeer(peerSyncController.peerId) - peerSyncController.setNamespaceGroupToReplicate( - this.#enabledNamespaceGroups - ) + peerSyncController.setSyncEnabledState(this.#syncEnabledState) const peerQueue = this.#pendingDiscoveryKeys.get(protomux) if (peerQueue) { From f427f649f112b2cbe56c190ce41f2e6ea530c3c8 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 16 May 2024 19:02:27 +0000 Subject: [PATCH 05/19] Rename "initial" to "presync" --- src/sync/peer-sync-controller.js | 2 +- src/sync/sync-api.js | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/sync/peer-sync-controller.js b/src/sync/peer-sync-controller.js index 451f46396..5e89236cd 100644 --- a/src/sync/peer-sync-controller.js +++ b/src/sync/peer-sync-controller.js @@ -203,7 +203,7 @@ export class PeerSyncController { case 'none': isAnySyncEnabled = isDataSyncEnabled = false break - case 'initial': + case 'presync': isAnySyncEnabled = true isDataSyncEnabled = false break diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index 5e48a259a..d8a0074c3 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -20,7 +20,7 @@ export const kResume = Symbol('resume') */ /** - * @typedef {'none' | 'initial' | 'all'} SyncEnabledState + * @typedef {'none' | 'presync' | 'all'} SyncEnabledState */ /** @@ -48,7 +48,7 @@ export const kResume = Symbol('resume') /** * @internal * @typedef {object} InternalSyncStateUnpaused - * @prop {'initial' | 'all'} syncEnabledState + * @prop {'presync' | 'all'} syncEnabledState * @prop {number} autostopAfter */ @@ -78,7 +78,7 @@ export class SyncApi extends TypedEmitter { #autostopper = new Autostopper(this.#autostop.bind(this)) /** @type {InternalSyncState} */ #internalSyncState = { - syncEnabledState: 'initial', + syncEnabledState: 'presync', autostopAfter: Infinity, } /** @type {Map>} */ @@ -99,7 +99,7 @@ export class SyncApi extends TypedEmitter { const syncEnabledState = this.#syncEnabledState switch (syncEnabledState) { case 'none': - case 'initial': + case 'presync': return false case 'all': return true @@ -225,13 +225,13 @@ export class SyncApi extends TypedEmitter { this.#internalSyncState = { stateToReturnToAfterPause: { ...this.#internalSyncState.stateToReturnToAfterPause, - syncEnabledState: 'initial', + syncEnabledState: 'presync', }, } } else { this.#internalSyncState = { ...this.#internalSyncState, - syncEnabledState: 'initial', + syncEnabledState: 'presync', } } this.#update() @@ -276,7 +276,7 @@ export class SyncApi extends TypedEmitter { const syncEnabledState = 'stateToReturnToAfterPause' in this.#internalSyncState ? 'none' - : 'initial' + : 'presync' for (const peerSyncController of this.#peerSyncControllers.values()) { peerSyncController.setSyncEnabledState(syncEnabledState) } From 072a2b6d526bf32c04239c0348dd7a1d860cfb3d Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 16 May 2024 19:49:31 +0000 Subject: [PATCH 06/19] Simplify background/foreground API with two booleans --- src/mapeo-manager.js | 6 +- src/sync/autostopper.js | 125 ----------------------- src/sync/sync-api.js | 208 ++++++++++++++------------------------ tests/sync/autostopper.js | 139 ------------------------- 4 files changed, 81 insertions(+), 397 deletions(-) delete mode 100644 src/sync/autostopper.js delete mode 100644 tests/sync/autostopper.js diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 444c4e8bd..8ffb0bfd6 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -41,7 +41,7 @@ import { LocalDiscovery } from './discovery/local-discovery.js' import { Roles } from './roles.js' import NoiseSecretStream from '@hyperswarm/secret-stream' import { Logger } from './logger.js' -import { kSyncState, kPause, kResume } from './sync/sync-api.js' +import { kSyncState, kBackground, kForeground } from './sync/sync-api.js' /** @typedef {import("@mapeo/schema").ProjectSettingsValue} ProjectValue */ /** @typedef {import('type-fest').SetNonNullable} ValidatedProjectKeys */ @@ -764,7 +764,7 @@ export class MapeoManager extends TypedEmitter { */ onBackgrounded() { const projects = this.#activeProjects.values() - for (const project of projects) project.$sync[kPause]() + for (const project of projects) project.$sync[kBackground]() } /** @@ -777,7 +777,7 @@ export class MapeoManager extends TypedEmitter { */ onForegrounded() { const projects = this.#activeProjects.values() - for (const project of projects) project.$sync[kResume]() + for (const project of projects) project.$sync[kForeground]() } /** diff --git a/src/sync/autostopper.js b/src/sync/autostopper.js deleted file mode 100644 index 1414fc537..000000000 --- a/src/sync/autostopper.js +++ /dev/null @@ -1,125 +0,0 @@ -import { ExhaustivenessError, assert } from '../utils.js' - -/** - * @internal - * @typedef {( - * | { type: 'incomplete' } - * | { - * type: 'done'; - * doneAt: number; - * timeoutId: null | ReturnType; - * } - * | { type: 'stopped' } - * )} AutostopperState - */ - -/** - * `Autostopper` manages auto-stop state for sync. - * - * Tell `Autostopper` the current state of sync (is it done?) and your desired - * `autoStopAfter` and it will manage the rest. - * - * Some details: - * - * - `autostopAfter` can be `0` if you want to stop sync as soon as it's done. - * - * - `autostopAfter` can be `Infinity` if you never want to auto-stop. - * - * - Changing `autostopAfter` does not restart the timer. For example, imagine - * `autostopAfter` is `100` and sync completes at time `T`. If you change - * `autostopAfter` to `200` at time `T < 100`, the timer will fire at time - * `T + 200`. - */ -export default class Autostopper { - /** @type {AutostopperState} */ - #state = { type: 'incomplete' } - #stop - #autostopAfter = Infinity - - /** - * Construct an `Autostopper`. By default, `isDone` is `false` and - * `autoStopAfter` is `Infinity`; change this by calling {@link update}. - * - * @param {() => void} stop The function to call when it's time to auto-stop. - */ - constructor(stop) { - this.#stop = stop - } - - /** @returns {boolean} */ - get #isDone() { - switch (this.#state.type) { - case 'incomplete': - return false - case 'done': - case 'stopped': - return true - default: - throw new ExhaustivenessError(this.#state) - } - } - - /** - * Update the state of the autostopper. - * - * @param {object} options - * @param {number} [options.autostopAfter] How long to wait before auto-stopping, in milliseconds. - * @param {boolean} [options.isDone] Is sync complete? - */ - update(options) { - if (typeof options.autostopAfter === 'number') { - assert( - options.autostopAfter >= 0, - 'autostopAfter must be a non-negative number' - ) - this.#autostopAfter = options.autostopAfter - } - const isDone = options.isDone ?? this.#isDone - - this.#clearTimeoutIfExists() - - if (!isDone) { - this.#state = { type: 'incomplete' } - return - } - - switch (this.#state.type) { - case 'incomplete': - this.#state = this.#doneState(Date.now()) - break - case 'done': - this.#state = this.#doneState(this.#state.doneAt) - break - case 'stopped': - break - default: - throw new ExhaustivenessError(this.#state) - } - } - - /** @returns {void} */ - #clearTimeoutIfExists() { - if (this.#state.type === 'done' && this.#state.timeoutId !== null) { - clearTimeout(this.#state.timeoutId) - } - } - - /** - * @param {number} doneAt - * @returns {AutostopperState} - */ - #doneState(doneAt) { - /** @type {AutostopperState} */ - const result = { type: 'done', doneAt, timeoutId: null } - - if (Number.isFinite(this.#autostopAfter)) { - const timeoutMs = Math.max(doneAt + this.#autostopAfter - Date.now(), 0) - result.timeoutId = setTimeout(() => { - this.#state = { type: 'stopped' } - this.#stop() - }, timeoutMs).unref() - } - - return result - } -} diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index d8a0074c3..c8486f068 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -7,13 +7,12 @@ import { } from './peer-sync-controller.js' import { Logger } from '../logger.js' import { NAMESPACES } from '../constants.js' -import { ExhaustivenessError, keyToId } from '../utils.js' -import Autostopper from './autostopper.js' +import { keyToId } from '../utils.js' export const kHandleDiscoveryKey = Symbol('handle discovery key') export const kSyncState = Symbol('sync state') -export const kPause = Symbol('pause') -export const kResume = Symbol('resume') +export const kBackground = Symbol('background') +export const kForeground = Symbol('foreground') /** * @typedef {'initial' | 'full'} SyncType @@ -45,24 +44,6 @@ export const kResume = Symbol('resume') * @property {(syncState: State) => void} sync-state */ -/** - * @internal - * @typedef {object} InternalSyncStateUnpaused - * @prop {'presync' | 'all'} syncEnabledState - * @prop {number} autostopAfter - */ - -/** - * @internal - * @typedef {object} InternalSyncStatePaused - * @prop {InternalSyncStateUnpaused} stateToReturnToAfterPause - */ - -/** - * @internal - * @typedef {InternalSyncStateUnpaused | InternalSyncStatePaused} InternalSyncState - */ - /** * @extends {TypedEmitter} */ @@ -75,60 +56,12 @@ export class SyncApi extends TypedEmitter { #pscByPeerId = new Map() /** @type {Set} */ #peerIds = new Set() - #autostopper = new Autostopper(this.#autostop.bind(this)) - /** @type {InternalSyncState} */ - #internalSyncState = { - syncEnabledState: 'presync', - autostopAfter: Infinity, - } + #wantsToSyncData = false + #isBackgrounded = false /** @type {Map>} */ #pendingDiscoveryKeys = new Map() #l - /** @returns {SyncEnabledState} */ - get #syncEnabledState() { - return 'syncEnabledState' in this.#internalSyncState - ? this.#internalSyncState.syncEnabledState - : 'none' - } - - /** - * @returns {boolean} - */ - get #isSyncingData() { - const syncEnabledState = this.#syncEnabledState - switch (syncEnabledState) { - case 'none': - case 'presync': - return false - case 'all': - return true - default: - throw new ExhaustivenessError(syncEnabledState) - } - } - - #update() { - const syncEnabledState = this.#syncEnabledState - for (const peerSyncController of this.#peerSyncControllers.values()) { - peerSyncController.setSyncEnabledState(syncEnabledState) - } - - this.#autostopper.update({ - autostopAfter: - 'stateToReturnToAfterPause' in this.#internalSyncState - ? 0 - : this.#internalSyncState.autostopAfter, - isDone: isSynced( - this[kSyncState].getState(), - this.#isSyncingData ? NAMESPACES : PRESYNC_NAMESPACES, - this.#peerSyncControllers - ), - }) - - this.emit('sync-state', this.getState()) - } - /** * * @param {object} opts @@ -148,11 +81,7 @@ export class SyncApi extends TypedEmitter { peerSyncControllers: this.#pscByPeerId, }) this[kSyncState].setMaxListeners(0) - this[kSyncState].on('state', (namespaceSyncState) => { - const state = this.#getState(namespaceSyncState) - this.emit('sync-state', state) - this.#update() - }) + this[kSyncState].on('state', this.#updateState) this.#coreManager.creatorCore.on('peer-add', this.#handlePeerAdd) this.#coreManager.creatorCore.on('peer-remove', this.#handlePeerRemove) @@ -200,58 +129,87 @@ export class SyncApi extends TypedEmitter { */ #getState(namespaceSyncState) { const state = reduceSyncState(namespaceSyncState) - state.data.syncing = this.#isSyncingData + + state.data.syncing = + this.#wantsToSyncData && + (!this.#isBackgrounded || + !isSynced(namespaceSyncState, 'full', this.#peerSyncControllers)) + return state } + #updateState = () => { + const namespaceSyncState = this[kSyncState].getState() + + /** @type {SyncEnabledState} */ let syncEnabledState + if (this.#isBackgrounded) { + let isStopped = true + for (const peerSyncController of this.#peerSyncControllers.values()) { + isStopped = peerSyncController.syncEnabledState === 'none' + break + } + if (isStopped) { + syncEnabledState = 'none' + } else if ( + isSynced( + namespaceSyncState, + this.#wantsToSyncData ? 'full' : 'initial', + this.#peerSyncControllers + ) + ) { + syncEnabledState = 'none' + } else if (this.#wantsToSyncData) { + syncEnabledState = 'all' + } else { + syncEnabledState = 'presync' + } + } else { + syncEnabledState = this.#wantsToSyncData ? 'all' : 'presync' + } + + this.#l.log(`Setting sync enabled state to "${syncEnabledState}"`) + for (const peerSyncController of this.#peerSyncControllers.values()) { + peerSyncController.setSyncEnabledState(syncEnabledState) + } + + this.emit('sync-state', this.#getState(namespaceSyncState)) + } + /** - * Start syncing data cores + * Start syncing data cores. + * + * If the app is backgrounded and sync has already completed, this will do + * nothing until the app is foregrounded. */ start() { - this.#internalSyncState = { - syncEnabledState: 'all', - // TODO: Make this configurable. Should be trivial to add. - // See . - autostopAfter: Infinity, - } - this.#update() + this.#wantsToSyncData = true + this.#updateState() } /** - * Stop syncing data cores (metadata cores will continue syncing in the background) + * Stop syncing data cores. + * + * Pre-sync cores will continue syncing unless the app is backgrounded. */ stop() { - if ('stateToReturnToAfterPause' in this.#internalSyncState) { - this.#internalSyncState = { - stateToReturnToAfterPause: { - ...this.#internalSyncState.stateToReturnToAfterPause, - syncEnabledState: 'presync', - }, - } - } else { - this.#internalSyncState = { - ...this.#internalSyncState, - syncEnabledState: 'presync', - } - } - this.#update() + this.#wantsToSyncData = false + this.#updateState() } - [kPause]() { - if (!('stateToReturnToAfterPause' in this.#internalSyncState)) { - this.#internalSyncState = { - stateToReturnToAfterPause: this.#internalSyncState, - } - this.#update() - } + /** + * Gracefully stop syncing all cores. + */ + [kBackground]() { + this.#isBackgrounded = true + this.#updateState() } - [kResume]() { - if ('stateToReturnToAfterPause' in this.#internalSyncState) { - this.#internalSyncState = - this.#internalSyncState.stateToReturnToAfterPause - this.#update() - } + /** + * Unpause. + */ + [kForeground]() { + this.#isBackgrounded = false + this.#updateState() } /** @@ -260,28 +218,17 @@ export class SyncApi extends TypedEmitter { */ async waitForSync(type) { const state = this[kSyncState].getState() - const namespaces = type === 'initial' ? PRESYNC_NAMESPACES : NAMESPACES - if (isSynced(state, namespaces, this.#peerSyncControllers)) return + if (isSynced(state, type, this.#peerSyncControllers)) return return new Promise((res) => { const _this = this this[kSyncState].on('state', function onState(state) { - if (!isSynced(state, namespaces, _this.#peerSyncControllers)) return + if (!isSynced(state, type, _this.#peerSyncControllers)) return _this[kSyncState].off('state', onState) res() }) }) } - #autostop() { - const syncEnabledState = - 'stateToReturnToAfterPause' in this.#internalSyncState - ? 'none' - : 'presync' - for (const peerSyncController of this.#peerSyncControllers.values()) { - peerSyncController.setSyncEnabledState(syncEnabledState) - } - } - /** * Bound to `this` * @@ -315,7 +262,7 @@ export class SyncApi extends TypedEmitter { // Add peer to all core states (via namespace sync states) this[kSyncState].addPeer(peerSyncController.peerId) - peerSyncController.setSyncEnabledState(this.#syncEnabledState) + this.#updateState() const peerQueue = this.#pendingDiscoveryKeys.get(protomux) if (peerQueue) { @@ -355,10 +302,11 @@ export class SyncApi extends TypedEmitter { * Is the sync state "synced", e.g. is there nothing left to sync * * @param {import('./sync-state.js').State} state - * @param {readonly import('../core-manager/index.js').Namespace[]} namespaces + * @param {SyncType} type * @param {Map} peerSyncControllers */ -function isSynced(state, namespaces, peerSyncControllers) { +function isSynced(state, type, peerSyncControllers) { + const namespaces = type === 'initial' ? PRESYNC_NAMESPACES : NAMESPACES for (const ns of namespaces) { if (state[ns].dataToSync) return false for (const psc of peerSyncControllers.values()) { diff --git a/tests/sync/autostopper.js b/tests/sync/autostopper.js deleted file mode 100644 index 45a393f64..000000000 --- a/tests/sync/autostopper.js +++ /dev/null @@ -1,139 +0,0 @@ -// @ts-check -import { mock, test } from 'node:test' -import assert from 'node:assert/strict' -import fakeTimers from '@sinonjs/fake-timers' -import Autostopper from '../../src/sync/autostopper.js' - -test('Autostopper', async (t) => { - /** @type {ReturnType} */ let clock - - t.beforeEach(() => { - clock = fakeTimers.install() - }) - - t.afterEach(() => { - clock.uninstall() - }) - - await t.test('stop is not called when Autostopper is instantiated', () => { - const stop = mock.fn() - - new Autostopper(stop) - - clock.runAll() - assert.strictEqual(stop.mock.callCount(), 0, 'no timers are started') - }) - - await t.test('autostop timeout is infinite to start', () => { - const stop = mock.fn() - const autostopper = new Autostopper(stop) - - autostopper.update({ isDone: true }) - clock.runAll() - assert.strictEqual(stop.mock.callCount(), 0, 'no timers are started') - }) - - await t.test('autostop timeout can be set', () => { - const stop = mock.fn() - const autostopper = new Autostopper(stop) - - autostopper.update({ isDone: true, autostopAfter: 100 }) - - assert.strictEqual(stop.mock.callCount(), 0) - - clock.tick(99) - assert.strictEqual(stop.mock.callCount(), 0) - - clock.tick(1) - assert.strictEqual(stop.mock.callCount(), 1) - }) - - await t.test( - 'autostop timeout can be updated from ∞ to a finite time, and the some of the time is "used up"', - () => { - const stop = mock.fn() - const autostopper = new Autostopper(stop) - autostopper.update({ isDone: true, autostopAfter: Infinity }) - clock.tick(100) - - autostopper.update({ autostopAfter: 150 }) - - clock.tick(49) - assert.strictEqual(stop.mock.callCount(), 0) - - clock.tick(1) - assert.strictEqual(stop.mock.callCount(), 1) - } - ) - - await t.test( - 'autostop timeout can be updated from ∞ to a finite time, and stop runs immediately if enough time has passed', - () => { - const stop = mock.fn() - const autostopper = new Autostopper(stop) - autostopper.update({ isDone: true, autostopAfter: Infinity }) - - clock.tick(100) - - autostopper.update({ autostopAfter: 50 }) - - clock.next() - assert.strictEqual(stop.mock.callCount(), 1) - } - ) - - await t.test( - 'autostop timeout can go from a finite time to an infinite time, canceling the timeout', - () => { - const stop = mock.fn() - const autostopper = new Autostopper(stop) - autostopper.update({ isDone: true, autostopAfter: 100 }) - autostopper.update({ autostopAfter: Infinity }) - - clock.runAll() - - assert.strictEqual(stop.mock.callCount(), 0) - } - ) - - await t.test('timeout is canceled if isDone goes back to `false`', () => { - const stop = mock.fn() - const autostopper = new Autostopper(stop) - autostopper.update({ isDone: true, autostopAfter: 100 }) - autostopper.update({ isDone: false }) - - clock.runAll() - - assert.strictEqual(stop.mock.callCount(), 0) - }) - - await t.test('stop is only one once per "cycle"', () => { - const stop = mock.fn() - const autostopper = new Autostopper(stop) - autostopper.update({ isDone: true, autostopAfter: 100 }) - - clock.tick(100) - assert.strictEqual(stop.mock.callCount(), 1) - - autostopper.update({ isDone: true, autostopAfter: 999 }) - clock.runAll() - autostopper.update({ isDone: true, autostopAfter: Infinity }) - clock.runAll() - autostopper.update({ isDone: true, autostopAfter: 999 }) - clock.runAll() - assert.strictEqual(stop.mock.callCount(), 1) - - autostopper.update({ isDone: false }) - autostopper.update({ isDone: true, autostopAfter: 123 }) - clock.tick(123) - assert.strictEqual(stop.mock.callCount(), 2) - }) - - await t.test('update throws if passed an invalid autostop time', () => { - const autostopper = new Autostopper(() => {}) - - assert.throws(() => autostopper.update({ autostopAfter: -1 })) - assert.throws(() => autostopper.update({ autostopAfter: -Infinity })) - assert.throws(() => autostopper.update({ autostopAfter: NaN })) - }) -}) From 8a9eb15b372fd6ff98527c867ac7ee1fec824f68 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 20 May 2024 16:07:09 -0500 Subject: [PATCH 07/19] Minor optimization when changing `syncEnabledState` Co-authored-by: Gregor MacLennan --- src/sync/peer-sync-controller.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/sync/peer-sync-controller.js b/src/sync/peer-sync-controller.js index 5e89236cd..3f1fd05f6 100644 --- a/src/sync/peer-sync-controller.js +++ b/src/sync/peer-sync-controller.js @@ -95,6 +95,9 @@ export class PeerSyncController { /** @param {SyncEnabledState} syncEnabledState */ setSyncEnabledState(syncEnabledState) { + if (this.#syncEnabledState === syncEnabledState) { + return + } this.#syncEnabledState = syncEnabledState this.#updateEnabledNamespaces() } From c6295051d29fd4e8d7745c2f8e75458b5484a171 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 16 May 2024 17:11:04 -0500 Subject: [PATCH 08/19] test: move FastifyController tests to `node:test` (#643) _I recommend reviewing this with whitespace changes disabled._ This test-only change drops Brittle from our FastifyController tests and replaces them with `node:test` and `node:assert`. --- tests/fastify-controller.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/fastify-controller.js b/tests/fastify-controller.js index 46ee1cff0..8a12c8a21 100644 --- a/tests/fastify-controller.js +++ b/tests/fastify-controller.js @@ -1,10 +1,10 @@ // @ts-check -import { test } from 'brittle' +import test from 'node:test' import Fastify from 'fastify' import { FastifyController } from '../src/fastify-controller.js' -test('lifecycle', async (t) => { +test('lifecycle', async () => { const fastify = Fastify() const fastifyController = new FastifyController({ fastify }) @@ -15,6 +15,7 @@ test('lifecycle', async (t) => { { host: '0.0.0.0' }, ] + // This should run without errors. for (const opts of startOptsFixtures) { await fastifyController.start(opts) await fastifyController.start(opts) @@ -25,7 +26,5 @@ test('lifecycle', async (t) => { await fastifyController.started() await fastifyController.started() await fastifyController.stop() - - t.pass('server lifecycle works with valid opts') } }) From eb3192c78f79652b4d5d84e850245b56113e92ec Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 20 May 2024 10:24:38 -0500 Subject: [PATCH 09/19] test: move basic manager tests to `node:test` (#662) This test-only change drops Brittle from our basic manager tests and replaces them with `node:test` and `node:assert`. Two notable things about this change: 1. There was a test that was commented out because of a Brittle bug. This uncomments it and sets the `skip: true` option. 2. Brittle has an ([undocumented][0]) `t.snapshot()` method which we can't use. I replaced it with a custom snapshot assertion. The rest was just mechanical changes. [0]: https://github.com/holepunchto/brittle/pull/45 --- test-e2e/manager-basic.js | 228 +++++++++++++++++++------------------- 1 file changed, 117 insertions(+), 111 deletions(-) diff --git a/test-e2e/manager-basic.js b/test-e2e/manager-basic.js index cc61370f2..102d8f3c5 100644 --- a/test-e2e/manager-basic.js +++ b/test-e2e/manager-basic.js @@ -1,5 +1,6 @@ // @ts-check -import { test } from 'brittle' +import test from 'node:test' +import assert from 'node:assert/strict' import { randomBytes, createHash } from 'crypto' import { KeyManager } from '@mapeo/crypto' import RAM from 'random-access-memory' @@ -8,6 +9,7 @@ import Fastify from 'fastify' import { getExpectedConfig } from './utils.js' import { defaultConfigPath } from '../tests/helpers/default-config.js' import { kDataTypes } from '../src/mapeo-project.js' +import { hashObject } from '../src/utils.js' const projectMigrationsFolder = new URL('../drizzle/project', import.meta.url) .pathname @@ -29,10 +31,10 @@ test('Managing created projects', async (t) => { name: 'project 2', }) - await t.test('initial information from listed projects', async (st) => { + await t.test('initial information from listed projects', async () => { const listedProjects = await manager.listProjects() - st.is(listedProjects.length, 2) + assert.equal(listedProjects.length, 2) const listedProject1 = listedProjects.find( (p) => p.projectId === project1Id @@ -42,40 +44,40 @@ test('Managing created projects', async (t) => { (p) => p.projectId === project2Id ) - st.ok(listedProject1) - st.absent(listedProject1?.name) - st.ok(listedProject1?.createdAt) - st.ok(listedProject1?.updatedAt) + assert(listedProject1) + assert(!listedProject1?.name) + assert(listedProject1?.createdAt) + assert(listedProject1?.updatedAt) - st.ok(listedProject2) - st.is(listedProject2?.name, 'project 2') - st.ok(listedProject2?.createdAt) - st.ok(listedProject2?.updatedAt) + assert(listedProject2) + assert.equal(listedProject2?.name, 'project 2') + assert(listedProject2?.createdAt) + assert(listedProject2?.updatedAt) }) const project1 = await manager.getProject(project1Id) const project2 = await manager.getProject(project2Id) - t.ok(project1) - t.ok(project2) + assert(project1) + assert(project2) - await t.test('initial settings from project instances', async (st) => { + await t.test('initial settings from project instances', async () => { const settings1 = await project1.$getProjectSettings() const settings2 = await project2.$getProjectSettings() - st.alike( + assert.deepEqual( settings1, { name: undefined, defaultPresets: undefined }, 'undefined name and default presets for project1' ) - st.alike( + assert.deepEqual( settings2, { name: 'project 2', defaultPresets: undefined }, 'matched name for project2 with undefined default presets' ) }) - await t.test('after updating project settings', async (st) => { + await t.test('after updating project settings', async () => { await project1.$setProjectSettings({ name: 'project 1', }) @@ -86,13 +88,13 @@ test('Managing created projects', async (t) => { const settings1 = await project1.$getProjectSettings() const settings2 = await project2.$getProjectSettings() - st.is(settings1.name, 'project 1') + assert.equal(settings1.name, 'project 1') - st.is(settings2.name, 'project 2 updated') + assert.equal(settings2.name, 'project 2 updated') const listedProjects = await manager.listProjects() - st.is(listedProjects.length, 2) + assert.equal(listedProjects.length, 2) const project1FromListed = listedProjects.find( (p) => p.projectId === project1Id @@ -102,15 +104,15 @@ test('Managing created projects', async (t) => { (p) => p.projectId === project2Id ) - st.ok(project1FromListed) - st.is(project1FromListed?.name, 'project 1') - st.ok(project1FromListed?.createdAt) - st.ok(project1FromListed?.updatedAt) + assert(project1FromListed) + assert.equal(project1FromListed?.name, 'project 1') + assert(project1FromListed?.createdAt) + assert(project1FromListed?.updatedAt) - st.ok(project2FromListed) - st.is(project2FromListed?.name, 'project 2 updated') - st.ok(project2FromListed?.createdAt) - st.ok(project2FromListed?.updatedAt) + assert(project2FromListed) + assert.equal(project2FromListed?.name, 'project 2 updated') + assert(project2FromListed?.createdAt) + assert(project2FromListed?.updatedAt) }) }) @@ -136,29 +138,29 @@ test('Consistent loading of config', async (t) => { const projectPresets = await project.preset.getMany() await t.test( 'load default config and check if correctly loaded', - async (st) => { - st.is( + async () => { + assert.equal( //@ts-ignore projectSettings.defaultPresets.point.length, expectedDefault.presets.length, 'the default presets loaded is equal to the number of presets in the default config' ) - st.alike( + assert.deepEqual( projectPresets.map((preset) => preset.name), expectedDefault.presets.map((preset) => preset.value.name), 'project is loading the default presets correctly' ) const projectFields = await project.field.getMany() - st.alike( + assert.deepEqual( projectFields.map((field) => field.tagKey), expectedDefault.fields.map((field) => field.value.tagKey), 'project is loading the default fields correctly' ) const projectIcons = await project[kDataTypes].icon.getMany() - st.alike( + assert.deepEqual( projectIcons.map((icon) => icon.name), expectedDefault.icons.map((icon) => icon.name), 'project is loading the default icons correctly' @@ -166,58 +168,55 @@ test('Consistent loading of config', async (t) => { } ) - await t.test( - 'loading non-default config when creating project', - async (st) => { - const configPath = 'tests/fixtures/config/completeConfig.zip' - const projectId = await manager.createProject({ configPath }) + await t.test('loading non-default config when creating project', async () => { + const configPath = 'tests/fixtures/config/completeConfig.zip' + const projectId = await manager.createProject({ configPath }) - const project = await manager.getProject(projectId) + const project = await manager.getProject(projectId) - const projectSettings = await project.$getProjectSettings() - st.is( - projectSettings.defaultPresets?.point.length, - expectedMinimal.presets.length, - 'the default presets loaded is equal to the number of presets in the default config' - ) + const projectSettings = await project.$getProjectSettings() + assert.equal( + projectSettings.defaultPresets?.point.length, + expectedMinimal.presets.length, + 'the default presets loaded is equal to the number of presets in the default config' + ) - const projectPresets = await project.preset.getMany() - st.alike( - projectPresets.map((preset) => preset.name), - expectedMinimal.presets.map((preset) => preset.value.name), - 'project is loading the default presets correctly' - ) + const projectPresets = await project.preset.getMany() + assert.deepEqual( + projectPresets.map((preset) => preset.name), + expectedMinimal.presets.map((preset) => preset.value.name), + 'project is loading the default presets correctly' + ) - const projectFields = await project.field.getMany() - st.alike( - projectFields.map((field) => field.tagKey), - expectedMinimal.fields.map((field) => field.value.tagKey), - 'project is loading the default fields correctly' - ) + const projectFields = await project.field.getMany() + assert.deepEqual( + projectFields.map((field) => field.tagKey), + expectedMinimal.fields.map((field) => field.value.tagKey), + 'project is loading the default fields correctly' + ) - const projectIcons = await project[kDataTypes].icon.getMany() - st.alike( - projectIcons.map((icon) => icon.name), - expectedMinimal.icons.map((icon) => icon.name), - 'project is loading the default icons correctly' - ) - } - ) + const projectIcons = await project[kDataTypes].icon.getMany() + assert.deepEqual( + projectIcons.map((icon) => icon.name), + expectedMinimal.icons.map((icon) => icon.name), + 'project is loading the default icons correctly' + ) + }) await t.test( 'load different config and check if correctly loaded', - async (st) => { + async () => { const configPath = 'tests/fixtures/config/completeConfig.zip' await project.importConfig({ configPath }) const projectPresets = await project.preset.getMany() - st.alike( + assert.deepEqual( projectPresets.map((preset) => preset.name), expectedMinimal.presets.map((preset) => preset.value.name), 'project presets explicitly loaded match expected config' ) const projectFields = await project.field.getMany() - st.alike( + assert.deepEqual( projectFields.map((field) => field.tagKey), expectedMinimal.fields.map((field) => field.value.tagKey), 'project fields explicitly loaded match expected config' @@ -225,7 +224,7 @@ test('Consistent loading of config', async (t) => { // TODO: since we don't delete icons, this wouldn't match // const projectIcons = await project1[kDataTypes].icon.getMany() - // st.alike( + // assert.deepEqual( // projectIcons.map((icon) => icon.name), // loadedIcons.map((icon) => icon.name) // ) @@ -261,10 +260,10 @@ test('Managing added projects', async (t) => { { waitForSync: false } ) - await t.test('initial information from listed projects', async (st) => { + await t.test('initial information from listed projects', async () => { const listedProjects = await manager.listProjects() - st.is(listedProjects.length, 2) + assert.equal(listedProjects.length, 2) const listedProject1 = listedProjects.find( (p) => p.projectId === project1Id @@ -274,41 +273,44 @@ test('Managing added projects', async (t) => { (p) => p.projectId === project2Id ) - st.ok(listedProject1) - st.is(listedProject1?.name, 'project 1') - st.absent(listedProject1?.createdAt) - st.absent(listedProject1?.updatedAt) + assert(listedProject1) + assert.equal(listedProject1?.name, 'project 1') + assert(!listedProject1?.createdAt) + assert(!listedProject1?.updatedAt) - st.ok(listedProject2) - st.is(listedProject2?.name, 'project 2') - st.absent(listedProject2?.createdAt) - st.absent(listedProject2?.updatedAt) + assert(listedProject2) + assert.equal(listedProject2?.name, 'project 2') + assert(!listedProject2?.createdAt) + assert(!listedProject2?.updatedAt) }) - // TODO: Ideally would use the todo opt but usage in a subtest doesn't work: https://github.com/holepunchto/brittle/issues/39 - // t.test('initial settings from project instances', async (t) => { - // const project1 = await manager.getProject(project1Id) - // const project2 = await manager.getProject(project2Id) - - // t.ok(project1) - // t.ok(project2) - - // const settings1 = await project1.$getProjectSettings() - // const settings2 = await project2.$getProjectSettings() - - // t.alike(settings1, { - // name: 'project 1', - // defaultPresets: undefined, - // }) - - // t.alike(settings2, { - // name: 'project 2', - // defaultPresets: undefined, - // }) - // }) + await t.test( + 'initial settings from project instances', + { skip: true }, + async () => { + const project1 = await manager.getProject(project1Id) + const project2 = await manager.getProject(project2Id) + + assert(project1) + assert(project2) + + const settings1 = await project1.$getProjectSettings() + const settings2 = await project2.$getProjectSettings() + + assert.deepEqual(settings1, { + name: 'project 1', + defaultPresets: undefined, + }) + + assert.deepEqual(settings2, { + name: 'project 2', + defaultPresets: undefined, + }) + } + ) }) -test('Managing both created and added projects', async (t) => { +test('Managing both created and added projects', async () => { const manager = new MapeoManager({ rootKey: KeyManager.generateRootKey(), projectMigrationsFolder, @@ -333,26 +335,26 @@ test('Managing both created and added projects', async (t) => { const listedProjects = await manager.listProjects() - t.is(listedProjects.length, 2) + assert.equal(listedProjects.length, 2) const createdProjectListed = listedProjects.find( ({ projectId }) => projectId === createdProjectId ) - t.ok(createdProjectListed, 'created project is listed') + assert(createdProjectListed, 'created project is listed') const addedProjectListed = listedProjects.find( ({ projectId }) => projectId === addedProjectId ) - t.ok(addedProjectListed, 'added project is listed') + assert(addedProjectListed, 'added project is listed') const createdProject = await manager.getProject(createdProjectId) - t.ok(createdProject) + assert(createdProject) const addedProject = await manager.getProject(addedProjectId) - t.ok(addedProject) + assert(addedProject) }) -test('Manager cannot add project that already exists', async (t) => { +test('Manager cannot add project that already exists', async () => { const manager = new MapeoManager({ rootKey: KeyManager.generateRootKey(), projectMigrationsFolder, @@ -366,7 +368,7 @@ test('Manager cannot add project that already exists', async (t) => { const existingProjectsCountBefore = (await manager.listProjects()).length - await t.exception( + await assert.rejects( async () => manager.addProject({ projectKey: Buffer.from(existingProjectId, 'hex'), @@ -378,10 +380,10 @@ test('Manager cannot add project that already exists', async (t) => { const existingProjectsCountAfter = (await manager.listProjects()).length - t.is(existingProjectsCountBefore, existingProjectsCountAfter) + assert.equal(existingProjectsCountBefore, existingProjectsCountAfter) }) -test('Consistent storage folders', async (t) => { +test('Consistent storage folders', async () => { /** @type {string[]} */ const storageNames = [] const manager = new MapeoManager({ @@ -410,7 +412,11 @@ test('Consistent storage folders', async (t) => { await project.$getOwnRole() } - t.snapshot(storageNames.sort()) + assert.equal( + hashObject(storageNames.sort()), + '0c177494a78a8564be24976b2805a06dd9b8bfc7515ba62f1cfec1cba6f66152', + 'storage names match snapshot' + ) }) /** From 205811607973cccc2ea145e2f90621819cae2dfc Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 20 May 2024 10:24:53 -0500 Subject: [PATCH 10/19] test: move Fastify server tests to `node:test` (#661) This test-only change drops Brittle from our Fastify server tests and replaces them with `node:test` and `node:assert`. --- test-e2e/manager-fastify-server.js | 69 +++++++++++++++--------------- 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/test-e2e/manager-fastify-server.js b/test-e2e/manager-fastify-server.js index 244082c98..5dd53dce8 100644 --- a/test-e2e/manager-fastify-server.js +++ b/test-e2e/manager-fastify-server.js @@ -1,5 +1,6 @@ // @ts-check -import { test } from 'brittle' +import test from 'node:test' +import assert from 'node:assert/strict' import { randomBytes } from 'crypto' import { join } from 'path' import { fileURLToPath } from 'url' @@ -33,7 +34,7 @@ const projectMigrationsFolder = new URL('../drizzle/project', import.meta.url) const clientMigrationsFolder = new URL('../drizzle/client', import.meta.url) .pathname -test('start/stop lifecycle', async (t) => { +test('start/stop lifecycle', async () => { const fastify = Fastify() const fastifyController = new FastifyController({ fastify }) @@ -58,7 +59,7 @@ test('start/stop lifecycle', async (t) => { variant: 'original', }) const response1 = await fetch(blobUrl1) - t.is(response1.status, 404, 'server started and listening') + assert.equal(response1.status, 404, 'server started and listening') const blobUrl2 = await project.$blobs.getUrl({ driveId: randomBytes(32).toString('hex'), @@ -66,7 +67,7 @@ test('start/stop lifecycle', async (t) => { type: 'video', variant: 'original', }) - t.is( + assert.equal( new URL(blobUrl1).port, new URL(blobUrl2).port, 'server port is the same' @@ -74,7 +75,7 @@ test('start/stop lifecycle', async (t) => { await fastifyController.stop() - await t.exception.all(async () => { + await assert.rejects(async () => { await fetch(blobUrl2) }, 'failed to fetch due to connection error') @@ -88,18 +89,18 @@ test('start/stop lifecycle', async (t) => { variant: 'original', }) const response3 = await fetch(blobUrl3) - t.is(response3.status, 404, 'server started and listening') + assert.equal(response3.status, 404, 'server started and listening') await fastifyController.stop() - await t.exception.all(async () => { + await assert.rejects(async () => { await fetch(blobUrl3) }, 'failed to fetch due to connection error') }) test('retrieving blobs using url', async (t) => { const clock = FakeTimers.install({ shouldAdvanceTime: true }) - t.teardown(() => clock.uninstall()) + t.after(() => clock.uninstall()) const fastify = Fastify() const fastifyController = new FastifyController({ fastify }) @@ -114,7 +115,7 @@ test('retrieving blobs using url', async (t) => { const project = await manager.getProject(await manager.createProject()) - const exceptionPromise1 = t.exception(async () => { + const exceptionPromise1 = assert.rejects(async () => { await project.$blobs.getUrl({ driveId: randomBytes(32).toString('hex'), name: 'foo', @@ -129,7 +130,7 @@ test('retrieving blobs using url', async (t) => { // Manager should await for the server to start internally fastifyController.start() - await t.test('blob does not exist', async (st) => { + await t.test('blob does not exist', async () => { const blobUrl = await project.$blobs.getUrl({ driveId: randomBytes(32).toString('hex'), name: 'foo', @@ -137,17 +138,17 @@ test('retrieving blobs using url', async (t) => { variant: 'original', }) - st.ok( + assert( new URL(blobUrl), 'retrieving url based on HTTP server resolves after starting it' ) const response = await fetch(blobUrl) - st.is(response.status, 404, 'response is 404') + assert.equal(response.status, 404, 'response is 404') }) - await t.test('blob exists', async (st) => { + await t.test('blob exists', async () => { const blobId = await project.$blobs.create( { original: join(BLOB_FIXTURES_DIR, 'original.png') }, { mimeType: 'image/png' } @@ -158,15 +159,15 @@ test('retrieving blobs using url', async (t) => { variant: 'original', }) - st.ok( + assert( new URL(blobUrl), 'retrieving url based on HTTP server resolves after starting it' ) const response = await fetch(blobUrl) - st.is(response.status, 200, 'response status ok') - st.is( + assert.equal(response.status, 200, 'response status ok') + assert.equal( response.headers.get('content-type'), 'image/png', 'matching content type header' @@ -175,12 +176,12 @@ test('retrieving blobs using url', async (t) => { const expected = await fs.readFile(join(BLOB_FIXTURES_DIR, 'original.png')) const body = Buffer.from(await response.arrayBuffer()) - st.alike(body, expected, 'matching reponse body') + assert.deepEqual(body, expected, 'matching reponse body') }) await fastifyController.stop() - const exceptionPromise2 = t.exception(async () => { + const exceptionPromise2 = assert.rejects(async () => { await project.$blobs.getUrl({ driveId: randomBytes(32).toString('hex'), name: 'foo', @@ -194,7 +195,7 @@ test('retrieving blobs using url', async (t) => { test('retrieving icons using url', async (t) => { const clock = FakeTimers.install({ shouldAdvanceTime: true }) - t.teardown(() => clock.uninstall()) + t.after(() => clock.uninstall()) const fastify = Fastify() const fastifyController = new FastifyController({ fastify }) @@ -209,7 +210,7 @@ test('retrieving icons using url', async (t) => { const project = await manager.getProject(await manager.createProject()) - const exceptionPromise1 = t.exception(async () => { + const exceptionPromise1 = assert.rejects(async () => { await project.$icons.getIconUrl(randomBytes(32).toString('hex'), { mimeType: 'image/png', pixelDensity: 1, @@ -222,7 +223,7 @@ test('retrieving icons using url', async (t) => { await fastifyController.start() - await t.test('icon does not exist', async (st) => { + await t.test('icon does not exist', async () => { const nonExistentIconId = randomBytes(32).toString('hex') const iconUrl = await project.$icons.getIconUrl(nonExistentIconId, { @@ -231,17 +232,17 @@ test('retrieving icons using url', async (t) => { pixelDensity: 1, }) - st.ok( + assert( new URL(iconUrl), 'retrieving url based on HTTP server resolves after starting it' ) const response = await fetch(iconUrl) - st.is(response.status, 404, 'response is 404') + assert.equal(response.status, 404, 'response is 404') }) - await t.test('icon exists', async (st) => { + await t.test('icon exists', async () => { const iconBuffer = randomBytes(128) const iconId = await project.$icons.create({ @@ -262,26 +263,26 @@ test('retrieving icons using url', async (t) => { pixelDensity: 1, }) - st.ok( + assert( new URL(iconUrl), 'retrieving url based on HTTP server resolves after starting it' ) const response = await fetch(iconUrl) - st.is(response.status, 200, 'response status ok') - st.is( + assert.equal(response.status, 200, 'response status ok') + assert.equal( response.headers.get('content-type'), 'image/png', 'matching content type header' ) const body = Buffer.from(await response.arrayBuffer()) - st.alike(body, iconBuffer, 'matching response body') + assert.deepEqual(body, iconBuffer, 'matching response body') }) await fastifyController.stop() - const exceptionPromise2 = t.exception(async () => { + const exceptionPromise2 = assert.rejects(async () => { await project.$icons.getIconUrl(randomBytes(32).toString('hex'), { mimeType: 'image/png', pixelDensity: 1, @@ -294,7 +295,7 @@ test('retrieving icons using url', async (t) => { test('retrieving style.json using stable url', async (t) => { const clock = FakeTimers.install({ shouldAdvanceTime: true }) - t.teardown(() => clock.uninstall()) + t.after(() => clock.uninstall()) const fastify = Fastify() @@ -322,7 +323,7 @@ test('retrieving style.json using stable url', async (t) => { fastify, }) - const exceptionPromise1 = t.exception(async () => { + const exceptionPromise1 = assert.rejects(async () => { await manager.getMapStyleJsonUrl() }, 'cannot retrieve style json url before HTTP server starts') @@ -333,15 +334,15 @@ test('retrieving style.json using stable url', async (t) => { const styleJsonUrl = await manager.getMapStyleJsonUrl() - t.ok(new URL(styleJsonUrl)) + assert(new URL(styleJsonUrl)) const response = await fetch(styleJsonUrl) - t.is(response.status, 200, 'response status ok') + assert.equal(response.status, 200, 'response status ok') await fastifyController.stop() - const exceptionPromise2 = t.exception(async () => { + const exceptionPromise2 = assert.rejects(async () => { await manager.getMapStyleJsonUrl() }, 'cannot retrieve style json url after HTTP server closes') From 6fdc9f30c36b8516f1b4533eb06de39d5b919e01 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 20 May 2024 10:25:29 -0500 Subject: [PATCH 11/19] test: move project settings tests to `node:test` (#660) This test-only change drops Brittle from our project settings tests and replaces them with `node:test` and `node:assert`. --- test-e2e/project-settings.js | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/test-e2e/project-settings.js b/test-e2e/project-settings.js index 16fce23e3..8428ebc88 100644 --- a/test-e2e/project-settings.js +++ b/test-e2e/project-settings.js @@ -1,5 +1,6 @@ // @ts-check -import { test } from 'brittle' +import test from 'node:test' +import assert from 'node:assert/strict' import { KeyManager } from '@mapeo/crypto' import RAM from 'random-access-memory' import Fastify from 'fastify' @@ -8,7 +9,7 @@ import { MapeoManager } from '../src/mapeo-manager.js' import { MapeoProject } from '../src/mapeo-project.js' import { removeUndefinedFields } from './utils.js' -test('Project settings create, read, and update operations', async (t) => { +test('Project settings create, read, and update operations', async () => { const fastify = Fastify() const manager = new MapeoManager({ @@ -24,21 +25,21 @@ test('Project settings create, read, and update operations', async (t) => { const projectId = await manager.createProject() - t.ok( + assert( projectId && typeof projectId === 'string', 'probably valid project ID returned when creating project' ) const project = await manager.getProject(projectId) - t.ok( + assert( project instanceof MapeoProject, 'manager.getProject() returns MapeoProject instance' ) const initialSettings = await project.$getProjectSettings() - t.alike( + assert.deepEqual( removeUndefinedFields(initialSettings), {}, 'project has no settings when initially created' @@ -50,11 +51,15 @@ test('Project settings create, read, and update operations', async (t) => { const updatedSettings = await project.$setProjectSettings(expectedSettings) - t.is(updatedSettings.name, expectedSettings.name, 'updatable settings change') + assert.equal( + updatedSettings.name, + expectedSettings.name, + 'updatable settings change' + ) const settings = await project.$getProjectSettings() - t.alike( + assert.deepEqual( settings, updatedSettings, 'retrieved settings are equivalent to most recently updated' From 2b6eb0695d5074ffd6afbb4452be089e8f97eaf5 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Mon, 20 May 2024 11:30:36 -0400 Subject: [PATCH 12/19] chore: add Node 20 to CI (#652) Co-authored-by: Evan Hahn --- .github/workflows/node.yml | 3 +-- test-e2e/device-info.js | 9 ++++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index 71f374eb6..e1adc6a19 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -16,8 +16,7 @@ jobs: fail-fast: false matrix: os: [macos-latest, ubuntu-latest, windows-latest] - node-version: - - '18.x' + node-version: [18.x, 20.x] steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} diff --git a/test-e2e/device-info.js b/test-e2e/device-info.js index 98eec4566..e57945960 100644 --- a/test-e2e/device-info.js +++ b/test-e2e/device-info.js @@ -1,10 +1,10 @@ // @ts-check import { test } from 'brittle' import { randomBytes } from 'crypto' -import { once } from 'node:events' import { KeyManager } from '@mapeo/crypto' import RAM from 'random-access-memory' import Fastify from 'fastify' +import { pEvent } from 'p-event' import { connectPeers, createManagers, waitForPeers } from './utils.js' import { MapeoManager } from '../src/mapeo-manager.js' @@ -163,8 +163,11 @@ test('device info sent to peers', async (t) => { const otherManagersReceivedNameChangePromise = Promise.all( otherManagers.map(async (manager) => { - const [peersFromEvent] = await once(manager, 'local-peers') - t.is(peersFromEvent.find(isChangedPeer)?.name, 'new name') + await pEvent( + manager, + 'local-peers', + (peers) => peers.find(isChangedPeer)?.name === 'new name' + ) const updatedLocalPeers = await manager.listLocalPeers() t.is(updatedLocalPeers.find(isChangedPeer)?.name, 'new name') From 660027a22c0d18166bdbb2ecef1c065852fcc6b2 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 20 May 2024 10:32:37 -0500 Subject: [PATCH 13/19] test: remove `t.plan()` calls (#658) We plan to replace Brittle with `node:test` which doesn't have an equivalent for `t.plan`. Remove it to make it easier to migrate. --- test-e2e/members.js | 25 ++++---- tests/config-import.js | 7 ++- tests/core-manager.js | 2 - tests/discovery/local-discovery.js | 1 - tests/local-peers.js | 96 ++++++++++++++---------------- tests/sync/namespace-sync-state.js | 83 +++++++++++++------------- 6 files changed, 105 insertions(+), 109 deletions(-) diff --git a/test-e2e/members.js b/test-e2e/members.js index caf55f395..57a045bac 100644 --- a/test-e2e/members.js +++ b/test-e2e/members.js @@ -1,6 +1,7 @@ // @ts-check import { test } from 'brittle' import { randomBytes } from 'crypto' +import { once } from 'node:events' import { COORDINATOR_ROLE_ID, @@ -166,7 +167,6 @@ test('getting invited member after invite accepted', async (t) => { }) test('invite uses custom role name when provided', async (t) => { - t.plan(1) const managers = await createManagers(2, t) const [invitor, invitee] = managers connectPeers(managers) @@ -174,9 +174,7 @@ test('invite uses custom role name when provided', async (t) => { const projectId = await invitor.createProject({ name: 'Mapeo' }) - invitee.invite.on('invite-received', ({ roleName }) => { - t.is(roleName, 'friend', 'roleName should be equal') - }) + const inviteReceivedPromise = once(invitee.invite, 'invite-received') await invite({ invitor, @@ -186,11 +184,13 @@ test('invite uses custom role name when provided', async (t) => { reject: true, }) + const [{ roleName }] = await inviteReceivedPromise + t.is(roleName, 'friend', 'roleName should be equal') + await disconnectPeers(managers) }) test('invite uses default role name when not provided', async (t) => { - t.plan(1) const managers = await createManagers(2, t) const [invitor, invitee] = managers connectPeers(managers) @@ -198,13 +198,7 @@ test('invite uses default role name when not provided', async (t) => { const projectId = await invitor.createProject({ name: 'Mapeo' }) - invitee.invite.on('invite-received', ({ roleName }) => { - t.is( - roleName, - ROLES[MEMBER_ROLE_ID].name, - '`roleName` should use the fallback by deriving `roleId`' - ) - }) + const inviteReceivedPromise = once(invitee.invite, 'invite-received') await invite({ invitor, @@ -213,6 +207,13 @@ test('invite uses default role name when not provided', async (t) => { reject: true, }) + const [{ roleName }] = await inviteReceivedPromise + t.is( + roleName, + ROLES[MEMBER_ROLE_ID].name, + '`roleName` should use the fallback by deriving `roleId`' + ) + await disconnectPeers(managers) }) diff --git a/tests/config-import.js b/tests/config-import.js index 5041870fc..bfaac6db3 100644 --- a/tests/config-import.js +++ b/tests/config-import.js @@ -1,6 +1,6 @@ // @ts-check import { test } from 'brittle' -import { size } from 'iterpal' +import { arrayFrom, size } from 'iterpal' import { defaultConfigPath } from './helpers/default-config.js' import { readConfig } from '../src/config-import.js' @@ -133,8 +133,9 @@ test('config import - icons', async (t) => { ) config = await readConfig('./tests/fixtures/config/validIcons.zip') - t.plan(15) // 2 icon assertions + (3+9) variant assertions + 1 no warnings - for await (const icon of config.icons()) { + const icons = await arrayFrom(config.icons()) + t.is(icons.length, 2) + for (const icon of icons) { if (icon.name === 'plant') { t.is(icon.variants.length, 3, '3 variants of plant icons') } else if (icon.name === 'tree') { diff --git a/tests/core-manager.js b/tests/core-manager.js index 084b400e9..5d86aa2dc 100644 --- a/tests/core-manager.js +++ b/tests/core-manager.js @@ -168,8 +168,6 @@ test('multiplexing waits for cores to be added', async function (t) { // Mapeo code expects replication to work when cores are not added to the // replication stream at the same time. This is not explicitly tested in // Hypercore so we check here that this behaviour works. - t.plan(2) - const a1 = await createCore() const a2 = await createCore() diff --git a/tests/discovery/local-discovery.js b/tests/discovery/local-discovery.js index d0774c9a3..171b031d7 100644 --- a/tests/discovery/local-discovery.js +++ b/tests/discovery/local-discovery.js @@ -119,7 +119,6 @@ async function testMultiple(t, { period, nPeers = 20 }) { const peersById = new Map() /** @type {Map} */ const connsById = new Map() - // t.plan(3 * nPeers + 1) const { promise: fullyConnectedPromise, resolve: onFullyConnected } = pDefer() diff --git a/tests/local-peers.js b/tests/local-peers.js index 11d367173..e1eb29b45 100644 --- a/tests/local-peers.js +++ b/tests/local-peers.js @@ -6,7 +6,7 @@ import { UnknownPeerError, kTestOnlySendRawInvite, } from '../src/local-peers.js' -import { once } from 'events' +import { on, once } from 'events' import { Duplex } from 'streamx' import { replicate } from './helpers/local-peers.js' import { randomBytes } from 'node:crypto' @@ -16,8 +16,6 @@ import Protomux from 'protomux' import { InviteResponse_Decision } from '../src/generated/rpc.js' test('sending and receiving invites', async (t) => { - t.plan(2) - const r1 = new LocalPeers() const r2 = new LocalPeers() @@ -33,24 +31,23 @@ test('sending and receiving invites', async (t) => { { ...validInvite, projectName: '' }, ] - r1.on('peers', async (peers) => { - t.is(peers.length, 1) - await Promise.all( - invalidInvites.map((i) => r1.sendInvite(peers[0].deviceId, i)) - ) - await r1.sendInvite(peers[0].deviceId, validInvite) - }) - - r2.on('invite', (_peerId, receivedInvite) => { - t.alike(receivedInvite, validInvite, 'received invite') - }) + const r1PeersPromise = once(r1, 'peers') + const r2InvitePromise = once(r2, 'invite') replicate(r1, r2) + + const [peers] = await r1PeersPromise + t.is(peers.length, 1) + await Promise.all( + invalidInvites.map((i) => r1.sendInvite(peers[0].deviceId, i)) + ) + await r1.sendInvite(peers[0].deviceId, validInvite) + + const [_, receivedInvite] = await r2InvitePromise + t.alike(receivedInvite, validInvite, 'received invite') }) test('sending and receiving invite responses', async (t) => { - t.plan(2) - const r1 = new LocalPeers() const r2 = new LocalPeers() @@ -63,22 +60,21 @@ test('sending and receiving invite responses', async (t) => { inviteId: testInviteId().slice(0, 31), } - r1.on('peers', async (peers) => { - t.is(peers.length, 1) - await r1.sendInviteResponse(peers[0].deviceId, invalidInviteResponse) - await r1.sendInviteResponse(peers[0].deviceId, validInviteResponse) - }) - - r2.on('invite-response', (_peerId, receivedResponse) => { - t.alike(receivedResponse, validInviteResponse, 'received invite response') - }) + const r1PeersPromise = once(r1, 'peers') + const r2InviteResponsePromise = once(r2, 'invite-response') replicate(r1, r2) + + const [peers] = await r1PeersPromise + t.is(peers.length, 1) + await r1.sendInviteResponse(peers[0].deviceId, invalidInviteResponse) + await r1.sendInviteResponse(peers[0].deviceId, validInviteResponse) + + const [_, receivedResponse] = await r2InviteResponsePromise + t.alike(receivedResponse, validInviteResponse, 'received invite response') }) test('sending and receiving project join details', async (t) => { - t.plan(2) - const r1 = new LocalPeers() const r2 = new LocalPeers() @@ -93,21 +89,22 @@ test('sending and receiving project join details', async (t) => { { ...validProjectJoinDetails, encryptionKeys: { auth: Buffer.alloc(0) } }, ] - r1.on('peers', async (peers) => { - t.is(peers.length, 1) - await Promise.all( - invalidProjectJoinDetails.map((d) => - r1.sendProjectJoinDetails(peers[0].deviceId, d) - ) - ) - await r1.sendProjectJoinDetails(peers[0].deviceId, validProjectJoinDetails) - }) - - r2.on('got-project-details', (_peerId, details) => { - t.alike(details, validProjectJoinDetails, 'received project join details') - }) + const r1PeersPromise = once(r1, 'peers') + const r2GotProjectDetailsPromise = once(r2, 'got-project-details') replicate(r1, r2) + + const [peers] = await r1PeersPromise + t.is(peers.length, 1) + await Promise.all( + invalidProjectJoinDetails.map((d) => + r1.sendProjectJoinDetails(peers[0].deviceId, d) + ) + ) + await r1.sendProjectJoinDetails(peers[0].deviceId, validProjectJoinDetails) + + const [_, details] = await r2GotProjectDetailsPromise + t.alike(details, validProjectJoinDetails, 'received project join details') }) test('messages to unknown peers', async (t) => { @@ -156,8 +153,6 @@ test('messages to unknown peers', async (t) => { }) test('handles invalid invites', async (t) => { - t.plan(1) - const r1 = new LocalPeers() const r2 = new LocalPeers() @@ -169,33 +164,34 @@ test('handles invalid invites', async (t) => { t.fail('should not receive invite') }) - r2.once('failed-to-handle-message', (messageType) => { - t.is(messageType, 'Invite') - }) + const r2FailedToHandleMessagePromise = once(r2, 'failed-to-handle-message') const destroy = replicate(r1, r2) t.teardown(destroy) + + const [messageType] = await r2FailedToHandleMessagePromise + t.is(messageType, 'Invite') }) test('Disconnected peer shows in state', async (t) => { - t.plan(6) const r1 = new LocalPeers() const r2 = new LocalPeers() let peerStateUpdates = 0 - r1.on('peers', async (peers) => { + const destroy = replicate(r1, r2) + + for await (const [peers] of on(r1, 'peers')) { t.is(peers.length, 1, 'one peer in state') if (peers[0].status === 'connected') { t.pass('peer appeared as connected') t.is(++peerStateUpdates, 1) destroy(new Error()) + break } else { t.pass('peer appeared as disconnected') t.is(++peerStateUpdates, 2) } - }) - - const destroy = replicate(r1, r2) + } }) test('next tick disconnect does not throw', async (t) => { diff --git a/tests/sync/namespace-sync-state.js b/tests/sync/namespace-sync-state.js index 7c98127e7..5255ec7d0 100644 --- a/tests/sync/namespace-sync-state.js +++ b/tests/sync/namespace-sync-state.js @@ -1,5 +1,6 @@ //@ts-check import test from 'brittle' +import pDefer from 'p-defer' import { KeyManager } from '@mapeo/crypto' import { NamespaceSyncState } from '../../src/sync/namespace-sync-state.js' import { @@ -11,7 +12,6 @@ import { import { randomBytes } from 'crypto' test('sync cores in a namespace', async function (t) { - t.plan(2) const projectKeyPair = KeyManager.generateProjectKeypair() const rootKey1 = randomBytes(16) const rootKey2 = randomBytes(16) @@ -38,8 +38,8 @@ test('sync cores in a namespace', async function (t) { waitForCores(cm2, getKeys(cm1, 'auth')), ]) - let syncState1Synced = false - let syncState2Synced = false + const syncState1Sync = pDefer() + const syncState2Sync = pDefer() const syncState1 = new NamespaceSyncState({ coreManager: cm1, @@ -50,20 +50,9 @@ test('sync cores in a namespace', async function (t) { state.localState.want === 0 && state.localState.wanted === 0 && state.localState.have === 30 && - state.localState.missing === 10 && - !syncState1Synced + state.localState.missing === 10 ) { - const expected = { - [km2.getIdentityKeypair().publicKey.toString('hex')]: { - want: 0, - wanted: 0, - have: 30, - missing: 10, - status: 'connected', - }, - } - t.alike(state.remoteStates, expected, 'syncState1 is synced') - syncState1Synced = true + syncState1Sync.resolve(state.remoteStates) } }, peerSyncControllers: new Map(), @@ -78,20 +67,9 @@ test('sync cores in a namespace', async function (t) { state.localState.want === 0 && state.localState.wanted === 0 && state.localState.have === 30 && - state.localState.missing === 10 && - !syncState2Synced + state.localState.missing === 10 ) { - const expected = { - [km1.getIdentityKeypair().publicKey.toString('hex')]: { - want: 0, - wanted: 0, - have: 30, - missing: 10, - status: 'connected', - }, - } - t.alike(state.remoteStates, expected, 'syncState2 is synced') - syncState2Synced = true + syncState2Sync.resolve(state.remoteStates) } }, peerSyncControllers: new Map(), @@ -118,10 +96,37 @@ test('sync cores in a namespace', async function (t) { const core = cm2.getCoreByKey(key) core?.download({ start: 0, end: -1 }) } + + t.alike( + await syncState1Sync.promise, + { + [km2.getIdentityKeypair().publicKey.toString('hex')]: { + want: 0, + wanted: 0, + have: 30, + missing: 10, + status: 'connected', + }, + }, + 'syncState1 is synced' + ) + + t.alike( + await syncState2Sync.promise, + { + [km1.getIdentityKeypair().publicKey.toString('hex')]: { + want: 0, + wanted: 0, + have: 30, + missing: 10, + status: 'connected', + }, + }, + 'syncState2 is synced' + ) }) -test('replicate with updating data', async function (t) { - t.plan(2) +test('replicate with updating data', async function () { const fillLength = 5000 const projectKeyPair = KeyManager.generateProjectKeypair() @@ -151,8 +156,8 @@ test('replicate with updating data', async function (t) { waitForCores(cm2, getKeys(cm1, 'auth')), ]) - let syncState1AlreadyDone = false - let syncState2AlreadyDone = false + const syncState1Sync = pDefer() + const syncState2Sync = pDefer() const syncState1 = new NamespaceSyncState({ coreManager: cm1, @@ -161,10 +166,7 @@ test('replicate with updating data', async function (t) { const { localState } = syncState1.getState() const synced = localState.wanted === 0 && localState.have === fillLength * 2 - if (synced && !syncState1AlreadyDone) { - t.ok(synced, 'syncState1 is synced') - syncState1AlreadyDone = true - } + if (synced) syncState1Sync.resolve() }, peerSyncControllers: new Map(), }) @@ -176,10 +178,7 @@ test('replicate with updating data', async function (t) { const { localState } = syncState2.getState() const synced = localState.wanted === 0 && localState.have === fillLength * 2 - if (synced && !syncState2AlreadyDone) { - t.ok(synced, 'syncState2 is synced') - syncState2AlreadyDone = true - } + if (synced) syncState2Sync.resolve() }, peerSyncControllers: new Map(), }) @@ -198,4 +197,6 @@ test('replicate with updating data', async function (t) { const core = cm2.getCoreByKey(key) core?.download({ start: 0, end: -1 }) } + + await Promise.all([syncState1Sync.promise, syncState2Sync.promise]) }) From 2126de66449bc14ef72296c64d561030fecda6d2 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 20 May 2024 13:40:02 -0500 Subject: [PATCH 14/19] feat: delete lots of data from indexers when leaving project (#469) When you leave a project, we now delete all indexed data outside of the `auth` namespace. Fixes [#427]. [#427]: https://github.com/digidem/mapeo-core-next/issues/427 Co-Authored-By: Andrew Chou --- package-lock.json | 21 ++++++++++++--------- package.json | 4 ++-- src/constants.js | 13 +++++++++++++ src/datastore/index.js | 22 +++++++++------------- src/datatype/index.d.ts | 4 ++++ src/datatype/index.js | 8 ++++++++ src/index-writer/index.js | 23 +++++++++++++++++++++-- src/mapeo-project.js | 38 +++++++++++++++++++++++++++----------- src/roles.js | 2 +- test-e2e/project-leave.js | 18 ++++++++++++++++++ 10 files changed, 115 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7528f0429..51e9c077f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@hyperswarm/secret-stream": "^6.1.2", "@mapeo/crypto": "1.0.0-alpha.10", "@mapeo/schema": "3.0.0-next.15", - "@mapeo/sqlite-indexer": "1.0.0-alpha.8", + "@mapeo/sqlite-indexer": "1.0.0-alpha.9", "@sinclair/typebox": "^0.29.6", "b4a": "^1.6.3", "bcp-47": "^2.1.0", @@ -41,7 +41,7 @@ "magic-bytes.js": "^1.10.0", "map-obj": "^5.0.2", "mime": "^4.0.1", - "multi-core-indexer": "^1.0.0-alpha.9", + "multi-core-indexer": "^1.0.0-alpha.10", "p-defer": "^4.0.0", "p-event": "^6.0.1", "p-timeout": "^6.1.2", @@ -1708,12 +1708,16 @@ } }, "node_modules/@mapeo/sqlite-indexer": { - "version": "1.0.0-alpha.8", - "resolved": "https://registry.npmjs.org/@mapeo/sqlite-indexer/-/sqlite-indexer-1.0.0-alpha.8.tgz", - "integrity": "sha512-qU+I6L4QKp6CkNA5AYu8dqADWaX+usfZq89c+fKOmIRZ+jR9ta3790PzCQbr5VCaYPDfp0OfemO1EjJ7RhHfcQ==", + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@mapeo/sqlite-indexer/-/sqlite-indexer-1.0.0-alpha.9.tgz", + "integrity": "sha512-TxuqTVmHjt3FHzQYos9dLakSi0Ibbn8I1frNgUwIo9fMAiuVVrsHIiibULZjnwqMMkkqGmvonVMCKZQOsdu32g==", "dependencies": { "@types/better-sqlite3": "^7.6.4", "better-sqlite3": "^8.4.0" + }, + "engines": { + "node": ">=18.17.1", + "npm": ">=9.6.7" } }, "node_modules/@node-rs/crc32": { @@ -6313,15 +6317,14 @@ "license": "MIT" }, "node_modules/multi-core-indexer": { - "version": "1.0.0-alpha.9", - "resolved": "https://registry.npmjs.org/multi-core-indexer/-/multi-core-indexer-1.0.0-alpha.9.tgz", - "integrity": "sha512-LopPO+BMy1UXyyDOXbDsJ6eXApo97riXnRqjL1LYwQ7+Q9oZlPWpRmyVY42+eApSt8Ub7AEcUjbl5yIszpffKQ==", + "version": "1.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/multi-core-indexer/-/multi-core-indexer-1.0.0-alpha.10.tgz", + "integrity": "sha512-H9QdpJ/MaelrBZw6jCcsrInE+hwUQmfz/2swtIdQNNh1IHUDGEdPkakjcZAyahpM5iIVz7EqyWO74aC03A3qSA==", "dependencies": { "@types/node": "^18.16.19", "@types/streamx": "^2.9.1", "b4a": "^1.6.4", "big-sparse-array": "^1.0.2", - "debug": "^4.3.3", "random-access-file": "^4.0.4", "streamx": "^2.15.0", "tiny-typed-emitter": "^2.1.0" diff --git a/package.json b/package.json index 421c12a6d..a0532b529 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,7 @@ "@hyperswarm/secret-stream": "^6.1.2", "@mapeo/crypto": "1.0.0-alpha.10", "@mapeo/schema": "3.0.0-next.15", - "@mapeo/sqlite-indexer": "1.0.0-alpha.8", + "@mapeo/sqlite-indexer": "1.0.0-alpha.9", "@sinclair/typebox": "^0.29.6", "b4a": "^1.6.3", "bcp-47": "^2.1.0", @@ -172,7 +172,7 @@ "magic-bytes.js": "^1.10.0", "map-obj": "^5.0.2", "mime": "^4.0.1", - "multi-core-indexer": "^1.0.0-alpha.9", + "multi-core-indexer": "^1.0.0-alpha.10", "p-defer": "^4.0.0", "p-event": "^6.0.1", "p-timeout": "^6.1.2", diff --git a/src/constants.js b/src/constants.js index 211f432ce..ea6f1a266 100644 --- a/src/constants.js +++ b/src/constants.js @@ -7,3 +7,16 @@ export const NAMESPACES = /** @type {const} */ ([ 'blobIndex', 'blob', ]) + +export const NAMESPACE_SCHEMAS = /** @type {const} */ ({ + data: ['observation', 'track'], + config: [ + 'translation', + 'preset', + 'field', + 'projectSettings', + 'deviceInfo', + 'icon', + ], + auth: ['coreOwnership', 'role'], +}) diff --git a/src/datastore/index.js b/src/datastore/index.js index bdd4654a7..1f657aa30 100644 --- a/src/datastore/index.js +++ b/src/datastore/index.js @@ -3,6 +3,7 @@ import { encode, decode, getVersionId, parseVersionId } from '@mapeo/schema' import MultiCoreIndexer from 'multi-core-indexer' import pDefer from 'p-defer' import { discoveryKey } from 'hypercore-crypto' +import { NAMESPACE_SCHEMAS } from '../constants.js' import { createMap } from '../utils.js' /** @@ -27,19 +28,6 @@ import { createMap } from '../utils.js' * @typedef {T extends any ? Omit : never} OmitUnion */ -const NAMESPACE_SCHEMAS = /** @type {const} */ ({ - data: ['observation', 'track'], - config: [ - 'translation', - 'preset', - 'field', - 'projectSettings', - 'deviceInfo', - 'icon', - ], - auth: ['coreOwnership', 'role'], -}) - /** * @typedef {typeof NAMESPACE_SCHEMAS} NamespaceSchemas */ @@ -229,6 +217,14 @@ export class DataStore extends TypedEmitter { async close() { await this.#coreIndexer.close() } + + /** + * Unlink all index files. This should only be called after `close()` has resolved. + */ + async unlink() { + await this.#coreIndexer.unlink() + } + #handleIndexerIdle = () => { for (const eventName of this.eventNames()) { if (!(eventName in this.#pendingEmits)) continue diff --git a/src/datatype/index.d.ts b/src/datatype/index.d.ts index ee6b72cac..2d6cd1758 100644 --- a/src/datatype/index.d.ts +++ b/src/datatype/index.d.ts @@ -64,6 +64,10 @@ export class DataType< get [kTable](): TTable + get schemaName(): TSchemaName + + get namespace(): TDataStore.namespace + get writerCore(): Hypercore<'binary', Buffer> [kCreateWithDocId]( diff --git a/src/datatype/index.js b/src/datatype/index.js index 85b52f012..222a78544 100644 --- a/src/datatype/index.js +++ b/src/datatype/index.js @@ -123,6 +123,14 @@ export class DataType extends TypedEmitter { return this.#table } + get schemaName() { + return this.#schemaName + } + + get namespace() { + return this.#dataStore.namespace + } + get writerCore() { return this.#dataStore.writerCore } diff --git a/src/index-writer/index.js b/src/index-writer/index.js index 05b8101c7..b89e0e9f5 100644 --- a/src/index-writer/index.js +++ b/src/index-writer/index.js @@ -22,7 +22,12 @@ import { Logger } from '../logger.js' * @template {MapeoDocTables} [TTables=MapeoDocTables] */ export class IndexWriter { - /** @type {Map} */ + /** + * @internal + * @typedef {TTables['_']['name']} SchemaName + */ + + /** @type {Map} */ #indexers = new Map() #mapDoc #l @@ -52,8 +57,11 @@ export class IndexWriter { } } + /** + * @returns {Iterable} + */ get schemas() { - return [...this.#indexers.keys()] + return this.#indexers.keys() } /** @@ -105,4 +113,15 @@ export class IndexWriter { } return indexed } + + /** + * @param {SchemaName} schemaName + */ + deleteSchema(schemaName) { + const indexer = this.#indexers.get(schemaName) + if (!indexer) { + throw new Error(`IndexWriter doesn't know a schema named "${schemaName}"`) + } + indexer.deleteAll() + } } diff --git a/src/mapeo-project.js b/src/mapeo-project.js index 31712e12f..55cbf503a 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -7,7 +7,7 @@ import { migrate } from 'drizzle-orm/better-sqlite3/migrator' import { discoveryKey } from 'hypercore-crypto' import { TypedEmitter } from 'tiny-typed-emitter' -import { NAMESPACES } from './constants.js' +import { NAMESPACES, NAMESPACE_SCHEMAS } from './constants.js' import { CoreManager } from './core-manager/index.js' import { DataStore } from './datastore/index.js' import { DataType, kCreateWithDocId } from './datatype/index.js' @@ -72,6 +72,7 @@ export class MapeoProject extends TypedEmitter { #projectId #deviceId #coreManager + #indexWriter #dataStores #dataTypes #blobStore @@ -152,7 +153,7 @@ export class MapeoProject extends TypedEmitter { logger: this.#l, }) - const indexWriter = new IndexWriter({ + this.#indexWriter = new IndexWriter({ tables: [ observationTable, trackTable, @@ -183,7 +184,7 @@ export class MapeoProject extends TypedEmitter { auth: new DataStore({ coreManager: this.#coreManager, namespace: 'auth', - batch: (entries) => indexWriter.batch(entries), + batch: (entries) => this.#indexWriter.batch(entries), storage: indexerStorage, }), config: new DataStore({ @@ -191,7 +192,7 @@ export class MapeoProject extends TypedEmitter { namespace: 'config', batch: (entries) => this.#handleConfigEntries(entries, { - projectIndexWriter: indexWriter, + projectIndexWriter: this.#indexWriter, sharedIndexWriter, }), storage: indexerStorage, @@ -199,7 +200,7 @@ export class MapeoProject extends TypedEmitter { data: new DataStore({ coreManager: this.#coreManager, namespace: 'data', - batch: (entries) => indexWriter.batch(entries), + batch: (entries) => this.#indexWriter.batch(entries), storage: indexerStorage, }), } @@ -664,7 +665,10 @@ export class MapeoProject extends TypedEmitter { ) } - // 2. Clear data from cores + // 2. Assign LEFT role for device + await this.#roles.assignRole(this.#deviceId, LEFT_ROLE_ID) + + // 3. Clear data from cores // TODO: only clear synced data const namespacesWithoutAuth = /** @satisfies {Exclude[]} */ ([ @@ -681,12 +685,24 @@ export class MapeoProject extends TypedEmitter { ]) ) - // TODO: 3. Clear data from indexes - // 3.1 Reset multi-core indexer state - // 3.2 Clear indexed data + // 4. Clear data from indexes + // 4.1 Reset multi-core indexer state + await Promise.all( + Object.values(this.#dataStores) + .filter((dataStore) => dataStore.namespace !== 'auth') + .map(async (dataStore) => { + await dataStore.close() + await dataStore.unlink() + }) + ) - // 4. Assign LEFT role for device - await this.#roles.assignRole(this.#deviceId, LEFT_ROLE_ID) + // 4.2 Clear indexed data + /** @type {Set} */ + const authSchemas = new Set(NAMESPACE_SCHEMAS.auth) + for (const schemaName of this.#indexWriter.schemas) { + const isAuthSchema = authSchemas.has(schemaName) + if (!isAuthSchema) this.#indexWriter.deleteSchema(schemaName) + } } /** @param {Object} opts diff --git a/src/roles.js b/src/roles.js index eda0b224f..ee9e7bae0 100644 --- a/src/roles.js +++ b/src/roles.js @@ -357,13 +357,13 @@ export class Roles { "Only the project creator can assign the project creator's role" ) } - const ownRole = await this.getRole(this.#ownDeviceId) if (roleId === LEFT_ROLE_ID) { if (deviceId !== this.#ownDeviceId) { throw new Error('Cannot assign LEFT role to another device') } } else { + const ownRole = await this.getRole(this.#ownDeviceId) if (!ownRole.roleAssignment.includes(roleId)) { throw new Error('Lacks permission to assign role ' + roleId) } diff --git a/test-e2e/project-leave.js b/test-e2e/project-leave.js index ccb30e647..fdf8c7717 100644 --- a/test-e2e/project-leave.js +++ b/test-e2e/project-leave.js @@ -248,6 +248,20 @@ test('Data access after leaving project', async (t) => { const [, coordinatorProject, memberProject] = projects + await memberProject.observation.create({ + schemaName: 'observation', + attachments: [], + tags: {}, + refs: [], + metadata: {}, + }) + t.ok( + (await memberProject.observation.getMany()).length >= 1, + 'Test is set up correctly' + ) + + await waitForSync(projects, 'initial') + await Promise.all([ coordinator.leaveProject(projectId), member.leaveProject(projectId), @@ -264,6 +278,10 @@ test('Data access after leaving project', async (t) => { metadata: {}, }) }, 'member cannot create new data after leaving') + await t.exception( + () => memberProject.observation.getMany(), + "Shouldn't be able to fetch observations after leaving" + ) t.alike( await memberProject.$getProjectSettings(), From ba539cfdcc5ccd0ce64ac717349c2e9b2bf901bc Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 20 May 2024 21:54:14 +0000 Subject: [PATCH 15/19] Move "previous sync enabled state" to `SyncApi` --- src/sync/peer-sync-controller.js | 4 ---- src/sync/sync-api.js | 11 +++++------ 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/sync/peer-sync-controller.js b/src/sync/peer-sync-controller.js index 3f1fd05f6..1f6ffc88b 100644 --- a/src/sync/peer-sync-controller.js +++ b/src/sync/peer-sync-controller.js @@ -89,10 +89,6 @@ export class PeerSyncController { return this.#syncCapability } - get syncEnabledState() { - return this.#syncEnabledState - } - /** @param {SyncEnabledState} syncEnabledState */ setSyncEnabledState(syncEnabledState) { if (this.#syncEnabledState === syncEnabledState) { diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index c8486f068..1df3cccea 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -58,6 +58,8 @@ export class SyncApi extends TypedEmitter { #peerIds = new Set() #wantsToSyncData = false #isBackgrounded = false + /** @type {SyncEnabledState} */ + #previousSyncEnabledState = 'none' /** @type {Map>} */ #pendingDiscoveryKeys = new Map() #l @@ -143,12 +145,7 @@ export class SyncApi extends TypedEmitter { /** @type {SyncEnabledState} */ let syncEnabledState if (this.#isBackgrounded) { - let isStopped = true - for (const peerSyncController of this.#peerSyncControllers.values()) { - isStopped = peerSyncController.syncEnabledState === 'none' - break - } - if (isStopped) { + if (this.#previousSyncEnabledState === 'none') { syncEnabledState = 'none' } else if ( isSynced( @@ -173,6 +170,8 @@ export class SyncApi extends TypedEmitter { } this.emit('sync-state', this.#getState(namespaceSyncState)) + + this.#previousSyncEnabledState = syncEnabledState } /** From 4a641e77dc74e9b316201f4c667fa5fcad2eb52e Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 20 May 2024 21:58:13 +0000 Subject: [PATCH 16/19] Rename "is backgrounded" to "has requested full stop" --- src/mapeo-manager.js | 10 +++++++--- src/sync/sync-api.js | 22 +++++++++++----------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 8ffb0bfd6..5ecf8d539 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -41,7 +41,11 @@ import { LocalDiscovery } from './discovery/local-discovery.js' import { Roles } from './roles.js' import NoiseSecretStream from '@hyperswarm/secret-stream' import { Logger } from './logger.js' -import { kSyncState, kBackground, kForeground } from './sync/sync-api.js' +import { + kSyncState, + kRequestFullStop, + kRescindFullStopRequest, +} from './sync/sync-api.js' /** @typedef {import("@mapeo/schema").ProjectSettingsValue} ProjectValue */ /** @typedef {import('type-fest').SetNonNullable} ValidatedProjectKeys */ @@ -764,7 +768,7 @@ export class MapeoManager extends TypedEmitter { */ onBackgrounded() { const projects = this.#activeProjects.values() - for (const project of projects) project.$sync[kBackground]() + for (const project of projects) project.$sync[kRequestFullStop]() } /** @@ -777,7 +781,7 @@ export class MapeoManager extends TypedEmitter { */ onForegrounded() { const projects = this.#activeProjects.values() - for (const project of projects) project.$sync[kForeground]() + for (const project of projects) project.$sync[kRescindFullStopRequest]() } /** diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index 1df3cccea..908f0a5a9 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -11,8 +11,8 @@ import { keyToId } from '../utils.js' export const kHandleDiscoveryKey = Symbol('handle discovery key') export const kSyncState = Symbol('sync state') -export const kBackground = Symbol('background') -export const kForeground = Symbol('foreground') +export const kRequestFullStop = Symbol('background') +export const kRescindFullStopRequest = Symbol('foreground') /** * @typedef {'initial' | 'full'} SyncType @@ -57,7 +57,7 @@ export class SyncApi extends TypedEmitter { /** @type {Set} */ #peerIds = new Set() #wantsToSyncData = false - #isBackgrounded = false + #hasRequestedFullStop = false /** @type {SyncEnabledState} */ #previousSyncEnabledState = 'none' /** @type {Map>} */ @@ -134,7 +134,7 @@ export class SyncApi extends TypedEmitter { state.data.syncing = this.#wantsToSyncData && - (!this.#isBackgrounded || + (!this.#hasRequestedFullStop || !isSynced(namespaceSyncState, 'full', this.#peerSyncControllers)) return state @@ -144,7 +144,7 @@ export class SyncApi extends TypedEmitter { const namespaceSyncState = this[kSyncState].getState() /** @type {SyncEnabledState} */ let syncEnabledState - if (this.#isBackgrounded) { + if (this.#hasRequestedFullStop) { if (this.#previousSyncEnabledState === 'none') { syncEnabledState = 'none' } else if ( @@ -196,18 +196,18 @@ export class SyncApi extends TypedEmitter { } /** - * Gracefully stop syncing all cores. + * Request a graceful stop to all sync. */ - [kBackground]() { - this.#isBackgrounded = true + [kRequestFullStop]() { + this.#hasRequestedFullStop = true this.#updateState() } /** - * Unpause. + * Rescind any requests for a full stop. */ - [kForeground]() { - this.#isBackgrounded = false + [kRescindFullStopRequest]() { + this.#hasRequestedFullStop = false this.#updateState() } From 8fe34aaec18b07facd878c7c93fe96c67d8295a8 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 20 May 2024 22:16:47 +0000 Subject: [PATCH 17/19] Rename syncing to isSyncEnabled --- src/sync/sync-api.js | 24 +++++++++++++++++------- test-e2e/sync.js | 2 +- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index 908f0a5a9..0bd81a04f 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -7,7 +7,7 @@ import { } from './peer-sync-controller.js' import { Logger } from '../logger.js' import { NAMESPACES } from '../constants.js' -import { keyToId } from '../utils.js' +import { ExhaustivenessError, keyToId } from '../utils.js' export const kHandleDiscoveryKey = Symbol('handle discovery key') export const kSyncState = Symbol('sync state') @@ -29,7 +29,7 @@ export const kRescindFullStopRequest = Symbol('foreground') * @property {number} wanted Number of blocks that connected peers want from us * @property {number} missing Number of blocks missing (we don't have them, but connected peers don't have them either) * @property {boolean} dataToSync Is there data available to sync? (want > 0 || wanted > 0) - * @property {boolean} syncing Are we currently syncing? + * @property {boolean} isSyncEnabled Do we want to sync this type of data? */ /** @@ -132,10 +132,20 @@ export class SyncApi extends TypedEmitter { #getState(namespaceSyncState) { const state = reduceSyncState(namespaceSyncState) - state.data.syncing = - this.#wantsToSyncData && - (!this.#hasRequestedFullStop || - !isSynced(namespaceSyncState, 'full', this.#peerSyncControllers)) + switch (this.#previousSyncEnabledState) { + case 'none': + state.initial.isSyncEnabled = state.data.isSyncEnabled = false + break + case 'presync': + state.initial.isSyncEnabled = true + state.data.isSyncEnabled = false + break + case 'all': + state.initial.isSyncEnabled = state.data.isSyncEnabled = true + break + default: + throw new ExhaustivenessError(this.#previousSyncEnabledState) + } return state } @@ -366,6 +376,6 @@ function createInitialSyncTypeState() { wanted: 0, missing: 0, dataToSync: false, - syncing: true, + isSyncEnabled: true, } } diff --git a/test-e2e/sync.js b/test-e2e/sync.js index 7e9a4376b..bb859c7d0 100644 --- a/test-e2e/sync.js +++ b/test-e2e/sync.js @@ -448,7 +448,7 @@ test('Correct sync state prior to data sync', async function (t) { wanted: docs.length, missing: 0, dataToSync: true, - syncing: false, + isSyncEnabled: false, }, connectedPeers: managers.length - 1, } From db4ff6c6318bc43897c5bf84ff8c60ece7e6ef67 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 20 May 2024 23:04:07 +0000 Subject: [PATCH 18/19] Add default param for #updateState --- src/sync/sync-api.js | 8 +- transform.ts | 319 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 transform.ts diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index 0bd81a04f..5a71be37a 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -83,7 +83,9 @@ export class SyncApi extends TypedEmitter { peerSyncControllers: this.#pscByPeerId, }) this[kSyncState].setMaxListeners(0) - this[kSyncState].on('state', this.#updateState) + this[kSyncState].on('state', (namespaceSyncState) => { + this.#updateState(namespaceSyncState) + }) this.#coreManager.creatorCore.on('peer-add', this.#handlePeerAdd) this.#coreManager.creatorCore.on('peer-remove', this.#handlePeerRemove) @@ -150,9 +152,7 @@ export class SyncApi extends TypedEmitter { return state } - #updateState = () => { - const namespaceSyncState = this[kSyncState].getState() - + #updateState(namespaceSyncState = this[kSyncState].getState()) { /** @type {SyncEnabledState} */ let syncEnabledState if (this.#hasRequestedFullStop) { if (this.#previousSyncEnabledState === 'none') { diff --git a/transform.ts b/transform.ts new file mode 100644 index 000000000..da4be8a65 --- /dev/null +++ b/transform.ts @@ -0,0 +1,319 @@ +import type { Transform, MemberExpression, CallExpression } from 'jscodeshift' +import assert from 'node:assert/strict' + +const transform: Transform = (fileInfo, api) => { + const { j } = api + const root = j(fileInfo.source) + + // Utilities + + const assertThrowsOrRejectsCall = ( + brittleCall: CallExpression, + method: 'throws' | 'rejects' + ): CallExpression => { + const args = [brittleCall.arguments[0]] + + const secondArg = brittleCall.arguments[1] + switch (secondArg?.type) { + case 'Literal': + if (secondArg.value instanceof RegExp) { + args.push( + j.objectExpression([ + j.objectProperty(j.identifier('message'), secondArg), + ]) + ) + } else { + args.push(j.identifier('undefined')) + args.push(secondArg) + } + break + case 'Identifier': + // Could be wrong. + args.push( + j.objectExpression([ + j.objectProperty(j.identifier('instanceOf'), secondArg), + ]) + ) + break + default: + break + } + + const thirdArg = brittleCall.arguments[2] + if (thirdArg) args.push(thirdArg) + + return j.callExpression( + j.memberExpression(j.identifier('assert'), j.identifier(method)), + args + ) + } + + // Replace the Brittle import with imports of `node:test` and `node:assert/strict`. + // + // Before: + // + // import ... from 'brittle' + // + // After: + // + // import test from 'node:test' + // import assert from 'node:assert/strict' + + root + .find(j.ImportDeclaration, { + source: { value: 'brittle' }, + }) + .replaceWith( + j.importDeclaration( + [j.importDefaultSpecifier(j.identifier('test'))], + j.stringLiteral('node:test') + ) + ) + .insertAfter( + j.importDeclaration( + [j.importDefaultSpecifier(j.identifier('assert'))], + j.stringLiteral('node:assert/strict') + ) + ) + + // Make trivial changes. + // + // Before: + // + // t.is(...) + // t.alike(...) + // + // After: + // + // assert.equal(...) + // assert.deepEqual(...) + + { + const toReplace: Map = new Map([ + ['is', 'equal'], + ['not', 'notEqual'], + ['alike', 'deepEqual'], + ['unlike', 'notDeepEqual'], + ['teardown', 'after'], + ['fail', 'fail'], + ]) + for (const [brittleMethod, assertMethod] of toReplace) { + root + .find(j.MemberExpression, { + object: { name: 't' }, + property: { name: brittleMethod }, + }) + .forEach((memberExpression) => { + memberExpression.value.object = j.identifier('assert') + memberExpression.value.property = j.identifier(assertMethod) + }) + } + } + + // Replace `t.ok` with `assert`. + // + // Before: + // + // t.ok(...) + // + // After: + // + // assert(...) + + root + .find(j.CallExpression, { + callee: { + type: 'MemberExpression', + object: { name: 't' }, + property: { name: 'ok' }, + }, + }) + .forEach((callExpression) => { + callExpression.value.callee = j.identifier('assert') + }) + + // Replace `t.absent(foo)` with `assert(!foo)`. + // + // Before: + // + // t.absent(foo, ...) + // + // After: + // + // assert(!foo, ...) + + root + .find(j.CallExpression, { + callee: { + type: 'MemberExpression', + object: { name: 't' }, + property: { name: 'absent' }, + }, + }) + .forEach((callExpression) => { + callExpression.value.callee = j.identifier('assert') + + const [firstArg] = callExpression.value.arguments + assert(firstArg && firstArg.type !== 'SpreadElement') + callExpression.value.arguments[0] = j.unaryExpression('!', firstArg) + }) + + // Replace `await`ed Brittle exceptions/executions with their assert + // equivalents. + // + // Before: + // + // await t.exception(...) + // await t.exception.all(...) + // await t.execution(...) + // + // After: + // + // await assert.rejects(...) + // await assert.rejects(...) + // await assert.doesNotReject(...) + + root + .find(j.AwaitExpression, { + argument: { + type: 'CallExpression', + callee: { + type: 'MemberExpression', + object: { name: 't' }, + property: { name: 'exception' }, + }, + }, + }) + .forEach((awaitExpression) => { + awaitExpression.value.argument = assertThrowsOrRejectsCall( + awaitExpression.value.argument as CallExpression, + 'rejects' + ) + }) + + root + .find(j.AwaitExpression, { + argument: { + type: 'CallExpression', + callee: { + type: 'MemberExpression', + object: { + type: 'MemberExpression', + object: { name: 't' }, + property: { name: 'exception' }, + }, + property: { name: 'all' }, + }, + }, + }) + .forEach((awaitExpression) => { + awaitExpression.value.argument = assertThrowsOrRejectsCall( + awaitExpression.value.argument as CallExpression, + 'rejects' + ) + }) + + root + .find(j.AwaitExpression, { + argument: { + type: 'CallExpression', + callee: { + type: 'MemberExpression', + object: { name: 't' }, + property: { name: 'execution' }, + }, + }, + }) + .forEach((awaitExpression) => { + const argument = awaitExpression.value.argument as CallExpression + argument.callee = j.memberExpression( + j.identifier('t'), + j.identifier('notThrowsAsync') + ) + }) + + // Replace remaining Brittle exceptions/executions with Node equivalents. + // + // Before: + // + // await t.exception(...) + // await t.exception.all(...) + // await t.execution(...) + // + // After: + // + // await t.rejects(...) + // await t.rejects(...) + // await t.notThrowsAsync(...) + // + // These might be ambiguous so we do our best. + + { + const firstArgLooksSync = (call: CallExpression): boolean => { + const [firstArg] = call.arguments + return Boolean( + firstArg && + (firstArg.type === 'ArrowFunctionExpression' || + firstArg.type === 'FunctionExpression') && + !firstArg.async + ) + } + + const updateBrittleException = ( + brittleException: CallExpression + ): CallExpression => + assertThrowsOrRejectsCall( + brittleException, + firstArgLooksSync(brittleException) ? 'throws' : 'rejects' + ) + + root + .find(j.CallExpression, { + callee: { + type: 'MemberExpression', + object: { name: 't' }, + property: { name: 'exception' }, + }, + }) + .forEach((callExpression) => { + callExpression.replace(updateBrittleException(callExpression.value)) + }) + + root + .find(j.CallExpression, { + callee: { + type: 'MemberExpression', + object: { + type: 'MemberExpression', + object: { name: 't' }, + property: { name: 'exception' }, + }, + property: { name: 'all' }, + }, + }) + .forEach((callExpression) => { + callExpression.replace(updateBrittleException(callExpression.value)) + }) + + root + .find(j.CallExpression, { + callee: { + type: 'MemberExpression', + object: { name: 't' }, + property: { name: 'execution' }, + }, + }) + .forEach((call) => { + call.value.callee = j.memberExpression( + j.identifier('assert'), + j.identifier( + firstArgLooksSync(call.value) ? 'doesNotThrow' : 'doesNotReject' + ) + ) + }) + } + + return root.toSource() +} + +export default transform From f649a2bf1a0b18a93b59869f120a4ad20477e492 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 21 May 2024 14:30:08 +0000 Subject: [PATCH 19/19] Remove accidentally-committed jscodeshift file --- transform.ts | 319 --------------------------------------------------- 1 file changed, 319 deletions(-) delete mode 100644 transform.ts diff --git a/transform.ts b/transform.ts deleted file mode 100644 index da4be8a65..000000000 --- a/transform.ts +++ /dev/null @@ -1,319 +0,0 @@ -import type { Transform, MemberExpression, CallExpression } from 'jscodeshift' -import assert from 'node:assert/strict' - -const transform: Transform = (fileInfo, api) => { - const { j } = api - const root = j(fileInfo.source) - - // Utilities - - const assertThrowsOrRejectsCall = ( - brittleCall: CallExpression, - method: 'throws' | 'rejects' - ): CallExpression => { - const args = [brittleCall.arguments[0]] - - const secondArg = brittleCall.arguments[1] - switch (secondArg?.type) { - case 'Literal': - if (secondArg.value instanceof RegExp) { - args.push( - j.objectExpression([ - j.objectProperty(j.identifier('message'), secondArg), - ]) - ) - } else { - args.push(j.identifier('undefined')) - args.push(secondArg) - } - break - case 'Identifier': - // Could be wrong. - args.push( - j.objectExpression([ - j.objectProperty(j.identifier('instanceOf'), secondArg), - ]) - ) - break - default: - break - } - - const thirdArg = brittleCall.arguments[2] - if (thirdArg) args.push(thirdArg) - - return j.callExpression( - j.memberExpression(j.identifier('assert'), j.identifier(method)), - args - ) - } - - // Replace the Brittle import with imports of `node:test` and `node:assert/strict`. - // - // Before: - // - // import ... from 'brittle' - // - // After: - // - // import test from 'node:test' - // import assert from 'node:assert/strict' - - root - .find(j.ImportDeclaration, { - source: { value: 'brittle' }, - }) - .replaceWith( - j.importDeclaration( - [j.importDefaultSpecifier(j.identifier('test'))], - j.stringLiteral('node:test') - ) - ) - .insertAfter( - j.importDeclaration( - [j.importDefaultSpecifier(j.identifier('assert'))], - j.stringLiteral('node:assert/strict') - ) - ) - - // Make trivial changes. - // - // Before: - // - // t.is(...) - // t.alike(...) - // - // After: - // - // assert.equal(...) - // assert.deepEqual(...) - - { - const toReplace: Map = new Map([ - ['is', 'equal'], - ['not', 'notEqual'], - ['alike', 'deepEqual'], - ['unlike', 'notDeepEqual'], - ['teardown', 'after'], - ['fail', 'fail'], - ]) - for (const [brittleMethod, assertMethod] of toReplace) { - root - .find(j.MemberExpression, { - object: { name: 't' }, - property: { name: brittleMethod }, - }) - .forEach((memberExpression) => { - memberExpression.value.object = j.identifier('assert') - memberExpression.value.property = j.identifier(assertMethod) - }) - } - } - - // Replace `t.ok` with `assert`. - // - // Before: - // - // t.ok(...) - // - // After: - // - // assert(...) - - root - .find(j.CallExpression, { - callee: { - type: 'MemberExpression', - object: { name: 't' }, - property: { name: 'ok' }, - }, - }) - .forEach((callExpression) => { - callExpression.value.callee = j.identifier('assert') - }) - - // Replace `t.absent(foo)` with `assert(!foo)`. - // - // Before: - // - // t.absent(foo, ...) - // - // After: - // - // assert(!foo, ...) - - root - .find(j.CallExpression, { - callee: { - type: 'MemberExpression', - object: { name: 't' }, - property: { name: 'absent' }, - }, - }) - .forEach((callExpression) => { - callExpression.value.callee = j.identifier('assert') - - const [firstArg] = callExpression.value.arguments - assert(firstArg && firstArg.type !== 'SpreadElement') - callExpression.value.arguments[0] = j.unaryExpression('!', firstArg) - }) - - // Replace `await`ed Brittle exceptions/executions with their assert - // equivalents. - // - // Before: - // - // await t.exception(...) - // await t.exception.all(...) - // await t.execution(...) - // - // After: - // - // await assert.rejects(...) - // await assert.rejects(...) - // await assert.doesNotReject(...) - - root - .find(j.AwaitExpression, { - argument: { - type: 'CallExpression', - callee: { - type: 'MemberExpression', - object: { name: 't' }, - property: { name: 'exception' }, - }, - }, - }) - .forEach((awaitExpression) => { - awaitExpression.value.argument = assertThrowsOrRejectsCall( - awaitExpression.value.argument as CallExpression, - 'rejects' - ) - }) - - root - .find(j.AwaitExpression, { - argument: { - type: 'CallExpression', - callee: { - type: 'MemberExpression', - object: { - type: 'MemberExpression', - object: { name: 't' }, - property: { name: 'exception' }, - }, - property: { name: 'all' }, - }, - }, - }) - .forEach((awaitExpression) => { - awaitExpression.value.argument = assertThrowsOrRejectsCall( - awaitExpression.value.argument as CallExpression, - 'rejects' - ) - }) - - root - .find(j.AwaitExpression, { - argument: { - type: 'CallExpression', - callee: { - type: 'MemberExpression', - object: { name: 't' }, - property: { name: 'execution' }, - }, - }, - }) - .forEach((awaitExpression) => { - const argument = awaitExpression.value.argument as CallExpression - argument.callee = j.memberExpression( - j.identifier('t'), - j.identifier('notThrowsAsync') - ) - }) - - // Replace remaining Brittle exceptions/executions with Node equivalents. - // - // Before: - // - // await t.exception(...) - // await t.exception.all(...) - // await t.execution(...) - // - // After: - // - // await t.rejects(...) - // await t.rejects(...) - // await t.notThrowsAsync(...) - // - // These might be ambiguous so we do our best. - - { - const firstArgLooksSync = (call: CallExpression): boolean => { - const [firstArg] = call.arguments - return Boolean( - firstArg && - (firstArg.type === 'ArrowFunctionExpression' || - firstArg.type === 'FunctionExpression') && - !firstArg.async - ) - } - - const updateBrittleException = ( - brittleException: CallExpression - ): CallExpression => - assertThrowsOrRejectsCall( - brittleException, - firstArgLooksSync(brittleException) ? 'throws' : 'rejects' - ) - - root - .find(j.CallExpression, { - callee: { - type: 'MemberExpression', - object: { name: 't' }, - property: { name: 'exception' }, - }, - }) - .forEach((callExpression) => { - callExpression.replace(updateBrittleException(callExpression.value)) - }) - - root - .find(j.CallExpression, { - callee: { - type: 'MemberExpression', - object: { - type: 'MemberExpression', - object: { name: 't' }, - property: { name: 'exception' }, - }, - property: { name: 'all' }, - }, - }) - .forEach((callExpression) => { - callExpression.replace(updateBrittleException(callExpression.value)) - }) - - root - .find(j.CallExpression, { - callee: { - type: 'MemberExpression', - object: { name: 't' }, - property: { name: 'execution' }, - }, - }) - .forEach((call) => { - call.value.callee = j.memberExpression( - j.identifier('assert'), - j.identifier( - firstArgLooksSync(call.value) ? 'doesNotThrow' : 'doesNotReject' - ) - ) - }) - } - - return root.toSource() -} - -export default transform