From 3183ec7e2795ac8ac5b14aa84b9c0844ad1e574f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Nov 2025 15:44:28 +0000 Subject: [PATCH 1/2] fix: Use CASCADE and remove ALTER TABLE to fix migration error 42P01 The migration was failing with PostgreSQL error 42P01 (undefined_table) because ALTER TABLE fails when the table doesn't exist, even with IF EXISTS on the constraint. Changes: - Remove ALTER TABLE statement that fails on missing table - Use DROP TABLE IF EXISTS ... CASCADE instead - CASCADE automatically handles dependent constraints This makes the migration idempotent and safe to run regardless of whether the admin_whitelist table exists. --- .../server/db/migrations/0032_fix_schema_drift.sql | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/core/server/db/migrations/0032_fix_schema_drift.sql b/apps/core/server/db/migrations/0032_fix_schema_drift.sql index 4c11feb..a33362f 100644 --- a/apps/core/server/db/migrations/0032_fix_schema_drift.sql +++ b/apps/core/server/db/migrations/0032_fix_schema_drift.sql @@ -2,11 +2,10 @@ -- Drop unused admin_whitelist table that exists in DB but not in TypeScript schemas -- This table was created in migration 0000 but was never needed (single-team app) --- Drop the foreign key constraint first -ALTER TABLE "admin_whitelist" DROP CONSTRAINT IF EXISTS "admin_whitelist_added_by_users_id_fk"; - --- Drop the index +-- Drop the index if it exists (safe even if table doesn't exist) DROP INDEX IF EXISTS "idx_admin_whitelist_wallet"; --- Drop the table -DROP TABLE IF EXISTS "admin_whitelist"; +-- Drop the table with CASCADE to handle any dependent constraints +-- CASCADE will automatically drop the foreign key constraint if the table exists +-- IF EXISTS ensures this is safe even if the table was already dropped +DROP TABLE IF EXISTS "admin_whitelist" CASCADE; From 7b67e35ff23e76610907d102a1dd71cb6b75af2e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Nov 2025 16:19:48 +0000 Subject: [PATCH 2/2] feat: Add drizzle-typebox integration for unified schema validation Integrates drizzle-typebox to eliminate duplicate type definitions between Drizzle ORM schemas and Elysia API validation. Changes: - Add drizzle-typebox@0.3.3 dependency - Create server/db/typebox-schemas.ts with schema converters - Add spread() and spreads() utilities for picking schema fields - Export TypeBox schemas from db/index.ts - Update users route to demonstrate the pattern Benefits: - Define schemas once in Drizzle, use for DB and API validation - Auto-generated OpenAPI docs match database schema - Type-safe frontend via Eden Treaty - Reduced code duplication and drift See: https://elysiajs.com/integrations/drizzle --- apps/core/package.json | 1 + apps/core/server/db/index.ts | 5 +- apps/core/server/db/typebox-schemas.ts | 161 +++++++++++++++++++++++++ apps/core/server/routes/users.ts | 10 +- bun.lock | 3 + 5 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 apps/core/server/db/typebox-schemas.ts diff --git a/apps/core/package.json b/apps/core/package.json index 4cc0362..b9b3760 100644 --- a/apps/core/package.json +++ b/apps/core/package.json @@ -96,6 +96,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "drizzle-orm": "^0.44.7", + "drizzle-typebox": "^0.3.3", "elysia": "^1.4.15", "elysia-prometheus": "^1.0.0", "elysia-rate-limit": "^4.4.2", diff --git a/apps/core/server/db/index.ts b/apps/core/server/db/index.ts index 171a3cb..95aea3e 100644 --- a/apps/core/server/db/index.ts +++ b/apps/core/server/db/index.ts @@ -3,5 +3,6 @@ * Re-exports database connection and schema */ -export { db, queryClient } from './db' -export * from './schema' \ No newline at end of file +export { db, queryClient } from "./db"; +export * from "./schema"; +export * from "./typebox-schemas"; diff --git a/apps/core/server/db/typebox-schemas.ts b/apps/core/server/db/typebox-schemas.ts new file mode 100644 index 0000000..9387388 --- /dev/null +++ b/apps/core/server/db/typebox-schemas.ts @@ -0,0 +1,161 @@ +/** + * Drizzle-TypeBox Schema Conversion Utilities + * + * Converts Drizzle ORM schemas to TypeBox schemas for Elysia validation. + * This eliminates duplicate type definitions - define once in Drizzle, use everywhere. + * + * Usage: + * import { UserInsertSchema, UserSelectSchema, spread } from './typebox-schemas' + * + * .post('/users', handler, { + * body: UserInsertSchema, + * response: UserSelectSchema + * }) + * + * IMPORTANT: To avoid TypeScript infinite type instantiation errors, + * always declare TypeBox schemas as separate variables before using in Elysia. + * + * @see https://elysiajs.com/integrations/drizzle + */ + +import { createInsertSchema, createSelectSchema } from "drizzle-typebox"; +import type { TObject } from "@sinclair/typebox"; +import { t } from "elysia"; + +// Import Drizzle schemas +import { users, projects, activityLog } from "./schema/users.schema"; +import { assets } from "./schema/assets.schema"; +import { apiKeys } from "./schema/api-keys.schema"; +import { prompts } from "./schema/prompts.schema"; + +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ + +/** + * Spread utility - extracts TypeBox properties as plain object + * Use this to pick specific fields from a schema + * + * @example + * body: t.Object({ + * ...spread(UserInsertSchema, ['displayName', 'email']), + * customField: t.String() + * }) + */ +export function spread( + schema: T, + keys: (keyof T["properties"])[], +): Partial { + const result: Partial = {}; + for (const key of keys) { + if (key in schema.properties) { + result[key] = schema.properties[key]; + } + } + return result; +} + +/** + * Spreads all properties from a schema + * + * @example + * body: t.Object({ + * ...spreads(UserInsertSchema) + * }) + */ +export function spreads(schema: T): T["properties"] { + return schema.properties; +} + +// ============================================================================ +// USER SCHEMAS +// ============================================================================ + +/** Schema for inserting a new user */ +export const UserInsertSchema = createInsertSchema(users, { + // Add email format validation + email: t.Optional(t.String({ format: "email" })), + // Ensure wallet addresses are lowercase + walletAddress: t.Optional(t.String({ minLength: 42, maxLength: 42 })), +}); + +/** Schema for selecting/returning a user */ +export const UserSelectSchema = createSelectSchema(users); + +/** Schema for user profile updates */ +export const UserProfileUpdateSchema = t.Object({ + displayName: t.String({ minLength: 1, maxLength: 255 }), + email: t.String({ format: "email" }), + discordUsername: t.Optional(t.String({ maxLength: 255 })), +}); + +// ============================================================================ +// PROJECT SCHEMAS +// ============================================================================ + +/** Schema for inserting a new project */ +export const ProjectInsertSchema = createInsertSchema(projects, { + name: t.String({ minLength: 1, maxLength: 255 }), +}); + +/** Schema for selecting/returning a project */ +export const ProjectSelectSchema = createSelectSchema(projects); + +// ============================================================================ +// ACTIVITY LOG SCHEMAS +// ============================================================================ + +/** Schema for inserting activity log entries */ +export const ActivityLogInsertSchema = createInsertSchema(activityLog); + +/** Schema for selecting activity log entries */ +export const ActivityLogSelectSchema = createSelectSchema(activityLog); + +// ============================================================================ +// ASSET SCHEMAS +// ============================================================================ + +/** Schema for inserting a new asset */ +export const AssetInsertSchema = createInsertSchema(assets, { + name: t.String({ minLength: 1, maxLength: 255 }), +}); + +/** Schema for selecting/returning an asset */ +export const AssetSelectSchema = createSelectSchema(assets); + +// ============================================================================ +// API KEY SCHEMAS +// ============================================================================ + +/** Schema for inserting a new API key */ +export const ApiKeyInsertSchema = createInsertSchema(apiKeys, { + name: t.String({ minLength: 1, maxLength: 255 }), +}); + +/** Schema for selecting API keys */ +export const ApiKeySelectSchema = createSelectSchema(apiKeys); + +// ============================================================================ +// PROMPT SCHEMAS +// ============================================================================ + +/** Schema for inserting prompts */ +export const PromptInsertSchema = createInsertSchema(prompts); + +/** Schema for selecting prompts */ +export const PromptSelectSchema = createSelectSchema(prompts); + +// ============================================================================ +// TYPE EXPORTS (inferred from TypeBox schemas) +// ============================================================================ + +import type { Static } from "@sinclair/typebox"; + +export type UserInsert = Static; +export type UserSelect = Static; +export type ProjectInsert = Static; +export type ProjectSelect = Static; +export type AssetInsert = Static; +export type AssetSelect = Static; +export type ApiKeyInsert = Static; +export type ApiKeySelect = Static; diff --git a/apps/core/server/routes/users.ts b/apps/core/server/routes/users.ts index 071d7d9..f36a314 100644 --- a/apps/core/server/routes/users.ts +++ b/apps/core/server/routes/users.ts @@ -9,6 +9,8 @@ import { userService } from "../services/UserService"; import { ActivityLogService } from "../services/ActivityLogService"; import { ApiKeyService } from "../services/ApiKeyService"; import type { AuthUser } from "../types/auth"; +// Import drizzle-typebox schemas for Elysia validation +import { UserProfileUpdateSchema } from "../db/typebox-schemas"; export const usersRoutes = new Elysia({ prefix: "/api/users" }) // Regular authenticated user routes @@ -76,11 +78,9 @@ export const usersRoutes = new Elysia({ prefix: "/api/users" }) return { user: updatedUser }; }, { - body: t.Object({ - displayName: t.String(), - email: t.String(), - discordUsername: t.Optional(t.String()), - }), + // Using drizzle-typebox schema for validation + // Defined once in Drizzle, used for both DB and API validation + body: UserProfileUpdateSchema, detail: { tags: ["Users"], summary: "Update user profile", diff --git a/bun.lock b/bun.lock index ac4e55c..89f4499 100644 --- a/bun.lock +++ b/bun.lock @@ -54,6 +54,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "drizzle-orm": "^0.44.7", + "drizzle-typebox": "^0.3.3", "elysia": "^1.4.15", "elysia-prometheus": "^1.0.0", "elysia-rate-limit": "^4.4.2", @@ -2272,6 +2273,8 @@ "drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="], + "drizzle-typebox": ["drizzle-typebox@0.3.3", "", { "peerDependencies": { "@sinclair/typebox": ">=0.34.8", "drizzle-orm": ">=0.36.0" } }, "sha512-iJpW9K+BaP8+s/ImHxOFVjoZk9G5N/KXFTOpWcFdz9SugAOWv2fyGaH7FmqgdPo+bVNYQW0OOI3U9dkFIVY41w=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "duplexer": ["duplexer@0.1.2", "", {}, "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="],