feat(database): add model_providers and role_model_routes tables

Adds workspace-scoped provider registry schema, relations, migration 0010,
and seedDefaultRoleRoutes() for pipeline defaults when aliases exist.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Randall Stillwell 2026-07-07 14:00:48 -05:00
parent fde698f439
commit f04122fd04
12 changed files with 3597 additions and 8 deletions

View file

@ -0,0 +1,36 @@
CREATE TABLE "model_providers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"alias" varchar(120) NOT NULL,
"display_name" varchar(200) NOT NULL,
"provider_type" varchar(40) NOT NULL,
"model_id" varchar(200) NOT NULL,
"base_url" text,
"api_key_env_var" varchar(120),
"extra_headers" jsonb,
"default_params" jsonb,
"metadata" jsonb,
"enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "role_model_routes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"role_id" varchar(120) NOT NULL,
"provider_alias_id" uuid NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "model_providers" ADD CONSTRAINT "model_providers_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "role_model_routes" ADD CONSTRAINT "role_model_routes_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "role_model_routes" ADD CONSTRAINT "role_model_routes_provider_alias_id_model_providers_id_fk" FOREIGN KEY ("provider_alias_id") REFERENCES "public"."model_providers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "model_providers_workspace_id_idx" ON "model_providers" USING btree ("workspace_id");--> statement-breakpoint
CREATE UNIQUE INDEX "model_providers_workspace_id_alias_unique" ON "model_providers" USING btree ("workspace_id","alias");--> statement-breakpoint
CREATE INDEX "role_model_routes_workspace_id_idx" ON "role_model_routes" USING btree ("workspace_id");--> statement-breakpoint
CREATE UNIQUE INDEX "role_model_routes_workspace_id_role_id_unique" ON "role_model_routes" USING btree ("workspace_id","role_id");--> statement-breakpoint
CREATE INDEX "role_model_routes_provider_alias_id_idx" ON "role_model_routes" USING btree ("provider_alias_id");

File diff suppressed because it is too large Load diff

View file

@ -71,6 +71,13 @@
"when": 1780455865684, "when": 1780455865684,
"tag": "0009_loving_rogue", "tag": "0009_loving_rogue",
"breakpoints": true "breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1783450794221,
"tag": "0010_legal_stryfe",
"breakpoints": true
} }
] ]
} }

View file

