diff --git a/package.json b/package.json index de7029d..6dcfb70 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "start:desktop": "electron dist/desktop/main.js", "db:generate": "drizzle-kit generate", "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:sqlite": "tsx scripts/dev/db-smoke.ts --db-type sqlite", "smoke:db:mysql": "tsx scripts/dev/db-smoke.ts --db-type mysql", diff --git a/scripts/dev/generate-schema-contract.ts b/scripts/dev/generate-schema-contract.ts new file mode 100644 index 0000000..98d6526 --- /dev/null +++ b/scripts/dev/generate-schema-contract.ts @@ -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`); diff --git a/src/server/db/generated/schemaContract.json b/src/server/db/generated/schemaContract.json new file mode 100644 index 0000000..8fec9d2 --- /dev/null +++ b/src/server/db/generated/schemaContract.json @@ -0,0 +1,1092 @@ +{ + "tables": { + "account_tokens": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "account_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "token": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "source": { + "logicalType": "text", + "notNull": false, + "defaultValue": "'manual'", + "primaryKey": false + }, + "enabled": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "true", + "primaryKey": false + }, + "is_default": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "token_group": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + } + } + }, + "accounts": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "site_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "username": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "access_token": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "api_token": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "balance": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "balance_used": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "quota": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "unit_cost": { + "logicalType": "real", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "value_score": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "status": { + "logicalType": "text", + "notNull": false, + "defaultValue": "'active'", + "primaryKey": false + }, + "checkin_enabled": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "true", + "primaryKey": false + }, + "last_checkin_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "last_balance_refresh": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "extra_config": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "is_pinned": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "sort_order": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + } + } + }, + "checkin_logs": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "account_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "status": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "message": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "reward": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + }, + "events": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "type": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "title": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "message": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "level": { + "logicalType": "text", + "notNull": true, + "defaultValue": "'info'", + "primaryKey": false + }, + "read": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "related_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "related_type": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + }, + "model_availability": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "account_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "model_name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "available": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "latency_ms": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "checked_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + }, + "proxy_logs": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "route_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "channel_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "account_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "model_requested": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "model_actual": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "status": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "http_status": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "latency_ms": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "prompt_tokens": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "completion_tokens": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "total_tokens": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "estimated_cost": { + "logicalType": "real", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "error_message": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "retry_count": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "billing_details": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false + } + } + }, + "route_channels": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "route_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "account_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "priority": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "weight": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "10", + "primaryKey": false + }, + "enabled": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "true", + "primaryKey": false + }, + "manual_override": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "success_count": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "fail_count": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "total_latency_ms": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "total_cost": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "last_used_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "last_fail_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "cooldown_until": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "token_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + } + } + }, + "settings": { + "columns": { + "key": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "value": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + } + } + }, + "site_disabled_models": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "site_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "model_name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + }, + "sites": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "url": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "platform": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "status": { + "logicalType": "text", + "notNull": true, + "defaultValue": "'active'", + "primaryKey": false + }, + "api_key": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "is_pinned": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "sort_order": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + } + } + }, + "token_model_availability": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "token_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "model_name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "available": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "latency_ms": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "checked_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + }, + "token_routes": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "model_pattern": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "model_mapping": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "enabled": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "true", + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + } + }, + "indexes": [ + { + "name": "account_tokens_account_enabled_idx", + "table": "account_tokens", + "columns": [ + "account_id", + "enabled" + ], + "unique": false + }, + { + "name": "account_tokens_account_id_idx", + "table": "account_tokens", + "columns": [ + "account_id" + ], + "unique": false + }, + { + "name": "account_tokens_enabled_idx", + "table": "account_tokens", + "columns": [ + "enabled" + ], + "unique": false + }, + { + "name": "accounts_site_id_idx", + "table": "accounts", + "columns": [ + "site_id" + ], + "unique": false + }, + { + "name": "accounts_site_status_idx", + "table": "accounts", + "columns": [ + "site_id", + "status" + ], + "unique": false + }, + { + "name": "accounts_status_idx", + "table": "accounts", + "columns": [ + "status" + ], + "unique": false + }, + { + "name": "checkin_logs_account_created_at_idx", + "table": "checkin_logs", + "columns": [ + "account_id", + "created_at" + ], + "unique": false + }, + { + "name": "checkin_logs_created_at_idx", + "table": "checkin_logs", + "columns": [ + "created_at" + ], + "unique": false + }, + { + "name": "checkin_logs_status_idx", + "table": "checkin_logs", + "columns": [ + "status" + ], + "unique": false + }, + { + "name": "events_created_at_idx", + "table": "events", + "columns": [ + "created_at" + ], + "unique": false + }, + { + "name": "events_read_created_at_idx", + "table": "events", + "columns": [ + "read", + "created_at" + ], + "unique": false + }, + { + "name": "events_type_created_at_idx", + "table": "events", + "columns": [ + "type", + "created_at" + ], + "unique": false + }, + { + "name": "model_availability_account_available_idx", + "table": "model_availability", + "columns": [ + "account_id", + "available" + ], + "unique": false + }, + { + "name": "model_availability_model_name_idx", + "table": "model_availability", + "columns": [ + "model_name" + ], + "unique": false + }, + { + "name": "proxy_logs_account_created_at_idx", + "table": "proxy_logs", + "columns": [ + "account_id", + "created_at" + ], + "unique": false + }, + { + "name": "proxy_logs_created_at_idx", + "table": "proxy_logs", + "columns": [ + "created_at" + ], + "unique": false + }, + { + "name": "proxy_logs_model_actual_created_at_idx", + "table": "proxy_logs", + "columns": [ + "model_actual", + "created_at" + ], + "unique": false + }, + { + "name": "proxy_logs_status_created_at_idx", + "table": "proxy_logs", + "columns": [ + "status", + "created_at" + ], + "unique": false + }, + { + "name": "route_channels_account_id_idx", + "table": "route_channels", + "columns": [ + "account_id" + ], + "unique": false + }, + { + "name": "route_channels_route_enabled_idx", + "table": "route_channels", + "columns": [ + "route_id", + "enabled" + ], + "unique": false + }, + { + "name": "route_channels_route_id_idx", + "table": "route_channels", + "columns": [ + "route_id" + ], + "unique": false + }, + { + "name": "route_channels_route_token_idx", + "table": "route_channels", + "columns": [ + "route_id", + "token_id" + ], + "unique": false + }, + { + "name": "route_channels_token_id_idx", + "table": "route_channels", + "columns": [ + "token_id" + ], + "unique": false + }, + { + "name": "site_disabled_models_site_id_idx", + "table": "site_disabled_models", + "columns": [ + "site_id" + ], + "unique": false + }, + { + "name": "site_disabled_models_site_model_unique", + "table": "site_disabled_models", + "columns": [ + "site_id", + "model_name" + ], + "unique": true + }, + { + "name": "sites_status_idx", + "table": "sites", + "columns": [ + "status" + ], + "unique": false + }, + { + "name": "token_model_availability_available_idx", + "table": "token_model_availability", + "columns": [ + "available" + ], + "unique": false + }, + { + "name": "token_model_availability_model_name_idx", + "table": "token_model_availability", + "columns": [ + "model_name" + ], + "unique": false + }, + { + "name": "token_model_availability_token_available_idx", + "table": "token_model_availability", + "columns": [ + "token_id", + "available" + ], + "unique": false + }, + { + "name": "token_model_availability_token_model_unique", + "table": "token_model_availability", + "columns": [ + "token_id", + "model_name" + ], + "unique": true + }, + { + "name": "token_routes_enabled_idx", + "table": "token_routes", + "columns": [ + "enabled" + ], + "unique": false + }, + { + "name": "token_routes_model_pattern_idx", + "table": "token_routes", + "columns": [ + "model_pattern" + ], + "unique": false + } + ], + "uniques": [ + { + "name": "site_disabled_models_site_model_unique", + "table": "site_disabled_models", + "columns": [ + "site_id", + "model_name" + ] + }, + { + "name": "token_model_availability_token_model_unique", + "table": "token_model_availability", + "columns": [ + "token_id", + "model_name" + ] + } + ], + "foreignKeys": [ + { + "table": "account_tokens", + "columns": [ + "account_id" + ], + "referencedTable": "accounts", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "accounts", + "columns": [ + "site_id" + ], + "referencedTable": "sites", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "checkin_logs", + "columns": [ + "account_id" + ], + "referencedTable": "accounts", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "model_availability", + "columns": [ + "account_id" + ], + "referencedTable": "accounts", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "route_channels", + "columns": [ + "account_id" + ], + "referencedTable": "accounts", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "route_channels", + "columns": [ + "route_id" + ], + "referencedTable": "token_routes", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "site_disabled_models", + "columns": [ + "site_id" + ], + "referencedTable": "sites", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "token_model_availability", + "columns": [ + "token_id" + ], + "referencedTable": "account_tokens", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + } + ] +} diff --git a/src/server/db/schemaContract.test.ts b/src/server/db/schemaContract.test.ts new file mode 100644 index 0000000..11b2795 --- /dev/null +++ b/src/server/db/schemaContract.test.ts @@ -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'], + }), + ); + }); +}); diff --git a/src/server/db/schemaContract.ts b/src/server/db/schemaContract.ts new file mode 100644 index 0000000..aee65d6 --- /dev/null +++ b/src/server/db/schemaContract.ts @@ -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; +} + +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; + 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 { + 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 = {}; + + 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): { + 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): 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(); + + 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, +};