feat: phase 3 part 1 - live scopes database schema and service

Implement foundation for live URL system with database schema, relations,
and comprehensive service layer for live scope management.

**Database Schema (Section 8.4):**
- **liveScopes table** with fields:
  - id (UUID primary key)
  - projectId (foreign key to projects, cascade delete)
  - slug (text, unique within project)
  - allowNewGenerations (boolean, default: true)
  - newGenerationsLimit (integer, default: 30)
  - meta (JSONB for flexible metadata)
  - createdAt, updatedAt timestamps

- **Constraints & Indexes:**
  - Unique constraint on (projectId, slug) combination
  - Index for project lookups
  - Index for project+slug lookups

- **Relations:**
  - projects → liveScopes (one-to-many)
  - liveScopes → project (many-to-one)

**Type Definitions:**
- Added LiveScope, NewLiveScope types from database schema
- Added LiveScopeWithStats interface with computed fields:
  - currentGenerations: number (count of images in scope)
  - lastGeneratedAt: Date | null (latest generation timestamp)
  - images?: Image[] (optional images array)
- Added LiveScopeFilters interface for query filtering

**LiveScopeService:**
- **Core CRUD operations:**
  - create() - Create new scope
  - getById() - Get scope by UUID
  - getBySlug() - Get scope by slug within project
  - getByIdOrThrow() / getBySlugOrThrow() - With error handling
  - update() - Update scope settings
  - delete() - Hard delete scope (images preserved)

- **Statistics & Computed Fields:**
  - getByIdWithStats() - Scope with generation count and last generated date
  - getBySlugWithStats() - Same but lookup by slug
  - list() - Paginated list with stats for each scope

- **Business Logic:**
  - canGenerateNew() - Check if scope allows new generations
  - createOrGet() - Lazy creation with project defaults
  - Uses images.meta field for scope tracking (scope, isLiveUrl)

**Technical Notes:**
- Images track scope via meta.scope field (no schema changes to images table)
- Scope statistics computed from images where meta.scope = slug and meta.isLiveUrl = true
- Project settings (allowNewLiveScopes, newLiveScopesGenerationLimit) already added in Phase 2
- All code compiles with zero new TypeScript errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Oleg Proskurin 2025-11-17 22:50:20 +07:00
parent 7d87202934
commit 1ad5b483ef
4 changed files with 354 additions and 1 deletions

View File

