feat(routing): add logical group schema foundation
This commit is contained in:
78
internal/store/migrations/0010_logical_groups_and_routes.sql
Normal file
78
internal/store/migrations/0010_logical_groups_and_routes.sql
Normal file
@@ -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);
|
||||||
@@ -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) {
|
func TestDetectLegacy0001Schema(t *testing.T) {
|
||||||
store := openTestDB(t)
|
store := openTestDB(t)
|
||||||
db := store.SQLDB()
|
db := store.SQLDB()
|
||||||
|
|||||||
@@ -133,7 +133,15 @@ func TestStoreAppliesLatestMigration(t *testing.T) {
|
|||||||
store := openTestStore(t)
|
store := openTestStore(t)
|
||||||
defer closeTestStore(t, store)
|
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) {
|
if !tableExists(t, store.SQLDB(), table) {
|
||||||
t.Fatalf("table %q does not exist after latest migration", 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)
|
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) {
|
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) {
|
func closeTestStore(t *testing.T, store *sqlite.DB) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user