feat(pcd): add PCD type generator and corresponding types for database schema

This commit is contained in:
Sam Chau
2026-01-27 21:32:31 +10:30
parent 2e36df30e5
commit d079c0dfd7
3 changed files with 1231 additions and 0 deletions

View File

@@ -0,0 +1,542 @@
/**
* PCD Type Generator
*
* Generates TypeScript types from the PCD schema SQL.
* Uses SQLite introspection to ensure types match the actual schema.
*
* Usage:
* deno task generate:pcd-types # Uses default version (1.0.0)
* deno task generate:pcd-types --version=1.1.0 # Uses specific version
* deno task generate:pcd-types --local=/path/to/schema.sql # Uses local file
*/
import { Database } from '@jsr/db__sqlite';
// ============================================================================
// CONFIGURATION
// ============================================================================
const SCHEMA_REPO = 'Dictionarry-Hub/schema';
const DEFAULT_VERSION = '1.0.0'; // Schema versions are branch names (e.g., 1.0.0, 1.1.0)
const SCHEMA_PATH = 'ops/0.schema.sql';
const OUTPUT_DIR = './src/lib/shared/pcd';
const OUTPUT_PATH = `${OUTPUT_DIR}/types.ts`;
// ============================================================================
// CLI ARGUMENT PARSING
// ============================================================================
interface CliArgs {
version: string;
localPath?: string;
help: boolean;
}
function parseArgs(): CliArgs {
const args: CliArgs = {
version: DEFAULT_VERSION,
help: false
};
for (const arg of Deno.args) {
if (arg === '--help' || arg === '-h') {
args.help = true;
} else if (arg.startsWith('--version=')) {
args.version = arg.slice('--version='.length);
} else if (arg.startsWith('--local=')) {
args.localPath = arg.slice('--local='.length);
}
}
return args;
}
function printHelp(): void {
console.log(`
PCD Type Generator
Generates TypeScript types from the PCD schema SQL.
USAGE:
deno task generate:pcd-types [OPTIONS]
OPTIONS:
--version=<ver> Use specific schema version/branch (default: ${DEFAULT_VERSION})
--local=<path> Use local schema file instead of fetching from GitHub
--help, -h Show this help message
EXAMPLES:
deno task generate:pcd-types # Fetch version ${DEFAULT_VERSION}
deno task generate:pcd-types --version=1.1.0 # Fetch version 1.1.0
deno task generate:pcd-types --local=./schema.sql # Use local file
OUTPUT:
${OUTPUT_PATH}
`);
}
// ============================================================================
// SCHEMA FETCHING
// ============================================================================
async function fetchSchemaFromGitHub(version: string): Promise<string> {
const url = `https://raw.githubusercontent.com/${SCHEMA_REPO}/${version}/${SCHEMA_PATH}`;
console.log(`Fetching schema from: ${url}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch schema: ${response.status} ${response.statusText}`);
}
return await response.text();
}
async function loadSchemaFromFile(path: string): Promise<string> {
console.log(`Loading schema from: ${path}`);
return await Deno.readTextFile(path);
}
// ============================================================================
// SQLITE INTROSPECTION
// ============================================================================
interface ColumnInfo {
cid: number;
name: string;
type: string;
notnull: number;
dflt_value: string | null;
pk: number;
}
interface ForeignKeyInfo {
id: number;
seq: number;
table: string;
from: string;
to: string;
on_update: string;
on_delete: string;
}
interface TableInfo {
name: string;
columns: ColumnInfo[];
foreignKeys: ForeignKeyInfo[];
}
function introspectDatabase(db: Database): TableInfo[] {
// Get all table names
const tables = db
.prepare(
`SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
ORDER BY name`
)
.all() as { name: string }[];
const tableInfos: TableInfo[] = [];
for (const { name } of tables) {
// Get column info
const columns = db.prepare(`PRAGMA table_info('${name}')`).all() as ColumnInfo[];
// Get foreign key info
const foreignKeys = db.prepare(`PRAGMA foreign_key_list('${name}')`).all() as ForeignKeyInfo[];
tableInfos.push({ name, columns, foreignKeys });
}
return tableInfos;
}
// ============================================================================
// TYPE GENERATION
// ============================================================================
/**
* Map SQLite types to TypeScript types
*/
function sqliteTypeToTs(sqliteType: string, nullable: boolean): string {
const type = sqliteType.toUpperCase();
let tsType: string;
if (type.includes('INT')) {
tsType = 'number';
} else if (type.includes('CHAR') || type.includes('TEXT') || type.includes('CLOB')) {
tsType = 'string';
} else if (type.includes('REAL') || type.includes('FLOA') || type.includes('DOUB')) {
tsType = 'number';
} else if (type.includes('BLOB')) {
tsType = 'Uint8Array';
} else if (type === '' || type === 'NUMERIC') {
// SQLite allows untyped columns
tsType = 'unknown';
} else {
// Default to string for VARCHAR, etc.
tsType = 'string';
}
return nullable ? `${tsType} | null` : tsType;
}
/**
* Convert snake_case to PascalCase
*/
function toPascalCase(str: string): string {
return str
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join('');
}
/**
* Check if a column has a default value or is auto-generated
*/
function isGenerated(column: ColumnInfo): boolean {
// Primary key with INTEGER (autoincrement in SQLite)
if (column.pk === 1 && column.type.toUpperCase().includes('INTEGER')) {
return true;
}
// Has a default value
if (column.dflt_value !== null) {
return true;
}
return false;
}
/**
* Check if a column is actually nullable
* Primary key INTEGER columns are never nullable in practice
*/
function isNullable(column: ColumnInfo): boolean {
// Primary key INTEGER columns are autoincrement and never null
if (column.pk === 1 && column.type.toUpperCase().includes('INTEGER')) {
return false;
}
return column.notnull === 0;
}
/**
* Generate TypeScript interface for a table
*/
function generateTableInterface(table: TableInfo): string {
const interfaceName = `${toPascalCase(table.name)}Table`;
const lines: string[] = [];
lines.push(`export interface ${interfaceName} {`);
for (const column of table.columns) {
const nullable = isNullable(column);
const tsType = sqliteTypeToTs(column.type, nullable);
const generated = isGenerated(column);
if (generated) {
// Wrap in Generated<T> for auto-generated columns
const baseType = nullable ? tsType.replace(' | null', '') : tsType;
lines.push(`\t${column.name}: Generated<${baseType}>${nullable ? ' | null' : ''};`);
} else {
lines.push(`\t${column.name}: ${tsType};`);
}
}
lines.push('}');
return lines.join('\n');
}
/**
* Generate the database interface that maps table names to interfaces
*/
function generateDatabaseInterface(tables: TableInfo[]): string {
const lines: string[] = [];
lines.push('export interface PCDDatabase {');
for (const table of tables) {
const interfaceName = `${toPascalCase(table.name)}Table`;
lines.push(`\t${table.name}: ${interfaceName};`);
}
lines.push('}');
return lines.join('\n');
}
/**
* Generate row types (non-Generated versions for query results)
*/
function generateRowType(table: TableInfo): string {
const rowTypeName = `${toPascalCase(table.name)}Row`;
const lines: string[] = [];
lines.push(`export interface ${rowTypeName} {`);
for (const column of table.columns) {
const nullable = isNullable(column);
const tsType = sqliteTypeToTs(column.type, nullable);
lines.push(`\t${column.name}: ${tsType};`);
}
lines.push('}');
return lines.join('\n');
}
/**
* Generate the complete types file
*/
function generateTypesFile(tables: TableInfo[], version: string): string {
const lines: string[] = [];
// Header
lines.push(`/**
* PCD Database Schema Types
*
* AUTO-GENERATED - DO NOT EDIT MANUALLY
*
* Generated from: https://github.com/${SCHEMA_REPO}/blob/${version}/${SCHEMA_PATH}
* Generated at: ${new Date().toISOString()}
*
* To regenerate: deno task generate:pcd-types --version=${version}
*/
import type { Generated } from 'kysely';
`);
// Group tables by entity - ordered by importance/usage
const qualityProfileTables = [
'quality_profiles',
'quality_profile_tags',
'quality_groups',
'quality_group_members',
'quality_profile_qualities',
'quality_profile_languages',
'quality_profile_custom_formats',
'test_entities',
'test_releases'
];
const customFormatTables = [
'custom_formats',
'custom_format_tags',
'custom_format_conditions',
'custom_format_tests'
// condition_* tables added dynamically below
];
const regexTables = ['regular_expressions', 'regular_expression_tags'];
const delayProfileTables = ['delay_profiles', 'delay_profile_tags'];
const mediaManagementTables = [
'radarr_naming',
'sonarr_naming',
'radarr_media_settings',
'sonarr_media_settings',
'radarr_quality_definitions',
'sonarr_quality_definitions'
];
const coreTables = ['tags', 'languages', 'qualities', 'quality_api_mappings'];
// Categories in display order
const categories = [
'QUALITY PROFILES',
'CUSTOM FORMATS',
'REGULAR EXPRESSIONS',
'DELAY PROFILES',
'MEDIA MANAGEMENT',
'CORE'
] as const;
const categorized = new Map<string, TableInfo[]>();
for (const cat of categories) {
categorized.set(cat, []);
}
for (const table of tables) {
if (qualityProfileTables.includes(table.name)) {
categorized.get('QUALITY PROFILES')!.push(table);
} else if (customFormatTables.includes(table.name) || table.name.startsWith('condition_')) {
categorized.get('CUSTOM FORMATS')!.push(table);
} else if (regexTables.includes(table.name)) {
categorized.get('REGULAR EXPRESSIONS')!.push(table);
} else if (delayProfileTables.includes(table.name)) {
categorized.get('DELAY PROFILES')!.push(table);
} else if (mediaManagementTables.includes(table.name)) {
categorized.get('MEDIA MANAGEMENT')!.push(table);
} else if (coreTables.includes(table.name)) {
categorized.get('CORE')!.push(table);
} else {
// Unknown tables go to CORE as fallback
console.warn(`Unknown table: ${table.name} - adding to CORE`);
categorized.get('CORE')!.push(table);
}
}
// Sort tables within each category by the predefined order
const sortByOrder = (tables: TableInfo[], order: string[]): TableInfo[] => {
return tables.sort((a, b) => {
const aIndex = order.indexOf(a.name);
const bIndex = order.indexOf(b.name);
// Tables in the order list come first, sorted by their position
// Tables not in the list (like condition_*) come after, sorted alphabetically
if (aIndex === -1 && bIndex === -1) return a.name.localeCompare(b.name);
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
});
};
categorized.set('QUALITY PROFILES', sortByOrder(categorized.get('QUALITY PROFILES')!, qualityProfileTables));
categorized.set('CUSTOM FORMATS', sortByOrder(categorized.get('CUSTOM FORMATS')!, customFormatTables));
categorized.set('REGULAR EXPRESSIONS', sortByOrder(categorized.get('REGULAR EXPRESSIONS')!, regexTables));
categorized.set('DELAY PROFILES', sortByOrder(categorized.get('DELAY PROFILES')!, delayProfileTables));
categorized.set('MEDIA MANAGEMENT', sortByOrder(categorized.get('MEDIA MANAGEMENT')!, mediaManagementTables));
categorized.set('CORE', sortByOrder(categorized.get('CORE')!, coreTables));
// Generate Kysely table interfaces
lines.push('// ============================================================================');
lines.push('// KYSELY TABLE INTERFACES');
lines.push('// ============================================================================');
lines.push('// Use these with Kysely for type-safe queries with Generated<T> support');
lines.push('');
for (const [category, categoryTables] of categorized) {
if (categoryTables.length === 0) continue;
lines.push(`// ${category}`);
lines.push('');
for (const table of categoryTables) {
lines.push(generateTableInterface(table));
lines.push('');
}
}
// Generate database interface
lines.push('// ============================================================================');
lines.push('// DATABASE INTERFACE');
lines.push('// ============================================================================');
lines.push('');
lines.push(generateDatabaseInterface(tables));
lines.push('');
// Generate row types
lines.push('// ============================================================================');
lines.push('// ROW TYPES (Query Results)');
lines.push('// ============================================================================');
lines.push('// Use these for query result types (no Generated<T> wrapper)');
lines.push('');
for (const [category, categoryTables] of categorized) {
if (categoryTables.length === 0) continue;
lines.push(`// ${category}`);
lines.push('');
for (const table of categoryTables) {
lines.push(generateRowType(table));
lines.push('');
}
}
// Generate helper types
lines.push('// ============================================================================');
lines.push('// HELPER TYPES');
lines.push('// ============================================================================');
lines.push('');
lines.push('/** Extract insertable type from a table (Generated fields become optional) */');
lines.push('export type Insertable<T> = {');
lines.push('\t[K in keyof T]: T[K] extends Generated<infer U>');
lines.push('\t\t? U | undefined');
lines.push('\t\t: T[K] extends Generated<infer U> | null');
lines.push('\t\t\t? U | null | undefined');
lines.push('\t\t\t: T[K];');
lines.push('};');
lines.push('');
lines.push('/** Extract selectable type from a table (Generated<T> becomes T) */');
lines.push('export type Selectable<T> = {');
lines.push('\t[K in keyof T]: T[K] extends Generated<infer U>');
lines.push('\t\t? U');
lines.push('\t\t: T[K] extends Generated<infer U> | null');
lines.push('\t\t\t? U | null');
lines.push('\t\t\t: T[K];');
lines.push('};');
lines.push('');
return lines.join('\n');
}
// ============================================================================
// MAIN
// ============================================================================
async function main(): Promise<void> {
const args = parseArgs();
if (args.help) {
printHelp();
Deno.exit(0);
}
try {
// Load schema
let schemaSql: string;
let sourceVersion = args.version;
if (args.localPath) {
schemaSql = await loadSchemaFromFile(args.localPath);
sourceVersion = 'local';
} else {
schemaSql = await fetchSchemaFromGitHub(args.version);
}
console.log(`Schema loaded (${schemaSql.length} bytes)`);
// Create in-memory database and run schema
console.log('Creating database and applying schema...');
const db = new Database(':memory:');
try {
db.exec(schemaSql);
} catch (error) {
console.error('Failed to execute schema SQL:', error);
Deno.exit(1);
}
// Introspect database
console.log('Introspecting database structure...');
const tables = introspectDatabase(db);
console.log(`Found ${tables.length} tables`);
// Generate types
console.log('Generating TypeScript types...');
const typesContent = generateTypesFile(tables, sourceVersion);
// Ensure output directory exists
await Deno.mkdir(OUTPUT_DIR, { recursive: true });
// Write output
await Deno.writeTextFile(OUTPUT_PATH, typesContent);
console.log(`\nTypes written to: ${OUTPUT_PATH}`);
// Summary
console.log('\nGenerated types for:');
for (const table of tables) {
console.log(` - ${table.name} (${table.columns.length} columns)`);
}
db.close();
} catch (error) {
console.error('Error:', error);
Deno.exit(1);
}
}
main();