@ -0,0 +1,271 @@
import { eq, desc, count, and, isNull, sql } from 'drizzle-orm';
import { db } from '@/db';
import { liveScopes, images } from '@banatie/database';
import type { LiveScope, NewLiveScope, LiveScopeFilters, LiveScopeWithStats } from '@/types/models';
import { buildWhereClause, buildEqCondition } from '@/utils/helpers';
import { ERROR_MESSAGES } from '@/utils/constants';
export class LiveScopeService {
/**
* Create new live scope
* @param data - New scope data (projectId, slug, settings)
* @returns Created scope record
*/
async create(data: NewLiveScope): Promise<LiveScope> {
const [scope] = await db.insert(liveScopes).values(data).returning();
if (!scope) {
throw new Error('Failed to create live scope record');
}
return scope;
}
/**
* Get scope by ID
* @param id - Scope UUID
* @returns Scope record or null
*/
async getById(id: string): Promise<LiveScope | null> {
const scope = await db.query.liveScopes.findFirst({
where: eq(liveScopes.id, id),
});
return scope || null;
}
/**
* Get scope by slug within a project
* @param projectId - Project UUID
* @param slug - Scope slug
* @returns Scope record or null
*/
async getBySlug(projectId: string, slug: string): Promise<LiveScope | null> {
const scope = await db.query.liveScopes.findFirst({
where: and(eq(liveScopes.projectId, projectId), eq(liveScopes.slug, slug)),
});
return scope || null;
}
/**
* Get scope by ID or throw error
* @param id - Scope UUID
* @returns Scope record
* @throws Error if not found
*/
async getByIdOrThrow(id: string): Promise<LiveScope> {
const scope = await this.getById(id);
if (!scope) {
throw new Error('Live scope not found');
}
return scope;
}
/**
* Get scope by slug or throw error
* @param projectId - Project UUID
* @param slug - Scope slug
* @returns Scope record
* @throws Error if not found
*/
async getBySlugOrThrow(projectId: string, slug: string): Promise<LiveScope> {
const scope = await this.getBySlug(projectId, slug);
if (!scope) {
throw new Error('Live scope not found');
}
return scope;
}
/**
* Get scope with computed statistics
* @param id - Scope UUID
* @returns Scope with currentGenerations count and lastGeneratedAt
*/
async getByIdWithStats(id: string): Promise<LiveScopeWithStats> {
const scope = await this.getByIdOrThrow(id);
// Count images in this scope (use meta field: { scope: slug, isLiveUrl: true })
const scopeImages = await db.query.images.findMany({
where: and(
eq(images.projectId, scope.projectId),
isNull(images.deletedAt),
sql`${images.meta}->>'scope' = ${scope.slug}`,
sql`(${images.meta}->>'isLiveUrl')::boolean = true`,
),
orderBy: [desc(images.createdAt)],
});
const currentGenerations = scopeImages.length;
const lastGeneratedAt = scopeImages.length > 0 ? scopeImages[0]!.createdAt : null;
return {
...scope,
currentGenerations,
lastGeneratedAt,
images: scopeImages,
};
}
/**
* Get scope by slug with computed statistics
* @param projectId - Project UUID
* @param slug - Scope slug
* @returns Scope with statistics
*/
async getBySlugWithStats(projectId: string, slug: string): Promise<LiveScopeWithStats> {
const scope = await this.getBySlugOrThrow(projectId, slug);
return this.getByIdWithStats(scope.id);
}
/**
* List scopes in a project with pagination
* @param filters - Query filters (projectId, optional slug)
* @param limit - Max results to return
* @param offset - Number of results to skip
* @returns Array of scopes with stats and total count
*/
async list(
filters: LiveScopeFilters,
limit: number,
offset: number,
): Promise<{ scopes: LiveScopeWithStats[]; total: number }> {
const conditions = [
buildEqCondition(liveScopes, 'projectId', filters.projectId),
buildEqCondition(liveScopes, 'slug', filters.slug),
];
const whereClause = buildWhereClause(conditions);
const [scopesList, countResult] = await Promise.all([
db.query.liveScopes.findMany({
where: whereClause,
orderBy: [desc(liveScopes.createdAt)],
limit,
offset,
}),
db.select({ count: count() }).from(liveScopes).where(whereClause),
]);
const totalCount = countResult[0]?.count || 0;
// Compute stats for each scope
const scopesWithStats = await Promise.all(
scopesList.map(async (scope) => {
const scopeImages = await db.query.images.findMany({
where: and(
eq(images.projectId, scope.projectId),
isNull(images.deletedAt),
sql`${images.meta}->>'scope' = ${scope.slug}`,
sql`(${images.meta}->>'isLiveUrl')::boolean = true`,
),
orderBy: [desc(images.createdAt)],
});
return {
...scope,
currentGenerations: scopeImages.length,
lastGeneratedAt: scopeImages.length > 0 ? scopeImages[0]!.createdAt : null,
};
}),
);
return {
scopes: scopesWithStats,
total: Number(totalCount),
};
}
/**
* Update scope settings
* @param id - Scope UUID
* @param updates - Fields to update (allowNewGenerations, newGenerationsLimit, meta)
* @returns Updated scope record
*/
async update(
id: string,
updates: {
allowNewGenerations?: boolean;
newGenerationsLimit?: number;
meta?: Record<string, unknown>;
},
): Promise<LiveScope> {
// Verify scope exists
await this.getByIdOrThrow(id);
const [updated] = await db
.update(liveScopes)
.set({
...updates,
updatedAt: new Date(),
})
.where(eq(liveScopes.id, id))
.returning();
if (!updated) {
throw new Error('Failed to update live scope');
}
return updated;
}
/**
* Delete scope (hard delete)
* Note: Images in this scope are preserved with meta.scope field
* @param id - Scope UUID
*/
async delete(id: string): Promise<void> {
await db.delete(liveScopes).where(eq(liveScopes.id, id));
}
/**
* Check if scope can accept new generations
* @param scope - Scope record
* @param currentCount - Current number of generations (optional, will query if not provided)
* @returns true if new generations are allowed
*/
async canGenerateNew(scope: LiveScope, currentCount?: number): Promise<boolean> {
if (!scope.allowNewGenerations) {
return false;
}
if (currentCount === undefined) {
const stats = await this.getByIdWithStats(scope.id);
currentCount = stats.currentGenerations;
}
return currentCount < scope.newGenerationsLimit;
}
/**
* Create scope automatically (lazy creation) with project defaults
* @param projectId - Project UUID
* @param slug - Scope slug
* @param projectDefaults - Default settings from project (allowNewGenerations, limit)
* @returns Created scope or existing scope if already exists
*/
async createOrGet(
projectId: string,
slug: string,
projectDefaults: {
allowNewLiveScopes: boolean;
newLiveScopesGenerationLimit: number;
},
): Promise<LiveScope> {
// Check if scope already exists
const existing = await this.getBySlug(projectId, slug);
if (existing) {
return existing;
}
// Check if project allows new scope creation
if (!projectDefaults.allowNewLiveScopes) {
throw new Error(ERROR_MESSAGES.SCOPE_CREATION_DISABLED);
}
// Create new scope with project defaults
return this.create({
projectId,
slug,
allowNewGenerations: true,
newGenerationsLimit: projectDefaults.newLiveScopesGenerationLimit,
meta: {},
});
}
}

