forked from boos/BoosAPI
test(db): add live schema parity checks for sqlite mysql and postgres
This commit is contained in:
@@ -43,6 +43,8 @@
|
||||
"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",
|
||||
"test:schema:unit": "vitest run --root . src/server/db/schemaContract.test.ts src/server/db/schemaArtifactGenerator.test.ts src/server/db/schemaIntrospection.test.ts src/server/db/schemaParity.test.ts",
|
||||
"test:schema:parity": "vitest run --root . src/server/db/schemaParity.live.test.ts",
|
||||
"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",
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
CREATE TABLE IF NOT EXISTS `account_tokens` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `name` TEXT NOT NULL, `token` TEXT NOT NULL, `source` TEXT DEFAULT 'manual', `enabled` BOOLEAN DEFAULT true, `is_default` BOOLEAN DEFAULT false, `created_at` TEXT DEFAULT datetime('now'), `updated_at` TEXT DEFAULT datetime('now'), `token_group` TEXT, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `accounts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `username` TEXT, `access_token` TEXT NOT NULL, `api_token` TEXT, `balance` DOUBLE DEFAULT 0, `balance_used` DOUBLE DEFAULT 0, `quota` DOUBLE DEFAULT 0, `unit_cost` DOUBLE, `value_score` DOUBLE DEFAULT 0, `status` TEXT DEFAULT 'active', `checkin_enabled` BOOLEAN DEFAULT true, `last_checkin_at` TEXT, `last_balance_refresh` TEXT, `extra_config` TEXT, `created_at` TEXT DEFAULT datetime('now'), `updated_at` TEXT DEFAULT datetime('now'), `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `checkin_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `status` TEXT NOT NULL, `message` TEXT, `reward` TEXT, `created_at` TEXT DEFAULT datetime('now'), FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `key` TEXT NOT NULL, `description` TEXT, `enabled` BOOLEAN DEFAULT true, `expires_at` TEXT, `max_cost` DOUBLE, `used_cost` DOUBLE DEFAULT 0, `max_requests` INT, `used_requests` INT DEFAULT 0, `supported_models` TEXT, `allowed_route_ids` TEXT, `site_weight_multipliers` TEXT, `last_used_at` TEXT, `created_at` TEXT DEFAULT datetime('now'), `updated_at` TEXT DEFAULT datetime('now'));
|
||||
CREATE TABLE IF NOT EXISTS `events` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `message` TEXT, `level` TEXT NOT NULL DEFAULT 'info', `read` BOOLEAN DEFAULT false, `related_id` INT, `related_type` TEXT, `created_at` TEXT DEFAULT datetime('now'));
|
||||
CREATE TABLE IF NOT EXISTS `model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` TEXT DEFAULT datetime('now'), FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `proxy_files` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `owner_type` TEXT NOT NULL, `owner_id` TEXT NOT NULL, `filename` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `purpose` TEXT, `byte_size` INT NOT NULL, `sha256` TEXT NOT NULL, `content_base64` TEXT NOT NULL, `created_at` TEXT DEFAULT datetime('now'), `updated_at` TEXT DEFAULT datetime('now'), `deleted_at` TEXT);
|
||||
CREATE TABLE IF NOT EXISTS `proxy_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT, `channel_id` INT, `account_id` INT, `model_requested` TEXT, `model_actual` TEXT, `status` TEXT, `http_status` INT, `latency_ms` INT, `prompt_tokens` INT, `completion_tokens` INT, `total_tokens` INT, `estimated_cost` DOUBLE, `error_message` TEXT, `retry_count` INT DEFAULT 0, `created_at` TEXT DEFAULT datetime('now'), `billing_details` TEXT);
|
||||
CREATE TABLE IF NOT EXISTS `proxy_video_tasks` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `upstream_video_id` TEXT NOT NULL, `site_url` TEXT NOT NULL, `token_value` TEXT NOT NULL, `requested_model` TEXT, `actual_model` TEXT, `channel_id` INT, `account_id` INT, `status_snapshot` TEXT, `upstream_response_meta` TEXT, `last_upstream_status` INT, `last_polled_at` TEXT, `created_at` TEXT DEFAULT datetime('now'), `updated_at` TEXT DEFAULT datetime('now'));
|
||||
CREATE TABLE IF NOT EXISTS `route_channels` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT NOT NULL, `account_id` INT NOT NULL, `token_id` INT, `source_model` TEXT, `priority` INT DEFAULT 0, `weight` INT DEFAULT 10, `enabled` BOOLEAN DEFAULT true, `manual_override` BOOLEAN DEFAULT false, `success_count` INT DEFAULT 0, `fail_count` INT DEFAULT 0, `total_latency_ms` INT DEFAULT 0, `total_cost` DOUBLE DEFAULT 0, `last_used_at` TEXT, `last_selected_at` TEXT, `last_fail_at` TEXT, `consecutive_fail_count` INT NOT NULL DEFAULT 0, `cooldown_level` INT NOT NULL DEFAULT 0, `cooldown_until` TEXT, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE, FOREIGN KEY (`route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE SET NULL);
|
||||
CREATE TABLE IF NOT EXISTS `sites` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `platform` TEXT NOT NULL, `status` VARCHAR(191) NOT NULL DEFAULT 'active', `api_key` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `proxy_url` TEXT, `use_system_proxy` BOOLEAN DEFAULT false, `custom_headers` JSON, `external_checkin_url` TEXT, `global_weight` DOUBLE DEFAULT 1);
|
||||
CREATE TABLE IF NOT EXISTS `accounts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `username` TEXT, `access_token` TEXT NOT NULL, `api_token` TEXT, `balance` DOUBLE DEFAULT 0, `balance_used` DOUBLE DEFAULT 0, `quota` DOUBLE DEFAULT 0, `unit_cost` DOUBLE, `value_score` DOUBLE DEFAULT 0, `status` VARCHAR(191) DEFAULT 'active', `checkin_enabled` BOOLEAN DEFAULT true, `last_checkin_at` DATETIME, `last_balance_refresh` DATETIME, `extra_config` JSON, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `account_tokens` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `name` TEXT NOT NULL, `token` TEXT NOT NULL, `source` VARCHAR(191) DEFAULT 'manual', `enabled` BOOLEAN DEFAULT true, `is_default` BOOLEAN DEFAULT false, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `token_group` TEXT, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `checkin_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `status` TEXT NOT NULL, `message` TEXT, `reward` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `key` TEXT NOT NULL, `description` TEXT, `enabled` BOOLEAN DEFAULT true, `expires_at` DATETIME, `max_cost` DOUBLE, `used_cost` DOUBLE DEFAULT 0, `max_requests` INT, `used_requests` INT DEFAULT 0, `supported_models` JSON, `allowed_route_ids` JSON, `site_weight_multipliers` JSON, `last_used_at` DATETIME, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP);
|
||||
CREATE TABLE IF NOT EXISTS `events` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `message` TEXT, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `read` BOOLEAN DEFAULT false, `related_id` INT, `related_type` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP);
|
||||
CREATE TABLE IF NOT EXISTS `model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `proxy_files` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `owner_type` TEXT NOT NULL, `owner_id` TEXT NOT NULL, `filename` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `purpose` TEXT, `byte_size` INT NOT NULL, `sha256` TEXT NOT NULL, `content_base64` TEXT NOT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `deleted_at` DATETIME);
|
||||
CREATE TABLE IF NOT EXISTS `proxy_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT, `channel_id` INT, `account_id` INT, `model_requested` TEXT, `model_actual` TEXT, `status` TEXT, `http_status` INT, `latency_ms` INT, `prompt_tokens` INT, `completion_tokens` INT, `total_tokens` INT, `estimated_cost` DOUBLE, `error_message` TEXT, `retry_count` INT DEFAULT 0, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `billing_details` JSON);
|
||||
CREATE TABLE IF NOT EXISTS `proxy_video_tasks` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `upstream_video_id` TEXT NOT NULL, `site_url` TEXT NOT NULL, `token_value` TEXT NOT NULL, `requested_model` TEXT, `actual_model` TEXT, `channel_id` INT, `account_id` INT, `status_snapshot` JSON, `upstream_response_meta` JSON, `last_upstream_status` INT, `last_polled_at` DATETIME, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP);
|
||||
CREATE TABLE IF NOT EXISTS `token_routes` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `model_pattern` TEXT NOT NULL, `model_mapping` JSON, `enabled` BOOLEAN DEFAULT true, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `display_name` TEXT, `display_icon` TEXT, `decision_snapshot` JSON, `decision_refreshed_at` DATETIME, `routing_strategy` VARCHAR(191) DEFAULT 'weighted');
|
||||
CREATE TABLE IF NOT EXISTS `route_channels` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT NOT NULL, `account_id` INT NOT NULL, `token_id` INT, `source_model` TEXT, `priority` INT DEFAULT 0, `weight` INT DEFAULT 10, `enabled` BOOLEAN DEFAULT true, `manual_override` BOOLEAN DEFAULT false, `success_count` INT DEFAULT 0, `fail_count` INT DEFAULT 0, `total_latency_ms` INT DEFAULT 0, `total_cost` DOUBLE DEFAULT 0, `last_used_at` DATETIME, `last_selected_at` DATETIME, `last_fail_at` DATETIME, `consecutive_fail_count` INT NOT NULL DEFAULT 0, `cooldown_level` INT NOT NULL DEFAULT 0, `cooldown_until` DATETIME, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE, FOREIGN KEY (`route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE SET NULL);
|
||||
CREATE TABLE IF NOT EXISTS `settings` (`key` TEXT NOT NULL PRIMARY KEY, `value` TEXT);
|
||||
CREATE TABLE IF NOT EXISTS `site_disabled_models` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `model_name` TEXT NOT NULL, `created_at` TEXT DEFAULT datetime('now'), FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `sites` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `platform` TEXT NOT NULL, `status` TEXT NOT NULL DEFAULT 'active', `api_key` TEXT, `created_at` TEXT DEFAULT datetime('now'), `updated_at` TEXT DEFAULT datetime('now'), `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `proxy_url` TEXT, `use_system_proxy` BOOLEAN DEFAULT false, `custom_headers` TEXT, `external_checkin_url` TEXT, `global_weight` DOUBLE DEFAULT 1);
|
||||
CREATE TABLE IF NOT EXISTS `token_model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `token_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` TEXT DEFAULT datetime('now'), FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `token_routes` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `model_pattern` TEXT NOT NULL, `model_mapping` TEXT, `enabled` BOOLEAN DEFAULT true, `created_at` TEXT DEFAULT datetime('now'), `updated_at` TEXT DEFAULT datetime('now'), `display_name` TEXT, `display_icon` TEXT, `decision_snapshot` TEXT, `decision_refreshed_at` TEXT, `routing_strategy` TEXT DEFAULT 'weighted');
|
||||
CREATE TABLE IF NOT EXISTS `site_disabled_models` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `model_name` TEXT NOT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `token_model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `token_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE CASCADE);
|
||||
CREATE UNIQUE INDEX `downstream_api_keys_key_unique` ON `downstream_api_keys` (`key`(191));
|
||||
CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availability` (`account_id`, `model_name`(191));
|
||||
CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191));
|
||||
|
||||
@@ -1,27 +1 @@
|
||||
CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `key` TEXT NOT NULL, `description` TEXT, `enabled` BOOLEAN DEFAULT true, `expires_at` TEXT, `max_cost` DOUBLE, `used_cost` DOUBLE DEFAULT 0, `max_requests` INT, `used_requests` INT DEFAULT 0, `supported_models` TEXT, `allowed_route_ids` TEXT, `site_weight_multipliers` TEXT, `last_used_at` TEXT, `created_at` TEXT DEFAULT datetime('now'), `updated_at` TEXT DEFAULT datetime('now'));
|
||||
CREATE TABLE IF NOT EXISTS `proxy_files` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `owner_type` TEXT NOT NULL, `owner_id` TEXT NOT NULL, `filename` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `purpose` TEXT, `byte_size` INT NOT NULL, `sha256` TEXT NOT NULL, `content_base64` TEXT NOT NULL, `created_at` TEXT DEFAULT datetime('now'), `updated_at` TEXT DEFAULT datetime('now'), `deleted_at` TEXT);
|
||||
CREATE TABLE IF NOT EXISTS `proxy_video_tasks` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `upstream_video_id` TEXT NOT NULL, `site_url` TEXT NOT NULL, `token_value` TEXT NOT NULL, `requested_model` TEXT, `actual_model` TEXT, `channel_id` INT, `account_id` INT, `status_snapshot` TEXT, `upstream_response_meta` TEXT, `last_upstream_status` INT, `last_polled_at` TEXT, `created_at` TEXT DEFAULT datetime('now'), `updated_at` TEXT DEFAULT datetime('now'));
|
||||
ALTER TABLE `route_channels` ADD COLUMN `source_model` TEXT;
|
||||
ALTER TABLE `route_channels` ADD COLUMN `last_selected_at` TEXT;
|
||||
ALTER TABLE `route_channels` ADD COLUMN `consecutive_fail_count` INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE `route_channels` ADD COLUMN `cooldown_level` INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE `sites` ADD COLUMN `proxy_url` TEXT;
|
||||
ALTER TABLE `sites` ADD COLUMN `use_system_proxy` BOOLEAN DEFAULT false;
|
||||
ALTER TABLE `sites` ADD COLUMN `custom_headers` TEXT;
|
||||
ALTER TABLE `sites` ADD COLUMN `external_checkin_url` TEXT;
|
||||
ALTER TABLE `sites` ADD COLUMN `global_weight` DOUBLE DEFAULT 1;
|
||||
ALTER TABLE `token_routes` ADD COLUMN `display_name` TEXT;
|
||||
ALTER TABLE `token_routes` ADD COLUMN `display_icon` TEXT;
|
||||
ALTER TABLE `token_routes` ADD COLUMN `decision_snapshot` TEXT;
|
||||
ALTER TABLE `token_routes` ADD COLUMN `decision_refreshed_at` TEXT;
|
||||
ALTER TABLE `token_routes` ADD COLUMN `routing_strategy` TEXT DEFAULT 'weighted';
|
||||
CREATE UNIQUE INDEX `downstream_api_keys_key_unique` ON `downstream_api_keys` (`key`(191));
|
||||
CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availability` (`account_id`, `model_name`(191));
|
||||
CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191));
|
||||
CREATE UNIQUE INDEX `proxy_video_tasks_public_id_unique` ON `proxy_video_tasks` (`public_id`(191));
|
||||
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_name_idx` ON `downstream_api_keys` (`name`(191));
|
||||
CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`(191));
|
||||
CREATE INDEX `proxy_video_tasks_created_at_idx` ON `proxy_video_tasks` (`created_at`(191));
|
||||
CREATE INDEX `proxy_video_tasks_upstream_video_id_idx` ON `proxy_video_tasks` (`upstream_video_id`(191));
|
||||
-- no schema changes detected for mysql
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT true, "is_default" BOOLEAN DEFAULT false, "created_at" TEXT DEFAULT datetime('now'), "updated_at" TEXT DEFAULT datetime('now'), "token_group" TEXT, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "checkin_enabled" BOOLEAN DEFAULT true, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT DEFAULT datetime('now'), "updated_at" TEXT DEFAULT datetime('now'), "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT DEFAULT datetime('now'), FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" BOOLEAN DEFAULT true, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT DEFAULT datetime('now'), "updated_at" TEXT DEFAULT datetime('now'));
|
||||
CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT NOT NULL DEFAULT 'info', "read" BOOLEAN DEFAULT false, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT DEFAULT datetime('now'));
|
||||
CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT DEFAULT datetime('now'), FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT DEFAULT datetime('now'), "updated_at" TEXT DEFAULT datetime('now'), "deleted_at" TEXT);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT DEFAULT datetime('now'), "billing_details" TEXT);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT DEFAULT datetime('now'), "updated_at" TEXT DEFAULT datetime('now'));
|
||||
CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER NOT NULL, "account_id" INTEGER NOT NULL, "token_id" INTEGER, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT true, "manual_override" BOOLEAN DEFAULT false, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TEXT, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE, FOREIGN KEY ("route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE SET NULL);
|
||||
CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "platform" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'active', "api_key" TEXT, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT false, "custom_headers" JSONB, "external_checkin_url" TEXT, "global_weight" DOUBLE PRECISION DEFAULT 1);
|
||||
CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "checkin_enabled" BOOLEAN DEFAULT true, "last_checkin_at" TIMESTAMP, "last_balance_refresh" TIMESTAMP, "extra_config" JSONB, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT true, "is_default" BOOLEAN DEFAULT false, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "token_group" TEXT, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" BOOLEAN DEFAULT true, "expires_at" TIMESTAMP, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" JSONB, "allowed_route_ids" JSONB, "site_weight_multipliers" JSONB, "last_used_at" TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT NOT NULL DEFAULT 'info', "read" BOOLEAN DEFAULT false, "related_id" INTEGER, "related_type" TEXT, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMP);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "billing_details" JSONB);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" JSONB, "upstream_response_meta" JSONB, "last_upstream_status" INTEGER, "last_polled_at" TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "model_pattern" TEXT NOT NULL, "model_mapping" JSONB, "enabled" BOOLEAN DEFAULT true, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "display_name" TEXT, "display_icon" TEXT, "decision_snapshot" JSONB, "decision_refreshed_at" TIMESTAMP, "routing_strategy" TEXT DEFAULT 'weighted');
|
||||
CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER NOT NULL, "account_id" INTEGER NOT NULL, "token_id" INTEGER, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT true, "manual_override" BOOLEAN DEFAULT false, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TIMESTAMP, "last_selected_at" TIMESTAMP, "last_fail_at" TIMESTAMP, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TIMESTAMP, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE, FOREIGN KEY ("route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE SET NULL);
|
||||
CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT NOT NULL PRIMARY KEY, "value" TEXT);
|
||||
CREATE TABLE IF NOT EXISTS "site_disabled_models" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "created_at" TEXT DEFAULT datetime('now'), FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "platform" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'active', "api_key" TEXT, "created_at" TEXT DEFAULT datetime('now'), "updated_at" TEXT DEFAULT datetime('now'), "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT false, "custom_headers" TEXT, "external_checkin_url" TEXT, "global_weight" DOUBLE PRECISION DEFAULT 1);
|
||||
CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "token_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT DEFAULT datetime('now'), FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "model_pattern" TEXT NOT NULL, "model_mapping" TEXT, "enabled" BOOLEAN DEFAULT true, "created_at" TEXT DEFAULT datetime('now'), "updated_at" TEXT DEFAULT datetime('now'), "display_name" TEXT, "display_icon" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "routing_strategy" TEXT DEFAULT 'weighted');
|
||||
CREATE TABLE IF NOT EXISTS "site_disabled_models" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "token_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE CASCADE);
|
||||
CREATE UNIQUE INDEX "downstream_api_keys_key_unique" ON "downstream_api_keys" ("key");
|
||||
CREATE UNIQUE INDEX "model_availability_account_model_unique" ON "model_availability" ("account_id", "model_name");
|
||||
CREATE UNIQUE INDEX "proxy_files_public_id_unique" ON "proxy_files" ("public_id");
|
||||
|
||||
@@ -1,27 +1 @@
|
||||
CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" BOOLEAN DEFAULT true, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT DEFAULT datetime('now'), "updated_at" TEXT DEFAULT datetime('now'));
|
||||
CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT DEFAULT datetime('now'), "updated_at" TEXT DEFAULT datetime('now'), "deleted_at" TEXT);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT DEFAULT datetime('now'), "updated_at" TEXT DEFAULT datetime('now'));
|
||||
ALTER TABLE "route_channels" ADD COLUMN "source_model" TEXT;
|
||||
ALTER TABLE "route_channels" ADD COLUMN "last_selected_at" TEXT;
|
||||
ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "sites" ADD COLUMN "proxy_url" TEXT;
|
||||
ALTER TABLE "sites" ADD COLUMN "use_system_proxy" BOOLEAN DEFAULT false;
|
||||
ALTER TABLE "sites" ADD COLUMN "custom_headers" TEXT;
|
||||
ALTER TABLE "sites" ADD COLUMN "external_checkin_url" TEXT;
|
||||
ALTER TABLE "sites" ADD COLUMN "global_weight" DOUBLE PRECISION DEFAULT 1;
|
||||
ALTER TABLE "token_routes" ADD COLUMN "display_name" TEXT;
|
||||
ALTER TABLE "token_routes" ADD COLUMN "display_icon" TEXT;
|
||||
ALTER TABLE "token_routes" ADD COLUMN "decision_snapshot" TEXT;
|
||||
ALTER TABLE "token_routes" ADD COLUMN "decision_refreshed_at" TEXT;
|
||||
ALTER TABLE "token_routes" ADD COLUMN "routing_strategy" TEXT DEFAULT 'weighted';
|
||||
CREATE UNIQUE INDEX "downstream_api_keys_key_unique" ON "downstream_api_keys" ("key");
|
||||
CREATE UNIQUE INDEX "model_availability_account_model_unique" ON "model_availability" ("account_id", "model_name");
|
||||
CREATE UNIQUE INDEX "proxy_files_public_id_unique" ON "proxy_files" ("public_id");
|
||||
CREATE UNIQUE INDEX "proxy_video_tasks_public_id_unique" ON "proxy_video_tasks" ("public_id");
|
||||
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");
|
||||
CREATE INDEX "downstream_api_keys_name_idx" ON "downstream_api_keys" ("name");
|
||||
CREATE INDEX "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at");
|
||||
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");
|
||||
-- no schema changes detected for postgres
|
||||
|
||||
@@ -89,4 +89,21 @@ describe('schema artifact generator', () => {
|
||||
expect(artifacts.postgresUpgrade).toContain('CREATE INDEX "sites_status_idx" ON "sites"');
|
||||
expect(artifacts.postgresUpgrade).toContain('CREATE UNIQUE INDEX "accounts_site_email_unique" ON "accounts"');
|
||||
});
|
||||
|
||||
it('orders dependent tables safely and emits executable datetime defaults for external dialects', () => {
|
||||
const artifacts = generateDialectArtifacts(readSchemaContract());
|
||||
|
||||
expect(
|
||||
artifacts.mysqlBootstrap.indexOf('CREATE TABLE IF NOT EXISTS `accounts`'),
|
||||
).toBeLessThan(
|
||||
artifacts.mysqlBootstrap.indexOf('CREATE TABLE IF NOT EXISTS `account_tokens`'),
|
||||
);
|
||||
expect(
|
||||
artifacts.postgresBootstrap.indexOf('CREATE TABLE IF NOT EXISTS "accounts"'),
|
||||
).toBeLessThan(
|
||||
artifacts.postgresBootstrap.indexOf('CREATE TABLE IF NOT EXISTS "account_tokens"'),
|
||||
);
|
||||
expect(artifacts.mysqlBootstrap).toContain('`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP');
|
||||
expect(artifacts.postgresBootstrap).toContain('"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,8 +45,11 @@ function mapColumnType(dialect: Dialect, columnName: string, column: SchemaContr
|
||||
case 'real':
|
||||
return 'DOUBLE';
|
||||
case 'datetime':
|
||||
return 'DATETIME';
|
||||
case 'json':
|
||||
return 'JSON';
|
||||
case 'text':
|
||||
return column.defaultValue != null ? 'VARCHAR(191)' : 'TEXT';
|
||||
default:
|
||||
return 'TEXT';
|
||||
}
|
||||
@@ -60,18 +63,29 @@ function mapColumnType(dialect: Dialect, columnName: string, column: SchemaContr
|
||||
case 'real':
|
||||
return 'DOUBLE PRECISION';
|
||||
case 'datetime':
|
||||
return 'TIMESTAMP';
|
||||
case 'json':
|
||||
return 'JSONB';
|
||||
case 'text':
|
||||
default:
|
||||
return 'TEXT';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDefaultValue(defaultValue: string | null): string {
|
||||
if (defaultValue == null) {
|
||||
function formatDefaultValue(column: SchemaContractColumn): string {
|
||||
if (column.defaultValue == null || column.primaryKey) {
|
||||
return '';
|
||||
}
|
||||
return ` DEFAULT ${defaultValue}`;
|
||||
|
||||
if (column.logicalType === 'datetime') {
|
||||
return ' DEFAULT CURRENT_TIMESTAMP';
|
||||
}
|
||||
|
||||
if (column.logicalType === 'boolean') {
|
||||
return ` DEFAULT ${column.defaultValue.toLowerCase()}`;
|
||||
}
|
||||
|
||||
return ` DEFAULT ${column.defaultValue}`;
|
||||
}
|
||||
|
||||
function buildColumnDefinition(
|
||||
@@ -81,7 +95,7 @@ function buildColumnDefinition(
|
||||
): string {
|
||||
const sqlType = mapColumnType(dialect, columnName, column);
|
||||
const notNull = column.notNull ? ' NOT NULL' : '';
|
||||
const defaultValue = formatDefaultValue(column.defaultValue);
|
||||
const defaultValue = formatDefaultValue(column);
|
||||
const primaryKey = column.primaryKey ? ' PRIMARY KEY' : '';
|
||||
return `${quoteIdentifier(dialect, columnName)} ${sqlType}${notNull}${defaultValue}${primaryKey}`;
|
||||
}
|
||||
@@ -93,6 +107,42 @@ function buildForeignKeyClause(dialect: Dialect, foreignKey: SchemaContractForei
|
||||
return `FOREIGN KEY (${sourceColumns}) REFERENCES ${quoteIdentifier(dialect, foreignKey.referencedTable)}(${targetColumns})${onDelete}`;
|
||||
}
|
||||
|
||||
function sortTableNamesForCreation(contract: SchemaContract): string[] {
|
||||
const ordered: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
const visiting = new Set<string>();
|
||||
const tableNames = Object.keys(contract.tables).sort((left, right) => left.localeCompare(right, 'en'));
|
||||
|
||||
const dependencies = new Map<string, string[]>();
|
||||
for (const tableName of tableNames) {
|
||||
const referencedTables = contract.foreignKeys
|
||||
.filter((foreignKey) => foreignKey.table === tableName)
|
||||
.map((foreignKey) => foreignKey.referencedTable)
|
||||
.filter((referencedTable) => referencedTable !== tableName && tableNames.includes(referencedTable))
|
||||
.sort((left, right) => left.localeCompare(right, 'en'));
|
||||
dependencies.set(tableName, [...new Set(referencedTables)]);
|
||||
}
|
||||
|
||||
function visit(tableName: string): void {
|
||||
if (visited.has(tableName) || visiting.has(tableName)) {
|
||||
return;
|
||||
}
|
||||
visiting.add(tableName);
|
||||
for (const dependency of dependencies.get(tableName) ?? []) {
|
||||
visit(dependency);
|
||||
}
|
||||
visiting.delete(tableName);
|
||||
visited.add(tableName);
|
||||
ordered.push(tableName);
|
||||
}
|
||||
|
||||
for (const tableName of tableNames) {
|
||||
visit(tableName);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function buildCreateTableStatement(
|
||||
dialect: Dialect,
|
||||
tableName: string,
|
||||
@@ -131,8 +181,7 @@ function buildIndexStatement(dialect: Dialect, index: SchemaContractIndex, contr
|
||||
}
|
||||
|
||||
function buildBootstrapSql(dialect: Dialect, contract: SchemaContract): string {
|
||||
const tableStatements = Object.keys(contract.tables)
|
||||
.sort((left, right) => left.localeCompare(right, 'en'))
|
||||
const tableStatements = sortTableNamesForCreation(contract)
|
||||
.map((tableName) => buildCreateTableStatement(dialect, tableName, contract));
|
||||
|
||||
const uniqueStatements = contract.uniques
|
||||
@@ -170,8 +219,14 @@ function buildUpgradeSql(
|
||||
|
||||
const previousTableNames = new Set(Object.keys(previousContract.tables));
|
||||
const currentTableNames = Object.keys(currentContract.tables).sort((left, right) => left.localeCompare(right, 'en'));
|
||||
const addedTableStatements = currentTableNames
|
||||
.filter((tableName) => !previousTableNames.has(tableName))
|
||||
const addedTableNames = currentTableNames.filter((tableName) => !previousTableNames.has(tableName));
|
||||
const addedTablesContract: SchemaContract = {
|
||||
tables: Object.fromEntries(addedTableNames.map((tableName) => [tableName, currentContract.tables[tableName]])),
|
||||
indexes: currentContract.indexes.filter((index) => addedTableNames.includes(index.table)),
|
||||
uniques: currentContract.uniques.filter((unique) => addedTableNames.includes(unique.table)),
|
||||
foreignKeys: currentContract.foreignKeys.filter((foreignKey) => addedTableNames.includes(foreignKey.table)),
|
||||
};
|
||||
const addedTableStatements = sortTableNamesForCreation(addedTablesContract)
|
||||
.map((tableName) => buildCreateTableStatement(dialect, tableName, currentContract));
|
||||
|
||||
const addColumnStatements: string[] = [];
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeDefaultValue, normalizeSqlType } from './schemaIntrospection.js';
|
||||
|
||||
describe('schema introspection normalization', () => {
|
||||
it('normalizes booleans consistently across dialects', () => {
|
||||
expect(normalizeSqlType('sqlite', 'INTEGER', 'use_system_proxy')).toBe('boolean');
|
||||
expect(normalizeSqlType('mysql', 'tinyint', 'use_system_proxy')).toBe('boolean');
|
||||
expect(normalizeSqlType('postgres', 'boolean', 'use_system_proxy')).toBe('boolean');
|
||||
});
|
||||
|
||||
it('normalizes common default values', () => {
|
||||
expect(normalizeDefaultValue("DEFAULT 'active'")).toBe("'active'");
|
||||
expect(normalizeDefaultValue('DEFAULT FALSE')).toBe('false');
|
||||
expect(normalizeDefaultValue("datetime('now')")).toBe("datetime('now')");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,824 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import mysql from 'mysql2/promise';
|
||||
import { tmpdir } from 'node:os';
|
||||
import pg from 'pg';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { resolveGeneratedArtifactPath } from './schemaArtifactGenerator.js';
|
||||
import type {
|
||||
LogicalColumnType,
|
||||
SchemaContract,
|
||||
SchemaContractColumn,
|
||||
SchemaContractForeignKey,
|
||||
SchemaContractIndex,
|
||||
SchemaContractTable,
|
||||
SchemaContractUnique,
|
||||
} from './schemaContract.js';
|
||||
import { resolveMigrationsFolder } from './schemaContract.js';
|
||||
|
||||
export type SchemaIntrospectionDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||
|
||||
export interface SchemaIntrospectionInput {
|
||||
dialect: SchemaIntrospectionDialect;
|
||||
connectionString: string;
|
||||
ssl?: boolean;
|
||||
}
|
||||
|
||||
export interface MaterializeFreshSchemaOptions {
|
||||
connectionString?: string;
|
||||
ssl?: boolean;
|
||||
}
|
||||
|
||||
type SqliteTableInfoRow = {
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
dflt_value: string | null;
|
||||
pk: number;
|
||||
};
|
||||
|
||||
type SqliteIndexListRow = {
|
||||
name: string;
|
||||
unique: number;
|
||||
};
|
||||
|
||||
type SqliteIndexInfoRow = {
|
||||
seqno: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type SqliteForeignKeyRow = {
|
||||
id: number;
|
||||
table: string;
|
||||
from: string;
|
||||
to: string;
|
||||
on_delete: string;
|
||||
};
|
||||
|
||||
type MySqlColumnRow = {
|
||||
table_name: string;
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
column_type: string;
|
||||
is_nullable: 'YES' | 'NO';
|
||||
column_default: string | null;
|
||||
};
|
||||
|
||||
type MySqlPrimaryKeyRow = {
|
||||
table_name: string;
|
||||
column_name: string;
|
||||
};
|
||||
|
||||
type MySqlIndexRow = {
|
||||
table_name: string;
|
||||
index_name: string;
|
||||
column_name: string;
|
||||
non_unique: number;
|
||||
};
|
||||
|
||||
type MySqlForeignKeyRow = {
|
||||
table_name: string;
|
||||
constraint_name: string;
|
||||
column_name: string;
|
||||
referenced_table_name: string;
|
||||
referenced_column_name: string;
|
||||
delete_rule: string | null;
|
||||
};
|
||||
|
||||
type PostgresColumnRow = {
|
||||
table_name: string;
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
udt_name: string;
|
||||
is_nullable: 'YES' | 'NO';
|
||||
column_default: string | null;
|
||||
};
|
||||
|
||||
type PostgresPrimaryKeyRow = {
|
||||
table_name: string;
|
||||
column_name: string;
|
||||
};
|
||||
|
||||
type PostgresIndexRow = {
|
||||
table_name: string;
|
||||
index_name: string;
|
||||
is_unique: boolean;
|
||||
column_name: string;
|
||||
};
|
||||
|
||||
type PostgresForeignKeyRow = {
|
||||
table_name: string;
|
||||
constraint_name: string;
|
||||
column_name: string;
|
||||
referenced_table_name: string;
|
||||
referenced_column_name: string;
|
||||
delete_rule: string | null;
|
||||
};
|
||||
|
||||
function splitMigrationStatements(sqlText: string): string[] {
|
||||
return sqlText
|
||||
.split('--> statement-breakpoint')
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
}
|
||||
|
||||
function splitSqlStatements(sqlText: string): string[] {
|
||||
return sqlText
|
||||
.split(/;\s*(?:\r?\n|$)/g)
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
}
|
||||
|
||||
function hasBalancedParentheses(value: string): boolean {
|
||||
let depth = 0;
|
||||
for (const char of value) {
|
||||
if (char === '(') depth += 1;
|
||||
if (char === ')') depth -= 1;
|
||||
if (depth < 0) return false;
|
||||
}
|
||||
return depth === 0;
|
||||
}
|
||||
|
||||
function unwrapSurroundingParentheses(value: string): string {
|
||||
let normalized = value.trim();
|
||||
while (normalized.startsWith('(') && normalized.endsWith(')')) {
|
||||
const inner = normalized.slice(1, -1).trim();
|
||||
if (!hasBalancedParentheses(inner)) {
|
||||
break;
|
||||
}
|
||||
normalized = inner;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeDeleteRule(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
return value.trim().replace(/\s+/g, ' ').toUpperCase();
|
||||
}
|
||||
|
||||
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('now')")
|
||||
|| normalizedDefault.includes('current_timestamp')
|
||||
|| normalizedDefault.includes('now()');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
export function normalizeSqlType(
|
||||
dialect: SchemaIntrospectionDialect,
|
||||
declaredType: string,
|
||||
columnName: string,
|
||||
rawDefaultValue: string | null = null,
|
||||
): LogicalColumnType {
|
||||
const normalizedType = declaredType.trim().toLowerCase();
|
||||
const normalizedDefault = normalizeDefaultValue(rawDefaultValue);
|
||||
|
||||
if (dialect === 'mysql') {
|
||||
if (normalizedType.includes('tinyint(1)') || normalizedType.includes('boolean') || normalizedType.includes('bool')) {
|
||||
return 'boolean';
|
||||
}
|
||||
if (normalizedType.includes('json')) {
|
||||
return 'json';
|
||||
}
|
||||
if (normalizedType.includes('timestamp') || normalizedType.includes('datetime') || normalizedType === 'date') {
|
||||
return 'datetime';
|
||||
}
|
||||
if (normalizedType.includes('int')) {
|
||||
return isBooleanLikeColumn(columnName, normalizedDefault) ? 'boolean' : 'integer';
|
||||
}
|
||||
if (normalizedType.includes('double') || normalizedType.includes('float') || normalizedType.includes('real') || normalizedType.includes('decimal')) {
|
||||
return 'real';
|
||||
}
|
||||
if (normalizedType.includes('char') || normalizedType.includes('text')) {
|
||||
if (isDateTimeLikeColumn(columnName, normalizedDefault)) return 'datetime';
|
||||
if (isJsonLikeColumn(columnName)) return 'json';
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
if (dialect === 'postgres') {
|
||||
if (normalizedType.includes('bool')) {
|
||||
return 'boolean';
|
||||
}
|
||||
if (normalizedType.includes('json')) {
|
||||
return 'json';
|
||||
}
|
||||
if (normalizedType.includes('timestamp') || normalizedType === 'date') {
|
||||
return 'datetime';
|
||||
}
|
||||
if (normalizedType.includes('int')) {
|
||||
return isBooleanLikeColumn(columnName, normalizedDefault) ? 'boolean' : 'integer';
|
||||
}
|
||||
if (normalizedType.includes('double') || normalizedType.includes('real') || normalizedType.includes('numeric') || normalizedType.includes('decimal')) {
|
||||
return 'real';
|
||||
}
|
||||
if (normalizedType.includes('char') || normalizedType.includes('text')) {
|
||||
if (isDateTimeLikeColumn(columnName, normalizedDefault)) return 'datetime';
|
||||
if (isJsonLikeColumn(columnName)) return 'json';
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedType.includes('int')) {
|
||||
return isBooleanLikeColumn(columnName, normalizedDefault) ? 'boolean' : 'integer';
|
||||
}
|
||||
if (normalizedType.includes('real') || normalizedType.includes('double') || normalizedType.includes('float') || normalizedType.includes('decimal')) {
|
||||
return 'real';
|
||||
}
|
||||
if (normalizedType.includes('json')) {
|
||||
return 'json';
|
||||
}
|
||||
if (normalizedType.includes('timestamp') || normalizedType.includes('datetime') || normalizedType === 'date') {
|
||||
return 'datetime';
|
||||
}
|
||||
if (normalizedType.includes('text') || normalizedType.includes('char') || normalizedType.includes('clob')) {
|
||||
if (isDateTimeLikeColumn(columnName, normalizedDefault)) return 'datetime';
|
||||
if (isJsonLikeColumn(columnName)) return 'json';
|
||||
return 'text';
|
||||
}
|
||||
if (isDateTimeLikeColumn(columnName, normalizedDefault)) return 'datetime';
|
||||
return 'text';
|
||||
}
|
||||
|
||||
function normalizeDefaultValueForColumn(
|
||||
rawDefaultValue: string | null | undefined,
|
||||
logicalType: LogicalColumnType | null,
|
||||
): string | null {
|
||||
if (rawDefaultValue == null) return null;
|
||||
|
||||
let normalized = String(rawDefaultValue).trim();
|
||||
if (!normalized) return null;
|
||||
|
||||
normalized = normalized.replace(/^default\s+/i, '').trim();
|
||||
normalized = unwrapSurroundingParentheses(normalized);
|
||||
normalized = normalized.replace(/::[\w\s.\[\]"]+/g, '').trim();
|
||||
normalized = unwrapSurroundingParentheses(normalized);
|
||||
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (lowered === 'null') return null;
|
||||
|
||||
if (logicalType === 'datetime' || lowered === 'current_timestamp' || lowered === 'current_timestamp()' || lowered === 'now()' || lowered.includes("datetime('now')")) {
|
||||
return "datetime('now')";
|
||||
}
|
||||
|
||||
if (logicalType === 'boolean') {
|
||||
if (lowered === '1' || lowered === 'true' || lowered === "b'1'") return 'true';
|
||||
if (lowered === '0' || lowered === 'false' || lowered === "b'0'") return 'false';
|
||||
}
|
||||
|
||||
if (lowered === 'true' || lowered === 'false') return lowered;
|
||||
if (/^-?\d+(?:\.\d+)?$/.test(normalized)) return normalized;
|
||||
if (/^'.*'$/.test(normalized)) return normalized;
|
||||
if (/^[a-z_][a-z0-9_]*$/i.test(normalized)) return `'${normalized}'`;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeDefaultValue(rawDefaultValue: string | null | undefined): string | null {
|
||||
return normalizeDefaultValueForColumn(rawDefaultValue, null);
|
||||
}
|
||||
|
||||
function sortForeignKeys(foreignKeys: SchemaContractForeignKey[]): SchemaContractForeignKey[] {
|
||||
return foreignKeys.sort((left, right) => {
|
||||
const leftKey = `${left.table}:${left.columns.join(',')}`;
|
||||
const rightKey = `${right.table}:${right.columns.join(',')}`;
|
||||
return leftKey.localeCompare(rightKey, 'en');
|
||||
});
|
||||
}
|
||||
|
||||
function buildSqliteTables(sqlite: Database.Database): Record<string, SchemaContractTable> {
|
||||
const tableRows = 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 tables: Record<string, SchemaContractTable> = {};
|
||||
for (const { name: tableName } of tableRows) {
|
||||
const rows = sqlite.prepare(`PRAGMA table_info("${tableName}")`).all() as SqliteTableInfoRow[];
|
||||
const columns = Object.fromEntries(rows.map((row) => {
|
||||
const logicalType = normalizeSqlType('sqlite', row.type, row.name, row.dflt_value);
|
||||
return [row.name, {
|
||||
logicalType,
|
||||
notNull: row.notnull === 1,
|
||||
defaultValue: normalizeDefaultValueForColumn(row.dflt_value, logicalType),
|
||||
primaryKey: row.pk > 0,
|
||||
} satisfies SchemaContractColumn];
|
||||
}));
|
||||
tables[tableName] = { columns };
|
||||
}
|
||||
return tables;
|
||||
}
|
||||
|
||||
function buildSqliteIndexes(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 SqliteIndexListRow[];
|
||||
for (const row of rows) {
|
||||
if (!row.name || row.name.startsWith('sqlite_autoindex')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const columns = (sqlite.prepare(`PRAGMA index_info("${row.name}")`).all() as SqliteIndexInfoRow[])
|
||||
.sort((left, right) => left.seqno - right.seqno)
|
||||
.map((item) => item.name)
|
||||
.filter(Boolean);
|
||||
|
||||
indexes.push({
|
||||
name: row.name,
|
||||
table: tableName,
|
||||
columns,
|
||||
unique: row.unique === 1,
|
||||
});
|
||||
|
||||
if (row.unique === 1) {
|
||||
uniques.push({
|
||||
name: row.name,
|
||||
table: tableName,
|
||||
columns,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
indexes.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
||||
uniques.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
||||
return { indexes, uniques };
|
||||
}
|
||||
|
||||
function buildSqliteForeignKeys(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 SqliteForeignKeyRow[];
|
||||
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: normalizeDeleteRule(row.on_delete),
|
||||
});
|
||||
}
|
||||
|
||||
foreignKeys.push(...grouped.values());
|
||||
}
|
||||
|
||||
return sortForeignKeys(foreignKeys);
|
||||
}
|
||||
|
||||
async function introspectSqliteSchema(connectionString: string): Promise<SchemaContract> {
|
||||
const sqlitePath = connectionString === ':memory:' ? ':memory:' : resolve(connectionString);
|
||||
const sqlite = new Database(sqlitePath, { readonly: true });
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
try {
|
||||
const tables = buildSqliteTables(sqlite);
|
||||
const { indexes, uniques } = buildSqliteIndexes(sqlite, tables);
|
||||
const foreignKeys = buildSqliteForeignKeys(sqlite, tables);
|
||||
return { tables, indexes, uniques, foreignKeys };
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function introspectMySqlSchema(input: SchemaIntrospectionInput): Promise<SchemaContract> {
|
||||
const connectionOptions: mysql.ConnectionOptions = { uri: input.connectionString };
|
||||
if (input.ssl) {
|
||||
connectionOptions.ssl = { rejectUnauthorized: false };
|
||||
}
|
||||
const connection = await mysql.createConnection(connectionOptions);
|
||||
|
||||
try {
|
||||
const [tableRows] = await connection.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name ASC
|
||||
`);
|
||||
const tableNames = (tableRows as Array<{ table_name: string }>).map((row) => row.table_name);
|
||||
|
||||
const [primaryKeyRows] = await connection.query(`
|
||||
SELECT kcu.table_name, kcu.column_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_schema = kcu.constraint_schema
|
||||
AND tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_name = kcu.table_name
|
||||
WHERE tc.table_schema = DATABASE()
|
||||
AND tc.constraint_type = 'PRIMARY KEY'
|
||||
`);
|
||||
const primaryKeys = new Set((primaryKeyRows as MySqlPrimaryKeyRow[]).map((row) => `${row.table_name}.${row.column_name}`));
|
||||
|
||||
const [columnRows] = await connection.query(`
|
||||
SELECT table_name, column_name, data_type, column_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
ORDER BY table_name ASC, ordinal_position ASC
|
||||
`);
|
||||
|
||||
const tableMap = new Map<string, SchemaContractTable>();
|
||||
for (const tableName of tableNames) {
|
||||
tableMap.set(tableName, { columns: {} });
|
||||
}
|
||||
|
||||
for (const row of columnRows as MySqlColumnRow[]) {
|
||||
const logicalType = normalizeSqlType('mysql', row.column_type || row.data_type, row.column_name, row.column_default);
|
||||
tableMap.get(row.table_name)!.columns[row.column_name] = {
|
||||
logicalType,
|
||||
notNull: row.is_nullable === 'NO',
|
||||
defaultValue: primaryKeys.has(`${row.table_name}.${row.column_name}`)
|
||||
? null
|
||||
: normalizeDefaultValueForColumn(row.column_default, logicalType),
|
||||
primaryKey: primaryKeys.has(`${row.table_name}.${row.column_name}`),
|
||||
};
|
||||
}
|
||||
|
||||
const tables = Object.fromEntries(tableNames.map((tableName) => [tableName, tableMap.get(tableName)!]));
|
||||
|
||||
const [indexRows] = await connection.query(`
|
||||
SELECT table_name, index_name, column_name, non_unique
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND index_name <> 'PRIMARY'
|
||||
ORDER BY table_name ASC, index_name ASC, seq_in_index ASC
|
||||
`);
|
||||
const indexGroups = new Map<string, SchemaContractIndex>();
|
||||
for (const row of indexRows as MySqlIndexRow[]) {
|
||||
const key = `${row.table_name}:${row.index_name}`;
|
||||
const existing = indexGroups.get(key);
|
||||
if (existing) {
|
||||
existing.columns.push(row.column_name);
|
||||
continue;
|
||||
}
|
||||
indexGroups.set(key, {
|
||||
name: row.index_name,
|
||||
table: row.table_name,
|
||||
columns: [row.column_name],
|
||||
unique: Number(row.non_unique) === 0,
|
||||
});
|
||||
}
|
||||
|
||||
const indexes = [...indexGroups.values()].sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
||||
const uniques = indexes
|
||||
.filter((index) => index.unique)
|
||||
.map((index) => ({ name: index.name, table: index.table, columns: [...index.columns] }))
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
||||
|
||||
const [foreignKeyRows] = await connection.query(`
|
||||
SELECT
|
||||
kcu.table_name,
|
||||
kcu.constraint_name,
|
||||
kcu.column_name,
|
||||
kcu.referenced_table_name,
|
||||
kcu.referenced_column_name,
|
||||
rc.delete_rule
|
||||
FROM information_schema.key_column_usage kcu
|
||||
JOIN information_schema.referential_constraints rc
|
||||
ON rc.constraint_schema = kcu.constraint_schema
|
||||
AND rc.constraint_name = kcu.constraint_name
|
||||
WHERE kcu.table_schema = DATABASE()
|
||||
AND kcu.referenced_table_name IS NOT NULL
|
||||
ORDER BY kcu.table_name ASC, kcu.constraint_name ASC, kcu.ordinal_position ASC
|
||||
`);
|
||||
const foreignKeyGroups = new Map<string, SchemaContractForeignKey>();
|
||||
for (const row of foreignKeyRows as MySqlForeignKeyRow[]) {
|
||||
const key = `${row.table_name}:${row.constraint_name}`;
|
||||
const existing = foreignKeyGroups.get(key);
|
||||
if (existing) {
|
||||
existing.columns.push(row.column_name);
|
||||
existing.referencedColumns.push(row.referenced_column_name);
|
||||
continue;
|
||||
}
|
||||
foreignKeyGroups.set(key, {
|
||||
table: row.table_name,
|
||||
columns: [row.column_name],
|
||||
referencedTable: row.referenced_table_name,
|
||||
referencedColumns: [row.referenced_column_name],
|
||||
onDelete: normalizeDeleteRule(row.delete_rule),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tables,
|
||||
indexes,
|
||||
uniques,
|
||||
foreignKeys: sortForeignKeys([...foreignKeyGroups.values()]),
|
||||
};
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function introspectPostgresSchema(input: SchemaIntrospectionInput): Promise<SchemaContract> {
|
||||
const clientOptions: pg.ClientConfig = { connectionString: input.connectionString };
|
||||
if (input.ssl) {
|
||||
clientOptions.ssl = { rejectUnauthorized: false };
|
||||
}
|
||||
const client = new pg.Client(clientOptions);
|
||||
await client.connect();
|
||||
|
||||
try {
|
||||
const tableResult = await client.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name ASC
|
||||
`);
|
||||
const tableNames = tableResult.rows.map((row) => String(row.table_name));
|
||||
|
||||
const primaryKeyResult = await client.query(`
|
||||
SELECT kcu.table_name, kcu.column_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_schema = kcu.constraint_schema
|
||||
AND tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_name = kcu.table_name
|
||||
WHERE tc.table_schema = current_schema()
|
||||
AND tc.constraint_type = 'PRIMARY KEY'
|
||||
`);
|
||||
const primaryKeys = new Set((primaryKeyResult.rows as PostgresPrimaryKeyRow[]).map((row) => `${row.table_name}.${row.column_name}`));
|
||||
|
||||
const columnResult = await client.query(`
|
||||
SELECT table_name, column_name, data_type, udt_name, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
ORDER BY table_name ASC, ordinal_position ASC
|
||||
`);
|
||||
const tableMap = new Map<string, SchemaContractTable>();
|
||||
for (const tableName of tableNames) {
|
||||
tableMap.set(tableName, { columns: {} });
|
||||
}
|
||||
|
||||
for (const row of columnResult.rows as PostgresColumnRow[]) {
|
||||
const logicalType = normalizeSqlType('postgres', `${row.data_type} ${row.udt_name}`, row.column_name, row.column_default);
|
||||
tableMap.get(row.table_name)!.columns[row.column_name] = {
|
||||
logicalType,
|
||||
notNull: row.is_nullable === 'NO',
|
||||
defaultValue: primaryKeys.has(`${row.table_name}.${row.column_name}`)
|
||||
? null
|
||||
: normalizeDefaultValueForColumn(row.column_default, logicalType),
|
||||
primaryKey: primaryKeys.has(`${row.table_name}.${row.column_name}`),
|
||||
};
|
||||
}
|
||||
|
||||
const tables = Object.fromEntries(tableNames.map((tableName) => [tableName, tableMap.get(tableName)!]));
|
||||
|
||||
const indexResult = await client.query(`
|
||||
SELECT
|
||||
t.relname AS table_name,
|
||||
i.relname AS index_name,
|
||||
ix.indisunique AS is_unique,
|
||||
a.attname AS column_name
|
||||
FROM pg_class t
|
||||
JOIN pg_namespace ns ON ns.oid = t.relnamespace
|
||||
JOIN pg_index ix ON t.oid = ix.indrelid
|
||||
JOIN pg_class i ON i.oid = ix.indexrelid
|
||||
JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS ord(attnum, n) ON TRUE
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ord.attnum
|
||||
WHERE ns.nspname = current_schema()
|
||||
AND t.relkind = 'r'
|
||||
AND NOT ix.indisprimary
|
||||
ORDER BY t.relname ASC, i.relname ASC, ord.n ASC
|
||||
`);
|
||||
const indexGroups = new Map<string, SchemaContractIndex>();
|
||||
for (const row of indexResult.rows as PostgresIndexRow[]) {
|
||||
const key = `${row.table_name}:${row.index_name}`;
|
||||
const existing = indexGroups.get(key);
|
||||
if (existing) {
|
||||
existing.columns.push(row.column_name);
|
||||
continue;
|
||||
}
|
||||
indexGroups.set(key, {
|
||||
name: row.index_name,
|
||||
table: row.table_name,
|
||||
columns: [row.column_name],
|
||||
unique: !!row.is_unique,
|
||||
});
|
||||
}
|
||||
|
||||
const indexes = [...indexGroups.values()].sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
||||
const uniques = indexes
|
||||
.filter((index) => index.unique)
|
||||
.map((index) => ({ name: index.name, table: index.table, columns: [...index.columns] }))
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
||||
|
||||
const foreignKeyResult = await client.query(`
|
||||
SELECT
|
||||
tc.table_name,
|
||||
tc.constraint_name,
|
||||
kcu.column_name,
|
||||
ccu.table_name AS referenced_table_name,
|
||||
ccu.column_name AS referenced_column_name,
|
||||
rc.delete_rule
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_schema = kcu.constraint_schema
|
||||
AND tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_name = kcu.table_name
|
||||
JOIN information_schema.referential_constraints rc
|
||||
ON tc.constraint_schema = rc.constraint_schema
|
||||
AND tc.constraint_name = rc.constraint_name
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON rc.unique_constraint_schema = ccu.constraint_schema
|
||||
AND rc.unique_constraint_name = ccu.constraint_name
|
||||
WHERE tc.table_schema = current_schema()
|
||||
AND tc.constraint_type = 'FOREIGN KEY'
|
||||
ORDER BY tc.table_name ASC, tc.constraint_name ASC, kcu.ordinal_position ASC
|
||||
`);
|
||||
const foreignKeyGroups = new Map<string, SchemaContractForeignKey>();
|
||||
for (const row of foreignKeyResult.rows as PostgresForeignKeyRow[]) {
|
||||
const key = `${row.table_name}:${row.constraint_name}`;
|
||||
const existing = foreignKeyGroups.get(key);
|
||||
if (existing) {
|
||||
existing.columns.push(row.column_name);
|
||||
existing.referencedColumns.push(row.referenced_column_name);
|
||||
continue;
|
||||
}
|
||||
foreignKeyGroups.set(key, {
|
||||
table: row.table_name,
|
||||
columns: [row.column_name],
|
||||
referencedTable: row.referenced_table_name,
|
||||
referencedColumns: [row.referenced_column_name],
|
||||
onDelete: normalizeDeleteRule(row.delete_rule),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tables,
|
||||
indexes,
|
||||
uniques,
|
||||
foreignKeys: sortForeignKeys([...foreignKeyGroups.values()]),
|
||||
};
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
export async function introspectLiveSchema(input: SchemaIntrospectionInput): Promise<SchemaContract> {
|
||||
if (input.dialect === 'sqlite') {
|
||||
return introspectSqliteSchema(input.connectionString);
|
||||
}
|
||||
if (input.dialect === 'mysql') {
|
||||
return introspectMySqlSchema(input);
|
||||
}
|
||||
return introspectPostgresSchema(input);
|
||||
}
|
||||
|
||||
function readBootstrapSql(dialect: Exclude<SchemaIntrospectionDialect, 'sqlite'>): string {
|
||||
const filename = dialect === 'mysql' ? 'mysql.bootstrap.sql' : 'postgres.bootstrap.sql';
|
||||
return readFileSync(resolveGeneratedArtifactPath(filename), 'utf8');
|
||||
}
|
||||
|
||||
async function resetMySqlSchema(connection: mysql.Connection): Promise<void> {
|
||||
await connection.query('SET FOREIGN_KEY_CHECKS = 0');
|
||||
const [rows] = await connection.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_type = 'BASE TABLE'
|
||||
`);
|
||||
for (const row of rows as Array<{ table_name: string }>) {
|
||||
await connection.query(`DROP TABLE IF EXISTS \`${row.table_name}\``);
|
||||
}
|
||||
await connection.query('SET FOREIGN_KEY_CHECKS = 1');
|
||||
}
|
||||
|
||||
async function resetPostgresSchema(client: pg.Client): Promise<void> {
|
||||
const result = await client.query(`
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname = current_schema()
|
||||
ORDER BY tablename ASC
|
||||
`);
|
||||
for (const row of result.rows as Array<{ tablename: string }>) {
|
||||
await client.query(`DROP TABLE IF EXISTS "${row.tablename}" CASCADE`);
|
||||
}
|
||||
}
|
||||
|
||||
function applySqliteMigrations(sqlite: Database.Database): void {
|
||||
const migrationsFolder = resolveMigrationsFolder();
|
||||
const migrationFiles = readdirSync(migrationsFolder)
|
||||
.filter((entry) => entry.endsWith('.sql'))
|
||||
.sort((left, right) => left.localeCompare(right, 'en'));
|
||||
|
||||
for (const migrationFile of migrationFiles) {
|
||||
const sqlText = readFileSync(join(migrationsFolder, migrationFile), 'utf8');
|
||||
for (const statement of splitMigrationStatements(sqlText)) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function materializeFreshSchema(
|
||||
dialect: SchemaIntrospectionDialect,
|
||||
options: MaterializeFreshSchemaOptions = {},
|
||||
): Promise<string> {
|
||||
if (dialect === 'sqlite') {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'metapi-schema-parity-'));
|
||||
const sqlitePath = resolve(tempDir, `${randomUUID()}.db`);
|
||||
const sqlite = new Database(sqlitePath);
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
try {
|
||||
applySqliteMigrations(sqlite);
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
return sqlitePath;
|
||||
}
|
||||
|
||||
if (!options.connectionString) {
|
||||
throw new Error(`connectionString is required to materialize ${dialect} parity schema`);
|
||||
}
|
||||
|
||||
const bootstrapStatements = splitSqlStatements(readBootstrapSql(dialect));
|
||||
if (dialect === 'mysql') {
|
||||
const connectionOptions: mysql.ConnectionOptions = { uri: options.connectionString };
|
||||
if (options.ssl) {
|
||||
connectionOptions.ssl = { rejectUnauthorized: false };
|
||||
}
|
||||
const connection = await mysql.createConnection(connectionOptions);
|
||||
try {
|
||||
await resetMySqlSchema(connection);
|
||||
for (const statement of bootstrapStatements) {
|
||||
await connection.query(statement);
|
||||
}
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
return options.connectionString;
|
||||
}
|
||||
|
||||
const clientOptions: pg.ClientConfig = { connectionString: options.connectionString };
|
||||
if (options.ssl) {
|
||||
clientOptions.ssl = { rejectUnauthorized: false };
|
||||
}
|
||||
const client = new pg.Client(clientOptions);
|
||||
await client.connect();
|
||||
try {
|
||||
await resetPostgresSchema(client);
|
||||
for (const statement of bootstrapStatements) {
|
||||
await client.query(statement);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
return options.connectionString;
|
||||
}
|
||||
|
||||
export const __schemaIntrospectionTestUtils = {
|
||||
splitMigrationStatements,
|
||||
splitSqlStatements,
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SchemaContract } from './schemaContract.js';
|
||||
import { introspectLiveSchema, materializeFreshSchema } from './schemaIntrospection.js';
|
||||
|
||||
const dbDir = dirname(fileURLToPath(import.meta.url));
|
||||
const schemaContractPath = resolve(dbDir, 'generated/schemaContract.json');
|
||||
const contract = JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract;
|
||||
|
||||
const mysqlParity = process.env.DB_PARITY_MYSQL_URL ? it : it.skip;
|
||||
const postgresParity = process.env.DB_PARITY_POSTGRES_URL ? it : it.skip;
|
||||
|
||||
describe('live schema parity', () => {
|
||||
it('matches the contract for sqlite', async () => {
|
||||
const sqliteUrl = await materializeFreshSchema('sqlite');
|
||||
const live = await introspectLiveSchema({ dialect: 'sqlite', connectionString: sqliteUrl });
|
||||
expect(live).toEqual(contract);
|
||||
});
|
||||
|
||||
mysqlParity('matches the contract for mysql', async () => {
|
||||
const mysqlUrl = await materializeFreshSchema('mysql', {
|
||||
connectionString: process.env.DB_PARITY_MYSQL_URL!,
|
||||
});
|
||||
const live = await introspectLiveSchema({ dialect: 'mysql', connectionString: mysqlUrl });
|
||||
expect(live).toEqual(contract);
|
||||
});
|
||||
|
||||
postgresParity('matches the contract for postgres', async () => {
|
||||
const postgresUrl = await materializeFreshSchema('postgres', {
|
||||
connectionString: process.env.DB_PARITY_POSTGRES_URL!,
|
||||
});
|
||||
const live = await introspectLiveSchema({ dialect: 'postgres', connectionString: postgresUrl });
|
||||
expect(live).toEqual(contract);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SchemaContract } from './schemaContract.js';
|
||||
|
||||
const dbDir = dirname(fileURLToPath(import.meta.url));
|
||||
const schemaPath = resolve(dbDir, 'schema.ts');
|
||||
const generatedDir = resolve(dbDir, 'generated');
|
||||
const supportPaths = [
|
||||
resolve(dbDir, 'runtimeSchemaBootstrap.ts'),
|
||||
resolve(dbDir, 'siteSchemaCompatibility.ts'),
|
||||
@@ -13,33 +14,53 @@ const supportPaths = [
|
||||
resolve(dbDir, 'accountTokenSchemaCompatibility.ts'),
|
||||
resolve(dbDir, 'sharedIndexSchemaCompatibility.ts'),
|
||||
];
|
||||
const schemaContractPath = resolve(generatedDir, 'schemaContract.json');
|
||||
|
||||
function extractAllMatches(content: string, pattern: RegExp): string[] {
|
||||
return Array.from(content.matchAll(pattern), (match) => match[1]);
|
||||
}
|
||||
|
||||
describe('database schema parity', () => {
|
||||
it('keeps external runtime table support in sync with schema.ts', () => {
|
||||
const schemaContent = readFileSync(schemaPath, 'utf8');
|
||||
it('keeps generated schema artifacts present', () => {
|
||||
const artifactPaths = [
|
||||
schemaContractPath,
|
||||
resolve(generatedDir, 'mysql.bootstrap.sql'),
|
||||
resolve(generatedDir, 'mysql.upgrade.sql'),
|
||||
resolve(generatedDir, 'postgres.bootstrap.sql'),
|
||||
resolve(generatedDir, 'postgres.upgrade.sql'),
|
||||
];
|
||||
|
||||
for (const artifactPath of artifactPaths) {
|
||||
expect(existsSync(artifactPath), artifactPath).toBe(true);
|
||||
expect(readFileSync(artifactPath, 'utf8').trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps runtime support modules scoped to contract-defined tables and indexes', () => {
|
||||
const contract = JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract;
|
||||
const supportContent = supportPaths
|
||||
.map((filePath) => readFileSync(filePath, 'utf8'))
|
||||
.join('\n');
|
||||
|
||||
const schemaTables = extractAllMatches(schemaContent, /sqliteTable\('([^']+)'/g);
|
||||
const missingTables = schemaTables.filter((tableName) => !supportContent.includes(tableName));
|
||||
const knownTables = new Set(Object.keys(contract.tables));
|
||||
const knownIndexes = new Set([
|
||||
...contract.indexes.map((index) => index.name),
|
||||
...contract.uniques.map((unique) => unique.name),
|
||||
]);
|
||||
|
||||
expect(missingTables).toEqual([]);
|
||||
});
|
||||
const supportTables = extractAllMatches(
|
||||
supportContent,
|
||||
/(?:CREATE TABLE IF NOT EXISTS|ALTER TABLE|INSERT INTO)\s+["`]?([a-z_][a-z0-9_]*)["`]?/gi,
|
||||
);
|
||||
const supportIndexes = extractAllMatches(
|
||||
supportContent,
|
||||
/(?:CREATE UNIQUE INDEX(?: IF NOT EXISTS)?|CREATE INDEX(?: IF NOT EXISTS)?|indexName:\s*')["`]?([a-z_][a-z0-9_]*)/gi,
|
||||
);
|
||||
|
||||
it('keeps external runtime index support in sync with schema.ts', () => {
|
||||
const schemaContent = readFileSync(schemaPath, 'utf8');
|
||||
const supportContent = supportPaths
|
||||
.map((filePath) => readFileSync(filePath, 'utf8'))
|
||||
.join('\n');
|
||||
const unknownTables = [...new Set(supportTables)].filter((tableName) => !knownTables.has(tableName)).sort();
|
||||
const unknownIndexes = [...new Set(supportIndexes)].filter((indexName) => !knownIndexes.has(indexName)).sort();
|
||||
|
||||
const schemaIndexes = extractAllMatches(schemaContent, /(?:uniqueIndex|index)\('([^']+)'/g);
|
||||
const missingIndexes = schemaIndexes.filter((indexName) => !supportContent.includes(indexName));
|
||||
|
||||
expect(missingIndexes).toEqual([]);
|
||||
expect(unknownTables).toEqual([]);
|
||||
expect(unknownIndexes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user