diff --git a/backend/package.json b/backend/package.json index 50d8f17..7420e38 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,7 +11,11 @@ "dev": "tsx watch src/server.ts", "build": "tsc -p tsconfig.json", "start": "node dist/server.js", - "test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs" + "db:migrate:plan": "npm run build && node dist/db/migrate-cli.js plan", + "db:migrate:up": "npm run build && node dist/db/migrate-cli.js up", + "db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify", + "db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", + "test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-read-repository.test.mjs" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/db/legacy-read-repository.ts b/backend/src/db/legacy-read-repository.ts new file mode 100644 index 0000000..f8fd104 --- /dev/null +++ b/backend/src/db/legacy-read-repository.ts @@ -0,0 +1,145 @@ +import type { MySqlPool } from './mysql.js'; + +type LegacyEntity = 'stores' | 'rooms' | 'orders' | 'devices'; + +export interface LegacyTableMapping { + table: string; + idColumn: string; + tenantColumn: string; + parentColumn?: string; + nameColumn?: string; + statusColumn?: string; + codeColumn?: string; + startColumn?: string; + endColumn?: string; +} + +export interface LegacyReadOptions { + tenantId: number; + parentId?: number; + limit?: number; +} + +export interface LegacyRecord { + legacyId: string; + tenantId: string; + parentId: string | null; + name: string | null; + code: string | null; + status: string | null; + startAt: Date | string | null; + endAt: Date | string | null; +} + +export const defaultLegacyMappings: Record = { + stores: { + table: 'member_store_info', + idColumn: 'id', + tenantColumn: 'tenant_id', + nameColumn: 'store_name', + statusColumn: 'status' + }, + rooms: { + table: 'member_room_info', + idColumn: 'id', + tenantColumn: 'tenant_id', + parentColumn: 'store_id', + nameColumn: 'room_name', + codeColumn: 'room_no', + statusColumn: 'status' + }, + orders: { + table: 'member_order_info', + idColumn: 'id', + tenantColumn: 'tenant_id', + parentColumn: 'room_id', + codeColumn: 'order_no', + statusColumn: 'status', + startColumn: 'start_time', + endColumn: 'end_time' + }, + devices: { + table: 'member_device_info', + idColumn: 'id', + tenantColumn: 'tenant_id', + parentColumn: 'room_id', + nameColumn: 'device_name', + codeColumn: 'device_id', + statusColumn: 'status' + } +}; + +const identifierPattern = /^[A-Za-z][A-Za-z0-9_]*$/; + +function quoteIdentifier(identifier: string): string { + if (!identifierPattern.test(identifier)) { + throw new Error(`Unsafe legacy SQL identifier: ${identifier}`); + } + return `\`${identifier}\``; +} + +function selectedColumn(column: string | undefined, alias: string): string { + return column ? `${quoteIdentifier(column)} AS ${quoteIdentifier(alias)}` : `NULL AS ${quoteIdentifier(alias)}`; +} + +function normalizeLimit(limit = 100): number { + if (!Number.isInteger(limit) || limit < 1 || limit > 500) { + throw new Error('Legacy read limit must be an integer between 1 and 500.'); + } + return limit; +} + +export class LegacyReadRepository { + constructor( + private readonly pool: Pick, + private readonly mappings: Record = defaultLegacyMappings + ) {} + + listStores(options: LegacyReadOptions): Promise { + return this.list('stores', options); + } + + listRooms(options: LegacyReadOptions): Promise { + return this.list('rooms', options); + } + + listOrders(options: LegacyReadOptions): Promise { + return this.list('orders', options); + } + + listDevices(options: LegacyReadOptions): Promise { + return this.list('devices', options); + } + + private async list(entity: LegacyEntity, options: LegacyReadOptions): Promise { + const mapping = this.mappings[entity]; + const limit = normalizeLimit(options.limit); + const clauses = [`${quoteIdentifier(mapping.tenantColumn)} = ?`]; + const parameters: Array = [options.tenantId]; + + if (mapping.parentColumn && options.parentId !== undefined) { + clauses.push(`${quoteIdentifier(mapping.parentColumn)} = ?`); + parameters.push(options.parentId); + } + + parameters.push(limit); + const sql = [ + 'SELECT', + `${quoteIdentifier(mapping.idColumn)} AS ${quoteIdentifier('legacyId')},`, + `${quoteIdentifier(mapping.tenantColumn)} AS ${quoteIdentifier('tenantId')},`, + `${selectedColumn(mapping.parentColumn, 'parentId')},`, + `${selectedColumn(mapping.nameColumn, 'name')},`, + `${selectedColumn(mapping.codeColumn, 'code')},`, + `${selectedColumn(mapping.statusColumn, 'status')},`, + `${selectedColumn(mapping.startColumn, 'startAt')},`, + selectedColumn(mapping.endColumn, 'endAt'), + `FROM ${quoteIdentifier(mapping.table)}`, + `WHERE ${clauses.join(' AND ')}`, + `ORDER BY ${quoteIdentifier(mapping.idColumn)} ASC`, + 'LIMIT ?' + ].join(' '); + + const [rows] = await this.pool.query(sql, parameters); + return rows as LegacyRecord[]; + } +} diff --git a/backend/src/db/migrate-cli.ts b/backend/src/db/migrate-cli.ts new file mode 100644 index 0000000..c9e7d22 --- /dev/null +++ b/backend/src/db/migrate-cli.ts @@ -0,0 +1,49 @@ +import { loadConfig } from '../config.js'; +import { closeMySqlPool, createMySqlPool } from './mysql.js'; +import { + executeMigrationPlan, + loadMigrationPlan, + type MigrationDirection +} from './migration-runner.js'; + +const command = process.argv[2] ?? 'plan'; +const validCommands = new Set(['plan', 'up', 'verify', 'down']); + +if (!validCommands.has(command)) { + console.error('Usage: migrate-cli.js '); + process.exitCode = 2; +} else { + const direction: MigrationDirection = command === 'plan' ? 'up' : command as MigrationDirection; + const plan = await loadMigrationPlan(direction); + + if (command === 'plan') { + console.log(JSON.stringify({ + mode: 'dry-run', + direction: plan.direction, + file: plan.file, + checksum: plan.checksum, + statementCount: plan.statements.length + }, null, 2)); + } else { + const config = loadConfig(); + if (!config.mysql.passwordConfigured) { + console.error('QIPAI_MYSQL_PASSWORD is required for live migration commands.'); + process.exitCode = 2; + } else { + const pool = createMySqlPool(config); + try { + const result = await executeMigrationPlan(pool, plan); + console.log(JSON.stringify({ + mode: 'live', + direction: result.direction, + file: result.file, + checksum: result.checksum, + statementCount: result.statements.length, + affectedRows: result.affectedRows + }, null, 2)); + } finally { + await closeMySqlPool(pool); + } + } + } +} diff --git a/backend/src/db/migration-runner.ts b/backend/src/db/migration-runner.ts new file mode 100644 index 0000000..81919fb --- /dev/null +++ b/backend/src/db/migration-runner.ts @@ -0,0 +1,153 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { MySqlPool } from './mysql.js'; + +export type MigrationDirection = 'up' | 'verify' | 'down'; + +export interface MigrationPlan { + direction: MigrationDirection; + file: string; + checksum: string; + statements: readonly string[]; +} + +export interface MigrationExecutionResult extends MigrationPlan { + executed: boolean; + affectedRows: number; +} + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const migrationFiles: Record = { + up: 'database/migrations/2026061601_m01b_core_schema.up.sql', + verify: 'database/migrations/2026061601_m01b_core_schema.verify.sql', + down: 'database/migrations/2026061601_m01b_core_schema.down.sql' +}; + +export function splitSqlStatements(sql: string): string[] { + const statements: string[] = []; + let current = ''; + let quote: "'" | '"' | '`' | null = null; + let escaped = false; + let lineComment = false; + let blockComment = false; + + for (let index = 0; index < sql.length; index += 1) { + const character = sql[index]; + const next = sql[index + 1]; + + if (lineComment) { + if (character === '\n') { + lineComment = false; + current += character; + } + continue; + } + + if (blockComment) { + if (character === '*' && next === '/') { + blockComment = false; + index += 1; + } + continue; + } + + if (!quote && character === '-' && next === '-' && (index === 0 || /\s/.test(sql[index - 1]))) { + lineComment = true; + index += 1; + continue; + } + + if (!quote && character === '/' && next === '*') { + blockComment = true; + index += 1; + continue; + } + + current += character; + + if (quote) { + if (escaped) { + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === quote) { + if (next === quote) { + current += next; + index += 1; + } else { + quote = null; + } + } + continue; + } + + if (character === "'" || character === '"' || character === '`') { + quote = character; + continue; + } + + if (character === ';') { + const statement = current.slice(0, -1).trim(); + if (statement) { + statements.push(statement); + } + current = ''; + } + } + + const trailing = current.trim(); + if (trailing) { + statements.push(trailing); + } + + if (quote || blockComment) { + throw new Error('Migration SQL contains an unterminated quote or comment.'); + } + + return statements; +} + +export async function loadMigrationPlan(direction: MigrationDirection): Promise { + const relativeFile = migrationFiles[direction]; + const sql = await readFile(resolve(repoRoot, relativeFile), 'utf8'); + + return { + direction, + file: relativeFile, + checksum: createHash('sha256').update(sql).digest('hex'), + statements: splitSqlStatements(sql) + }; +} + +export async function executeMigrationPlan( + pool: Pick, + plan: MigrationPlan, + dryRun = false +): Promise { + if (dryRun) { + return { ...plan, executed: false, affectedRows: 0 }; + } + + let affectedRows = 0; + for (const [index, statement] of plan.statements.entries()) { + const [result] = await pool.query(statement); + if (plan.direction === 'verify') { + const minimumRows = [10, 26, 1][index] ?? 1; + if (!Array.isArray(result) || result.length < minimumRows) { + throw new Error( + `Migration verification statement ${index + 1} returned fewer than ${minimumRows} rows.` + ); + } + } + if (result && typeof result === 'object' && 'affectedRows' in result) { + const value = Reflect.get(result, 'affectedRows'); + if (typeof value === 'number') { + affectedRows += value; + } + } + } + + return { ...plan, executed: true, affectedRows }; +} diff --git a/backend/src/db/mysql.ts b/backend/src/db/mysql.ts index d71c052..809d561 100644 --- a/backend/src/db/mysql.ts +++ b/backend/src/db/mysql.ts @@ -23,3 +23,7 @@ export function toPoolOptions(config: AppConfig): PoolOptions { dateStrings: false }; } + +export async function closeMySqlPool(pool: MySqlPool): Promise { + await pool.end(); +} diff --git a/backend/tests/legacy-read-repository.test.mjs b/backend/tests/legacy-read-repository.test.mjs new file mode 100644 index 0000000..69c034d --- /dev/null +++ b/backend/tests/legacy-read-repository.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { LegacyReadRepository } from '../dist/db/legacy-read-repository.js'; + +const calls = []; +const fakePool = { + async query(sql, parameters) { + calls.push({ sql, parameters }); + return [[{ + legacyId: '7', + tenantId: '2', + parentId: '3', + name: 'Room A', + code: 'A01', + status: 'OPEN', + startAt: null, + endAt: null + }], []]; + } +}; + +const repository = new LegacyReadRepository(fakePool); +const rooms = await repository.listRooms({ tenantId: 2, parentId: 3, limit: 25 }); + +assert.equal(rooms.length, 1); +assert.equal(calls.length, 1); +assert.match(calls[0].sql, /FROM `member_room_info`/); +assert.match(calls[0].sql, /`tenant_id` = \?/); +assert.match(calls[0].sql, /`store_id` = \?/); +assert.match(calls[0].sql, /ORDER BY `id` ASC LIMIT \?/); +assert.deepEqual(calls[0].parameters, [2, 3, 25]); +assert.doesNotMatch(calls[0].sql, /\b(?:INSERT|UPDATE|DELETE|REPLACE)\b/i); + +await assert.rejects( + () => repository.listStores({ tenantId: 2, limit: 501 }), + /between 1 and 500/ +); + +const unsafeRepository = new LegacyReadRepository(fakePool, { + stores: { + table: 'member_store_info; DROP TABLE users', + idColumn: 'id', + tenantColumn: 'tenant_id' + }, + rooms: { + table: 'member_room_info', + idColumn: 'id', + tenantColumn: 'tenant_id' + }, + orders: { + table: 'member_order_info', + idColumn: 'id', + tenantColumn: 'tenant_id' + }, + devices: { + table: 'member_device_info', + idColumn: 'id', + tenantColumn: 'tenant_id' + } +}); + +await assert.rejects( + () => unsafeRepository.listStores({ tenantId: 2 }), + /Unsafe legacy SQL identifier/ +); + +console.log('PASS: legacy read-only repository contracts are present.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs new file mode 100644 index 0000000..1433ab7 --- /dev/null +++ b/backend/tests/migration-runner.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { + executeMigrationPlan, + loadMigrationPlan, + splitSqlStatements +} from '../dist/db/migration-runner.js'; + +assert.deepEqual( + splitSqlStatements("SELECT 'a;b'; -- comment\nSELECT `semi;colon`;"), + ["SELECT 'a;b'", 'SELECT `semi;colon`'] +); + +const plan = await loadMigrationPlan('up'); +assert.equal(plan.direction, 'up'); +assert.match(plan.file, /2026061601_m01b_core_schema\.up\.sql$/); +assert.match(plan.checksum, /^[a-f0-9]{64}$/); +assert.ok(plan.statements.length >= 11); + +const calls = []; +const fakePool = { + async query(sql) { + calls.push(sql); + return [{ affectedRows: 1 }, []]; + } +}; + +const dryRun = await executeMigrationPlan(fakePool, plan, true); +assert.equal(dryRun.executed, false); +assert.equal(calls.length, 0); + +const liveResult = await executeMigrationPlan(fakePool, { + direction: 'up', + file: 'test.sql', + checksum: 'test', + statements: ['SELECT 1', 'SELECT 2'] +}); +assert.equal(liveResult.executed, true); +assert.equal(liveResult.affectedRows, 2); +assert.deepEqual(calls, ['SELECT 1', 'SELECT 2']); + +await assert.rejects( + () => executeMigrationPlan({ + async query() { + return [[], []]; + } + }, { + direction: 'verify', + file: 'verify.sql', + checksum: 'test', + statements: ['SELECT table_name'] + }), + /returned fewer than 10 rows/ +); + +console.log('PASS: migration runner plan, parser and execution contracts are present.'); diff --git a/scripts/dev/windows/check-backend.ps1 b/scripts/dev/windows/check-backend.ps1 index b520c1e..b6505f3 100644 --- a/scripts/dev/windows/check-backend.ps1 +++ b/scripts/dev/windows/check-backend.ps1 @@ -8,11 +8,16 @@ $requiredFiles = @( "backend/src/app.ts", "backend/src/config.ts", "backend/src/db/mysql.ts", + "backend/src/db/migration-runner.ts", + "backend/src/db/migrate-cli.ts", + "backend/src/db/legacy-read-repository.ts", "backend/src/routes/health.ts", "backend/src/server.ts", "backend/tests/backend-contract.test.mjs", "backend/tests/migration-contract.test.mjs", "backend/tests/mysql-pool-contract.test.mjs", + "backend/tests/migration-runner.test.mjs", + "backend/tests/legacy-read-repository.test.mjs", "database/migrations/2026061601_m01b_core_schema.up.sql", "database/migrations/2026061601_m01b_core_schema.down.sql", "database/migrations/2026061601_m01b_core_schema.verify.sql", @@ -36,6 +41,11 @@ if ($LASTEXITCODE -ne 0) { throw "backend build failed" } +& npm --prefix backend run db:migrate:plan +if ($LASTEXITCODE -ne 0) { + throw "backend migration dry-run failed" +} + $oldHost = $env:QIPAI_API_HOST $oldPort = $env:QIPAI_API_PORT $oldVersion = $env:QIPAI_API_VERSION