From 7f75d8a6706eca5c8df3c7a9e13129ec1b28d3db Mon Sep 17 00:00:00 2001 From: phamnazage-jpg Date: Thu, 28 May 2026 15:26:16 +0800 Subject: [PATCH] feat(routing): add logical group schema foundation --- .../0010_logical_groups_and_routes.sql | 78 +++++++ internal/store/sqlite/db_test.go | 20 ++ tests/integration/store_init_test.go | 197 +++++++++++++++++- 3 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 internal/store/migrations/0010_logical_groups_and_routes.sql diff --git a/internal/store/migrations/0010_logical_groups_and_routes.sql b/internal/store/migrations/0010_logical_groups_and_routes.sql new file mode 100644 index 00000000..1cee394f --- /dev/null +++ b/internal/store/migrations/0010_logical_groups_and_routes.sql @@ -0,0 +1,78 @@ +CREATE TABLE logical_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + logical_group_id TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + status TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + route_policy TEXT NOT NULL DEFAULT 'priority', + sticky_mode TEXT NOT NULL DEFAULT 'conversation_preferred', + conversation_ttl_seconds INTEGER NOT NULL DEFAULT 7200, + user_model_ttl_seconds INTEGER NOT NULL DEFAULT 1800, + failover_threshold INTEGER NOT NULL DEFAULT 2, + cooldown_seconds INTEGER NOT NULL DEFAULT 600, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_logical_groups_status ON logical_groups(status); + +CREATE TABLE logical_group_models ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + logical_group_id TEXT NOT NULL, + public_model TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_logical_group_models_group + FOREIGN KEY (logical_group_id) + REFERENCES logical_groups(logical_group_id) + ON DELETE CASCADE, + CONSTRAINT uq_logical_group_models_group_model + UNIQUE (logical_group_id, public_model) +); + +CREATE INDEX idx_logical_group_models_group_id ON logical_group_models(logical_group_id); +CREATE INDEX idx_logical_group_models_status ON logical_group_models(status); + +CREATE TABLE logical_group_routes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + route_id TEXT NOT NULL UNIQUE, + logical_group_id TEXT NOT NULL, + name TEXT NOT NULL, + status TEXT NOT NULL, + priority INTEGER NOT NULL, + weight INTEGER NOT NULL DEFAULT 100, + shadow_group_id TEXT NOT NULL, + shadow_host_id TEXT NOT NULL, + upstream_base_url_hint TEXT NOT NULL DEFAULT '', + cooldown_until TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_logical_group_routes_group + FOREIGN KEY (logical_group_id) + REFERENCES logical_groups(logical_group_id) + ON DELETE CASCADE +); + +CREATE INDEX idx_logical_group_routes_group_id ON logical_group_routes(logical_group_id); +CREATE INDEX idx_logical_group_routes_shadow_host_id ON logical_group_routes(shadow_host_id); +CREATE INDEX idx_logical_group_routes_status_priority ON logical_group_routes(status, priority); + +CREATE TABLE logical_group_route_models ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + route_id TEXT NOT NULL, + public_model TEXT NOT NULL, + shadow_model TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_logical_group_route_models_route + FOREIGN KEY (route_id) + REFERENCES logical_group_routes(route_id) + ON DELETE CASCADE, + CONSTRAINT uq_logical_group_route_models_route_model + UNIQUE (route_id, public_model) +); + +CREATE INDEX idx_logical_group_route_models_route_id ON logical_group_route_models(route_id); +CREATE INDEX idx_logical_group_route_models_status ON logical_group_route_models(status); diff --git a/internal/store/sqlite/db_test.go b/internal/store/sqlite/db_test.go index 773ca9da..77d4dd34 100644 --- a/internal/store/sqlite/db_test.go +++ b/internal/store/sqlite/db_test.go @@ -102,6 +102,26 @@ func TestTableExists(t *testing.T) { } } +func TestOpenAppliesLogicalRoutingTables(t *testing.T) { + store := openTestDB(t) + db := store.SQLDB() + + for _, table := range []string{ + "logical_groups", + "logical_group_models", + "logical_group_routes", + "logical_group_route_models", + } { + found, err := tableExists(context.Background(), db, table) + if err != nil { + t.Fatalf("tableExists(%q) error = %v", table, err) + } + if !found { + t.Fatalf("tableExists(%q) = false, want true", table) + } + } +} + func TestDetectLegacy0001Schema(t *testing.T) { store := openTestDB(t) db := store.SQLDB() diff --git a/tests/integration/store_init_test.go b/tests/integration/store_init_test.go index 662e934b..ee74b2c0 100644 --- a/tests/integration/store_init_test.go +++ b/tests/integration/store_init_test.go @@ -133,7 +133,15 @@ func TestStoreAppliesLatestMigration(t *testing.T) { store := openTestStore(t) defer closeTestStore(t, store) - for _, table := range []string{"import_runs", "import_run_items", "import_run_item_events"} { + for _, table := range []string{ + "import_runs", + "import_run_items", + "import_run_item_events", + "logical_groups", + "logical_group_models", + "logical_group_routes", + "logical_group_route_models", + } { if !tableExists(t, store.SQLDB(), table) { t.Fatalf("table %q does not exist after latest migration", table) } @@ -165,6 +173,185 @@ func TestStoreAppliesLatestMigration(t *testing.T) { t.Fatalf("column %q missing from import_run_items", column) } } + + for _, column := range []string{ + "logical_group_id", + "display_name", + "route_policy", + "sticky_mode", + "conversation_ttl_seconds", + "user_model_ttl_seconds", + "failover_threshold", + "cooldown_seconds", + } { + if !tableColumnExists(t, store.SQLDB(), "logical_groups", column) { + t.Fatalf("column %q missing from logical_groups", column) + } + } + + for _, column := range []string{ + "logical_group_id", + "public_model", + "status", + } { + if !tableColumnExists(t, store.SQLDB(), "logical_group_models", column) { + t.Fatalf("column %q missing from logical_group_models", column) + } + } + + for _, column := range []string{ + "route_id", + "logical_group_id", + "priority", + "weight", + "shadow_group_id", + "shadow_host_id", + "upstream_base_url_hint", + "cooldown_until", + } { + if !tableColumnExists(t, store.SQLDB(), "logical_group_routes", column) { + t.Fatalf("column %q missing from logical_group_routes", column) + } + } + + for _, column := range []string{ + "route_id", + "public_model", + "shadow_model", + "status", + } { + if !tableColumnExists(t, store.SQLDB(), "logical_group_route_models", column) { + t.Fatalf("column %q missing from logical_group_route_models", column) + } + } +} + +func TestStoreInitEnforcesLogicalRoutingConstraints(t *testing.T) { + ctx := context.Background() + store := openTestStore(t) + defer closeTestStore(t, store) + + db := store.SQLDB() + mustExecSQL(t, db, ` +INSERT INTO logical_groups ( + logical_group_id, + display_name, + status +) VALUES (?, ?, ?)`, + "gpt-shared", + "GPT Shared", + "active", + ) + + if _, err := db.ExecContext(ctx, ` +INSERT INTO logical_groups ( + logical_group_id, + display_name, + status +) VALUES (?, ?, ?)`, + "gpt-shared", + "GPT Shared Duplicate", + "active", + ); err == nil { + t.Fatal("duplicate logical_group_id insert error = nil, want unique constraint failure") + } + + mustExecSQL(t, db, ` +INSERT INTO logical_group_models ( + logical_group_id, + public_model +) VALUES (?, ?)`, + "gpt-shared", + "gpt-5.4", + ) + + if _, err := db.ExecContext(ctx, ` +INSERT INTO logical_group_models ( + logical_group_id, + public_model +) VALUES (?, ?)`, + "gpt-shared", + "gpt-5.4", + ); err == nil { + t.Fatal("duplicate logical_group_models insert error = nil, want unique constraint failure") + } + + if _, err := db.ExecContext(ctx, ` +INSERT INTO logical_group_routes ( + route_id, + logical_group_id, + name, + status, + priority, + shadow_group_id, + shadow_host_id +) VALUES (?, ?, ?, ?, ?, ?, ?)`, + "route-missing-group", + "missing-group", + "Missing Group Route", + "active", + 10, + "shadow-group-a", + "shadow-host-a", + ); err == nil { + t.Fatal("logical_group_routes missing group error = nil, want foreign key failure") + } + + mustExecSQL(t, db, ` +INSERT INTO logical_group_routes ( + route_id, + logical_group_id, + name, + status, + priority, + shadow_group_id, + shadow_host_id +) VALUES (?, ?, ?, ?, ?, ?, ?)`, + "route-asxs", + "gpt-shared", + "ASXS", + "active", + 10, + "gpt-shared__asxs", + "remote43", + ) + + if _, err := db.ExecContext(ctx, ` +INSERT INTO logical_group_route_models ( + route_id, + public_model, + shadow_model +) VALUES (?, ?, ?)`, + "missing-route", + "gpt-5.4", + "gpt-5.4", + ); err == nil { + t.Fatal("logical_group_route_models missing route error = nil, want foreign key failure") + } + + mustExecSQL(t, db, ` +INSERT INTO logical_group_route_models ( + route_id, + public_model, + shadow_model +) VALUES (?, ?, ?)`, + "route-asxs", + "gpt-5.4", + "gpt-5.4", + ) + + if _, err := db.ExecContext(ctx, ` +INSERT INTO logical_group_route_models ( + route_id, + public_model, + shadow_model +) VALUES (?, ?, ?)`, + "route-asxs", + "gpt-5.4", + "gpt-5.4-alt", + ); err == nil { + t.Fatal("duplicate logical_group_route_models insert error = nil, want unique constraint failure") + } } func TestStoreInitBackfillsLedgerForCompletePreLedgerSchema(t *testing.T) { @@ -303,6 +490,14 @@ func mustExec(t *testing.T, db *sql.DB, statement string) { } } +func mustExecSQL(t *testing.T, db *sql.DB, statement string, args ...any) { + t.Helper() + + if _, err := db.ExecContext(context.Background(), statement, args...); err != nil { + t.Fatalf("ExecContext() error = %v", err) + } +} + func closeTestStore(t *testing.T, store *sqlite.DB) { t.Helper()