View File

@ -1,16 +1,18 @@
import type { generations, images, flows, promptUrlCache } from '@banatie/database';
import type { generations, images, flows, promptUrlCache, liveScopes } from '@banatie/database';
// Database model types (inferred from Drizzle schema)
export type Generation = typeof generations.$inferSelect;
export type Image = typeof images.$inferSelect;
export type Flow = typeof flows.$inferSelect;
export type PromptUrlCacheEntry = typeof promptUrlCache.$inferSelect;
export type LiveScope = typeof liveScopes.$inferSelect;
// Insert types (for creating new records)
export type NewGeneration = typeof generations.$inferInsert;
export type NewImage = typeof images.$inferInsert;
export type NewFlow = typeof flows.$inferInsert;
export type NewPromptUrlCacheEntry = typeof promptUrlCache.$inferInsert;
export type NewLiveScope = typeof liveScopes.$inferInsert;
// Generation status enum (matches DB schema)
export type GenerationStatus = 'pending' | 'processing' | 'success' | 'failed';
@ -51,6 +53,13 @@ export interface FlowWithCounts extends Flow {
images?: Image[];
}
// Enhanced live scope with computed stats
export interface LiveScopeWithStats extends LiveScope {
currentGenerations: number;
lastGeneratedAt: Date | null;
images?: Image[];
}
// Pagination metadata
export interface PaginationMeta {
total: number;
@ -81,6 +90,12 @@ export interface FlowFilters {
projectId: string;
}
// Query filters for live scopes
export interface LiveScopeFilters {
projectId: string;
slug?: string | undefined;
}
// Cache statistics
export interface CacheStats {
hits: number;

View File

@ -6,6 +6,7 @@ import { flows } from './flows';
import { images } from './images';
import { generations } from './generations';
import { promptUrlCache } from './promptUrlCache';
import { liveScopes } from './liveScopes';
// Export all tables
export * from './organizations';
@ -15,6 +16,7 @@ export * from './flows';
export * from './images';
export * from './generations';
export * from './promptUrlCache';
export * from './liveScopes';
// Define relations
export const organizationsRelations = relations(organizations, ({ many }) => ({
@ -32,6 +34,7 @@ export const projectsRelations = relations(projects, ({ one, many }) => ({
images: many(images),
generations: many(generations),
promptUrlCache: many(promptUrlCache),
liveScopes: many(liveScopes),
}));
export const apiKeysRelations = relations(apiKeys, ({ one, many }) => ({
@ -110,3 +113,10 @@ export const promptUrlCacheRelations = relations(promptUrlCache, ({ one }) => ({
references: [images.id],
}),
}));
export const liveScopesRelations = relations(liveScopes, ({ one }) => ({
project: one(projects, {
fields: [liveScopes.projectId],
references: [projects.id],
}),
}));

View File

@ -0,0 +1,57 @@
import { pgTable, uuid, text, boolean, integer, jsonb, timestamp, index, unique } from 'drizzle-orm/pg-core';
import { projects } from './projects';
/**
* Live Scopes Table (Section 8.4)
*
* Live scopes organize and control image generation via CDN live URLs.
* Each scope represents a logical separation within a project (e.g., "hero-section", "product-gallery").
*
* Live URL format: /cdn/:orgSlug/:projectSlug/live/:scope?prompt=...&aspectRatio=...
*/
export const liveScopes = pgTable(
'live_scopes',
{
id: uuid('id').primaryKey().defaultRandom(),
// Relations
projectId: uuid('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
// Scope identifier used in URLs (alphanumeric + hyphens + underscores)
// Must be unique within project
slug: text('slug').notNull(),
// Controls whether new generations can be triggered in this scope
// Already generated images are ALWAYS served publicly regardless of this setting
allowNewGenerations: boolean('allow_new_generations').notNull().default(true),
// Maximum number of generations allowed in this scope
// Only affects NEW generations, does not affect regeneration
newGenerationsLimit: integer('new_generations_limit').notNull().default(30),
// Flexible metadata storage
meta: jsonb('meta').$type<Record<string, unknown>>().notNull().default({}),
// Timestamps
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => ({
// Unique constraint: slug must be unique within project
projectSlugUnique: unique('live_scopes_project_slug_unique').on(table.projectId, table.slug),
// Index for querying scopes by project
projectIdx: index('idx_live_scopes_project').on(table.projectId),
// Index for slug lookups within project
projectSlugIdx: index('idx_live_scopes_project_slug').on(table.projectId, table.slug),
}),
);
export type LiveScope = typeof liveScopes.$inferSelect;
export type NewLiveScope = typeof liveScopes.$inferInsert;