From 201e2f25800923b3c58e0004d50cee837e6e9bc1 Mon Sep 17 00:00:00 2001 From: hashbk Date: Wed, 29 Jul 2026 06:25:27 +0000 Subject: [PATCH] feat(db): add TypeORM migration support for production environments Replace synchronize: true with a proper migration system for production safety. Development mode retains synchronize for fast iteration, while production uses explicit migration files for auditable, controllable schema changes. - Add centralized entity list (src/entities/index.ts) shared by app.module.ts and data-source.ts to prevent drift - Add data-source.ts for TypeORM CLI migration commands - Generate initial schema migration with all 26 entities - Add MigrationService for runtime migration execution with existing database detection (marks initial migration as already run) - Conditionally disable synchronize and enable migrations based on NODE_ENV (dev: synchronize=true, prod: migrations) - Add npm scripts for migration:generate/run/revert/show/create - Fix Invitation.note column missing explicit type for reflect-metadata - Add ts-node commonjs config for CLI compatibility - Update .env.example with NODE_ENV documentation --- .env.example | 5 + package.json | 8 +- src/app.module.ts | 58 +- src/data-source.ts | 20 + src/database/database-init.service.ts | 4 + src/database/database.module.ts | 8 +- src/database/migration.service.ts | 113 ++++ src/entities/index.ts | 60 ++ src/migrations/1785305180672-InitialSchema.ts | 540 ++++++++++++++++++ src/migrations/README.md | 39 ++ .../user/entities/invitation.entity.ts | 2 +- tsconfig.json | 6 + 12 files changed, 806 insertions(+), 57 deletions(-) create mode 100644 src/data-source.ts create mode 100644 src/database/migration.service.ts create mode 100644 src/entities/index.ts create mode 100644 src/migrations/1785305180672-InitialSchema.ts create mode 100644 src/migrations/README.md diff --git a/.env.example b/.env.example index 829f224..87485fa 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,11 @@ # Server Configuration PORT=3000 +# Environment (development or production) +# In production: database migrations are used instead of schema synchronization +# In development: schema is auto-synchronized for faster iteration +# NODE_ENV=production + # JWT Configuration JWT_SECRET=your-super-secret-jwt-key-change-in-production diff --git a/package.json b/package.json index 70a6629..ba5e571 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,13 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "typeorm": "typeorm-ts-node-commonjs", + "migration:generate": "npx typeorm-ts-node-commonjs migration:generate -d src/data-source.ts", + "migration:run": "npx typeorm-ts-node-commonjs migration:run -d src/data-source.ts", + "migration:revert": "npx typeorm-ts-node-commonjs migration:revert -d src/data-source.ts", + "migration:show": "npx typeorm-ts-node-commonjs migration:show -d src/data-source.ts", + "migration:create": "npx typeorm-ts-node-commonjs migration:create" }, "dependencies": { "@nestjs-modules/mailer": "^2.0.2", diff --git a/src/app.module.ts b/src/app.module.ts index e8ba061..3f3abbf 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -14,37 +14,15 @@ import { OidcModule } from './modules/oidc/oidc.module'; import { SysinfoModule } from './modules/sysinfo/sysinfo.module'; import { DashboardModule } from './modules/dashboard/dashboard.module'; import { DatabaseModule } from './database/database.module'; -import { Sysinfo, Peer } from './common/entities'; -import { ConnectionAudit } from './modules/audit/entities/connection-audit.entity'; -import { FileAudit } from './modules/audit/entities/file-audit.entity'; -import { AlarmAudit } from './modules/audit/entities/alarm-audit.entity'; -import { AddressBook } from './modules/address-book/entities/address-book.entity'; -import { AddressBookPeer } from './modules/address-book/entities/address-book-peer.entity'; -import { AddressBookTag } from './modules/address-book/entities/address-book-tag.entity'; -import { AddressBookPeerTag } from './modules/address-book/entities/address-book-peer-tag.entity'; -import { AddressBookRule } from './modules/address-book/entities/address-book-rule.entity'; -import { User } from './modules/user/entities/user.entity'; -import { UserToken } from './modules/user/entities/user-token.entity'; -import { OidcProvider } from './modules/oidc/entities/oidc-provider.entity'; -import { OidcAuthState } from './modules/oidc/entities/oidc-auth-state.entity'; -import { DeviceGroup } from './modules/device-group/entities/device-group.entity'; -import { DeviceGroupUserPermission } from './modules/device-group/entities/device-group-user-permission.entity'; -import { UserUserPermission } from './modules/device-group/entities/user-user-permission.entity'; import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard'; -import { LoginSession } from './modules/auth/entities/login-session.entity'; -import { PasskeyCredential } from './modules/auth/entities/passkey-credential.entity'; -import { SystemSetting } from './modules/settings/entities/system-setting.entity'; -import { ActiveConnection } from './modules/heartbeat/entities/active-connection.entity'; import { SettingsModule } from './modules/settings/settings.module'; import { LdapModule } from './modules/ldap/ldap.module'; import { StrategyModule } from './modules/strategy/strategy.module'; -import { Strategy } from './modules/strategy/entities/strategy.entity'; import { UpdateCheckModule } from './modules/update-check/update-check.module'; import { NexusModule } from './modules/nexus/nexus.module'; -import { NexusToken } from './modules/nexus/entities/nexus-token.entity'; -import { NexusBuild } from './modules/nexus/entities/nexus-build.entity'; import { UserGroupModule } from './modules/user-group/user-group.module'; -import { UserGroup } from './modules/user-group/entities/user-group.entity'; +import { ALL_ENTITIES } from './entities'; +import { InitialSchema1785305180672 } from './migrations/1785305180672-InitialSchema'; /** * 应用根模块 @@ -82,34 +60,10 @@ import { UserGroup } from './modules/user-group/entities/user-group.entity'; TypeOrmModule.forRoot({ type: 'sqlite', database: process.env.DB_PATH || 'rustdesk-console.db', - entities: [ - Sysinfo, - Peer, - ConnectionAudit, - FileAudit, - AlarmAudit, - AddressBook, - AddressBookPeer, - AddressBookTag, - AddressBookPeerTag, - AddressBookRule, - User, - UserToken, - OidcProvider, - OidcAuthState, - DeviceGroup, - DeviceGroupUserPermission, - UserUserPermission, - LoginSession, - PasskeyCredential, - SystemSetting, - ActiveConnection, - Strategy, - NexusToken, - NexusBuild, - UserGroup, - ], - synchronize: true, + entities: ALL_ENTITIES, + synchronize: process.env.NODE_ENV !== 'production', + migrationsRun: false, + migrations: [InitialSchema1785305180672], logging: false, }), DatabaseModule, diff --git a/src/data-source.ts b/src/data-source.ts new file mode 100644 index 0000000..d65edeb --- /dev/null +++ b/src/data-source.ts @@ -0,0 +1,20 @@ +import 'reflect-metadata'; +import 'dotenv/config'; +import { DataSource } from 'typeorm'; +import { ALL_ENTITIES } from './entities'; +import { InitialSchema1785305180672 } from './migrations/1785305180672-InitialSchema'; + +/** + * TypeORM CLI 专用数据源配置 + * 用于 migration:generate / migration:run / migration:revert 等 CLI 命令 + * + * 注意:此文件独立于 NestJS 运行,通过 ts-node 加载 + * 实体列表与 app.module.ts 共享 src/entities/index.ts + */ +export default new DataSource({ + type: 'sqlite', + database: process.env.DB_PATH || 'rustdesk-console.db', + entities: ALL_ENTITIES, + migrations: [InitialSchema1785305180672], + synchronize: false, +}); diff --git a/src/database/database-init.service.ts b/src/database/database-init.service.ts index 1e009ea..965e849 100644 --- a/src/database/database-init.service.ts +++ b/src/database/database-init.service.ts @@ -7,6 +7,7 @@ import { User, UserStatus } from '../modules/user/entities/user.entity'; import { OidcProvider } from '../modules/oidc/entities/oidc-provider.entity'; import { OidcAuthState } from '../modules/oidc/entities/oidc-auth-state.entity'; import { UserGroupService } from '../modules/user-group/user-group.service'; +import { MigrationService } from './migration.service'; @Injectable() /** @@ -26,10 +27,13 @@ export class DatabaseInitService implements OnModuleInit { private oidcProviderRepository: Repository, @InjectRepository(OidcAuthState) private oidcAuthStateRepository: Repository, + private readonly migrationService: MigrationService, private readonly userGroupService: UserGroupService, ) {} async onModuleInit() { + // 在种子数据初始化之前运行数据库迁移(生产环境) + await this.migrationService.runMigrationsIfNeeded(); const defaultGroup = await this.userGroupService.initializeStorage(); await this.createDefaultAdmin(defaultGroup.guid); await this.createDefaultOidcProviders(); diff --git a/src/database/database.module.ts b/src/database/database.module.ts index c80b487..746606f 100644 --- a/src/database/database.module.ts +++ b/src/database/database.module.ts @@ -6,6 +6,7 @@ import { OidcProvider } from '../modules/oidc/entities/oidc-provider.entity'; import { OidcAuthState } from '../modules/oidc/entities/oidc-auth-state.entity'; import { SystemSetting } from '../modules/settings/entities/system-setting.entity'; import { DatabaseInitService } from './database-init.service'; +import { MigrationService } from './migration.service'; import { UserGroupModule } from '../modules/user-group/user-group.module'; @Global() @@ -14,7 +15,8 @@ import { UserGroupModule } from '../modules/user-group/user-group.module'; * 负责数据库连接和初始化配置 * * 提供服务: - * - DatabaseInitService + * - MigrationService - 生产环境数据库迁移执行 + * - DatabaseInitService - 种子数据初始化 */ @Module({ imports: [ @@ -27,7 +29,7 @@ import { UserGroupModule } from '../modules/user-group/user-group.module'; ]), UserGroupModule, ], - providers: [DatabaseInitService], - exports: [DatabaseInitService], + providers: [MigrationService, DatabaseInitService], + exports: [MigrationService, DatabaseInitService], }) export class DatabaseModule {} diff --git a/src/database/migration.service.ts b/src/database/migration.service.ts new file mode 100644 index 0000000..35a3cdb --- /dev/null +++ b/src/database/migration.service.ts @@ -0,0 +1,113 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +/** + * MigrationService + * 在生产环境中负责数据库迁移的执行 + * + * 职责: + * 1. 检测已有数据库(从 synchronize 迁移过来的),标记初始迁移为已执行 + * 2. 执行所有待处理的数据库迁移 + * + * 设计说明: + * 此服务不使用 OnModuleInit,而是由 DatabaseInitService 在种子数据初始化前显式调用, + * 以确保迁移在种子数据操作之前完成。 + * 不能使用 migrationsRun: true,因为 TypeORM 在 DataSource 初始化阶段执行迁移, + * 早于任何 NestJS 的 OnModuleInit 钩子,无法在迁移执行前标记已有数据库的初始迁移。 + */ +@Injectable() +export class MigrationService { + private readonly logger = new Logger(MigrationService.name); + + constructor( + @InjectDataSource() + private dataSource: DataSource, + ) {} + + /** + * 在生产环境中运行数据库迁移(包括已有数据库的初始迁移标记) + * 应在 DatabaseInitService 的种子数据操作之前调用 + */ + async runMigrationsIfNeeded(): Promise { + if (process.env.NODE_ENV !== 'production') return; + + await this.markInitialMigrationAsRunIfNeeded(); + await this.runPendingMigrations(); + } + + /** + * 执行所有待处理的数据库迁移 + */ + private async runPendingMigrations(): Promise { + try { + const hasPending = await this.dataSource.showMigrations(); + if (hasPending) { + this.logger.log('Running pending database migrations...'); + await this.dataSource.runMigrations({ transaction: 'all' }); + this.logger.log('Migrations completed successfully'); + } else { + this.logger.log('No pending migrations'); + } + } catch (error) { + this.logger.error( + `Failed to run migrations: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } + } + + /** + * 标记初始迁移为已执行(针对从 synchronize 迁移过来的已有数据库) + * + * 场景:生产环境中已有通过 synchronize: true 创建的数据库, + * 启用迁移后 TypeORM 会认为初始迁移未执行而尝试重新建表。 + * 此方法检测已有数据库并标记初始迁移为已执行,避免重复建表。 + */ + private async markInitialMigrationAsRunIfNeeded(): Promise { + const initialMigrationName = 'InitialSchema1785305180672'; + + try { + // 检查 migrations 表是否存在 + const hasMigrationsTable = await this.dataSource.query( + "SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'", + ); + + if (hasMigrationsTable.length === 0) { + // migrations 表不存在,说明是全新数据库,TypeORM 会正常执行迁移 + return; + } + + // 检查初始迁移是否已记录 + const existingRecord = await this.dataSource.query( + 'SELECT * FROM migrations WHERE name = ?', + [initialMigrationName], + ); + + if (existingRecord.length > 0) { + // 初始迁移已记录,无需处理 + return; + } + + // migrations 表存在但初始迁移未记录 → 检查核心表是否存在 + const hasUsersTable = await this.dataSource.query( + "SELECT name FROM sqlite_master WHERE type='table' AND name='users'", + ); + + if (hasUsersTable.length > 0) { + // 核心表存在,说明是从 synchronize 迁移过来的已有数据库 + await this.dataSource.query( + 'INSERT INTO migrations (timestamp, name) VALUES (?, ?)', + [1785305180672, initialMigrationName], + ); + this.logger.log( + 'Marked initial migration as already executed for existing database', + ); + } + } catch (error) { + this.logger.warn( + `Failed to check migration status: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} diff --git a/src/entities/index.ts b/src/entities/index.ts new file mode 100644 index 0000000..db45845 --- /dev/null +++ b/src/entities/index.ts @@ -0,0 +1,60 @@ +/** + * 集中实体列表 + * 统一管理所有 TypeORM 实体,供 app.module.ts 和 data-source.ts 共享 + * 避免两处维护不同的实体列表导致不同步 + */ +import { Sysinfo } from '../common/entities/sysinfo.entity'; +import { Peer } from '../common/entities/peer.entity'; +import { ConnectionAudit } from '../modules/audit/entities/connection-audit.entity'; +import { FileAudit } from '../modules/audit/entities/file-audit.entity'; +import { AlarmAudit } from '../modules/audit/entities/alarm-audit.entity'; +import { AddressBook } from '../modules/address-book/entities/address-book.entity'; +import { AddressBookPeer } from '../modules/address-book/entities/address-book-peer.entity'; +import { AddressBookTag } from '../modules/address-book/entities/address-book-tag.entity'; +import { AddressBookPeerTag } from '../modules/address-book/entities/address-book-peer-tag.entity'; +import { AddressBookRule } from '../modules/address-book/entities/address-book-rule.entity'; +import { User } from '../modules/user/entities/user.entity'; +import { UserToken } from '../modules/user/entities/user-token.entity'; +import { Invitation } from '../modules/user/entities/invitation.entity'; +import { OidcProvider } from '../modules/oidc/entities/oidc-provider.entity'; +import { OidcAuthState } from '../modules/oidc/entities/oidc-auth-state.entity'; +import { DeviceGroup } from '../modules/device-group/entities/device-group.entity'; +import { DeviceGroupUserPermission } from '../modules/device-group/entities/device-group-user-permission.entity'; +import { UserUserPermission } from '../modules/device-group/entities/user-user-permission.entity'; +import { LoginSession } from '../modules/auth/entities/login-session.entity'; +import { PasskeyCredential } from '../modules/auth/entities/passkey-credential.entity'; +import { SystemSetting } from '../modules/settings/entities/system-setting.entity'; +import { ActiveConnection } from '../modules/heartbeat/entities/active-connection.entity'; +import { Strategy } from '../modules/strategy/entities/strategy.entity'; +import { NexusToken } from '../modules/nexus/entities/nexus-token.entity'; +import { NexusBuild } from '../modules/nexus/entities/nexus-build.entity'; +import { UserGroup } from '../modules/user-group/entities/user-group.entity'; + +export const ALL_ENTITIES = [ + Sysinfo, + Peer, + ConnectionAudit, + FileAudit, + AlarmAudit, + AddressBook, + AddressBookPeer, + AddressBookTag, + AddressBookPeerTag, + AddressBookRule, + User, + UserToken, + Invitation, + OidcProvider, + OidcAuthState, + DeviceGroup, + DeviceGroupUserPermission, + UserUserPermission, + LoginSession, + PasskeyCredential, + SystemSetting, + ActiveConnection, + Strategy, + NexusToken, + NexusBuild, + UserGroup, +]; diff --git a/src/migrations/1785305180672-InitialSchema.ts b/src/migrations/1785305180672-InitialSchema.ts new file mode 100644 index 0000000..4acd45c --- /dev/null +++ b/src/migrations/1785305180672-InitialSchema.ts @@ -0,0 +1,540 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Initial Schema Migration + * Creates all database tables with complete schema including foreign keys and indexes. + * This migration represents the complete initial database schema. + */ +export class InitialSchema1785305180672 implements MigrationInterface { + name = 'InitialSchema1785305180672'; + + public async up(queryRunner: QueryRunner): Promise { + // Independent tables (no foreign key dependencies) + await queryRunner.query(` + CREATE TABLE "sysinfos" ( + "uuid" varchar PRIMARY KEY NOT NULL, + "hostname" varchar, + "username" varchar, + "os" varchar, + "cpu" varchar, + "memory" varchar, + "preset_username" varchar, + "preset_strategy_name" varchar, + "preset_device_group_name" varchar, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')) + ) + `); + + await queryRunner.query(` + CREATE TABLE "strategies" ( + "guid" varchar PRIMARY KEY NOT NULL, + "name" varchar NOT NULL, + "note" text, + "configOptions" text, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), + CONSTRAINT "UQ_c9ac805e6a43148f0647f543c29" UNIQUE ("name") + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_c9ac805e6a43148f0647f543c2" ON "strategies" ("name")`); + + await queryRunner.query(` + CREATE TABLE "connection_audits" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "deviceId" varchar(255) NOT NULL, + "deviceUuid" text NOT NULL, + "connId" varchar(255), + "sessionId" varchar(255), + "ip" varchar(45) NOT NULL, + "action" varchar(10) NOT NULL, + "peerId" varchar(255), + "peerName" varchar(255), + "type" integer NOT NULL DEFAULT (-1), + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "requestedAt" datetime, + "establishedAt" datetime, + "closedAt" datetime, + "note" varchar(256) + ) + `); + + await queryRunner.query(` + CREATE TABLE "file_audits" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "deviceId" varchar(255) NOT NULL, + "deviceUuid" text NOT NULL, + "peerId" varchar(255) NOT NULL, + "type" integer NOT NULL, + "path" text, + "isFile" boolean NOT NULL, + "clientIp" varchar(45) NOT NULL, + "clientName" varchar(255) NOT NULL, + "fileCount" integer NOT NULL, + "files" json NOT NULL, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')) + ) + `); + + await queryRunner.query(` + CREATE TABLE "alarm_audits" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "deviceId" varchar(255) NOT NULL, + "deviceUuid" text NOT NULL, + "typ" integer NOT NULL, + "infoId" varchar(255), + "infoIp" varchar(45) NOT NULL, + "infoName" varchar(255), + "createdAt" datetime NOT NULL DEFAULT (datetime('now')) + ) + `); + + await queryRunner.query(` + CREATE TABLE "user_groups" ( + "guid" varchar PRIMARY KEY NOT NULL, + "name" varchar NOT NULL, + "normalizedName" varchar NOT NULL, + "note" text, + "isDefault" boolean NOT NULL DEFAULT (0), + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), + CONSTRAINT "UQ_b22a3ef69f790c9b03ffb803bfa" UNIQUE ("normalizedName") + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_b22a3ef69f790c9b03ffb803bf" ON "user_groups" ("normalizedName")`); + await queryRunner.query(`CREATE UNIQUE INDEX "UQ_user_groups_single_default" ON "user_groups" ("isDefault") WHERE "isDefault" = 1`); + + await queryRunner.query(` + CREATE TABLE "oidc_providers" ( + "guid" varchar PRIMARY KEY NOT NULL, + "name" varchar NOT NULL, + "type" text NOT NULL DEFAULT ('oidc'), + "issuer" varchar NOT NULL, + "clientId" varchar NOT NULL, + "clientSecret" varchar, + "scope" varchar, + "authorizationEndpoint" varchar, + "tokenEndpoint" varchar, + "userinfoEndpoint" varchar, + "jwksUri" varchar, + "enabled" boolean NOT NULL DEFAULT (1), + "priority" integer NOT NULL DEFAULT (0), + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')) + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_2399f67c42a46d6670b27338b4" ON "oidc_providers" ("name")`); + + await queryRunner.query(` + CREATE TABLE "oidc_auth_states" ( + "guid" varchar PRIMARY KEY NOT NULL, + "code" varchar NOT NULL, + "op" varchar NOT NULL, + "providerType" text NOT NULL DEFAULT ('oidc'), + "deviceId" varchar, + "deviceUuid" varchar, + "deviceInfo" text, + "redirectUri" text, + "state" text, + "status" text NOT NULL DEFAULT ('pending'), + "userGuid" varchar, + "accessToken" varchar, + "codeVerifier" text, + "nonce" text, + "frontendRedirectUrl" text, + "expiresAt" datetime NOT NULL, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')) + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_dff6ad917f82c6817539f49791" ON "oidc_auth_states" ("code")`); + await queryRunner.query(`CREATE INDEX "IDX_15643f36cf99bd1046a760646c" ON "oidc_auth_states" ("op")`); + + await queryRunner.query(` + CREATE TABLE "system_settings" ( + "key" varchar PRIMARY KEY NOT NULL, + "value" text NOT NULL, + "category" varchar NOT NULL, + "description" varchar, + "isSensitive" boolean NOT NULL DEFAULT (0), + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')) + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_b1b5bc664526d375c94ce9ad43" ON "system_settings" ("key")`); + await queryRunner.query(`CREATE INDEX "IDX_797d199fff9037e5b231dc4ffb" ON "system_settings" ("category")`); + + await queryRunner.query(` + CREATE TABLE "address_books" ( + "guid" varchar PRIMARY KEY NOT NULL, + "owner" varchar NOT NULL, + "isPersonal" boolean NOT NULL DEFAULT (0), + "isShared" boolean NOT NULL DEFAULT (0), + "name" varchar, + "note" text, + "info" text, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')) + ) + `); + + await queryRunner.query(` + CREATE TABLE "nexus_tokens" ( + "userGuid" varchar PRIMARY KEY NOT NULL, + "nexusToken" text NOT NULL, + "nexusUsername" varchar NOT NULL, + "expiresAt" datetime NOT NULL, + "currentUuid" varchar, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')) + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_82203a4749fdde33a9060753e7" ON "nexus_tokens" ("userGuid")`); + + // Tables with foreign key dependencies (ordered by dependency) + await queryRunner.query(` + CREATE TABLE "peers" ( + "uuid" varchar PRIMARY KEY NOT NULL, + "id" varchar NOT NULL, + "userGuid" varchar, + "deviceGroupGuid" varchar, + "strategyGuid" varchar, + "note" varchar, + "status" integer NOT NULL DEFAULT (1), + "ver" integer NOT NULL, + "modifiedAt" integer NOT NULL, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "lastHeartbeat" datetime, + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), + CONSTRAINT "FK_e27f5e43da4701b83496fe359b9" FOREIGN KEY ("strategyGuid") REFERENCES "strategies" ("guid") ON DELETE SET NULL ON UPDATE NO ACTION, + CONSTRAINT "FK_359e577524ef710f1e16b6de2b8" FOREIGN KEY ("deviceGroupGuid") REFERENCES "device_groups" ("guid") ON DELETE SET NULL ON UPDATE NO ACTION + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_ab6c529b67b0acf915add4322e" ON "peers" ("userGuid")`); + await queryRunner.query(`CREATE INDEX "IDX_359e577524ef710f1e16b6de2b" ON "peers" ("deviceGroupGuid")`); + await queryRunner.query(`CREATE INDEX "IDX_e27f5e43da4701b83496fe359b" ON "peers" ("strategyGuid")`); + + await queryRunner.query(` + CREATE TABLE "users" ( + "guid" varchar PRIMARY KEY NOT NULL, + "username" varchar NOT NULL, + "displayName" varchar, + "email" varchar, + "password" varchar, + "note" varchar, + "verifier" varchar, + "status" integer NOT NULL DEFAULT (1), + "isAdmin" boolean NOT NULL DEFAULT (0), + "emailVerificationCode" varchar, + "tfaSecret" varchar, + "info" text, + "thirdAuthType" varchar, + "oidcSubject" varchar, + "avatar" varchar, + "strategyGuid" varchar, + "userGroupGuid" varchar, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), + CONSTRAINT "UQ_fe0bb3f6520ee0469504521e710" UNIQUE ("username"), + CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE ("email"), + CONSTRAINT "UQ_6069281bb078ce8bfe6000221eb" UNIQUE ("oidcSubject"), + CONSTRAINT "FK_62b51ade0ce9c40e06c3465f874" FOREIGN KEY ("strategyGuid") REFERENCES "strategies" ("guid") ON DELETE SET NULL ON UPDATE NO ACTION, + CONSTRAINT "FK_ab7ab1b7f0c82372ab08064f112" FOREIGN KEY ("userGroupGuid") REFERENCES "user_groups" ("guid") ON DELETE SET NULL ON UPDATE NO ACTION + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_fe0bb3f6520ee0469504521e71" ON "users" ("username")`); + await queryRunner.query(`CREATE INDEX "IDX_97672ac88f789774dd47f7c8be" ON "users" ("email")`); + await queryRunner.query(`CREATE INDEX "IDX_6069281bb078ce8bfe6000221e" ON "users" ("oidcSubject")`); + await queryRunner.query(`CREATE INDEX "IDX_62b51ade0ce9c40e06c3465f87" ON "users" ("strategyGuid")`); + await queryRunner.query(`CREATE INDEX "IDX_ab7ab1b7f0c82372ab08064f11" ON "users" ("userGroupGuid")`); + + await queryRunner.query(` + CREATE TABLE "user_tokens" ( + "guid" varchar PRIMARY KEY NOT NULL, + "userGuid" varchar NOT NULL, + "jti" varchar(36) NOT NULL, + "deviceId" varchar, + "deviceUuid" varchar, + "expiresAt" datetime NOT NULL, + "isRevoked" boolean NOT NULL DEFAULT (0), + "deviceOs" varchar, + "deviceType" varchar, + "deviceName" varchar, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), + CONSTRAINT "FK_8d60c54ad272c1b5078f3cb86dc" FOREIGN KEY ("userGuid") REFERENCES "users" ("guid") ON DELETE CASCADE ON UPDATE NO ACTION + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_8d60c54ad272c1b5078f3cb86d" ON "user_tokens" ("userGuid")`); + await queryRunner.query(`CREATE INDEX "IDX_cf8bff5dc33a46985bf6b2071e" ON "user_tokens" ("jti")`); + + await queryRunner.query(` + CREATE TABLE "invitations" ( + "guid" varchar PRIMARY KEY NOT NULL, + "token" varchar NOT NULL, + "email" varchar NOT NULL, + "name" varchar NOT NULL, + "displayName" varchar, + "userGroupGuid" varchar, + "note" varchar, + "userGuid" varchar, + "expiresAt" datetime NOT NULL, + "usedAt" datetime, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), + CONSTRAINT "UQ_e577dcf9bb6d084373ed3998509" UNIQUE ("token") + ) + `); + + await queryRunner.query(` + CREATE TABLE "address_book_tags" ( + "guid" varchar PRIMARY KEY NOT NULL, + "addressBookGuid" varchar NOT NULL, + "name" varchar NOT NULL, + "color" bigint NOT NULL DEFAULT (0), + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), + CONSTRAINT "FK_28ac1c09ecf9a5b918311008e56" FOREIGN KEY ("addressBookGuid") REFERENCES "address_books" ("guid") ON DELETE CASCADE ON UPDATE NO ACTION + ) + `); + + await queryRunner.query(` + CREATE TABLE "address_book_peers" ( + "guid" varchar PRIMARY KEY NOT NULL, + "addressBookGuid" varchar NOT NULL, + "deviceId" varchar NOT NULL, + "hash" text, + "password" text, + "alias" varchar, + "note" text, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), + CONSTRAINT "FK_bcf8fa37522d5338200a5adc0f9" FOREIGN KEY ("addressBookGuid") REFERENCES "address_books" ("guid") ON DELETE CASCADE ON UPDATE NO ACTION + ) + `); + + await queryRunner.query(` + CREATE TABLE "address_book_rules" ( + "guid" varchar NOT NULL, + "addressBookGuid" varchar NOT NULL, + "targetUserId" varchar, + "targetGroupId" varchar, + "rule" integer NOT NULL DEFAULT (1), + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY ("guid", "addressBookGuid"), + CONSTRAINT "FK_c00ccc9f827bedcdcff573aac3f" FOREIGN KEY ("targetGroupId") REFERENCES "user_groups" ("guid") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_812f0ec8c35dee5e6388a3cbddf" FOREIGN KEY ("addressBookGuid") REFERENCES "address_books" ("guid") ON DELETE CASCADE ON UPDATE NO ACTION + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_0774a2f2e209686910e9bd13fa" ON "address_book_rules" ("targetUserId")`); + await queryRunner.query(`CREATE INDEX "IDX_c00ccc9f827bedcdcff573aac3" ON "address_book_rules" ("targetGroupId")`); + + await queryRunner.query(` + CREATE TABLE "address_book_peer_tags" ( + "peerGuid" varchar NOT NULL, + "tagGuid" varchar NOT NULL, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY ("peerGuid", "tagGuid"), + CONSTRAINT "FK_1fa1cb6f8a11c6688a83c5da0fa" FOREIGN KEY ("peerGuid") REFERENCES "address_book_peers" ("guid") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "FK_a55915fc46903ac9da334ac14ed" FOREIGN KEY ("tagGuid") REFERENCES "address_book_tags" ("guid") ON DELETE NO ACTION ON UPDATE NO ACTION + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_1fa1cb6f8a11c6688a83c5da0f" ON "address_book_peer_tags" ("peerGuid")`); + await queryRunner.query(`CREATE INDEX "IDX_a55915fc46903ac9da334ac14e" ON "address_book_peer_tags" ("tagGuid")`); + + await queryRunner.query(` + CREATE TABLE "device_groups" ( + "guid" varchar PRIMARY KEY NOT NULL, + "name" varchar NOT NULL, + "note" text, + "strategyGuid" varchar, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), + CONSTRAINT "UQ_84d2dfcb096662dfe895555e13a" UNIQUE ("name"), + CONSTRAINT "FK_236b551f94f22691a7bee4c97b3" FOREIGN KEY ("strategyGuid") REFERENCES "strategies" ("guid") ON DELETE SET NULL ON UPDATE NO ACTION + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_84d2dfcb096662dfe895555e13" ON "device_groups" ("name")`); + await queryRunner.query(`CREATE INDEX "IDX_236b551f94f22691a7bee4c97b" ON "device_groups" ("strategyGuid")`); + + await queryRunner.query(` + CREATE TABLE "device_group_user_permissions" ( + "deviceGroupGuid" varchar NOT NULL, + "userGuid" varchar NOT NULL, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY ("deviceGroupGuid", "userGuid"), + CONSTRAINT "FK_3e77483646900525f873826fb8d" FOREIGN KEY ("deviceGroupGuid") REFERENCES "device_groups" ("guid") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_c9b546610bbe3dd19fc522a1386" FOREIGN KEY ("userGuid") REFERENCES "users" ("guid") ON DELETE CASCADE ON UPDATE NO ACTION + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_3e77483646900525f873826fb8" ON "device_group_user_permissions" ("deviceGroupGuid")`); + await queryRunner.query(`CREATE INDEX "IDX_c9b546610bbe3dd19fc522a138" ON "device_group_user_permissions" ("userGuid")`); + + await queryRunner.query(` + CREATE TABLE "user_user_permissions" ( + "userGuid" varchar NOT NULL, + "targetUserGuid" varchar NOT NULL, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY ("userGuid", "targetUserGuid"), + CONSTRAINT "FK_1ab78c399f60b2a7e58aa8707fc" FOREIGN KEY ("userGuid") REFERENCES "users" ("guid") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_8aa3b35e740139ee2abb538cfc0" FOREIGN KEY ("targetUserGuid") REFERENCES "users" ("guid") ON DELETE CASCADE ON UPDATE NO ACTION + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_1ab78c399f60b2a7e58aa8707f" ON "user_user_permissions" ("userGuid")`); + await queryRunner.query(`CREATE INDEX "IDX_8aa3b35e740139ee2abb538cfc" ON "user_user_permissions" ("targetUserGuid")`); + + await queryRunner.query(` + CREATE TABLE "login_sessions" ( + "guid" varchar PRIMARY KEY NOT NULL, + "userGuid" varchar NOT NULL, + "method" varchar NOT NULL DEFAULT ('email'), + "email" varchar, + "code" varchar, + "expiresAt" datetime NOT NULL, + "used" boolean NOT NULL DEFAULT (0), + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')) + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_a5b1783954d94b336a89a2f40d" ON "login_sessions" ("guid")`); + await queryRunner.query(`CREATE INDEX "IDX_cbaf941cb752c16ae3c3137b71" ON "login_sessions" ("userGuid")`); + + await queryRunner.query(` + CREATE TABLE "passkey_credentials" ( + "guid" varchar PRIMARY KEY NOT NULL, + "userGuid" varchar NOT NULL, + "credentialId" varchar NOT NULL, + "credentialPublicKey" varchar NOT NULL, + "counter" integer NOT NULL DEFAULT (0), + "transports" varchar, + "deviceType" varchar, + "backedUp" boolean NOT NULL DEFAULT (0), + "name" varchar, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), + CONSTRAINT "UQ_e5df58f68fe430aa62c9c2747a0" UNIQUE ("credentialId") + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_d36839bce7302352eadea2e783" ON "passkey_credentials" ("userGuid")`); + + await queryRunner.query(` + CREATE TABLE "active_connections" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "connId" integer NOT NULL, + "deviceUuid" varchar NOT NULL, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + CONSTRAINT "FK_adf75288c21bc3145d9f5f9e6eb" FOREIGN KEY ("deviceUuid") REFERENCES "peers" ("uuid") ON DELETE CASCADE ON UPDATE NO ACTION + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_c0dea39c6f7cf528fe1d8c352f" ON "active_connections" ("connId")`); + await queryRunner.query(`CREATE INDEX "IDX_adf75288c21bc3145d9f5f9e6e" ON "active_connections" ("deviceUuid")`); + + await queryRunner.query(` + CREATE TABLE "nexus_builds" ( + "uuid" varchar PRIMARY KEY NOT NULL, + "userGuid" varchar NOT NULL, + "os" varchar NOT NULL, + "arch" varchar NOT NULL, + "appName" varchar NOT NULL, + "custom" text, + "status" varchar NOT NULL DEFAULT ('pending'), + "files" text, + "message" varchar, + "createdAt" datetime NOT NULL DEFAULT (datetime('now')), + "updatedAt" datetime NOT NULL DEFAULT (datetime('now')) + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_8eed64bc9d10bf8956634c657a" ON "nexus_builds" ("uuid")`); + await queryRunner.query(`CREATE INDEX "IDX_404dc7952f0eb6448b6aa7e28b" ON "nexus_builds" ("userGuid")`); + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop tables in reverse dependency order + await queryRunner.query(`DROP INDEX "IDX_404dc7952f0eb6448b6aa7e28b"`); + await queryRunner.query(`DROP INDEX "IDX_8eed64bc9d10bf8956634c657a"`); + await queryRunner.query(`DROP TABLE "nexus_builds"`); + + await queryRunner.query(`DROP INDEX "IDX_adf75288c21bc3145d9f5f9e6e"`); + await queryRunner.query(`DROP INDEX "IDX_c0dea39c6f7cf528fe1d8c352f"`); + await queryRunner.query(`DROP TABLE "active_connections"`); + + await queryRunner.query(`DROP INDEX "IDX_d36839bce7302352eadea2e783"`); + await queryRunner.query(`DROP TABLE "passkey_credentials"`); + + await queryRunner.query(`DROP INDEX "IDX_cbaf941cb752c16ae3c3137b71"`); + await queryRunner.query(`DROP INDEX "IDX_a5b1783954d94b336a89a2f40d"`); + await queryRunner.query(`DROP TABLE "login_sessions"`); + + await queryRunner.query(`DROP INDEX "IDX_8aa3b35e740139ee2abb538cfc"`); + await queryRunner.query(`DROP INDEX "IDX_1ab78c399f60b2a7e58aa8707f"`); + await queryRunner.query(`DROP TABLE "user_user_permissions"`); + + await queryRunner.query(`DROP INDEX "IDX_c9b546610bbe3dd19fc522a138"`); + await queryRunner.query(`DROP INDEX "IDX_3e77483646900525f873826fb8"`); + await queryRunner.query(`DROP TABLE "device_group_user_permissions"`); + + await queryRunner.query(`DROP INDEX "IDX_236b551f94f22691a7bee4c97b"`); + await queryRunner.query(`DROP INDEX "IDX_84d2dfcb096662dfe895555e13"`); + await queryRunner.query(`DROP TABLE "device_groups"`); + + await queryRunner.query(`DROP INDEX "IDX_a55915fc46903ac9da334ac14e"`); + await queryRunner.query(`DROP INDEX "IDX_1fa1cb6f8a11c6688a83c5da0f"`); + await queryRunner.query(`DROP TABLE "address_book_peer_tags"`); + + await queryRunner.query(`DROP INDEX "IDX_c00ccc9f827bedcdcff573aac3"`); + await queryRunner.query(`DROP INDEX "IDX_0774a2f2e209686910e9bd13fa"`); + await queryRunner.query(`DROP TABLE "address_book_rules"`); + + await queryRunner.query(`DROP TABLE "address_book_peers"`); + + await queryRunner.query(`DROP TABLE "address_book_tags"`); + + await queryRunner.query(`DROP TABLE "invitations"`); + + await queryRunner.query(`DROP INDEX "IDX_cf8bff5dc33a46985bf6b2071e"`); + await queryRunner.query(`DROP INDEX "IDX_8d60c54ad272c1b5078f3cb86d"`); + await queryRunner.query(`DROP TABLE "user_tokens"`); + + await queryRunner.query(`DROP INDEX "IDX_ab7ab1b7f0c82372ab08064f11"`); + await queryRunner.query(`DROP INDEX "IDX_62b51ade0ce9c40e06c3465f87"`); + await queryRunner.query(`DROP INDEX "IDX_6069281bb078ce8bfe6000221e"`); + await queryRunner.query(`DROP INDEX "IDX_97672ac88f789774dd47f7c8be"`); + await queryRunner.query(`DROP INDEX "IDX_fe0bb3f6520ee0469504521e71"`); + await queryRunner.query(`DROP TABLE "users"`); + + await queryRunner.query(`DROP INDEX "IDX_e27f5e43da4701b83496fe359b"`); + await queryRunner.query(`DROP INDEX "IDX_359e577524ef710f1e16b6de2b"`); + await queryRunner.query(`DROP INDEX "IDX_ab6c529b67b0acf915add4322e"`); + await queryRunner.query(`DROP TABLE "peers"`); + + await queryRunner.query(`DROP INDEX "IDX_82203a4749fdde33a9060753e7"`); + await queryRunner.query(`DROP TABLE "nexus_tokens"`); + + await queryRunner.query(`DROP TABLE "address_books"`); + + await queryRunner.query(`DROP INDEX "IDX_797d199fff9037e5b231dc4ffb"`); + await queryRunner.query(`DROP INDEX "IDX_b1b5bc664526d375c94ce9ad43"`); + await queryRunner.query(`DROP TABLE "system_settings"`); + + await queryRunner.query(`DROP INDEX "IDX_15643f36cf99bd1046a760646c"`); + await queryRunner.query(`DROP INDEX "IDX_dff6ad917f82c6817539f49791"`); + await queryRunner.query(`DROP TABLE "oidc_auth_states"`); + + await queryRunner.query(`DROP INDEX "IDX_2399f67c42a46d6670b27338b4"`); + await queryRunner.query(`DROP TABLE "oidc_providers"`); + + await queryRunner.query(`DROP INDEX "UQ_user_groups_single_default"`); + await queryRunner.query(`DROP INDEX "IDX_b22a3ef69f790c9b03ffb803bf"`); + await queryRunner.query(`DROP TABLE "user_groups"`); + + await queryRunner.query(`DROP TABLE "alarm_audits"`); + await queryRunner.query(`DROP TABLE "file_audits"`); + await queryRunner.query(`DROP TABLE "connection_audits"`); + + await queryRunner.query(`DROP INDEX "IDX_c9ac805e6a43148f0647f543c2"`); + await queryRunner.query(`DROP TABLE "strategies"`); + + await queryRunner.query(`DROP TABLE "sysinfos"`); + } +} diff --git a/src/migrations/README.md b/src/migrations/README.md new file mode 100644 index 0000000..20619ad --- /dev/null +++ b/src/migrations/README.md @@ -0,0 +1,39 @@ +# Migrations + +This directory contains TypeORM migration files. + +## Usage + +```bash +# Generate a migration from entity changes +npm run migration:generate -- src/migrations/MigrationName + +# Run pending migrations +npm run migration:run + +# Revert the last migration +npm run migration:revert + +# Show migration status +npm run migration:show + +# Create an empty migration (for manual SQL) +npm run migration:create -- src/migrations/MigrationName +``` + +## SQLite Considerations + +SQLite's ALTER TABLE only supports: +- Adding columns (ADD COLUMN) +- Renaming tables (RENAME TABLE) +- Renaming columns (RENAME COLUMN, SQLite >= 3.25.0) + +**Not supported**: dropping columns, modifying column types/constraints. + +When dropping columns or modifying column attributes, use the "rebuild table" pattern: +1. CREATE TABLE new_table (...) +2. INSERT INTO new_table SELECT ... FROM old_table +3. DROP TABLE old_table +4. ALTER TABLE new_table RENAME TO old_table + +Always review generated migration SQL for SQLite compatibility before committing. diff --git a/src/modules/user/entities/invitation.entity.ts b/src/modules/user/entities/invitation.entity.ts index c152d66..294fdc3 100644 --- a/src/modules/user/entities/invitation.entity.ts +++ b/src/modules/user/entities/invitation.entity.ts @@ -52,7 +52,7 @@ export class Invitation { /** * 备注 */ - @Column({ nullable: true }) + @Column({ type: 'varchar', nullable: true }) note: string | null; /** diff --git a/tsconfig.json b/tsconfig.json index aba29b0..61c9b1a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,10 @@ { + "ts-node": { + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node" + } + }, "compilerOptions": { "module": "nodenext", "moduleResolution": "nodenext",