@ -9,7 +9,8 @@
".": "./src/index.ts", ".": "./src/index.ts",
"./schema": "./src/schema/index.ts", "./schema": "./src/schema/index.ts",
"./client": "./src/client.ts", "./client": "./src/client.ts",
"./markdown-backlog": "./src/markdown-backlog/index.ts" "./markdown-backlog": "./src/markdown-backlog/index.ts",
"./model-routing": "./src/model-routing/index.ts"
}, },
"scripts": { "scripts": {
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
@ -23,6 +24,7 @@
"import:markdown-backlog": "tsx src/scripts/import-markdown-backlog.ts" "import:markdown-backlog": "tsx src/scripts/import-markdown-backlog.ts"
}, },
"dependencies": { "dependencies": {
"@tasks/shared": "workspace:*",
"drizzle-orm": "^0.38.0", "drizzle-orm": "^0.38.0",
"postgres": "^3.4.5", "postgres": "^3.4.5",
"yaml": "^2.8.3", "yaml": "^2.8.3",

View file

@ -0,0 +1,2 @@
export { seedDefaultRoleRoutes } from "./seed-default-role-routes";
export type { SeedDefaultRoleRoutesResult } from "./seed-default-role-routes";

View file

@ -0,0 +1,67 @@
import { and, eq } from "drizzle-orm";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { PIPELINE_DEFAULT_ROLE_ROUTES } from "@tasks/shared/model-routing";
import type * as schema from "../schema/index";
import { modelProviders, roleModelRoutes } from "../schema/index";
type Database = PostgresJsDatabase<typeof schema>;
export type SeedDefaultRoleRoutesResult = {
inserted: number;
skipped: number;
};
/**
* Inserts pipeline default role → alias routes for a workspace when matching
* provider aliases already exist. Does not create providers.
*/
export async function seedDefaultRoleRoutes(
db: Database,
workspaceId: string,
): Promise<SeedDefaultRoleRoutesResult> {
const providers = await db
.select({ id: modelProviders.id, alias: modelProviders.alias })
.from(modelProviders)
.where(eq(modelProviders.workspaceId, workspaceId));
const aliasToId = new Map(providers.map((row) => [row.alias, row.id]));
let inserted = 0;
let skipped = 0;
let sortOrder = 0;
for (const [roleId, alias] of Object.entries(PIPELINE_DEFAULT_ROLE_ROUTES)) {
const providerId = aliasToId.get(alias);
if (!providerId) {
skipped++;
continue;
}
const [existing] = await db
.select({ id: roleModelRoutes.id })
.from(roleModelRoutes)
.where(
and(
eq(roleModelRoutes.workspaceId, workspaceId),
eq(roleModelRoutes.roleId, roleId),
),
)
.limit(1);
if (existing) {
skipped++;
continue;
}
await db.insert(roleModelRoutes).values({
workspaceId,
roleId,
providerAliasId: providerId,
sortOrder: sortOrder++,
});
inserted++;
}
return { inserted, skipped };
}

View file

@ -13,3 +13,5 @@ export * from "./markdown_backlog";
export * from "./cursor_sync"; export * from "./cursor_sync";
export * from "./audit"; export * from "./audit";
export * from "./agent_runs"; export * from "./agent_runs";
export * from "./model_providers";
export * from "./role_model_routes";

View file

@ -0,0 +1,51 @@
import {
boolean,
index,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
varchar,
integer,
} from "drizzle-orm/pg-core";
import { workspaces } from "./workspaces";
/** Workspace-scoped model provider alias — secrets live in env vars, not here. */
export const modelProviders = pgTable(
"model_providers",
{
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id")
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
alias: varchar("alias", { length: 120 }).notNull(),
displayName: varchar("display_name", { length: 200 }).notNull(),
providerType: varchar("provider_type", { length: 40 }).notNull(),
modelId: varchar("model_id", { length: 200 }).notNull(),
baseUrl: text("base_url"),
apiKeyEnvVar: varchar("api_key_env_var", { length: 120 }),
extraHeaders: jsonb("extra_headers").$type<Record<string, string> | null>(),
defaultParams: jsonb("default_params").$type<{
temperature?: number;
maxTokens?: number;
} | null>(),
metadata: jsonb("metadata").$type<Record<string, unknown> | null>(),
enabled: boolean("enabled").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => ({
workspaceIdIdx: index("model_providers_workspace_id_idx").on(table.workspaceId),
workspaceAliasUnique: uniqueIndex("model_providers_workspace_id_alias_unique").on(
table.workspaceId,
table.alias,
),
}),
);

View file

@ -19,6 +19,8 @@ import { cursorSyncMappings } from "./cursor_sync";
import { workspaces, workspaceInvites } from "./workspaces"; import { workspaces, workspaceInvites } from "./workspaces";
import { auditLog } from "./audit"; import { auditLog } from "./audit";
import { agentRuns } from "./agent_runs"; import { agentRuns } from "./agent_runs";
import { modelProviders } from "./model_providers";
import { roleModelRoutes } from "./role_model_routes";
export const objectRelations = pgTable( export const objectRelations = pgTable(
"object_relations", "object_relations",
@ -116,6 +118,8 @@ export const workspacesRelations = relations(workspaces, ({ one, many }) => ({
objectTypeDefs: many(objectTypeDefs), objectTypeDefs: many(objectTypeDefs),
propertyDefinitions: many(propertyDefinitions), propertyDefinitions: many(propertyDefinitions),
markdownBacklogItems: many(markdownBacklogItems), markdownBacklogItems: many(markdownBacklogItems),
modelProviders: many(modelProviders),
roleModelRoutes: many(roleModelRoutes),
})); }));
export const objectsRelations = relations(objects, ({ one, many }) => ({ export const objectsRelations = relations(objects, ({ one, many }) => ({
@ -264,3 +268,22 @@ export const cursorSyncMappingsRelations = relations(cursorSyncMappings, ({ one
references: [markdownBacklogItems.id], references: [markdownBacklogItems.id],
}), }),
})); }));
export const modelProvidersRelations = relations(modelProviders, ({ one, many }) => ({
workspace: one(workspaces, {
fields: [modelProviders.workspaceId],
references: [workspaces.id],
}),
roleRoutes: many(roleModelRoutes),
}));
export const roleModelRoutesRelations = relations(roleModelRoutes, ({ one }) => ({
workspace: one(workspaces, {
fields: [roleModelRoutes.workspaceId],
references: [workspaces.id],
}),
provider: one(modelProviders, {
fields: [roleModelRoutes.providerAliasId],
references: [modelProviders.id],
}),
}));

View file

@ -0,0 +1,45 @@
import {
boolean,
index,
integer,
pgTable,
timestamp,
uniqueIndex,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { workspaces } from "./workspaces";
import { modelProviders } from "./model_providers";
/** Maps pipeline role_id strings to a workspace provider alias row. */
export const roleModelRoutes = pgTable(
"role_model_routes",
{
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id")
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
roleId: varchar("role_id", { length: 120 }).notNull(),
providerAliasId: uuid("provider_alias_id")
.notNull()
.references(() => modelProviders.id, { onDelete: "cascade" }),
enabled: boolean("enabled").notNull().default(true),
sortOrder: integer("sort_order").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => ({
workspaceIdIdx: index("role_model_routes_workspace_id_idx").on(table.workspaceId),
workspaceRoleUnique: uniqueIndex(
"role_model_routes_workspace_id_role_id_unique",
).on(table.workspaceId, table.roleId),
providerAliasIdIdx: index("role_model_routes_provider_alias_id_idx").on(
table.providerAliasId,
),
}),
);

View file

@ -4,7 +4,7 @@ slug: db-model-providers-migration
title: Drizzle schema and migration for model_providers and role_model_routes title: Drizzle schema and migration for model_providers and role_model_routes
plan_slug: automation-foundation plan_slug: automation-foundation
epic_slug: model-provider-registry epic_slug: model-provider-registry
status: ready status: done
priority: P1 priority: P1
tenant_id: global tenant_id: global
owner: unassigned owner: unassigned
@ -39,9 +39,9 @@ Add `model_providers` and `role_model_routes` tables with workspace scoping and
## Subtasks ## Subtasks
- [ ] Define Drizzle tables - [x] Define Drizzle tables
- [ ] Generate and commit migration - [x] Generate and commit migration
- [ ] Verify `workspace_id` index on both tables - [x] Verify `workspace_id` index on both tables
## Estimation ## Estimation
@ -49,9 +49,9 @@ S
## Acceptance criteria ## Acceptance criteria
- [ ] Migration applies cleanly with `pnpm db:migrate` - [x] Migration applies cleanly with `pnpm db:migrate`
- [ ] Every query example in epic doc filters by `workspace_id` - [x] Every query example in epic doc filters by `workspace_id`
- [ ] Types align with `packages/shared` zod schemas - [x] Types align with `packages/shared` zod schemas
## Links ## Links

View file

@ -354,6 +354,9 @@ importers:
packages/database: packages/database:
dependencies: dependencies:
'@tasks/shared':
specifier: workspace:*
version: link:../shared
drizzle-orm: drizzle-orm:
specifier: ^0.38.0 specifier: ^0.38.0
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4) version: 0.38.4(@opentelemetry/api@1.9.0)(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4)