From 73818e28c6024e27172621f0b3b2e5125916face Mon Sep 17 00:00:00 2001 From: undefined Date: Fri, 9 Oct 2026 01:09:34 +0800 Subject: [PATCH] fix(mongo): inherit virtual/autoInc from legacy _fields entries When migrating from the pre-3.7.0 `_fields` format (per-table entries keyed by `{ table, field }` with ObjectId `_id`), the new-format initialization in `_createFields` only persisted the `fields` list and silently dropped the `virtual` and `autoInc` markers. Consequences after upgrading: - `_migratePrimary` no longer short-circuits on the stored `autoInc` and rescans the table on every startup. If rows were ever deleted, the recalculated counter reuses ids of deleted rows, silently corrupting references from other tables. - With a stale/lost `virtual` marker, `_migrateVirtual` may consider the table shape mismatched and run its destructive full-table rewrite ($out to `_migrate_` + drop + rename), which can destroy primary-key columns when the actual data does not match the recorded flag. Fix: when initializing a new-format entry, look up the legacy entry for the same table and inherit `virtual` / `autoInc` if present. --- packages/mongo/src/index.ts | 14 ++- packages/mongo/tests/meta-migration.spec.ts | 123 ++++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 packages/mongo/tests/meta-migration.spec.ts diff --git a/packages/mongo/src/index.ts b/packages/mongo/src/index.ts index 7d7895f0..cbc0bcc6 100644 --- a/packages/mongo/src/index.ts +++ b/packages/mongo/src/index.ts @@ -13,6 +13,10 @@ const tempKey = '__temp_minato_mongo__' interface TableMeta { _id: string + /** @deprecated legacy format (< 3.7.0) stored one entry per table with an ObjectId `_id` */ + table?: string + /** @deprecated legacy format (< 3.7.0) stored one entry per table with an ObjectId `_id` */ + field?: string virtual?: boolean migrate?: boolean autoInc?: number @@ -149,7 +153,15 @@ export class MongoDriver extends Driver { const meta = { _id: table }, found = await metaTable.findOne(meta) if (!found?.fields) { this.ctx.logger?.info('initializing fields for table %s', table) - await metaTable.updateOne(meta, { $set: { fields: Object.keys(fields) } }, { upsert: true }) + // inherit `virtual` / `autoInc` from legacy-format entries (see #_migratePrimary) + // otherwise both values are lost after the migration, which will make + // `_migratePrimary` re-scan the whole table on every startup and may even + // trigger an unwanted full-table rewrite in `_migrateVirtual` + const legacy = await metaTable.findOne({ table, field: this.model(table).primary }) + const $set: Partial = { fields: Object.keys(fields) } + if (legacy?.virtual !== undefined) $set.virtual = legacy.virtual + if (legacy?.autoInc !== undefined) $set.autoInc = legacy.autoInc + await metaTable.updateOne(meta, { $set }, { upsert: true }) return } for (const key in fields) { diff --git a/packages/mongo/tests/meta-migration.spec.ts b/packages/mongo/tests/meta-migration.spec.ts new file mode 100644 index 00000000..c2bfd4f0 --- /dev/null +++ b/packages/mongo/tests/meta-migration.spec.ts @@ -0,0 +1,123 @@ +import { Database, Driver } from '@cordisjs/plugin-database' +import { Context, Fiber } from 'cordis' +import MongoDriver from '@cordisjs/plugin-database-mongo' +import LoggerConsole from '@cordisjs/plugin-logger-console' +import { expect } from '@cordisjs/database-tests' +import { afterAll, beforeAll, describe, it } from 'vitest' + +interface Legacy { + id?: number + text?: string +} + +declare module '@cordisjs/plugin-database' { + interface Tables { + mongo_legacy: Legacy + } +} + +describe('@cordisjs/plugin-database-mongo/meta-migration', () => { + const ctx = new Context() + + let database: Database + let fiber: Fiber | undefined + + const reset = async () => { + await fiber?.dispose() + fiber = await ctx.plugin(MongoDriver, { + host: 'localhost', + port: 27017, + database: 'test_meta_migration', + }) + database.refresh() + await database.prepared() + } + + const getMeta = async () => { + const driver = database['getDriver']('mongo_legacy') as MongoDriver + return driver.db.collection('_fields').findOne({ _id: 'mongo_legacy' }) + } + + const getRawCollection = () => { + const driver = database['getDriver']('mongo_legacy') as MongoDriver + return driver.db.collection('mongo_legacy') + } + + beforeAll(async () => { + await ctx.plugin(Database) + await ctx.plugin(LoggerConsole) + fiber = await ctx.plugin(MongoDriver, { + host: 'localhost', + port: 27017, + database: 'test_meta_migration', + }) + database = ctx.model as Database + }) + + afterAll(async () => { + await database.dropAll() + await fiber?.dispose() + }) + + it('should inherit virtual/autoInc from legacy-format _fields entries', async () => { + database.extend('mongo_legacy', { + id: 'unsigned', + text: 'string', + }, { + autoInc: true, + }) + + await database.remove('mongo_legacy', {}) + const created = [] + for (let i = 0; i < 3; i++) { + created.push(await database.create('mongo_legacy', { text: `row ${i}` })) + } + // remove the row with the highest id: if the stored `autoInc` is lost, + // `_migratePrimary` will recalculate it as `max(remaining ids)` and the + // next insert will silently reuse the id of the deleted row + await database.remove('mongo_legacy', { id: created[2].id }) + + // simulate the legacy (< 3.7.0) `_fields` state: per-table entries keyed by + // `{ table, field }` with an ObjectId `_id`, carrying `virtual` and `autoInc` + const driver = database['getDriver']('mongo_legacy') as MongoDriver + const metaTable = driver.db.collection('_fields') + const { ObjectId } = await import('mongodb') + const current = await metaTable.findOne({ _id: 'mongo_legacy' }) + await metaTable.deleteOne({ _id: 'mongo_legacy' }) + await metaTable.insertOne({ + _id: new ObjectId(), + table: 'mongo_legacy', + field: 'id', + virtual: false, + autoInc: current.autoInc, + }) + + // restart the driver: `_createFields` should initialize the new-format entry + // and inherit `virtual` / `autoInc` from the legacy entry instead of losing them + await reset() + + const meta = await getMeta() + expect(meta.virtual).to.equal(false) + expect(meta.autoInc).to.equal(current.autoInc) + + // data must survive untouched (no destructive re-migration, ids still resolvable) + await expect(database.get('mongo_legacy', {})).to.eventually.have.deep.members(created.slice(0, 2)) + // the next insert must NOT reuse the id of the deleted row + const next = await database.create('mongo_legacy', { text: 'row 3' }) + expect(next.id).to.equal(current.autoInc + 1) + }) + + it('should not lose autoInc when _fields is dropped entirely', async () => { + // dropping `_fields` simulates a cold re-detect: virtual/autoInc are inferred + // from the data itself, which is the pre-existing behavior — this test guards + // that the inheritance path above does not interfere with it + const driver = database['getDriver']('mongo_legacy') as unknown as Driver + await driver.drop('_fields') + await reset() + + const rows = await database.get('mongo_legacy', {}) + expect(rows.length).to.equal(3) + const next = await database.create('mongo_legacy', { text: 'row 4' }) + expect(rows.every(row => row.id !== next.id)).to.equal(true) + }) +})