1
0
forked from boos/BoosAPI

Merge pull request #86 from cita-777/ci-parity-speedup

ci: speed up PR checks and isolate schema parity
This commit is contained in:
Cita
2026-03-15 15:29:33 +08:00
committed by GitHub
10 changed files with 457 additions and 66 deletions
+47
View File
@@ -0,0 +1,47 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
time: "03:00"
timezone: Asia/Shanghai
open-pull-requests-limit: 10
groups:
minor-and-patch:
patterns:
- "*"
update-types:
- minor
- patch
dev-dependencies:
dependency-type: development
patterns:
- "*"
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
time: "03:00"
timezone: Asia/Shanghai
open-pull-requests-limit: 5
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: docker
directory: /docker
schedule:
interval: weekly
day: monday
time: "03:00"
timezone: Asia/Shanghai
open-pull-requests-limit: 5
groups:
docker:
patterns:
- "*"
+268 -11
View File
@@ -3,11 +3,54 @@ name: CI
on: on:
push: push:
branches: [main, master] branches: [main, master]
tags: ['v*'] paths-ignore:
- 'docs/**'
- 'README.md'
- 'README_EN.md'
- 'CONTRIBUTING.md'
- 'SECURITY.md'
- 'CODE_OF_CONDUCT.md'
pull_request: pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
NODE_VERSION: 22
jobs: jobs:
test-and-build: changes:
name: Detect Docs Changes
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
outputs:
docs: ${{ steps.filter.outputs.docs }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Detect docs-related changes
id: filter
uses: dorny/paths-filter@v3
with:
filters: |
docs:
- 'docs/**'
- 'README.md'
- 'README_EN.md'
- 'CONTRIBUTING.md'
- 'SECURITY.md'
- 'CODE_OF_CONDUCT.md'
- '.github/workflows/docs-pages.yml'
test-core:
name: Test Core
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
@@ -18,26 +61,242 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: ${{ env.NODE_VERSION }}
cache: npm cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci --prefer-offline --no-audit --no-fund
- name: Run tests - name: Run tests
env:
DB_PARITY_SKIP_LIVE_SCHEMA: 'true'
run: npm test run: npm test
- name: Build build-web:
run: npm run build name: Build Web
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: npm ci --prefer-offline --no-audit --no-fund
- name: Build web
run: npm run build:web
build-server:
name: Build Server
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: npm ci --prefer-offline --no-audit --no-fund
- name: Build server
run: npm run build:server
build-desktop:
name: Build Desktop
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: npm ci --prefer-offline --no-audit --no-fund
- name: Build desktop
run: npm run build:desktop
schema-sqlite:
name: Schema Check (SQLite)
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: npm ci --prefer-offline --no-audit --no-fund
- name: Run sqlite schema parity
run: npm run test:schema:parity
- name: Run sqlite schema upgrade
run: npm run test:schema:upgrade
schema-mysql:
name: Schema Check (MySQL)
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 20
services:
mysql:
image: mysql:8.4
env:
MYSQL_DATABASE: metapi
MYSQL_ROOT_PASSWORD: root
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -proot"
--health-interval=10s
--health-timeout=5s
--health-retries=10
env:
DB_PARITY_MYSQL_URL: mysql://root:root@127.0.0.1:3306/metapi
DB_PARITY_SQLITE: 'false'
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: npm ci --prefer-offline --no-audit --no-fund
- name: Run mysql schema parity
run: npm run test:schema:parity
- name: Run mysql schema upgrade
run: npm run test:schema:upgrade
schema-postgres:
name: Schema Check (Postgres)
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: metapi
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U postgres -d metapi"
--health-interval=10s
--health-timeout=5s
--health-retries=10
env:
DB_PARITY_POSTGRES_URL: postgres://postgres:postgres@127.0.0.1:5432/metapi
DB_PARITY_SQLITE: 'false'
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: npm ci --prefer-offline --no-audit --no-fund
- name: Run postgres schema parity
run: npm run test:schema:parity
- name: Run postgres schema upgrade
run: npm run test:schema:upgrade
docs-build:
name: Build Docs
if: github.event_name == 'pull_request' && needs.changes.outputs.docs == 'true'
needs: changes
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: npm ci --prefer-offline --no-audit --no-fund
- name: Build docs
run: npm run docs:build
audit:
name: Audit Production Dependencies
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10
# Keep PR audit informational so build/test/schema remain the merge gate.
continue-on-error: true
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: npm ci --prefer-offline --no-audit --no-fund
- name: Audit production dependencies - name: Audit production dependencies
run: npm audit --omit=dev --audit-level=high run: npm audit --omit=dev --audit-level=high
publish-docker-arch: publish-docker-arch:
name: Publish Docker Image (${{ matrix.arch }}) name: Publish Docker Image (${{ matrix.arch }})
if: github.event_name == 'push'
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
needs: test-and-build
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
timeout-minutes: 45 timeout-minutes: 45
strategy: strategy:
fail-fast: false fail-fast: false
@@ -78,7 +337,6 @@ jobs:
tags: | tags: |
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch type=ref,event=branch
type=ref,event=tag
type=sha,format=short type=sha,format=short
- name: Add architecture suffix to tags - name: Add architecture suffix to tags
@@ -111,9 +369,9 @@ jobs:
publish-docker: publish-docker:
name: Publish Docker Manifest name: Publish Docker Manifest
if: github.event_name == 'push'
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: publish-docker-arch needs: publish-docker-arch
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
@@ -144,7 +402,6 @@ jobs:
tags: | tags: |
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch type=ref,event=branch
type=ref,event=tag
type=sha,format=short type=sha,format=short
- name: Create multi-arch manifests - name: Create multi-arch manifests
+6 -1
View File
@@ -3,6 +3,11 @@ name: Docs Pages
on: on:
push: push:
branches: [main] branches: [main]
paths:
- 'docs/**'
- '.github/workflows/docs-pages.yml'
- 'package.json'
- 'package-lock.json'
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -11,7 +16,7 @@ permissions:
id-token: write id-token: write
concurrency: concurrency:
group: docs-pages group: docs-pages-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
+12 -12
View File
@@ -25,23 +25,23 @@ CREATE INDEX `account_tokens_enabled_idx` ON `account_tokens` (`enabled`);
CREATE INDEX `accounts_site_id_idx` ON `accounts` (`site_id`); CREATE INDEX `accounts_site_id_idx` ON `accounts` (`site_id`);
CREATE INDEX `accounts_site_status_idx` ON `accounts` (`site_id`, `status`(191)); CREATE INDEX `accounts_site_status_idx` ON `accounts` (`site_id`, `status`(191));
CREATE INDEX `accounts_status_idx` ON `accounts` (`status`(191)); CREATE INDEX `accounts_status_idx` ON `accounts` (`status`(191));
CREATE INDEX `checkin_logs_account_created_at_idx` ON `checkin_logs` (`account_id`, `created_at`(191)); CREATE INDEX `checkin_logs_account_created_at_idx` ON `checkin_logs` (`account_id`, `created_at`);
CREATE INDEX `checkin_logs_created_at_idx` ON `checkin_logs` (`created_at`(191)); CREATE INDEX `checkin_logs_created_at_idx` ON `checkin_logs` (`created_at`);
CREATE INDEX `checkin_logs_status_idx` ON `checkin_logs` (`status`(191)); CREATE INDEX `checkin_logs_status_idx` ON `checkin_logs` (`status`(191));
CREATE INDEX `downstream_api_keys_enabled_idx` ON `downstream_api_keys` (`enabled`); CREATE INDEX `downstream_api_keys_enabled_idx` ON `downstream_api_keys` (`enabled`);
CREATE INDEX `downstream_api_keys_expires_at_idx` ON `downstream_api_keys` (`expires_at`(191)); CREATE INDEX `downstream_api_keys_expires_at_idx` ON `downstream_api_keys` (`expires_at`);
CREATE INDEX `downstream_api_keys_name_idx` ON `downstream_api_keys` (`name`(191)); CREATE INDEX `downstream_api_keys_name_idx` ON `downstream_api_keys` (`name`(191));
CREATE INDEX `events_created_at_idx` ON `events` (`created_at`(191)); CREATE INDEX `events_created_at_idx` ON `events` (`created_at`);
CREATE INDEX `events_read_created_at_idx` ON `events` (`read`, `created_at`(191)); CREATE INDEX `events_read_created_at_idx` ON `events` (`read`, `created_at`);
CREATE INDEX `events_type_created_at_idx` ON `events` (`type`(191), `created_at`(191)); CREATE INDEX `events_type_created_at_idx` ON `events` (`type`(191), `created_at`);
CREATE INDEX `model_availability_account_available_idx` ON `model_availability` (`account_id`, `available`); CREATE INDEX `model_availability_account_available_idx` ON `model_availability` (`account_id`, `available`);
CREATE INDEX `model_availability_model_name_idx` ON `model_availability` (`model_name`(191)); CREATE INDEX `model_availability_model_name_idx` ON `model_availability` (`model_name`(191));
CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`(191)); CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`);
CREATE INDEX `proxy_logs_account_created_at_idx` ON `proxy_logs` (`account_id`, `created_at`(191)); CREATE INDEX `proxy_logs_account_created_at_idx` ON `proxy_logs` (`account_id`, `created_at`);
CREATE INDEX `proxy_logs_created_at_idx` ON `proxy_logs` (`created_at`(191)); CREATE INDEX `proxy_logs_created_at_idx` ON `proxy_logs` (`created_at`);
CREATE INDEX `proxy_logs_model_actual_created_at_idx` ON `proxy_logs` (`model_actual`(191), `created_at`(191)); CREATE INDEX `proxy_logs_model_actual_created_at_idx` ON `proxy_logs` (`model_actual`(191), `created_at`);
CREATE INDEX `proxy_logs_status_created_at_idx` ON `proxy_logs` (`status`(191), `created_at`(191)); CREATE INDEX `proxy_logs_status_created_at_idx` ON `proxy_logs` (`status`(191), `created_at`);
CREATE INDEX `proxy_video_tasks_created_at_idx` ON `proxy_video_tasks` (`created_at`(191)); CREATE INDEX `proxy_video_tasks_created_at_idx` ON `proxy_video_tasks` (`created_at`);
CREATE INDEX `proxy_video_tasks_upstream_video_id_idx` ON `proxy_video_tasks` (`upstream_video_id`(191)); CREATE INDEX `proxy_video_tasks_upstream_video_id_idx` ON `proxy_video_tasks` (`upstream_video_id`(191));
CREATE INDEX `route_channels_account_id_idx` ON `route_channels` (`account_id`); CREATE INDEX `route_channels_account_id_idx` ON `route_channels` (`account_id`);
CREATE INDEX `route_channels_route_enabled_idx` ON `route_channels` (`route_id`, `enabled`); CREATE INDEX `route_channels_route_enabled_idx` ON `route_channels` (`route_id`, `enabled`);
@@ -114,6 +114,15 @@ describe('schema artifact generator', () => {
expect(artifacts.mysqlBootstrap).not.toContain('CREATE TABLE IF NOT EXISTS `settings` (`key` TEXT NOT NULL PRIMARY KEY, `value` TEXT)'); expect(artifacts.mysqlBootstrap).not.toContain('CREATE TABLE IF NOT EXISTS `settings` (`key` TEXT NOT NULL PRIMARY KEY, `value` TEXT)');
}); });
it('does not add mysql text prefixes to non-text index columns', () => {
const artifacts = generateDialectArtifacts(readSchemaContract());
expect(artifacts.mysqlBootstrap).toContain('CREATE INDEX `checkin_logs_account_created_at_idx` ON `checkin_logs` (`account_id`, `created_at`)');
expect(artifacts.mysqlBootstrap).toContain('CREATE INDEX `events_read_created_at_idx` ON `events` (`read`, `created_at`)');
expect(artifacts.mysqlBootstrap).not.toContain('`created_at`(191)');
expect(artifacts.mysqlBootstrap).not.toContain('`read`(191)');
});
it('rejects destructive diffs when generating additive upgrades', () => { it('rejects destructive diffs when generating additive upgrades', () => {
const current = readSchemaContract(); const current = readSchemaContract();
const previous = structuredClone(current); const previous = structuredClone(current);
+1 -1
View File
@@ -33,7 +33,7 @@ function quoteIdentifier(dialect: SqlDialect, identifier: string): string {
} }
function escapeMysqlTextPrefix(columnType: LogicalColumnType): string { function escapeMysqlTextPrefix(columnType: LogicalColumnType): string {
return columnType === 'text' || columnType === 'datetime' || columnType === 'json' ? '(191)' : ''; return columnType === 'text' ? '(191)' : '';
} }
function mapColumnType(dialect: SqlDialect, columnName: string, column: SchemaContractColumn): string { function mapColumnType(dialect: SqlDialect, columnName: string, column: SchemaContractColumn): string {
+7 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { normalizeDefaultValue, normalizeSqlType } from './schemaIntrospection.js'; import { normalizeDefaultValue, normalizeSqlType, readMySqlField } from './schemaIntrospection.js';
describe('schema introspection normalization', () => { describe('schema introspection normalization', () => {
it('normalizes booleans consistently across dialects', () => { it('normalizes booleans consistently across dialects', () => {
@@ -13,4 +13,10 @@ describe('schema introspection normalization', () => {
expect(normalizeDefaultValue('DEFAULT FALSE')).toBe('false'); expect(normalizeDefaultValue('DEFAULT FALSE')).toBe('false');
expect(normalizeDefaultValue("datetime('now')")).toBe("datetime('now')"); expect(normalizeDefaultValue("datetime('now')")).toBe("datetime('now')");
}); });
it('reads mysql information_schema fields regardless of casing', () => {
expect(readMySqlField({ COLUMN_TYPE: 'varchar(191)' }, 'column_type')).toBe('varchar(191)');
expect(readMySqlField({ column_type: 'text' }, 'column_type')).toBe('text');
expect(readMySqlField({ Table_Name: 'settings' }, 'table_name')).toBe('settings');
});
}); });
+101 -38
View File
@@ -122,6 +122,20 @@ type PostgresForeignKeyRow = {
delete_rule: string | null; delete_rule: string | null;
}; };
export function readMySqlField<T>(row: Record<string, unknown>, field: string): T | undefined {
const exact = row[field];
if (exact !== undefined) return exact as T;
const upper = row[field.toUpperCase()];
if (upper !== undefined) return upper as T;
const lower = row[field.toLowerCase()];
if (lower !== undefined) return lower as T;
const matchedKey = Object.keys(row).find((key) => key.toLowerCase() === field.toLowerCase());
return matchedKey ? row[matchedKey] as T : undefined;
}
function splitMigrationStatements(sqlText: string): string[] { function splitMigrationStatements(sqlText: string): string[] {
return sqlText return sqlText
.split('--> statement-breakpoint') .split('--> statement-breakpoint')
@@ -452,16 +466,20 @@ async function introspectMySqlSchema(input: SchemaIntrospectionInput): Promise<S
try { try {
const [tableRows] = await connection.query(` const [tableRows] = await connection.query(`
SELECT table_name SELECT table_name AS table_name
FROM information_schema.tables FROM information_schema.tables
WHERE table_schema = DATABASE() WHERE table_schema = DATABASE()
AND table_type = 'BASE TABLE' AND table_type = 'BASE TABLE'
ORDER BY table_name ASC ORDER BY table_name ASC
`); `);
const tableNames = (tableRows as Array<{ table_name: string }>).map((row) => row.table_name); const tableNames = (tableRows as Array<Record<string, unknown>>)
.map((row) => readMySqlField<string>(row, 'table_name'))
.filter((tableName): tableName is string => typeof tableName === 'string');
const [primaryKeyRows] = await connection.query(` const [primaryKeyRows] = await connection.query(`
SELECT kcu.table_name, kcu.column_name SELECT
kcu.table_name AS table_name,
kcu.column_name AS column_name
FROM information_schema.table_constraints tc FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu JOIN information_schema.key_column_usage kcu
ON tc.constraint_schema = kcu.constraint_schema ON tc.constraint_schema = kcu.constraint_schema
@@ -470,10 +488,20 @@ async function introspectMySqlSchema(input: SchemaIntrospectionInput): Promise<S
WHERE tc.table_schema = DATABASE() WHERE tc.table_schema = DATABASE()
AND tc.constraint_type = 'PRIMARY KEY' AND tc.constraint_type = 'PRIMARY KEY'
`); `);
const primaryKeys = new Set((primaryKeyRows as MySqlPrimaryKeyRow[]).map((row) => `${row.table_name}.${row.column_name}`)); const primaryKeys = new Set((primaryKeyRows as Array<Record<string, unknown>>).map((row) => {
const tableName = readMySqlField<string>(row, 'table_name');
const columnName = readMySqlField<string>(row, 'column_name');
return tableName && columnName ? `${tableName}.${columnName}` : null;
}).filter((value): value is string => value !== null));
const [columnRows] = await connection.query(` const [columnRows] = await connection.query(`
SELECT table_name, column_name, data_type, column_type, is_nullable, column_default SELECT
table_name AS table_name,
column_name AS column_name,
data_type AS data_type,
column_type AS column_type,
is_nullable AS is_nullable,
column_default AS column_default
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = DATABASE() WHERE table_schema = DATABASE()
ORDER BY table_name ASC, ordinal_position ASC ORDER BY table_name ASC, ordinal_position ASC
@@ -484,40 +512,61 @@ async function introspectMySqlSchema(input: SchemaIntrospectionInput): Promise<S
tableMap.set(tableName, { columns: {} }); tableMap.set(tableName, { columns: {} });
} }
for (const row of columnRows as MySqlColumnRow[]) { for (const row of columnRows as Array<Record<string, unknown>>) {
const logicalType = normalizeSqlType('mysql', row.column_type || row.data_type, row.column_name, row.column_default); const tableName = readMySqlField<string>(row, 'table_name');
tableMap.get(row.table_name)!.columns[row.column_name] = { const columnName = readMySqlField<string>(row, 'column_name');
const declaredType = readMySqlField<string>(row, 'column_type') || readMySqlField<string>(row, 'data_type');
const isNullable = readMySqlField<string>(row, 'is_nullable');
const columnDefault = readMySqlField<string | null>(row, 'column_default') ?? null;
if (!tableName || !columnName || !declaredType) {
continue;
}
const logicalType = normalizeSqlType('mysql', declaredType, columnName, columnDefault);
tableMap.get(tableName)!.columns[columnName] = {
logicalType, logicalType,
notNull: row.is_nullable === 'NO', notNull: isNullable === 'NO',
defaultValue: primaryKeys.has(`${row.table_name}.${row.column_name}`) defaultValue: primaryKeys.has(`${tableName}.${columnName}`)
? null ? null
: normalizeDefaultValueForColumn(row.column_default, logicalType), : normalizeDefaultValueForColumn(columnDefault, logicalType),
primaryKey: primaryKeys.has(`${row.table_name}.${row.column_name}`), primaryKey: primaryKeys.has(`${tableName}.${columnName}`),
}; };
} }
const tables = Object.fromEntries(tableNames.map((tableName) => [tableName, tableMap.get(tableName)!])); const tables = Object.fromEntries(tableNames.map((tableName) => [tableName, tableMap.get(tableName)!]));
const [indexRows] = await connection.query(` const [indexRows] = await connection.query(`
SELECT table_name, index_name, column_name, non_unique SELECT
table_name AS table_name,
index_name AS index_name,
column_name AS column_name,
non_unique AS non_unique
FROM information_schema.statistics FROM information_schema.statistics
WHERE table_schema = DATABASE() WHERE table_schema = DATABASE()
AND index_name <> 'PRIMARY' AND index_name <> 'PRIMARY'
ORDER BY table_name ASC, index_name ASC, seq_in_index ASC ORDER BY table_name ASC, index_name ASC, seq_in_index ASC
`); `);
const indexGroups = new Map<string, SchemaContractIndex>(); const indexGroups = new Map<string, SchemaContractIndex>();
for (const row of indexRows as MySqlIndexRow[]) { for (const row of indexRows as Array<Record<string, unknown>>) {
const key = `${row.table_name}:${row.index_name}`; const tableName = readMySqlField<string>(row, 'table_name');
const indexName = readMySqlField<string>(row, 'index_name');
const columnName = readMySqlField<string>(row, 'column_name');
const nonUnique = readMySqlField<number>(row, 'non_unique');
if (!tableName || !indexName || !columnName) {
continue;
}
const key = `${tableName}:${indexName}`;
const existing = indexGroups.get(key); const existing = indexGroups.get(key);
if (existing) { if (existing) {
existing.columns.push(row.column_name); existing.columns.push(columnName);
continue; continue;
} }
indexGroups.set(key, { indexGroups.set(key, {
name: row.index_name, name: indexName,
table: row.table_name, table: tableName,
columns: [row.column_name], columns: [columnName],
unique: Number(row.non_unique) === 0, unique: Number(nonUnique) === 0,
}); });
} }
@@ -529,12 +578,12 @@ async function introspectMySqlSchema(input: SchemaIntrospectionInput): Promise<S
const [foreignKeyRows] = await connection.query(` const [foreignKeyRows] = await connection.query(`
SELECT SELECT
kcu.table_name, kcu.table_name AS table_name,
kcu.constraint_name, kcu.constraint_name AS constraint_name,
kcu.column_name, kcu.column_name AS column_name,
kcu.referenced_table_name, kcu.referenced_table_name AS referenced_table_name,
kcu.referenced_column_name, kcu.referenced_column_name AS referenced_column_name,
rc.delete_rule rc.delete_rule AS delete_rule
FROM information_schema.key_column_usage kcu FROM information_schema.key_column_usage kcu
JOIN information_schema.referential_constraints rc JOIN information_schema.referential_constraints rc
ON rc.constraint_schema = kcu.constraint_schema ON rc.constraint_schema = kcu.constraint_schema
@@ -544,20 +593,30 @@ async function introspectMySqlSchema(input: SchemaIntrospectionInput): Promise<S
ORDER BY kcu.table_name ASC, kcu.constraint_name ASC, kcu.ordinal_position ASC ORDER BY kcu.table_name ASC, kcu.constraint_name ASC, kcu.ordinal_position ASC
`); `);
const foreignKeyGroups = new Map<string, SchemaContractForeignKey>(); const foreignKeyGroups = new Map<string, SchemaContractForeignKey>();
for (const row of foreignKeyRows as MySqlForeignKeyRow[]) { for (const row of foreignKeyRows as Array<Record<string, unknown>>) {
const key = `${row.table_name}:${row.constraint_name}`; const tableName = readMySqlField<string>(row, 'table_name');
const constraintName = readMySqlField<string>(row, 'constraint_name');
const columnName = readMySqlField<string>(row, 'column_name');
const referencedTableName = readMySqlField<string>(row, 'referenced_table_name');
const referencedColumnName = readMySqlField<string>(row, 'referenced_column_name');
const deleteRule = readMySqlField<string | null>(row, 'delete_rule') ?? null;
if (!tableName || !constraintName || !columnName || !referencedTableName || !referencedColumnName) {
continue;
}
const key = `${tableName}:${constraintName}`;
const existing = foreignKeyGroups.get(key); const existing = foreignKeyGroups.get(key);
if (existing) { if (existing) {
existing.columns.push(row.column_name); existing.columns.push(columnName);
existing.referencedColumns.push(row.referenced_column_name); existing.referencedColumns.push(referencedColumnName);
continue; continue;
} }
foreignKeyGroups.set(key, { foreignKeyGroups.set(key, {
table: row.table_name, table: tableName,
columns: [row.column_name], columns: [columnName],
referencedTable: row.referenced_table_name, referencedTable: referencedTableName,
referencedColumns: [row.referenced_column_name], referencedColumns: [referencedColumnName],
onDelete: normalizeDeleteRule(row.delete_rule), onDelete: normalizeDeleteRule(deleteRule),
}); });
} }
@@ -736,13 +795,17 @@ function readBootstrapSql(dialect: Exclude<SchemaIntrospectionDialect, 'sqlite'>
async function resetMySqlSchema(connection: mysql.Connection): Promise<void> { async function resetMySqlSchema(connection: mysql.Connection): Promise<void> {
await connection.query('SET FOREIGN_KEY_CHECKS = 0'); await connection.query('SET FOREIGN_KEY_CHECKS = 0');
const [rows] = await connection.query(` const [rows] = await connection.query(`
SELECT table_name SELECT table_name AS table_name
FROM information_schema.tables FROM information_schema.tables
WHERE table_schema = DATABASE() WHERE table_schema = DATABASE()
AND table_type = 'BASE TABLE' AND table_type = 'BASE TABLE'
`); `);
for (const row of rows as Array<{ table_name: string }>) { for (const row of rows as Array<Record<string, unknown>>) {
await connection.query(`DROP TABLE IF EXISTS \`${row.table_name}\``); const tableName = readMySqlField<string>(row, 'table_name');
if (!tableName) {
continue;
}
await connection.query(`DROP TABLE IF EXISTS \`${tableName}\``);
} }
await connection.query('SET FOREIGN_KEY_CHECKS = 1'); await connection.query('SET FOREIGN_KEY_CHECKS = 1');
} }
+3 -1
View File
@@ -9,11 +9,13 @@ const dbDir = dirname(fileURLToPath(import.meta.url));
const schemaContractPath = resolve(dbDir, 'generated/schemaContract.json'); const schemaContractPath = resolve(dbDir, 'generated/schemaContract.json');
const contract = JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract; const contract = JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract;
const skipLiveSchema = process.env.DB_PARITY_SKIP_LIVE_SCHEMA === 'true';
const sqliteParity = !skipLiveSchema && process.env.DB_PARITY_SQLITE !== 'false' ? it : it.skip;
const mysqlParity = process.env.DB_PARITY_MYSQL_URL ? it : it.skip; const mysqlParity = process.env.DB_PARITY_MYSQL_URL ? it : it.skip;
const postgresParity = process.env.DB_PARITY_POSTGRES_URL ? it : it.skip; const postgresParity = process.env.DB_PARITY_POSTGRES_URL ? it : it.skip;
describe('live schema parity', () => { describe('live schema parity', () => {
it('matches the contract for sqlite', async () => { sqliteParity('matches the contract for sqlite', async () => {
const sqliteUrl = await materializeFreshSchema('sqlite'); const sqliteUrl = await materializeFreshSchema('sqlite');
const live = await introspectLiveSchema({ dialect: 'sqlite', connectionString: sqliteUrl }); const live = await introspectLiveSchema({ dialect: 'sqlite', connectionString: sqliteUrl });
expect(live).toEqual(contract); expect(live).toEqual(contract);
+3 -1
View File
@@ -3,11 +3,13 @@ import currentContract from './generated/schemaContract.json' with { type: 'json
import { applyContractFixtureThenUpgrade, introspectLiveSchema } from './schemaIntrospection.js'; import { applyContractFixtureThenUpgrade, introspectLiveSchema } from './schemaIntrospection.js';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
const skipLiveSchema = process.env.DB_PARITY_SKIP_LIVE_SCHEMA === 'true';
const sqliteUpgrade = !skipLiveSchema && process.env.DB_PARITY_SQLITE !== 'false' ? it : it.skip;
const mysqlUpgrade = process.env.DB_PARITY_MYSQL_URL ? it : it.skip; const mysqlUpgrade = process.env.DB_PARITY_MYSQL_URL ? it : it.skip;
const postgresUpgrade = process.env.DB_PARITY_POSTGRES_URL ? it : it.skip; const postgresUpgrade = process.env.DB_PARITY_POSTGRES_URL ? it : it.skip;
describe('schema upgrade parity', () => { describe('schema upgrade parity', () => {
it('upgrades sqlite to the current contract', async () => { sqliteUpgrade('upgrades sqlite to the current contract', async () => {
const sqliteUrl = await applyContractFixtureThenUpgrade('sqlite', baselineContract, currentContract); const sqliteUrl = await applyContractFixtureThenUpgrade('sqlite', baselineContract, currentContract);
const live = await introspectLiveSchema({ dialect: 'sqlite', connectionString: sqliteUrl }); const live = await introspectLiveSchema({ dialect: 'sqlite', connectionString: sqliteUrl });
expect(live).toEqual(currentContract); expect(live).toEqual(currentContract);