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:
parent
fde698f439
commit
f04122fd04
12 changed files with 3597 additions and 8 deletions
36
packages/database/migrations/0010_legal_stryfe.sql
Normal file
36
packages/database/migrations/0010_legal_stryfe.sql
Normal 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");
|
||||
3351
packages/database/migrations/meta/0010_snapshot.json
Normal file
3351
packages/database/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -71,6 +71,13 @@
|
|||
"when": 1780455865684,
|
||||
"tag": "0009_loving_rogue",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1783450794221,
|
||||
"tag": "0010_legal_stryfe",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -9,7 +9,8 @@
|
|||
".": "./src/index.ts",
|
||||
"./schema": "./src/schema/index.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": {
|
||||
"db:generate": "drizzle-kit generate",
|
||||
|
|
@ -23,6 +24,7 @@
|
|||
"import:markdown-backlog": "tsx src/scripts/import-markdown-backlog.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tasks/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"postgres": "^3.4.5",
|
||||
"yaml": "^2.8.3",
|
||||
|
|
|
|||
2
packages/database/src/model-routing/index.ts
Normal file
2
packages/database/src/model-routing/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { seedDefaultRoleRoutes } from "./seed-default-role-routes";
|
||||
export type { SeedDefaultRoleRoutesResult } from "./seed-default-role-routes";
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -13,3 +13,5 @@ export * from "./markdown_backlog";
|
|||
export * from "./cursor_sync";
|
||||
export * from "./audit";
|
||||
export * from "./agent_runs";
|
||||
export * from "./model_providers";
|
||||
export * from "./role_model_routes";
|
||||
|
|
|
|||
51
packages/database/src/schema/model_providers.ts
Normal file
51
packages/database/src/schema/model_providers.ts
Normal 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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -19,6 +19,8 @@ import { cursorSyncMappings } from "./cursor_sync";
|
|||
import { workspaces, workspaceInvites } from "./workspaces";
|
||||
import { auditLog } from "./audit";
|
||||
import { agentRuns } from "./agent_runs";
|
||||
import { modelProviders } from "./model_providers";
|
||||
import { roleModelRoutes } from "./role_model_routes";
|
||||
|
||||
export const objectRelations = pgTable(
|
||||
"object_relations",
|
||||
|
|
@ -116,6 +118,8 @@ export const workspacesRelations = relations(workspaces, ({ one, many }) => ({
|
|||
objectTypeDefs: many(objectTypeDefs),
|
||||
propertyDefinitions: many(propertyDefinitions),
|
||||
markdownBacklogItems: many(markdownBacklogItems),
|
||||
modelProviders: many(modelProviders),
|
||||
roleModelRoutes: many(roleModelRoutes),
|
||||
}));
|
||||
|
||||
export const objectsRelations = relations(objects, ({ one, many }) => ({
|
||||
|
|
@ -264,3 +268,22 @@ export const cursorSyncMappingsRelations = relations(cursorSyncMappings, ({ one
|
|||
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],
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
45
packages/database/src/schema/role_model_routes.ts
Normal file
45
packages/database/src/schema/role_model_routes.ts
Normal 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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -4,7 +4,7 @@ slug: db-model-providers-migration
|
|||
title: Drizzle schema and migration for model_providers and role_model_routes
|
||||
plan_slug: automation-foundation
|
||||
epic_slug: model-provider-registry
|
||||
status: ready
|
||||
status: done
|
||||
priority: P1
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
|
|
@ -39,9 +39,9 @@ Add `model_providers` and `role_model_routes` tables with workspace scoping and
|
|||
|
||||
## Subtasks
|
||||
|
||||
- [ ] Define Drizzle tables
|
||||
- [ ] Generate and commit migration
|
||||
- [ ] Verify `workspace_id` index on both tables
|
||||
- [x] Define Drizzle tables
|
||||
- [x] Generate and commit migration
|
||||
- [x] Verify `workspace_id` index on both tables
|
||||
|
||||
## Estimation
|
||||
|
||||
|
|
@ -49,9 +49,9 @@ S
|
|||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Migration applies cleanly with `pnpm db:migrate`
|
||||
- [ ] Every query example in epic doc filters by `workspace_id`
|
||||
- [ ] Types align with `packages/shared` zod schemas
|
||||
- [x] Migration applies cleanly with `pnpm db:migrate`
|
||||
- [x] Every query example in epic doc filters by `workspace_id`
|
||||
- [x] Types align with `packages/shared` zod schemas
|
||||
|
||||
## Links
|
||||
|
||||
|
|
|
|||
|
|
@ -354,6 +354,9 @@ importers:
|
|||
|
||||
packages/database:
|
||||
dependencies:
|
||||
'@tasks/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
drizzle-orm:
|
||||
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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue