diff --git a/dbal/ts/src/adapters/prisma-adapter.ts b/dbal/ts/src/adapters/prisma-adapter.ts new file mode 100644 index 000000000..c9b5680f0 --- /dev/null +++ b/dbal/ts/src/adapters/prisma-adapter.ts @@ -0,0 +1,350 @@ +import { PrismaClient } from '@prisma/client' +import type { DBALAdapter, AdapterCapabilities } from './adapter' +import type { ListOptions, ListResult } from '../core/types' +import { DBALError } from '../core/errors' + +type PrismaAdapterDialect = 'postgres' | 'mysql' | 'sqlite' | 'generic' + +export interface PrismaAdapterOptions { + queryTimeout?: number + dialect?: PrismaAdapterDialect +} + +export class PrismaAdapter implements DBALAdapter { + private prisma: PrismaClient + private queryTimeout: number + private dialect: PrismaAdapterDialect + + constructor(databaseUrl?: string, options?: PrismaAdapterOptions) { + const inferredDialect = options?.dialect ?? PrismaAdapter.inferDialectFromUrl(databaseUrl) + this.dialect = inferredDialect ?? 'generic' + this.prisma = new PrismaClient({ + datasources: databaseUrl ? { db: { url: databaseUrl } } : undefined, + }) + this.queryTimeout = options?.queryTimeout ?? 30000 + } + + async create(entity: string, data: Record): Promise { + try { + const model = this.getModel(entity) + const result = await this.withTimeout( + model.create({ data: data as never }) + ) + return result + } catch (error) { + throw this.handleError(error, 'create', entity) + } + } + + async read(entity: string, id: string): Promise { + try { + const model = this.getModel(entity) + const result = await this.withTimeout( + model.findUnique({ where: { id } as never }) + ) + return result + } catch (error) { + throw this.handleError(error, 'read', entity) + } + } + + async update(entity: string, id: string, data: Record): Promise { + try { + const model = this.getModel(entity) + const result = await this.withTimeout( + model.update({ + where: { id } as never, + data: data as never + }) + ) + return result + } catch (error) { + throw this.handleError(error, 'update', entity) + } + } + + async delete(entity: string, id: string): Promise { + try { + const model = this.getModel(entity) + await this.withTimeout( + model.delete({ where: { id } as never }) + ) + return true + } catch (error) { + if (this.isNotFoundError(error)) { + return false + } + throw this.handleError(error, 'delete', entity) + } + } + + async list(entity: string, options?: ListOptions): Promise> { + try { + const model = this.getModel(entity) + const page = options?.page || 1 + const limit = options?.limit || 50 + const skip = (page - 1) * limit + + const where = options?.filter ? this.buildWhereClause(options.filter) : undefined + const orderBy = options?.sort ? this.buildOrderBy(options.sort) : undefined + + const [data, total] = await Promise.all([ + this.withTimeout( + model.findMany({ + where: where as never, + orderBy: orderBy as never, + skip, + take: limit, + }) + ), + this.withTimeout( + model.count({ where: where as never }) + ) + ]) as [unknown[], number] + + return { + data: data as unknown[], + total, + page, + limit, + hasMore: skip + limit < total, + } + } catch (error) { + throw this.handleError(error, 'list', entity) + } + } + + async findFirst(entity: string, filter?: Record): Promise { + try { + const model = this.getModel(entity) + const where = filter ? this.buildWhereClause(filter) : undefined + const result = await this.withTimeout( + model.findFirst({ where: where as never }) + ) + return result + } catch (error) { + throw this.handleError(error, 'findFirst', entity) + } + } + + async findByField(entity: string, field: string, value: unknown): Promise { + try { + const model = this.getModel(entity) + const result = await this.withTimeout( + model.findUnique({ where: { [field]: value } as never }) + ) + return result + } catch (error) { + throw this.handleError(error, 'findByField', entity) + } + } + + async upsert( + entity: string, + uniqueField: string, + uniqueValue: unknown, + createData: Record, + updateData: Record + ): Promise { + try { + const model = this.getModel(entity) + const result = await this.withTimeout( + model.upsert({ + where: { [uniqueField]: uniqueValue } as never, + create: createData as never, + update: updateData as never, + }) + ) + return result + } catch (error) { + throw this.handleError(error, 'upsert', entity) + } + } + + async updateByField(entity: string, field: string, value: unknown, data: Record): Promise { + try { + const model = this.getModel(entity) + const result = await this.withTimeout( + model.update({ + where: { [field]: value } as never, + data: data as never, + }) + ) + return result + } catch (error) { + throw this.handleError(error, 'updateByField', entity) + } + } + + async deleteByField(entity: string, field: string, value: unknown): Promise { + try { + const model = this.getModel(entity) + await this.withTimeout( + model.delete({ where: { [field]: value } as never }) + ) + return true + } catch (error) { + if (this.isNotFoundError(error)) { + return false + } + throw this.handleError(error, 'deleteByField', entity) + } + } + + async deleteMany(entity: string, filter?: Record): Promise { + try { + const model = this.getModel(entity) + const where = filter ? this.buildWhereClause(filter) : undefined + const result = await this.withTimeout( + model.deleteMany({ where: where as never }) + ) + return result.count + } catch (error) { + throw this.handleError(error, 'deleteMany', entity) + } + } + + async updateMany(entity: string, filter: Record, data: Record): Promise { + try { + const model = this.getModel(entity) + const where = this.buildWhereClause(filter) + const result = await this.withTimeout( + model.updateMany({ where: where as never, data: data as never }) + ) + return result.count + } catch (error) { + throw this.handleError(error, 'updateMany', entity) + } + } + + async createMany(entity: string, data: Record[]): Promise { + try { + const model = this.getModel(entity) + const result = await this.withTimeout( + model.createMany({ data: data as never }) + ) + return result.count + } catch (error) { + throw this.handleError(error, 'createMany', entity) + } + } + + async getCapabilities(): Promise { + return this.buildCapabilities() + } + + async close(): Promise { + await this.prisma.$disconnect() + } + + private getModel(entity: string): any { + const modelName = entity.charAt(0).toLowerCase() + entity.slice(1) + const model = (this.prisma as any)[modelName] + + if (!model) { + throw DBALError.notFound(`Entity ${entity} not found`) + } + + return model + } + + private buildWhereClause(filter: Record): Record { + const where: Record = {} + + for (const [key, value] of Object.entries(filter)) { + if (value === null || value === undefined) { + where[key] = null + } else if (typeof value === 'object' && !Array.isArray(value)) { + where[key] = value + } else { + where[key] = value + } + } + + return where + } + + private buildOrderBy(sort: Record): Record { + return sort + } + + private async withTimeout(promise: Promise): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(DBALError.timeout()), this.queryTimeout) + ) + ]) + } + + private isNotFoundError(error: unknown): boolean { + return error instanceof Error && error.message.includes('not found') + } + + private handleError(error: unknown, operation: string, entity: string): DBALError { + if (error instanceof DBALError) { + return error + } + + if (error instanceof Error) { + if (error.message.includes('Unique constraint')) { + return DBALError.conflict(`${entity} already exists`) + } + if (error.message.includes('Foreign key constraint')) { + return DBALError.validationError('Related resource not found') + } + if (error.message.includes('not found')) { + return DBALError.notFound(`${entity} not found`) + } + return DBALError.internal(`Database error during ${operation}: ${error.message}`) + } + + return DBALError.internal(`Unknown error during ${operation}`) + } + + private buildCapabilities(): AdapterCapabilities { + const fullTextSearch = this.dialect === 'postgres' || this.dialect === 'mysql' + + return { + transactions: true, + joins: true, + fullTextSearch, + ttl: false, + jsonQueries: true, + aggregations: true, + relations: true, + } + } + + private static inferDialectFromUrl(url?: string): PrismaAdapterDialect | undefined { + if (!url) { + return undefined + } + + if (url.startsWith('postgresql://') || url.startsWith('postgres://')) { + return 'postgres' + } + + if (url.startsWith('mysql://')) { + return 'mysql' + } + + if (url.startsWith('file:') || url.startsWith('sqlite://')) { + return 'sqlite' + } + + return undefined + } +} + +export class PostgresAdapter extends PrismaAdapter { + constructor(databaseUrl?: string, options?: PrismaAdapterOptions) { + super(databaseUrl, { ...options, dialect: 'postgres' }) + } +} + +export class MySQLAdapter extends PrismaAdapter { + constructor(databaseUrl?: string, options?: PrismaAdapterOptions) { + super(databaseUrl, { ...options, dialect: 'mysql' }) + } +} diff --git a/frontends/nextjs/src/lib/db/sessions/get-session-by-id.ts b/frontends/nextjs/src/lib/db/sessions/get-session-by-id.ts new file mode 100644 index 000000000..92e5a2f84 --- /dev/null +++ b/frontends/nextjs/src/lib/db/sessions/get-session-by-id.ts @@ -0,0 +1 @@ +export { getSessionById } from './getters/get-session-by-id' diff --git a/frontends/nextjs/src/lib/db/sessions/update-session.ts b/frontends/nextjs/src/lib/db/sessions/update-session.ts new file mode 100644 index 000000000..be4e49f0d --- /dev/null +++ b/frontends/nextjs/src/lib/db/sessions/update-session.ts @@ -0,0 +1 @@ +export { updateSession } from './crud/update/update-session' diff --git a/frontends/nextjs/src/lib/db/users/add-user.ts b/frontends/nextjs/src/lib/db/users/add-user.ts new file mode 100644 index 000000000..8f1f1aba7 --- /dev/null +++ b/frontends/nextjs/src/lib/db/users/add-user.ts @@ -0,0 +1 @@ +export { addUser } from './crud/add/add-user'