diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 43c27473..94e451f7 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -80,8 +80,6 @@ export const DEFAULT_FALLBACK_MAP_FILE_PATH = require.resolve( export const DEFAULT_ONLINE_STYLE_URL = 'https://demotiles.maplibre.org/style.json' -export const kRPC = Symbol('rpc') - /** * @typedef {Omit} PublicPeerInfo */ @@ -221,13 +219,6 @@ export class MapeoManager extends TypedEmitter { this.#localDiscovery.on('connection', this.#replicate.bind(this)) } - /** - * MapeoRPC instance, used for tests - */ - get [kRPC]() { - return this.#localPeers - } - get deviceId() { return this.#deviceId } diff --git a/test-e2e/cross-version-sync.js b/test-e2e/cross-version-sync.js new file mode 100644 index 00000000..0646dc6b --- /dev/null +++ b/test-e2e/cross-version-sync.js @@ -0,0 +1,79 @@ +import { valueOf } from '@comapeo/schema' +import { generate } from '@mapeo/mock-data' +import assert from 'node:assert/strict' +import test from 'node:test' +import { + connectPeers, + createManager, + createOldManagerOnVersion2_0_1, + invite, + waitForPeers, +} from './utils.js' + +test('syncing @comapeo/core@2.0.1 with the current version', async (t) => { + const oldManager = await createOldManagerOnVersion2_0_1('old') + await oldManager.setDeviceInfo({ name: 'old', deviceType: 'mobile' }) + + const newManager = createManager('new', t) + await newManager.setDeviceInfo({ name: 'new', deviceType: 'desktop' }) + + const managers = [oldManager, newManager] + + const disconnect = connectPeers(managers) + t.after(disconnect) + await waitForPeers(managers) + + const [oldManagerPeers, newManagerPeers] = await Promise.all( + managers.map((manager) => manager.listLocalPeers()) + ) + assert.equal(oldManagerPeers.length, 1, 'old manager sees 1 peer') + assert.equal(newManagerPeers.length, 1, 'new manager sees 1 peer') + assert( + oldManagerPeers.some((p) => p.deviceId === newManager.deviceId), + 'old manager sees new manager' + ) + assert( + newManagerPeers.some((p) => p.deviceId === oldManager.deviceId), + 'new manager sees old manager' + ) + + const projectId = await oldManager.createProject({ name: 'foo bar' }) + + await invite({ + projectId, + invitor: oldManager, + invitees: [newManager], + }) + + const projects = await Promise.all( + managers.map((manager) => manager.getProject(projectId)) + ) + const [oldProject, newProject] = projects + assert.equal( + (await newProject.$getProjectSettings()).name, + 'foo bar', + 'new manager sees the project' + ) + + oldProject.$sync.start() + newProject.$sync.start() + + const [oldObservation, newObservation] = await Promise.all( + projects.map((project) => + project.observation.create(valueOf(generate('observation')[0])) + ) + ) + + await Promise.all( + projects.map((project) => project.$sync.waitForSync('full')) + ) + + assert( + await oldProject.observation.getByDocId(newObservation.docId), + 'old project gets observation from new project' + ) + assert( + await newProject.observation.getByDocId(oldObservation.docId), + 'new project gets observation from old project' + ) +}) diff --git a/test-e2e/migration.js b/test-e2e/migration.js index e62406c6..319e9baa 100644 --- a/test-e2e/migration.js +++ b/test-e2e/migration.js @@ -1,13 +1,12 @@ import test from 'node:test' import { KeyManager } from '@mapeo/crypto' -import { MapeoManager as MapeoManagerPreMigration } from '@comapeo/core2.0.1' import RAM from 'random-access-memory' import { MapeoManager } from '../src/mapeo-manager.js' import Fastify from 'fastify' import assert from 'node:assert/strict' -import { fileURLToPath } from 'node:url' import fsPromises from 'node:fs/promises' import { temporaryDirectory } from 'tempy' +import { createOldManagerOnVersion2_0_1 } from './utils.js' const projectMigrationsFolder = new URL('../drizzle/project', import.meta.url) .pathname @@ -15,25 +14,12 @@ const clientMigrationsFolder = new URL('../drizzle/client', import.meta.url) .pathname test('migration of localDeviceInfo table', async (t) => { - const comapeoCorePreMigrationUrl = await import.meta.resolve?.( - '@comapeo/core2.0.1' - ) - assert(comapeoCorePreMigrationUrl, 'Could not resolve @comapeo/core2.0.1') - const clientMigrationsFolderPreMigration = fileURLToPath( - new URL('../drizzle/client', comapeoCorePreMigrationUrl) - ) - const projectMigrationsFolderPreMigration = fileURLToPath( - new URL('../drizzle/project', comapeoCorePreMigrationUrl) - ) - const dbFolder = temporaryDirectory() const rootKey = KeyManager.generateRootKey() t.after(() => fsPromises.rm(dbFolder, { recursive: true })) - const managerPreMigration = new MapeoManagerPreMigration({ + const managerPreMigration = await createOldManagerOnVersion2_0_1('seed', { rootKey, - projectMigrationsFolder: projectMigrationsFolderPreMigration, - clientMigrationsFolder: clientMigrationsFolderPreMigration, dbFolder, coreStorage: () => new RAM(), fastify: Fastify(), diff --git a/test-e2e/utils.js b/test-e2e/utils.js index a9c0a19c..d07fdf8d 100644 --- a/test-e2e/utils.js +++ b/test-e2e/utils.js @@ -2,15 +2,16 @@ import sodium from 'sodium-universal' import RAM from 'random-access-memory' import Fastify from 'fastify' import { arrayFrom } from 'iterpal' +import assert from 'node:assert/strict' import * as path from 'node:path' import { fork } from 'node:child_process' import { createRequire } from 'node:module' import { fileURLToPath } from 'node:url' import * as v8 from 'node:v8' import { pEvent } from 'p-event' +import { MapeoManager as MapeoManager_2_0_1 } from '@comapeo/core2.0.1' import { MapeoManager, roles } from '../src/index.js' -import { kRPC } from '../src/mapeo-manager.js' import { generate } from '@mapeo/mock-data' import { valueOf } from '../src/utils.js' import { randomBytes, randomInt } from 'node:crypto' @@ -19,6 +20,8 @@ import fsPromises from 'node:fs/promises' import { kSyncState } from '../src/sync/sync-api.js' import { readConfig } from '../src/config-import.js' +/** @import { MemberApi } from '../src/member-api.js' */ + const FAST_TESTS = !!process.env.FAST_TESTS const projectMigrationsFolder = new URL('../drizzle/project', import.meta.url) .pathname @@ -26,7 +29,17 @@ const clientMigrationsFolder = new URL('../drizzle/client', import.meta.url) .pathname /** - * @param {readonly MapeoManager[]} managers + * @internal + * @typedef {Pick< + * MapeoManager, + * 'startLocalPeerDiscoveryServer' | + * 'stopLocalPeerDiscoveryServer' | + * 'connectLocalPeer' + * >} ConnectableManager + */ + +/** + * @param {ReadonlyArray} managers * @returns {() => Promise} */ export function connectPeers(managers) { @@ -52,17 +65,40 @@ export function connectPeers(managers) { } } +/** + * @internal + * @typedef {WaitForPeersManager & { + * getProject(projectId: string): PromiseLike<{ + * $member: Pick + * }> + * }} InvitorManager + */ + +/** + * @internal + * @typedef {WaitForPeersManager & { + * deviceId: string + * invite: { + * on( + * event: 'invite-received', + * listener: (invite: { inviteId: string } + * ) => unknown): void + * accept(invite: unknown): PromiseLike + * reject(invite: unknown): unknown + * } + * }} InviteeManager + */ + /** * Invite mapeo clients to a project * - * @param {{ - * invitor: MapeoManager, - * projectId: string, - * invitees: MapeoManager[], - * roleId?: import('../src/roles.js').RoleIdAssignableToOthers, - * roleName?: string - * reject?: boolean - * }} opts + * @param {object} options + * @param {string} options.projectId + * @param {InvitorManager} options.invitor + * @param {ReadonlyArray} options.invitees + * @param {import('../src/roles.js').RoleIdAssignableToOthers} [options.roleId] + * @param {string} [options.roleName] + * @param {boolean} [options.reject] */ export async function invite({ invitor, @@ -101,46 +137,68 @@ export async function invite({ ) } +/** + * A simple Promise-aware version of `Array.prototype.every`. + * + * Similar to the [p-every package](https://www.npmjs.com/package/p-every), + * which I couldn't figure out how to import without type errors. + * + * @template T + * @param {Iterable} iterable + * @param {(value: T) => boolean | PromiseLike} predicate + * @returns {Promise} + */ +async function pEvery(iterable, predicate) { + const results = await Promise.all([...iterable].map(predicate)) + return results.every(Boolean) +} + +/** + * @internal + * @typedef {Pick & { + * on(event: 'local-peers', listener: () => unknown): void; + * off(event: 'local-peers', listener: () => unknown): void; + * }} WaitForPeersManager + */ + /** * Waits for all manager instances to be connected to each other * - * @param {readonly MapeoManager[]} managers + * @param {ReadonlyArray} managers * @param {{ waitForDeviceInfo?: boolean }} [opts] Optionally wait for device names to be set * @returns {Promise} */ -export const waitForPeers = (managers, { waitForDeviceInfo = false } = {}) => - new Promise((res) => { - const deviceIds = new Set(managers.map((m) => m.deviceId)) - - const isDone = () => - managers.every((manager) => { - const unconnectedDeviceIds = new Set(deviceIds) - unconnectedDeviceIds.delete(manager.deviceId) - for (const peer of manager[kRPC].peers) { - if ( - peer.status === 'connected' && - (!waitForDeviceInfo || peer.name) - ) { - unconnectedDeviceIds.delete(peer.deviceId) - } +export async function waitForPeers( + managers, + { waitForDeviceInfo = false } = {} +) { + const deviceIds = new Set(managers.map((m) => m.deviceId)) + + /** @returns {Promise} */ + const isDone = async () => + pEvery(managers, async (manager) => { + const unconnectedDeviceIds = new Set(deviceIds) + unconnectedDeviceIds.delete(manager.deviceId) + for (const peer of await manager.listLocalPeers()) { + if (peer.status === 'connected' && (!waitForDeviceInfo || peer.name)) { + unconnectedDeviceIds.delete(peer.deviceId) } - return unconnectedDeviceIds.size === 0 - }) + } + return unconnectedDeviceIds.size === 0 + }) - if (isDone()) { - res() - return - } + if (await isDone()) return - const onLocalPeers = () => { - if (isDone()) { + return new Promise((res) => { + const onLocalPeers = async () => { + if (await isDone()) { for (const manager of managers) manager.off('local-peers', onLocalPeers) res() } } - for (const manager of managers) manager.on('local-peers', onLocalPeers) }) +} /** * Create `count` manager instances. Each instance has a deterministic identity @@ -174,6 +232,7 @@ export async function createManagers( * @param {string} seed * @param {import('node:test').TestContext} t * @param {Partial[0]>} [overrides] + * @returns {MapeoManager} */ export function createManager(seed, t, overrides = {}) { /** @type {string} */ let dbFolder @@ -208,6 +267,31 @@ export function createManager(seed, t, overrides = {}) { ...overrides, }) } +/** + * @param {string} seed + * @param {Partial[0]>} [overrides] + * @returns {Promise} + */ +export async function createOldManagerOnVersion2_0_1(seed, overrides = {}) { + const comapeoCorePreMigrationUrl = await import.meta.resolve?.( + '@comapeo/core2.0.1' + ) + assert(comapeoCorePreMigrationUrl, 'Could not resolve @comapeo/core2.0.1') + + return new MapeoManager_2_0_1({ + rootKey: getRootKey(seed), + clientMigrationsFolder: fileURLToPath( + new URL('../drizzle/client', comapeoCorePreMigrationUrl) + ), + projectMigrationsFolder: fileURLToPath( + new URL('../drizzle/project', comapeoCorePreMigrationUrl) + ), + dbFolder: ':memory:', + coreStorage: () => new RAM(), + fastify: Fastify(), + ...overrides, + }) +} /** * `ManagerCustodian` helps you test the creation of multiple managers accessing