feat(db): generate canonical schema contract from sqlite migrations

This commit is contained in:
ciat-777
2026-03-14 22:45:05 +08:00
parent 908b5195b2
commit 590f34af93
5 changed files with 1457 additions and 0 deletions
+2
View File
@@ -41,6 +41,8 @@
"start:desktop": "electron dist/desktop/main.js", "start:desktop": "electron dist/desktop/main.js",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/server/db/migrate.ts", "db:migrate": "tsx src/server/db/migrate.ts",
"schema:contract": "tsx scripts/dev/generate-schema-contract.ts",
"schema:generate": "npm run db:generate && npm run schema:contract",
"smoke:db": "tsx scripts/dev/db-smoke.ts", "smoke:db": "tsx scripts/dev/db-smoke.ts",
"smoke:db:sqlite": "tsx scripts/dev/db-smoke.ts --db-type sqlite", "smoke:db:sqlite": "tsx scripts/dev/db-smoke.ts --db-type sqlite",
"smoke:db:mysql": "tsx scripts/dev/db-smoke.ts --db-type mysql", "smoke:db:mysql": "tsx scripts/dev/db-smoke.ts --db-type mysql",
+6
View File
@@ -0,0 +1,6 @@
import { writeSchemaContractFile } from '../../src/server/db/schemaContract.js';
const contract = writeSchemaContractFile();
const tableCount = Object.keys(contract.tables).length;
console.log(`[schema:contract] wrote ${tableCount} tables`);
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { buildSchemaContractFromSqliteMigrations } from './schemaContract.js';
describe('schema contract generation', () => {
it('captures the current schema shape from sqlite migrations', () => {
const contract = buildSchemaContractFromSqliteMigrations();
expect(contract.tables.sites.columns.status).toMatchObject({
logicalType: 'text',
notNull: true,
primaryKey: false,
});
expect(contract.tables.account_tokens.columns.token_group).toBeDefined();
expect(contract.tables.site_disabled_models).toBeDefined();
expect(contract.indexes).toContainEqual(
expect.objectContaining({ name: 'sites_status_idx', table: 'sites', unique: false }),
);
expect(contract.uniques).toContainEqual(
expect.objectContaining({
name: 'site_disabled_models_site_model_unique',
table: 'site_disabled_models',
columns: ['site_id', 'model_name'],
}),
);
expect(contract.foreignKeys).toContainEqual(
expect.objectContaining({
table: 'site_disabled_models',
columns: ['site_id'],
referencedTable: 'sites',
referencedColumns: ['id'],
}),
);
});
});
+323
View File
@@ -0,0 +1,323 @@
import Database from 'better-sqlite3';
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
export type LogicalColumnType =
| 'integer'
| 'real'
| 'text'
| 'boolean'
| 'datetime'
| 'json';
export interface SchemaContractColumn {
logicalType: LogicalColumnType;
notNull: boolean;
defaultValue: string | null;
primaryKey: boolean;
}
export interface SchemaContractTable {
columns: Record<string, SchemaContractColumn>;
}
export interface SchemaContractIndex {
name: string;
table: string;
columns: string[];
unique: boolean;
}
export interface SchemaContractUnique {
name: string;
table: string;
columns: string[];
}
export interface SchemaContractForeignKey {
table: string;
columns: string[];
referencedTable: string;
referencedColumns: string[];
onDelete: string | null;
}
export interface SchemaContract {
tables: Record<string, SchemaContractTable>;
indexes: SchemaContractIndex[];
uniques: SchemaContractUnique[];
foreignKeys: SchemaContractForeignKey[];
}
type TableInfoRow = {
name: string;
type: string;
notnull: number;
dflt_value: string | null;
pk: number;
};
type IndexListRow = {
name: string;
unique: number;
origin: string;
partial: number;
};
type IndexInfoRow = {
seqno: number;
cid: number;
name: string;
};
type ForeignKeyRow = {
id: number;
seq: number;
table: string;
from: string;
to: string;
on_update: string;
on_delete: string;
match: string;
};
function resolveDbDir(): string {
return dirname(fileURLToPath(import.meta.url));
}
export function resolveMigrationsFolder(): string {
return resolve(resolveDbDir(), '../../../drizzle');
}
export function resolveGeneratedSchemaContractPath(): string {
return resolve(resolveDbDir(), 'generated/schemaContract.json');
}
function resolveMigrationFiles(migrationsFolder: string): string[] {
return readdirSync(migrationsFolder)
.filter((entry) => entry.endsWith('.sql'))
.sort((left, right) => left.localeCompare(right, 'en'));
}
function splitMigrationStatements(sqlText: string): string[] {
return sqlText
.split('--> statement-breakpoint')
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0);
}
function applySqliteMigrations(sqlite: Database.Database, migrationsFolder: string): void {
for (const migrationFile of resolveMigrationFiles(migrationsFolder)) {
const sqlText = readFileSync(join(migrationsFolder, migrationFile), 'utf8');
for (const statement of splitMigrationStatements(sqlText)) {
sqlite.exec(statement);
}
}
}
function normalizeDefaultValue(defaultValue: string | null): string | null {
if (defaultValue == null) return null;
return String(defaultValue).trim() || null;
}
function isBooleanLikeColumn(columnName: string, defaultValue: string | null): boolean {
const normalizedColumn = columnName.toLowerCase();
const normalizedDefault = (defaultValue || '').trim().toLowerCase();
if (normalizedDefault === 'true' || normalizedDefault === 'false') {
return true;
}
return normalizedColumn.startsWith('is_')
|| normalizedColumn.startsWith('use_')
|| normalizedColumn.startsWith('has_')
|| normalizedColumn.endsWith('_enabled')
|| normalizedColumn.endsWith('_available')
|| normalizedColumn === 'read'
|| normalizedColumn === 'enabled'
|| normalizedColumn === 'available'
|| normalizedColumn === 'manual_override';
}
function isDateTimeLikeColumn(columnName: string, defaultValue: string | null): boolean {
const normalizedColumn = columnName.toLowerCase();
const normalizedDefault = (defaultValue || '').toLowerCase();
return normalizedColumn.endsWith('_at')
|| normalizedColumn.endsWith('_until')
|| normalizedColumn.endsWith('_refresh')
|| normalizedDefault.includes('datetime(')
|| normalizedDefault.includes('current_timestamp');
}
function isJsonLikeColumn(columnName: string): boolean {
const normalizedColumn = columnName.toLowerCase();
return normalizedColumn.endsWith('_json')
|| normalizedColumn.includes('snapshot')
|| normalizedColumn.includes('mapping')
|| normalizedColumn.includes('headers')
|| normalizedColumn.includes('config')
|| normalizedColumn.includes('details')
|| normalizedColumn.includes('meta')
|| normalizedColumn.includes('models')
|| normalizedColumn.includes('route_ids')
|| normalizedColumn.includes('multipliers');
}
function normalizeLogicalType(columnName: string, declaredType: string, defaultValue: string | null): LogicalColumnType {
const normalizedType = declaredType.trim().toLowerCase();
if (normalizedType.includes('int')) {
return isBooleanLikeColumn(columnName, defaultValue) ? 'boolean' : 'integer';
}
if (normalizedType.includes('real') || normalizedType.includes('double') || normalizedType.includes('float')) {
return 'real';
}
if (normalizedType.includes('text') || normalizedType.includes('char') || normalizedType.includes('clob')) {
if (isDateTimeLikeColumn(columnName, defaultValue)) return 'datetime';
if (isJsonLikeColumn(columnName)) return 'json';
return 'text';
}
if (isDateTimeLikeColumn(columnName, defaultValue)) return 'datetime';
return 'text';
}
function readTables(sqlite: Database.Database): Record<string, SchemaContractTable> {
const tables = sqlite.prepare(`
SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name NOT LIKE 'sqlite_%'
ORDER BY name ASC
`).all() as Array<{ name: string }>;
const result: Record<string, SchemaContractTable> = {};
for (const table of tables) {
const rows = sqlite.prepare(`PRAGMA table_info("${table.name}")`).all() as TableInfoRow[];
const columns = Object.fromEntries(rows.map((row) => {
const defaultValue = normalizeDefaultValue(row.dflt_value);
return [row.name, {
logicalType: normalizeLogicalType(row.name, row.type, defaultValue),
notNull: row.notnull === 1,
defaultValue,
primaryKey: row.pk > 0,
} satisfies SchemaContractColumn];
}));
result[table.name] = { columns };
}
return result;
}
function readIndexes(sqlite: Database.Database, tables: Record<string, SchemaContractTable>): {
indexes: SchemaContractIndex[];
uniques: SchemaContractUnique[];
} {
const indexes: SchemaContractIndex[] = [];
const uniques: SchemaContractUnique[] = [];
for (const tableName of Object.keys(tables).sort((left, right) => left.localeCompare(right, 'en'))) {
const rows = sqlite.prepare(`PRAGMA index_list("${tableName}")`).all() as IndexListRow[];
for (const row of rows) {
if (!row.name || row.name.startsWith('sqlite_autoindex')) {
continue;
}
const indexColumns = (sqlite.prepare(`PRAGMA index_info("${row.name}")`).all() as IndexInfoRow[])
.sort((left, right) => left.seqno - right.seqno)
.map((item) => item.name)
.filter((name) => !!name);
const index: SchemaContractIndex = {
name: row.name,
table: tableName,
columns: indexColumns,
unique: row.unique === 1,
};
indexes.push(index);
if (row.unique === 1) {
uniques.push({
name: row.name,
table: tableName,
columns: indexColumns,
});
}
}
}
indexes.sort((left, right) => left.name.localeCompare(right.name, 'en'));
uniques.sort((left, right) => left.name.localeCompare(right.name, 'en'));
return { indexes, uniques };
}
function readForeignKeys(sqlite: Database.Database, tables: Record<string, SchemaContractTable>): SchemaContractForeignKey[] {
const foreignKeys: SchemaContractForeignKey[] = [];
for (const tableName of Object.keys(tables).sort((left, right) => left.localeCompare(right, 'en'))) {
const rows = sqlite.prepare(`PRAGMA foreign_key_list("${tableName}")`).all() as ForeignKeyRow[];
const grouped = new Map<number, SchemaContractForeignKey>();
for (const row of rows) {
const existing = grouped.get(row.id);
if (existing) {
existing.columns.push(row.from);
existing.referencedColumns.push(row.to);
continue;
}
grouped.set(row.id, {
table: tableName,
columns: [row.from],
referencedTable: row.table,
referencedColumns: [row.to],
onDelete: row.on_delete || null,
});
}
foreignKeys.push(...grouped.values());
}
foreignKeys.sort((left, right) => {
const leftKey = `${left.table}:${left.columns.join(',')}`;
const rightKey = `${right.table}:${right.columns.join(',')}`;
return leftKey.localeCompare(rightKey, 'en');
});
return foreignKeys;
}
export function buildSchemaContractFromSqliteMigrations(migrationsFolder = resolveMigrationsFolder()): SchemaContract {
const sqlite = new Database(':memory:');
sqlite.pragma('foreign_keys = ON');
try {
applySqliteMigrations(sqlite, migrationsFolder);
const tables = readTables(sqlite);
const { indexes, uniques } = readIndexes(sqlite, tables);
const foreignKeys = readForeignKeys(sqlite, tables);
return {
tables,
indexes,
uniques,
foreignKeys,
};
} finally {
sqlite.close();
}
}
export function writeSchemaContractFile(outputPath = resolveGeneratedSchemaContractPath()): SchemaContract {
const contract = buildSchemaContractFromSqliteMigrations();
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, `${JSON.stringify(contract, null, 2)}\n`, 'utf8');
return contract;
}
export const __schemaContractTestUtils = {
splitMigrationStatements,
normalizeDefaultValue,
normalizeLogicalType,
resolveMigrationsFolder,
};