From dbf82d2801072eeee7e5609e61967f743d22c505 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Sun, 26 Oct 2025 23:00:10 +0700 Subject: [PATCH] feat: update DB schemas --- packages/database/src/schema/flows.ts | 37 +++++ packages/database/src/schema/generations.ts | 137 ++++++++++++++++++ packages/database/src/schema/images.ts | 134 +++++++++++++++++ packages/database/src/schema/index.ts | 80 +++++++++- .../database/src/schema/promptUrlCache.ts | 77 ++++++++++ 5 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 packages/database/src/schema/flows.ts create mode 100644 packages/database/src/schema/generations.ts create mode 100644 packages/database/src/schema/images.ts create mode 100644 packages/database/src/schema/promptUrlCache.ts diff --git a/packages/database/src/schema/flows.ts b/packages/database/src/schema/flows.ts new file mode 100644 index 0000000..173a4fb --- /dev/null +++ b/packages/database/src/schema/flows.ts @@ -0,0 +1,37 @@ +import { pgTable, uuid, jsonb, timestamp, index } from 'drizzle-orm/pg-core'; +import { projects } from './projects'; + +export const flows = pgTable( + 'flows', + { + id: uuid('id').primaryKey().defaultRandom(), + + // Relations + projectId: uuid('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + + // Flow-scoped named aliases (user-assigned only) + // Technical aliases (@last, @first, @upload) computed programmatically + // Format: { "@hero": "image-uuid", "@product": "image-uuid" } + aliases: jsonb('aliases').$type>().notNull().default({}), + + // Flexible metadata storage + meta: jsonb('meta').$type>().notNull().default({}), + + // Timestamps + createdAt: timestamp('created_at').notNull().defaultNow(), + // Updates on every generation/upload activity within this flow + updatedAt: timestamp('updated_at') + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => ({ + // Index for querying flows by project, ordered by most recent + projectCreatedAtIdx: index('idx_flows_project').on(table.projectId, table.createdAt.desc()), + }), +); + +export type Flow = typeof flows.$inferSelect; +export type NewFlow = typeof flows.$inferInsert; diff --git a/packages/database/src/schema/generations.ts b/packages/database/src/schema/generations.ts new file mode 100644 index 0000000..18fca61 --- /dev/null +++ b/packages/database/src/schema/generations.ts @@ -0,0 +1,137 @@ +import { + pgTable, + uuid, + varchar, + text, + integer, + jsonb, + timestamp, + pgEnum, + index, + check, + type AnyPgColumn, +} from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { projects } from './projects'; +import { flows } from './flows'; +import { apiKeys } from './apiKeys'; + +// Enum for generation status +export const generationStatusEnum = pgEnum('generation_status', [ + 'pending', + 'processing', + 'success', + 'failed', +]); + +// Type for referenced images JSONB +export type ReferencedImage = { + imageId: string; + alias: string; +}; + +export const generations = pgTable( + 'generations', + { + id: uuid('id').primaryKey().defaultRandom(), + + // Relations + projectId: uuid('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + flowId: uuid('flow_id').references(() => flows.id, { onDelete: 'set null' }), + apiKeyId: uuid('api_key_id').references(() => apiKeys.id, { onDelete: 'set null' }), + + // Status + status: generationStatusEnum('status').notNull().default('pending'), + + // Prompts + originalPrompt: text('original_prompt').notNull(), + enhancedPrompt: text('enhanced_prompt'), // AI-enhanced version (if enabled) + + // Generation parameters + aspectRatio: varchar('aspect_ratio', { length: 10 }), + width: integer('width'), + height: integer('height'), + + // AI Model + modelName: varchar('model_name', { length: 100 }).notNull().default('gemini-flash-image-001'), + modelVersion: varchar('model_version', { length: 50 }), + + // Result + outputImageId: uuid('output_image_id').references( + (): AnyPgColumn => { + const { images } = require('./images'); + return images.id; + }, + { onDelete: 'set null' }, + ), + + // Referenced images used in generation + // Format: [{ "imageId": "uuid", "alias": "@product" }, ...] + referencedImages: jsonb('referenced_images').$type(), + + // Error handling + errorMessage: text('error_message'), + errorCode: varchar('error_code', { length: 50 }), + retryCount: integer('retry_count').notNull().default(0), + + // Metrics + processingTimeMs: integer('processing_time_ms'), + cost: integer('cost'), // In cents (USD) + + // Request context + requestId: uuid('request_id'), + userAgent: text('user_agent'), + ipAddress: varchar('ip_address', { length: 45 }), // IPv6 max length + + // Metadata + meta: jsonb('meta').$type>().notNull().default({}), + + // Audit + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at') + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => ({ + // CHECK constraints + statusSuccessCheck: check( + 'status_success_check', + sql`(${table.status} = 'success' AND ${table.outputImageId} IS NOT NULL) OR (${table.status} != 'success')`, + ), + statusFailedCheck: check( + 'status_failed_check', + sql`(${table.status} = 'failed' AND ${table.errorMessage} IS NOT NULL) OR (${table.status} != 'failed')`, + ), + retryCountCheck: check('retry_count_check', sql`${table.retryCount} >= 0`), + processingTimeCheck: check( + 'processing_time_check', + sql`${table.processingTimeMs} IS NULL OR ${table.processingTimeMs} >= 0`, + ), + costCheck: check('cost_check', sql`${table.cost} IS NULL OR ${table.cost} >= 0`), + + // Indexes + // Index for querying generations by project and status + projectStatusIdx: index('idx_generations_project_status').on( + table.projectId, + table.status, + table.createdAt.desc(), + ), + + // Index for flow-scoped generations (partial index) + flowIdx: index('idx_generations_flow') + .on(table.flowId, table.createdAt.desc()) + .where(sql`${table.flowId} IS NOT NULL`), + + // Index for output image lookup + outputIdx: index('idx_generations_output').on(table.outputImageId), + + // Index for request correlation + requestIdx: index('idx_generations_request').on(table.requestId), + }), +); + +export type Generation = typeof generations.$inferSelect; +export type NewGeneration = typeof generations.$inferInsert; diff --git a/packages/database/src/schema/images.ts b/packages/database/src/schema/images.ts new file mode 100644 index 0000000..80a1713 --- /dev/null +++ b/packages/database/src/schema/images.ts @@ -0,0 +1,134 @@ +import { + pgTable, + uuid, + varchar, + text, + integer, + jsonb, + timestamp, + pgEnum, + index, + uniqueIndex, + check, + type AnyPgColumn, +} from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { projects } from './projects'; +import { flows } from './flows'; +import { apiKeys } from './apiKeys'; + +// Enum for image source +export const imageSourceEnum = pgEnum('image_source', ['generated', 'uploaded']); + +// Type for focal point JSONB +export type FocalPoint = { + x: number; // 0.0 - 1.0 + y: number; // 0.0 - 1.0 +}; + +export const images = pgTable( + 'images', + { + id: uuid('id').primaryKey().defaultRandom(), + + // Relations + projectId: uuid('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + generationId: uuid('generation_id').references( + (): AnyPgColumn => { + const { generations } = require('./generations'); + return generations.id; + }, + { onDelete: 'set null' }, + ), + flowId: uuid('flow_id').references(() => flows.id, { onDelete: 'cascade' }), + apiKeyId: uuid('api_key_id').references(() => apiKeys.id, { onDelete: 'set null' }), + + // Storage (MinIO path format: orgSlug/projectSlug/category/YYYY-MM/filename.ext) + storageKey: varchar('storage_key', { length: 500 }).notNull().unique(), + storageUrl: text('storage_url').notNull(), + + // File metadata + mimeType: varchar('mime_type', { length: 100 }).notNull(), + fileSize: integer('file_size').notNull(), + fileHash: varchar('file_hash', { length: 64 }).notNull(), // SHA-256 for deduplication + + // Dimensions + width: integer('width'), + height: integer('height'), + aspectRatio: varchar('aspect_ratio', { length: 10 }), + + // Focal point for image transformations (imageflow) + // Normalized coordinates: { "x": 0.5, "y": 0.3 } where 0.0-1.0 + focalPoint: jsonb('focal_point').$type(), + + // Source + source: imageSourceEnum('source').notNull(), + + // Project-level alias (global scope) + // Flow-level aliases stored in flows.aliases + alias: varchar('alias', { length: 100 }), + + // Metadata + description: text('description'), + tags: text('tags').array(), + meta: jsonb('meta').$type>().notNull().default({}), + + // Audit + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at') + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + deletedAt: timestamp('deleted_at'), // Soft delete + }, + (table) => ({ + // CHECK constraints + sourceGeneratedCheck: check( + 'source_generation_check', + sql`(${table.source} = 'uploaded' AND ${table.generationId} IS NULL) OR (${table.source} = 'generated' AND ${table.generationId} IS NOT NULL)`, + ), + aliasFormatCheck: check( + 'alias_format_check', + sql`${table.alias} IS NULL OR ${table.alias} ~ '^@[a-zA-Z0-9_-]+$'`, + ), + fileSizeCheck: check('file_size_check', sql`${table.fileSize} > 0`), + widthCheck: check( + 'width_check', + sql`${table.width} IS NULL OR (${table.width} > 0 AND ${table.width} <= 8192)`, + ), + heightCheck: check( + 'height_check', + sql`${table.height} IS NULL OR (${table.height} > 0 AND ${table.height} <= 8192)`, + ), + + // Indexes + // Unique index for project-scoped aliases (partial index) + projectAliasIdx: uniqueIndex('idx_images_project_alias') + .on(table.projectId, table.alias) + .where(sql`${table.alias} IS NOT NULL AND ${table.deletedAt} IS NULL AND ${table.flowId} IS NULL`), + + // Index for querying images by project and source (partial index) + projectSourceIdx: index('idx_images_project_source') + .on(table.projectId, table.source, table.createdAt.desc()) + .where(sql`${table.deletedAt} IS NULL`), + + // Index for flow-scoped images (partial index) + flowIdx: index('idx_images_flow') + .on(table.flowId) + .where(sql`${table.flowId} IS NOT NULL`), + + // Index for generation lookup + generationIdx: index('idx_images_generation').on(table.generationId), + + // Index for storage key lookup + storageKeyIdx: index('idx_images_storage_key').on(table.storageKey), + + // Index for file hash (deduplication) + hashIdx: index('idx_images_hash').on(table.fileHash), + }), +); + +export type Image = typeof images.$inferSelect; +export type NewImage = typeof images.$inferInsert; diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts index 993970c..3955db1 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -2,11 +2,19 @@ import { relations } from 'drizzle-orm'; import { organizations } from './organizations'; import { projects } from './projects'; import { apiKeys } from './apiKeys'; +import { flows } from './flows'; +import { images } from './images'; +import { generations } from './generations'; +import { promptUrlCache } from './promptUrlCache'; // Export all tables export * from './organizations'; export * from './projects'; export * from './apiKeys'; +export * from './flows'; +export * from './images'; +export * from './generations'; +export * from './promptUrlCache'; // Define relations export const organizationsRelations = relations(organizations, ({ many }) => ({ @@ -20,9 +28,13 @@ export const projectsRelations = relations(projects, ({ one, many }) => ({ references: [organizations.id], }), apiKeys: many(apiKeys), + flows: many(flows), + images: many(images), + generations: many(generations), + promptUrlCache: many(promptUrlCache), })); -export const apiKeysRelations = relations(apiKeys, ({ one }) => ({ +export const apiKeysRelations = relations(apiKeys, ({ one, many }) => ({ organization: one(organizations, { fields: [apiKeys.organizationId], references: [organizations.id], @@ -31,4 +43,70 @@ export const apiKeysRelations = relations(apiKeys, ({ one }) => ({ fields: [apiKeys.projectId], references: [projects.id], }), + images: many(images), + generations: many(generations), +})); + +export const flowsRelations = relations(flows, ({ one, many }) => ({ + project: one(projects, { + fields: [flows.projectId], + references: [projects.id], + }), + images: many(images), + generations: many(generations), +})); + +export const imagesRelations = relations(images, ({ one, many }) => ({ + project: one(projects, { + fields: [images.projectId], + references: [projects.id], + }), + generation: one(generations, { + fields: [images.generationId], + references: [generations.id], + }), + flow: one(flows, { + fields: [images.flowId], + references: [flows.id], + }), + apiKey: one(apiKeys, { + fields: [images.apiKeyId], + references: [apiKeys.id], + }), + promptUrlCacheEntries: many(promptUrlCache), +})); + +export const generationsRelations = relations(generations, ({ one, many }) => ({ + project: one(projects, { + fields: [generations.projectId], + references: [projects.id], + }), + flow: one(flows, { + fields: [generations.flowId], + references: [flows.id], + }), + apiKey: one(apiKeys, { + fields: [generations.apiKeyId], + references: [apiKeys.id], + }), + outputImage: one(images, { + fields: [generations.outputImageId], + references: [images.id], + }), + promptUrlCacheEntries: many(promptUrlCache), +})); + +export const promptUrlCacheRelations = relations(promptUrlCache, ({ one }) => ({ + project: one(projects, { + fields: [promptUrlCache.projectId], + references: [projects.id], + }), + generation: one(generations, { + fields: [promptUrlCache.generationId], + references: [generations.id], + }), + image: one(images, { + fields: [promptUrlCache.imageId], + references: [images.id], + }), })); diff --git a/packages/database/src/schema/promptUrlCache.ts b/packages/database/src/schema/promptUrlCache.ts new file mode 100644 index 0000000..6e3d043 --- /dev/null +++ b/packages/database/src/schema/promptUrlCache.ts @@ -0,0 +1,77 @@ +import { + pgTable, + uuid, + varchar, + text, + integer, + jsonb, + timestamp, + index, + uniqueIndex, + check, +} from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { projects } from './projects'; +import { generations } from './generations'; +import { images } from './images'; + +export const promptUrlCache = pgTable( + 'prompt_url_cache', + { + id: uuid('id').primaryKey().defaultRandom(), + + // Relations + projectId: uuid('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + generationId: uuid('generation_id') + .notNull() + .references(() => generations.id, { onDelete: 'cascade' }), + imageId: uuid('image_id') + .notNull() + .references(() => images.id, { onDelete: 'cascade' }), + + // Cache keys (SHA-256 hashes) + promptHash: varchar('prompt_hash', { length: 64 }).notNull(), + queryParamsHash: varchar('query_params_hash', { length: 64 }).notNull(), + + // Original request (for debugging/reconstruction) + originalPrompt: text('original_prompt').notNull(), + requestParams: jsonb('request_params').$type>().notNull(), + + // Cache statistics + hitCount: integer('hit_count').notNull().default(0), + lastHitAt: timestamp('last_hit_at'), + + // Audit + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => ({ + // CHECK constraints + hitCountCheck: check('hit_count_check', sql`${table.hitCount} >= 0`), + + // Indexes + // Unique composite index for cache lookup + cacheKeyIdx: uniqueIndex('idx_cache_key').on( + table.projectId, + table.promptHash, + table.queryParamsHash, + ), + + // Index for generation lookup + generationIdx: index('idx_cache_generation').on(table.generationId), + + // Index for image lookup + imageIdx: index('idx_cache_image').on(table.imageId), + + // Index for cache hit analytics + hitsIdx: index('idx_cache_hits').on( + table.projectId, + table.hitCount.desc(), + table.createdAt.desc(), + ), + }), +); + +export type PromptUrlCache = typeof promptUrlCache.$inferSelect; +export type NewPromptUrlCache = typeof promptUrlCache.$inferInsert;