|
| 1 | +import fs from 'fs' |
| 2 | +import net from 'net' |
| 3 | +import { MongoMemoryServer } from 'mongodb-memory-server' |
| 4 | +import { MongoClient } from 'mongodb' |
| 5 | + |
| 6 | +// Use the same replica-set name Meteor uses for its dev mongod, so the same data dir works on both this |
| 7 | +// branch (our dev-mongo) and an older branch (Meteor's bundled mongod) |
| 8 | +const REPLSET_NAME = 'meteor' |
| 9 | +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) |
| 10 | + |
| 11 | +/** |
| 12 | + * Throw a clear error if `port` is already in use, so a conflict fails loudly instead of |
| 13 | + * mongodb-memory-server silently falling back to a random port. |
| 14 | + */ |
| 15 | +function assertPortFree(port) { |
| 16 | + return new Promise((resolve, reject) => { |
| 17 | + const tester = net |
| 18 | + .createServer() |
| 19 | + .once('error', (err) => { |
| 20 | + if (err.code === 'EADDRINUSE') { |
| 21 | + reject( |
| 22 | + new Error( |
| 23 | + `MongoDB dev port ${port} is already in use. Free that port, or set MONGO_PORT in .env to a free one.` |
| 24 | + ) |
| 25 | + ) |
| 26 | + } else { |
| 27 | + reject(err) |
| 28 | + } |
| 29 | + }) |
| 30 | + .once('listening', () => tester.close(() => resolve())) |
| 31 | + .listen(port, '127.0.0.1') |
| 32 | + }) |
| 33 | +} |
| 34 | + |
| 35 | +/** |
| 36 | + * Bring up a single-node replica set on an already-running mongod (started with --replSet but NOT |
| 37 | + * initiated), and make it PRIMARY with the given `host`. Non-destructive to existing data: |
| 38 | + * |
| 39 | + * - Fresh data dir (no config) -> replSetInitiate with our host. |
| 40 | + * - Existing config that lists our host -> nothing; mongod becomes primary on its own. |
| 41 | + * - Existing config with a stale host -> replSetReconfig({force:true}) to our host. |
| 42 | + * |
| 43 | + * The stale-host case is normal: both our dev-mongo and Meteor's mongod reconfigure the member host to |
| 44 | + * their own port on startup. We do this via reconfig (not by clearing the config), which never |
| 45 | + * re-initiates the oplog and so never risks the data. |
| 46 | + */ |
| 47 | +async function ensureSingleNodePrimary(serverUri, host) { |
| 48 | + const client = new MongoClient(serverUri, { directConnection: true }) |
| 49 | + await client.connect() |
| 50 | + try { |
| 51 | + const adb = client.db('admin') |
| 52 | + |
| 53 | + let state = 'ok' |
| 54 | + try { |
| 55 | + await adb.command({ replSetGetStatus: 1 }) |
| 56 | + } catch (e) { |
| 57 | + if (e.code === 94) |
| 58 | + state = 'uninitialized' // NotYetInitialized |
| 59 | + else if (e.code === 93) |
| 60 | + state = 'notmember' // InvalidReplicaSetConfig (we're not in the stored config) |
| 61 | + else throw e |
| 62 | + } |
| 63 | + |
| 64 | + if (state === 'uninitialized') { |
| 65 | + await adb.command({ replSetInitiate: { _id: REPLSET_NAME, members: [{ _id: 0, host }] } }) |
| 66 | + } else { |
| 67 | + const cfg = (await adb.command({ replSetGetConfig: 1 })).config |
| 68 | + const currentHost = cfg.members?.[0]?.host |
| 69 | + if (currentHost !== host || state === 'notmember') { |
| 70 | + cfg.members = [{ ...cfg.members[0], _id: 0, host }] |
| 71 | + cfg.version = (cfg.version || 0) + 1 |
| 72 | + await adb.command({ replSetReconfig: cfg, force: true }) |
| 73 | + } |
| 74 | + } |
| 75 | + |
| 76 | + const deadline = Date.now() + 30000 |
| 77 | + for (;;) { |
| 78 | + const hello = await adb.command({ hello: 1 }) |
| 79 | + if (hello.isWritablePrimary) break |
| 80 | + if (Date.now() > deadline) throw new Error('MongoDB did not become primary within 30s') |
| 81 | + await sleep(200) |
| 82 | + } |
| 83 | + } finally { |
| 84 | + await client.close().catch(() => {}) |
| 85 | + } |
| 86 | +} |
| 87 | + |
| 88 | +/** |
| 89 | + * Spawn a local MongoDB for development using mongodb-memory-server. |
| 90 | + * |
| 91 | + * It runs as a single-node replica set (change streams - which the backend relies on - require a |
| 92 | + * replica set), and stores its data in `dbPath`. The data is persistent: stop() does not delete |
| 93 | + * `dbPath`, and bring-up never clears or re-initiates user data. |
| 94 | + * |
| 95 | + * @param {{ dbPath: string, port: number, dbName: string, version: string }} opts |
| 96 | + * @returns {Promise<{ uri: string, stop: () => Promise<void> }>} |
| 97 | + */ |
| 98 | +export async function startDevMongo({ dbPath, port, dbName, version }) { |
| 99 | + // Fail loudly on a port conflict rather than letting mongodb-memory-server pick a random port. |
| 100 | + await assertPortFree(port) |
| 101 | + |
| 102 | + // mongodb-memory-server expects the data directory to exist. |
| 103 | + fs.mkdirSync(dbPath, { recursive: true }) |
| 104 | + |
| 105 | + // Start mongod as a replica-set member but WITHOUT mongodb-memory-server's auto-initiation. |
| 106 | + // We drive initiate/reconfig ourselves so we can adopt an existing data dir non-destructively. |
| 107 | + const server = await MongoMemoryServer.create({ |
| 108 | + binary: { version }, |
| 109 | + instance: { dbPath, port, storageEngine: 'wiredTiger', args: ['--replSet', REPLSET_NAME] }, |
| 110 | + }) |
| 111 | + |
| 112 | + try { |
| 113 | + // Assert the requested port BEFORE bringing up the replica set, and derive the member host from the |
| 114 | + // actual bound port - so a reconfig can never install a host that doesn't match this node. |
| 115 | + const actualPort = Number(new URL(server.getUri()).port) |
| 116 | + if (actualPort !== port) { |
| 117 | + throw new Error(`MongoDB started on unexpected port ${actualPort} (requested ${port}).`) |
| 118 | + } |
| 119 | + await ensureSingleNodePrimary(server.getUri(), `127.0.0.1:${actualPort}`) |
| 120 | + } catch (e) { |
| 121 | + await server.stop({ doCleanup: false }).catch(() => {}) |
| 122 | + throw e |
| 123 | + } |
| 124 | + |
| 125 | + const uri = `mongodb://127.0.0.1:${port}/${dbName}?replicaSet=${REPLSET_NAME}` |
| 126 | + const stop = async () => { |
| 127 | + // doCleanup: false keeps the on-disk data dir so dev data persists between runs. |
| 128 | + await server.stop({ doCleanup: false }) |
| 129 | + } |
| 130 | + |
| 131 | + return { uri, stop } |
| 132 | +} |
0 commit comments