`replaceOne`, `findOneAndReplace` and `bulkWrite`'s `replaceOne` all failed
with "bad update". `update.apply` rejected any update document whose first key
was not `$`-prefixed, so a replacement document -- which by definition has no
operators -- could not get through at all.
MongoDB decides on the first field and nothing else: `$`-prefixed means
operators, anything else means the document *is* the new content. An empty
document is a replacement too, and a legal one. `is_replacement` says which,
`apply_replacement` does the work, and because all three call sites already go
through `apply`, that one branch covers the update command, findAndModify and
the upsert builder.
What a replacement means, precisely:
- every field is replaced except `_id`, which is immutable and keeps its
position at the front, where it is stored and where the `_id_` index
descends on it;
- a replacement may restate the same `_id` but not a different one -- that
is `ImmutableId`, because otherwise a rewrite would silently change a
document's identity while the index entry kept the old key;
- when the target has no `_id` yet, the replacement supplies it. That is the
upsert path: `build_upsert_doc` seeds a document from the filter's
equalities, so `replaceOne({_id: 99}, {u: 1}, {upsert: true})` inserts
`{_id: 99, u: 1}` and not a generated ObjectId;
- a mixed document is refused from either side, rather than guessed at.
Two options on update specs are refused rather than ignored:
- `multi` with a replacement (FailedToParse). A replacement describes one
document; applying it to many would leave every match identical apart from
its `_id`.
- `sort`, a MongoDB 8.0 addition this server does not implement. Ignoring it
is the worst of the three answers -- `sort` chooses *which* match to write,
so the client would silently get a different document than it asked for.
spec scorecard 131 pass / 161 fail -> 161 pass / 131 fail
e2e.js 35 checks -> 45
Sixteen spec files improved and none regressed. The two `-sort` files briefly
did: they had been passing on their "server-side error" case, which our
"bad update" failure satisfied by accident, and passing for the wrong reason is
how a gap survives a scorecard.
Five mutations, each verified red: seeding the replacement from the old pairs,
dropping the `_id` comparison, dropping the `_id` a replacement supplies, and
removing the mixed-document guard from either loop.
Note `nModified` is still wrong for a write that changes nothing -- MongoDB
counts a document as modified only if applying the update altered it. That is
the remaining bulkWrite failure and is fixed next, separately.
252 lines
12 KiB
JavaScript
252 lines
12 KiB
JavaScript
// End-to-end test: official MongoDB Node.js driver against multiforadb.
|
|
const { MongoClient, ObjectId } = require('mongodb');
|
|
|
|
const URL = 'mongodb://127.0.0.1:27020';
|
|
const results = [];
|
|
function check(name, cond, detail = '') {
|
|
results.push({ name, ok: !!cond, detail: String(detail) });
|
|
if (!cond) console.error(` ✗ ${name} ${detail}`);
|
|
}
|
|
async function main() {
|
|
const client = new MongoClient(URL, { serverSelectionTimeoutMS: 5000 });
|
|
await client.connect();
|
|
const db = client.db('e2e');
|
|
const users = db.collection('users');
|
|
await users.drop().catch(() => {});
|
|
|
|
// --- insert ---
|
|
await users.insertOne({ name: 'alice', age: 30, tags: ['a', 'b'] });
|
|
const many = await users.insertMany([
|
|
{ name: 'bob', age: 25, tags: ['b'] },
|
|
{ name: 'carol', age: 35, tags: ['c', 'a'] },
|
|
{ name: 'dave', age: 40, tags: [] },
|
|
]);
|
|
check('insertMany acknowledged', many.acknowledged === true, many);
|
|
check('auto _id assigned', ObjectId.isValid(many.insertedIds[0]));
|
|
|
|
// --- find: filters, operators, sort, skip, limit, projection ---
|
|
const gt = await users.find({ age: { $gt: 28 } }).sort({ age: -1 }).toArray();
|
|
check('find $gt + sort desc', gt.map((d) => d.name).join(',') === 'dave,carol,alice', gt.map((d) => d.name));
|
|
|
|
const inq = await users.find({ name: { $in: ['alice', 'bob'] } }).count();
|
|
check('find $in count', inq === 2, inq);
|
|
|
|
const rgx = await users.find({ name: /^[bc]/ }).toArray();
|
|
check('find $regex', rgx.length === 2, rgx.map((d) => d.name));
|
|
|
|
const exists = await users.find({ tags: { $exists: true } }).count();
|
|
check('find $exists', exists === 4, exists);
|
|
|
|
const lim = await users.find({}).sort({ age: 1 }).skip(1).limit(2).toArray();
|
|
check('find skip+limit+sort', lim.map((d) => d.name).join(',') === 'alice,carol', lim.map((d) => d.name));
|
|
|
|
const proj = await users.findOne({ name: 'alice' }, { projection: { _id: 0, name: 1 } });
|
|
check('projection', proj.name === 'alice' && proj.age === undefined, JSON.stringify(proj));
|
|
|
|
const dot = await users.findOne({ 'tags.0': 'c' });
|
|
check('dot path + array', dot?.name === 'carol');
|
|
|
|
// --- count ---
|
|
check('countDocuments', (await users.countDocuments({})) === 4);
|
|
check('countDocuments with filter', (await users.countDocuments({ age: { $gte: 30 } })) === 3);
|
|
check('estimatedDocumentCount', (await users.estimatedDocumentCount()) === 4);
|
|
|
|
// --- update ---
|
|
const u1 = await users.updateOne({ name: 'alice' }, { $set: { vip: true }, $inc: { age: 1 } });
|
|
check('updateOne nModified', u1.modifiedCount === 1, u1);
|
|
const alice = await users.findOne({ name: 'alice' });
|
|
check('updateOne $set+$inc applied', alice.vip === true && alice.age === 31, JSON.stringify(alice));
|
|
|
|
const um = await users.updateMany({}, { $set: { seen: true } });
|
|
check('updateMany', um.modifiedCount === 4, um);
|
|
|
|
const push = await users.updateOne({ name: 'dave' }, { $push: { tags: 'x' } });
|
|
check('$push', push.modifiedCount === 1);
|
|
check('$push visible', (await users.findOne({ name: 'dave' })).tags.length === 1);
|
|
|
|
const ups = await users.updateOne({ name: 'erin' }, { $set: { age: 28 } }, { upsert: true });
|
|
check('upsert', ups.upsertedCount === 1 && ups.matchedCount === 0, ups);
|
|
check('upsert doc exists', (await users.findOne({ name: 'erin' }))?.age === 28);
|
|
|
|
// --- replacement-style writes ---
|
|
// A replacement is data, not instructions: it replaces every field except
|
|
// `_id`. Distinct enough from operator updates to be worth its own block --
|
|
// these all used to fail with "bad update", because the update path required
|
|
// a $-prefixed first field.
|
|
const repl = db.collection('repl');
|
|
await repl.drop().catch(() => {});
|
|
await repl.insertMany([{ _id: 1, a: 1, gone: 'x' }, { _id: 2, a: 2 }, { _id: 3, a: 3 }]);
|
|
|
|
const rp = await repl.replaceOne({ _id: 1 }, { fresh: 9 });
|
|
check('replaceOne modifies one', rp.modifiedCount === 1, JSON.stringify(rp));
|
|
const rpDoc = await repl.findOne({ _id: 1 });
|
|
check(
|
|
'replaceOne keeps _id and drops the old fields',
|
|
rpDoc._id === 1 && rpDoc.fresh === 9 && rpDoc.a === undefined && rpDoc.gone === undefined,
|
|
JSON.stringify(rpDoc),
|
|
);
|
|
|
|
const farRaw = await repl.findOneAndReplace({ _id: 2 }, { swapped: true }, { returnDocument: 'after' });
|
|
const far = farRaw.value ?? farRaw;
|
|
check('findOneAndReplace returns the new document', far.swapped === true && far._id === 2, JSON.stringify(farRaw));
|
|
|
|
const bulkRep = await repl.bulkWrite([{ replaceOne: { filter: { _id: 3 }, replacement: { via: 'bulk' } } }]);
|
|
check('bulkWrite replaceOne', bulkRep.modifiedCount === 1, String(bulkRep.modifiedCount));
|
|
|
|
const rpUp = await repl.replaceOne({ _id: 99 }, { u: 1 }, { upsert: true });
|
|
check('replacement upsert takes the _id from the filter', String(rpUp.upsertedId) === '99', JSON.stringify(rpUp.upsertedId));
|
|
const rpUpDoc = await repl.findOne({ _id: 99 });
|
|
check(
|
|
'upserted document is the replacement plus _id',
|
|
rpUpDoc && rpUpDoc.u === 1 && Object.keys(rpUpDoc).length === 2,
|
|
JSON.stringify(rpUpDoc),
|
|
);
|
|
|
|
let idChangeRefused = false;
|
|
try { await repl.replaceOne({ _id: 1 }, { _id: 1234, x: 1 }); } catch { idChangeRefused = true; }
|
|
check('a replacement may not change _id', idChangeRefused);
|
|
|
|
const rpEmpty = await repl.replaceOne({ _id: 1 }, {});
|
|
const rpEmptyDoc = await repl.findOne({ _id: 1 });
|
|
check(
|
|
'an empty replacement leaves only _id',
|
|
rpEmpty.modifiedCount === 1 && Object.keys(rpEmptyDoc).length === 1,
|
|
JSON.stringify(rpEmptyDoc),
|
|
);
|
|
|
|
// Raw command: the driver refuses updateMany-with-a-replacement client side,
|
|
// so going through it would test the driver rather than this server.
|
|
let multiRefused = null;
|
|
try {
|
|
await db.command({ update: 'repl', updates: [{ q: {}, u: { a: 1 }, multi: true }] });
|
|
} catch (e) { multiRefused = e; }
|
|
check(
|
|
'multi with a replacement is refused with FailedToParse',
|
|
multiRefused?.code === 9 && /replacement-style/.test(multiRefused.message),
|
|
`code=${multiRefused?.code} ${multiRefused?.message?.slice(0, 50)}`,
|
|
);
|
|
const multiOps = await db.command({ update: 'repl', updates: [{ q: {}, u: { $set: { touched: 1 } }, multi: true }] });
|
|
check('multi with operators still runs', multiOps.ok === 1 && multiOps.n === 4, JSON.stringify(multiOps));
|
|
|
|
// --- findOneAndUpdate (findAndModify) ---
|
|
const fam = await users.findOneAndUpdate(
|
|
{ name: 'bob' },
|
|
{ $set: { lucky: true } },
|
|
{ returnDocument: 'after' },
|
|
);
|
|
const famDoc = fam.value ?? fam;
|
|
check('findOneAndUpdate returns new', famDoc.lucky === true, JSON.stringify(fam));
|
|
const famRemove = await users.findOneAndDelete({ name: 'erin' });
|
|
const famDelDoc = famRemove.value ?? famRemove;
|
|
check('findOneAndDelete', famDelDoc?.name === 'erin', JSON.stringify(famRemove));
|
|
|
|
// --- aggregate ---
|
|
const grp = await users
|
|
.aggregate([
|
|
{ $match: { age: { $gte: 25 } } },
|
|
{ $group: { _id: '$tags.length', total: { $sum: '$age' } } },
|
|
{ $sort: { _id: 1 } },
|
|
])
|
|
.toArray();
|
|
check('aggregate $match+$group+$sum+$sort', grp.length >= 1 && grp.some((g) => g.total > 0), JSON.stringify(grp));
|
|
|
|
const cnt = await users.aggregate([{ $match: {} }, { $count: 'n' }]).toArray();
|
|
check('aggregate $count', cnt[0]?.n === 4, JSON.stringify(cnt));
|
|
|
|
// $sort with NO preceding $group. This shape used to kill the server: the
|
|
// stage materialized its document list from the reply arena and it was then
|
|
// freed with the general allocator. Every aggregate case above happens to
|
|
// sort after a $group, which leaves the stream already materialized and the
|
|
// guilty branch unreached -- so the bug survived the whole suite. The second
|
|
// aggregate is the part that actually proves recovery: if the server died,
|
|
// this connection is gone.
|
|
const sorted = await users.aggregate([{ $sort: { age: -1 } }]).toArray();
|
|
const ages = sorted.map((d) => d.age);
|
|
check(
|
|
'aggregate bare $sort (no $group) returns sorted docs',
|
|
ages.length === 4 && ages.every((a, i) => i === 0 || ages[i - 1] >= a),
|
|
JSON.stringify(ages),
|
|
);
|
|
const stillAlive = await users.aggregate([{ $match: {} }, { $count: 'n' }]).toArray();
|
|
check('server survives a bare $sort pipeline', stillAlive[0]?.n === 4, JSON.stringify(stillAlive));
|
|
|
|
// --- a database-level command must not leak the catalog lock ---
|
|
// db.aggregate() sends {aggregate: 1}, which names no collection. dispatch
|
|
// used to resolve the namespace after taking the catalog lock and bail with a
|
|
// plain return, leaking it shared forever. Reads kept working, so the damage
|
|
// only showed on the next write that had to create a collection -- which is
|
|
// the second half of this check, and would hang rather than fail.
|
|
let dbLevelErr = null;
|
|
try {
|
|
await db.aggregate([{ $listLocalSessions: {} }]).toArray();
|
|
} catch (e) {
|
|
dbLevelErr = e;
|
|
}
|
|
check(
|
|
'database-level aggregate gives a real error, not an empty reply',
|
|
dbLevelErr !== null && typeof dbLevelErr.message === 'string' && dbLevelErr.message !== 'n/a',
|
|
String(dbLevelErr && dbLevelErr.message).slice(0, 60),
|
|
);
|
|
const afterDbLevel = await db.collection('lock_probe').insertOne({ _id: 1 });
|
|
check('a write creating a collection still completes afterwards', afterDbLevel.insertedId === 1);
|
|
|
|
// --- unacknowledged writes must not desync the connection ---
|
|
// An OP_MSG request with moreToCome set gets no reply. Sending one anyway
|
|
// left it unread in the socket, so the *next* command on that connection
|
|
// read the wrong reply. maxPoolSize 1 pins both operations to one socket,
|
|
// which is what makes the bug visible; with a larger pool the driver may
|
|
// hand out a different connection and hide it. The driver also does this to
|
|
// itself on close, via endSessions with {w: 0}.
|
|
const w0client = new MongoClient(URL, { maxPoolSize: 1 });
|
|
try {
|
|
await w0client.connect();
|
|
const w0 = w0client.db('e2e').collection('unack');
|
|
await w0.deleteMany({});
|
|
await w0.insertOne({ _id: 1, v: 'acknowledged' });
|
|
const unack = await w0.insertOne({ _id: 2, v: 'unacknowledged' }, { writeConcern: { w: 0 } });
|
|
check('unacknowledged insert is not acknowledged', unack.acknowledged === false, JSON.stringify(unack));
|
|
// The assertion that matters: the same socket still works afterwards.
|
|
const after = await w0.countDocuments({});
|
|
check('connection survives an unacknowledged write', after === 2, `count=${after}`);
|
|
} finally {
|
|
await w0client.close();
|
|
}
|
|
|
|
// --- duplicate key ---
|
|
let dupErr = null;
|
|
try {
|
|
await users.insertOne({ _id: many.insertedIds[0], name: 'clobber' });
|
|
} catch (e) {
|
|
dupErr = e;
|
|
}
|
|
check('duplicate key rejected', dupErr?.code === 11000, dupErr?.message);
|
|
|
|
// --- listCollections / listDatabases ---
|
|
const colls = await db.listCollections({}, { nameOnly: true }).toArray();
|
|
check('listCollections', colls.some((c) => c.name === 'users'), JSON.stringify(colls));
|
|
const dbs = await client.db('admin').admin().listDatabases();
|
|
check('listDatabases', dbs.databases.some((d) => d.name === 'e2e'), JSON.stringify(dbs.databases.map((d) => d.name)));
|
|
|
|
// --- delete ---
|
|
const del1 = await users.deleteOne({ name: 'dave' });
|
|
check('deleteOne', del1.deletedCount === 1, del1);
|
|
const delMany = await users.deleteMany({});
|
|
check('deleteMany', delMany.deletedCount === 3, delMany);
|
|
check('empty after delete', (await users.countDocuments({})) === 0);
|
|
|
|
await client.close();
|
|
|
|
const failed = results.filter((r) => !r.ok);
|
|
console.log(`\n${results.length - failed.length}/${results.length} checks passed`);
|
|
if (failed.length) {
|
|
console.log('FAILED:', failed.map((f) => f.name).join(', '));
|
|
process.exit(1);
|
|
}
|
|
console.log('E2E_OK');
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error('E2E_FAIL', e);
|
|
process.exit(1);
|
|
});
|