diff --git a/packages/core/src/database.ts b/packages/core/src/database.ts index 4607195a..b9f8b162 100644 --- a/packages/core/src/database.ts +++ b/packages/core/src/database.ts @@ -85,15 +85,15 @@ export class Database extends Servi static readonly migrate = Symbol('minato.migrate') public tables: Dict = Object.create(null) - public drivers: Driver[] = [] + public drivers: Driver[] = [] public types: Dict = Object.create(null) - private _driver: Driver | undefined + private _driver: Driver | undefined private stashed = new Set() private prepareTasks: Dict> = Object.create(null) public migrateTasks: Dict> = Object.create(null) - async connect(driver: Driver.Constructor, ...args: Spread) { + async connect(driver: Driver.Constructor, ...args: Spread) { this.ctx.plugin(driver, args[0] as any) await this.ctx.start() } @@ -109,7 +109,7 @@ export class Database extends Servi await Promise.all(Object.values(this.prepareTasks)) } - private getDriver(table: string | Selection): Driver { + private getDriver(table: string | Selection): Driver { if (Selection.is(table)) return table.driver as any const model: Model = this.tables[table] if (!model) throw new Error(`cannot resolve table "${table}"`) @@ -602,12 +602,14 @@ export class Database extends Servi async drop>(table: K) { if (this[Database.transact]) throw new Error('cannot drop table in transaction') - await this.getDriver(table).drop(table) + const driver = this.getDriver(table) + if (driver.config.readonly) throw new Error('cannot drop table in read-only mode') + await driver.drop(table) } async dropAll() { if (this[Database.transact]) throw new Error('cannot drop table in transaction') - await Promise.all(Object.values(this.drivers).map(driver => driver.dropAll())) + await Promise.all(Object.values(this.drivers).filter(driver => !driver.config.readonly).map(driver => driver.dropAll())) } async stats() { diff --git a/packages/core/src/driver.ts b/packages/core/src/driver.ts index 3e420d52..70928b7d 100644 --- a/packages/core/src/driver.ts +++ b/packages/core/src/driver.ts @@ -1,11 +1,13 @@ import { Awaitable, deepEqual, defineProperty, Dict, mapValues, remove } from 'cosmokit' -import { Context, Logger, Service } from 'cordis' +import { Context, Logger, Service, z } from 'cordis' import { Eval, Update } from './eval.ts' import { Direction, Modifier, Selection } from './selection.ts' import { Field, Model, Relation } from './model.ts' import { Database } from './database.ts' import { Type } from './type.ts' import { FlatKeys, Keys, Values } from './utils.ts' +import enUS from './locales/en-US.yml' +import zhCN from './locales/zh-CN.yml' export namespace Driver { export interface Stats { @@ -52,10 +54,10 @@ export namespace Driver { } export namespace Driver { - export type Constructor = new (ctx: Context, config: T) => Driver + export type Constructor = new (ctx: Context, config: T) => Driver } -export abstract class Driver { +export abstract class Driver { static inject = ['model'] abstract start(): Promise @@ -165,13 +167,14 @@ export abstract class Driver { async _ensureSession() {} async prepareIndexes(table: string) { - const oldIndexes = await this.getIndexes(table) const { indexes } = this.model(table) + if (this.config.migrateStrategy === 'never' || this.config.readonly) return + const oldIndexes = await this.getIndexes(table) for (const index of indexes) { const oldIndex = oldIndexes.find(info => info.name === index.name) if (!oldIndex) { await this.createIndex(table, index) - } else if (!deepEqual(oldIndex, index)) { + } else if (this.config.migrateStrategy === 'auto' && !deepEqual(oldIndex, index)) { await this.dropIndex(table, index.name!) await this.createIndex(table, index) } @@ -179,6 +182,21 @@ export abstract class Driver { } } +export namespace Driver { + export interface Config { + readonly?: boolean + migrateStrategy?: 'auto' | 'create' | 'never' + } + + export const Config: z = z.object({ + readonly: z.boolean().default(false), + migrateStrategy: z.union([z.const('auto'), z.const('create'), z.const('never')]).default('auto'), + }).i18n({ + 'en-US': enUS, + 'zh-CN': zhCN, + }) +} + export interface MigrationHooks { before: (keys: string[]) => boolean after: (keys: string[]) => void diff --git a/packages/core/src/locales/en-US.yml b/packages/core/src/locales/en-US.yml new file mode 100644 index 00000000..a9bc22ba --- /dev/null +++ b/packages/core/src/locales/en-US.yml @@ -0,0 +1,3 @@ +$description: Access settings +readonly: Connect in read-only mode. +migrateStrategy: Table migration strategy. diff --git a/packages/core/src/locales/zh-CN.yml b/packages/core/src/locales/zh-CN.yml new file mode 100644 index 00000000..805ed2b3 --- /dev/null +++ b/packages/core/src/locales/zh-CN.yml @@ -0,0 +1,3 @@ +$description: 访问设置 +readonly: 以只读模式连接。 +migrateStrategy: 表迁移策略。 diff --git a/packages/core/src/selection.ts b/packages/core/src/selection.ts index 9366833d..91eae13b 100644 --- a/packages/core/src/selection.ts +++ b/packages/core/src/selection.ts @@ -146,6 +146,9 @@ class Executable { } async execute(): Promise { + if (this.driver.config.readonly && !['get', 'eval'].includes(this.type)) { + throw new Error(`database is in read-only mode`) + } await this.driver.database.prepared() await this.driver._ensureSession() return this.driver[this.type as any](this, ...this.args) diff --git a/packages/mongo/src/index.ts b/packages/mongo/src/index.ts index b0a50406..0c9dc7b7 100644 --- a/packages/mongo/src/index.ts +++ b/packages/mongo/src/index.ts @@ -229,6 +229,13 @@ export class MongoDriver extends Driver { /** synchronize table schema */ async prepare(table: string) { + if (this.config.migrateStrategy === 'never' || this.config.readonly) { + if (this.config.migrateStrategy === 'never' && this.shouldEnsurePrimary(table)) { + throw new Error(`immutable table ${table} cannot be autoInc`) + } + return + } + await Promise.all([ this._createInternalTable(), this.db.createCollection(table).catch(noop), @@ -536,7 +543,7 @@ export class MongoDriver extends Driver { } export namespace MongoDriver { - export interface Config extends MongoClientOptions { + export interface Config extends Driver.Config, MongoClientOptions { username?: string password?: string protocol?: string @@ -556,27 +563,30 @@ export namespace MongoDriver { optimizeIndex?: boolean } - export const Config: z = z.object({ - protocol: z.string().default('mongodb'), - host: z.string().default('localhost'), - port: z.natural().max(65535), - username: z.string(), - password: z.string().role('secret'), - database: z.string().required(), - authDatabase: z.string(), - writeConcern: z.object({ - w: z.union([ - z.const(undefined), - z.number().required(), - z.const('majority').required(), - ]), - wtimeoutMS: z.number(), - journal: z.boolean(), + export const Config: z = z.intersect([ + z.object({ + protocol: z.string().default('mongodb'), + host: z.string().default('localhost'), + port: z.natural().max(65535), + username: z.string(), + password: z.string().role('secret'), + database: z.string().required(), + authDatabase: z.string(), + writeConcern: z.object({ + w: z.union([ + z.const(undefined), + z.number().required(), + z.const('majority').required(), + ]), + wtimeoutMS: z.number(), + journal: z.boolean(), + }) as any, + }).i18n({ + 'en-US': enUS, + 'zh-CN': zhCN, }), - }).i18n({ - 'en-US': enUS, - 'zh-CN': zhCN, - }) + Driver.Config, + ]) } export default MongoDriver diff --git a/packages/mongo/tests/index.spec.ts b/packages/mongo/tests/index.spec.ts index 41dacd6b..2908d910 100644 --- a/packages/mongo/tests/index.spec.ts +++ b/packages/mongo/tests/index.spec.ts @@ -30,6 +30,9 @@ describe('@minatojs/driver-mongo', () => { aggregateNull: false, } }, + migration: { + definition: false, + }, transaction: { abort: false } diff --git a/packages/mysql/src/index.ts b/packages/mysql/src/index.ts index 75ddc333..d31f5fc6 100644 --- a/packages/mysql/src/index.ts +++ b/packages/mysql/src/index.ts @@ -233,10 +233,20 @@ export class MySQLDriver extends Driver { } if (!columns.length) { + if (this.config.readonly || this.config.migrateStrategy === 'never') { + throw new Error(`immutable table ${name} cannot be created`) + } this.logger.info('auto creating table %c', name) return this.query(`CREATE TABLE ${escapeId(name)} (${create.join(', ')}) COLLATE = ${this.sql.escape(this.config.charset ?? 'utf8mb4_general_ci')}`) } + if (this.config.readonly || this.config.migrateStrategy !== 'auto') { + if (create.length || update.length) { + throw new Error(`immutable table ${name} cannot be migrated`) + } + return + } + const operations = [ ...create.map(def => 'ADD ' + def), ...update, @@ -593,7 +603,7 @@ INSERT INTO mtt VALUES(json_extract(j, concat('$[', i, ']'))); SET i=i+1; END WH } export namespace MySQLDriver { - export interface Config extends PoolConfig {} + export interface Config extends Driver.Config, PoolConfig {} export const Config: z = z.intersect([ z.object({ @@ -602,8 +612,6 @@ export namespace MySQLDriver { user: z.string().default('root'), password: z.string().role('secret'), database: z.string().required(), - }), - z.object({ ssl: z.union([ z.const(undefined), z.object({ @@ -630,11 +638,12 @@ export namespace MySQLDriver { sessionTimeout: z.number(), }), ]) as any, + }).i18n({ + 'en-US': enUS, + 'zh-CN': zhCN, }), - ]).i18n({ - 'en-US': enUS, - 'zh-CN': zhCN, - }) + Driver.Config, + ]) } export default MySQLDriver diff --git a/packages/postgres/src/index.ts b/packages/postgres/src/index.ts index 47aabda2..990dfdc3 100644 --- a/packages/postgres/src/index.ts +++ b/packages/postgres/src/index.ts @@ -237,10 +237,20 @@ export class PostgresDriver extends Driver { } if (!columns.length) { + if (this.config.readonly || this.config.migrateStrategy === 'never') { + throw new Error(`immutable table ${name} cannot be created`) + } this.logger.info('auto creating table %c', name) return this.query(`CREATE TABLE ${escapeId(name)} (${create.join(', ')}, _pg_mtime BIGINT)`) } + if (this.config.readonly || this.config.migrateStrategy !== 'auto') { + if (create.length || update.length) { + throw new Error(`immutable table ${name} cannot be migrated`) + } + return + } + const operations = [ ...create.map(def => 'ADD ' + def), ...update, @@ -522,7 +532,7 @@ export class PostgresDriver extends Driver { } export namespace PostgresDriver { - export interface Config = {}> extends postgres.Options { + export interface Config = {}> extends Driver.Config, postgres.Options { host: string port: number user: string @@ -530,16 +540,19 @@ export namespace PostgresDriver { database: string } - export const Config: z = z.object({ - host: z.string().default('localhost'), - port: z.natural().max(65535).default(5432), - user: z.string().default('root'), - password: z.string().role('secret'), - database: z.string().required(), - }).i18n({ - 'en-US': enUS, - 'zh-CN': zhCN, - }) + export const Config: z = z.intersect([ + z.object({ + host: z.string().default('localhost'), + port: z.natural().max(65535).default(5432), + user: z.string().default('root'), + password: z.string().role('secret'), + database: z.string().required(), + }).i18n({ + 'en-US': enUS, + 'zh-CN': zhCN, + }), + Driver.Config, + ]) } export default PostgresDriver diff --git a/packages/sqlite/src/index.ts b/packages/sqlite/src/index.ts index da8d82c7..ee8cce4f 100644 --- a/packages/sqlite/src/index.ts +++ b/packages/sqlite/src/index.ts @@ -112,9 +112,18 @@ export class SQLiteDriver extends Driver { })) } + if (this.config.readonly || this.config.migrateStrategy === 'never') { + if (!columns.length || shouldMigrate || alter.length) { + throw new Error(`immutable table ${table} cannot be migrated`) + } + return + } + if (!columns.length) { this.logger.info('auto creating table %c', table) this._run(`CREATE TABLE ${escapeId(table)} (${[...columnDefs, ...indexDefs].join(', ')})`) + } else if (this.config.migrateStrategy === 'create') { + throw new Error(`immutable table ${table} cannot be migrated`) } else if (shouldMigrate) { // preserve old columns for (const { name, type, notnull, pk, dflt_value: value } of columns) { @@ -482,16 +491,19 @@ export class SQLiteDriver extends Driver { } export namespace SQLiteDriver { - export interface Config { + export interface Config extends Driver.Config { path: string } - export const Config: z = z.object({ - path: z.string().role('path').required(), - }).i18n({ - 'en-US': enUS, - 'zh-CN': zhCN, - }) + export const Config: z = z.intersect([ + z.object({ + path: z.string().role('path').required(), + }).i18n({ + 'en-US': enUS, + 'zh-CN': zhCN, + }), + Driver.Config, + ]) } export default SQLiteDriver diff --git a/packages/tests/src/migration.ts b/packages/tests/src/migration.ts index 80bfd670..abeed6d1 100644 --- a/packages/tests/src/migration.ts +++ b/packages/tests/src/migration.ts @@ -21,7 +21,13 @@ interface Tables { qux2: Qux2 } -function MigrationTests(database: Database) { +interface MigrationOptions { + definition?: boolean +} + +function MigrationTests(database: Database, options: MigrationOptions = {}) { + const { definition = true } = options + beforeEach(async () => { await database.drop('qux').catch(noop) }) @@ -105,6 +111,8 @@ function MigrationTests(database: Database) { flag: 'boolean', }) + await database.prepared() + database.migrate('qux', { flag: 'boolean', }, async (database) => { @@ -271,6 +279,62 @@ function MigrationTests(database: Database) { }, }))).to.not.be.undefined }) + + definition && it('immutable model', async () => { + const driver = Object.values(database.drivers)[0] + Reflect.deleteProperty(database.tables, 'qux') + + database.extend('qux', { + id: 'unsigned', + text: 'string(64)', + }) + + await database.upsert('qux', [ + { id: 1, text: 'foo' }, + { id: 2, text: 'bar' }, + ]) + + await expect(database.get('qux', {})).to.eventually.have.deep.members([ + { id: 1, text: 'foo' }, + { id: 2, text: 'bar' }, + ]) + + Reflect.deleteProperty(database.tables, 'qux') + driver.config.migrateStrategy = 'never' + database.extend('qux', { + id: 'unsigned', + text: 'integer' as any, + }) + + await expect(database.upsert('qux', [ + { id: 1, text: 'foo' }, + { id: 2, text: 'bar' }, + ])).to.eventually.be.rejectedWith('immutable') + await expect(database.get('qux', {})).to.eventually.be.rejectedWith('immutable') + + Reflect.deleteProperty(database.tables, 'qux') + Reflect.deleteProperty(database['prepareTasks'], 'qux') + driver.config.migrateStrategy = 'auto' + driver.config.readonly = true + + database.extend('qux', { + id: 'unsigned', + text: 'string(64)', + }) + + await expect(database.get('qux', {})).to.eventually.be.fulfilled + await expect(database.set('qux', 1, { text: 'foo' })).to.eventually.be.rejectedWith('read-only') + await expect(database.upsert('qux', [ + { id: 1, text: 'foo' }, + { id: 2, text: 'bar' }, + ])).to.eventually.be.rejectedWith('read-only') + await expect(database.remove('qux', 1)).to.eventually.be.rejectedWith('read-only') + + Reflect.deleteProperty(database.tables, 'qux') + Reflect.deleteProperty(database['prepareTasks'], 'qux') + driver.config.migrateStrategy = 'auto' + driver.config.readonly = false + }) } export default MigrationTests