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