diff --git a/Makefile b/Makefile index 6aea5e9..8f34119 100644 --- a/Makefile +++ b/Makefile @@ -96,7 +96,7 @@ prod-migrate: check-env test-up: @echo "🧪 Subindo Postgres TEST" - docker compose -f $(TEST_COMPOSE) up -d + docker compose -f $(TEST_COMPOSE) up -d --wait test-down: @echo "⬇️ Removendo containers TEST (mantendo volumes)" diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml index 810c057..913c613 100644 --- a/docker/docker-compose.test.yml +++ b/docker/docker-compose.test.yml @@ -2,7 +2,7 @@ services: postgres_test: image: postgres:16 container_name: barber_test_db - restart: always + restart: no environment: POSTGRES_DB: barber_test POSTGRES_USER: test @@ -11,6 +11,11 @@ services: - "5433:5432" volumes: - pg_test_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U test -d barber_test"] + interval: 2s + timeout: 2s + retries: 10 volumes: pg_test_data: diff --git a/docs/adr/0010-uuid-as-identifier-type.md b/docs/adr/0010-uuid-as-identifier-type.md new file mode 100644 index 0000000..a0b9ae3 --- /dev/null +++ b/docs/adr/0010-uuid-as-identifier-type.md @@ -0,0 +1,87 @@ +# ADR 0010 – UUID as Native Identifier Type + +## Context + +The initial database schema defined all primary and foreign key identifiers as `TEXT`: + +* `users.id` +* `barbers.id` +* `reservations.id` +* `reservations.user_id` +* `reservations.barber_id` + +Although UUID values were already generated and used at the application layer, the database did not enforce UUID typing. + +This created several structural problems: + +* No format validation at the database level +* Possibility of persisting invalid identifier values +* Semantic mismatch between application and persistence layers +* Larger index footprint compared to native `UUID` +* Weakened type guarantees at the persistence boundary + +Because the system is still in an early stage and no production data exists, the cost of correcting the schema is minimal. + +Keeping identifiers as `TEXT` would be a technically weak decision: it sacrifices type safety for no practical benefit. + +## Decision + +1. **All identifier columns will use PostgreSQL’s native `UUID` type** + + The following columns will be migrated from `TEXT` to `UUID`: + + * `users.id` + * `barbers.id` + * `reservations.id` + * `reservations.user_id` + * `reservations.barber_id` + +2. **UUID generation remains at the application layer** + + * The database will not define a default UUID generator (`gen_random_uuid()`). + * The application remains responsible for identifier creation. + * This preserves architectural consistency and avoids hidden persistence-layer behavior. + +3. **Foreign key constraints remain unchanged semantically** + + * Only the column types are adjusted. + * Referential integrity rules remain intact. + +## Consequences + +### Positive + +* Enforced structural validation at the database level +* Stronger type safety at the persistence boundary +* Improved semantic alignment between ORM and database schema +* Smaller and more efficient indexes compared to `TEXT` +* Clearer long-term schema correctness + +### Negative / Trade-offs + +* Requires a migration altering primary and foreign key column types +* Slightly more rigid schema (intentional constraint) +* Tighter coupling to PostgreSQL UUID support (acceptable given the PostgreSQL-first strategy) + +These trade-offs are deliberate and technically justified. The benefits in correctness and integrity outweigh the migration cost. + +## Resulting Structure + +### Database Schema + +* All primary keys defined as `UUID` +* All foreign keys referencing `UUID` +* No default UUID generation at the database level + +### Application Layer + +* UUID generation remains centralized in the application +* ORM models updated to reflect `UUID` type explicitly + +## Final Observations + +Using `TEXT` for identifiers when the domain explicitly uses UUIDs is an architectural inconsistency and a type-safety regression. + +This decision restores semantic correctness at the persistence boundary and prevents subtle data integrity issues. + +If, in the future, a multi-database strategy is adopted, identifier typing must be re-evaluated. For a PostgreSQL-first architecture, native `UUID` is the technically sound choice. diff --git a/docs/adr/README.md b/docs/adr/README.md index a038f80..b32615a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -36,6 +36,8 @@ The decisions described here **reflect the actual state of the code at the time | 0007 | Barber as Specialized User | | 0008 | Drizzle Schema in Database Provider | | 0009 | Postgres Error Mapper | +| 0010 | UUID as Identifier Type | + --- diff --git a/migrations/0005_migrate_ids_to_uuid.sql b/migrations/0005_migrate_ids_to_uuid.sql new file mode 100644 index 0000000..f934ec3 --- /dev/null +++ b/migrations/0005_migrate_ids_to_uuid.sql @@ -0,0 +1,83 @@ +BEGIN; + +-- Drop constraints that depend on TEXT ids + +ALTER TABLE reservations + DROP CONSTRAINT reservations_user_fk; + +ALTER TABLE reservations + DROP CONSTRAINT reservations_barber_fk; + +ALTER TABLE barbers + DROP CONSTRAINT barbers_user_fk; + +ALTER TABLE reservations + DROP CONSTRAINT reservations_no_overlap; + +-- Drop primary keys + +ALTER TABLE reservations + DROP CONSTRAINT reservations_pkey; + +ALTER TABLE barbers + DROP CONSTRAINT barbers_pkey; + +ALTER TABLE users + DROP CONSTRAINT users_pkey; + +-- Convert columns from TEXT to UUID + +ALTER TABLE users + ALTER COLUMN id TYPE uuid USING id::uuid; + +ALTER TABLE barbers + ALTER COLUMN id TYPE uuid USING id::uuid; + +ALTER TABLE reservations + ALTER COLUMN id TYPE uuid USING id::uuid; + +ALTER TABLE reservations + ALTER COLUMN user_id TYPE uuid USING user_id::uuid; + +ALTER TABLE reservations + ALTER COLUMN barber_id TYPE uuid USING barber_id::uuid; + +-- Recreate primary keys + +ALTER TABLE users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + +ALTER TABLE barbers + ADD CONSTRAINT barbers_pkey PRIMARY KEY (id); + +ALTER TABLE reservations + ADD CONSTRAINT reservations_pkey PRIMARY KEY (id); + +-- Recreate foreign keys + +ALTER TABLE reservations + ADD CONSTRAINT reservations_user_fk + FOREIGN KEY (user_id) + REFERENCES users(id); + +ALTER TABLE reservations + ADD CONSTRAINT reservations_barber_fk + FOREIGN KEY (barber_id) + REFERENCES barbers(id); + +ALTER TABLE barbers + ADD CONSTRAINT barbers_user_fk + FOREIGN KEY (id) + REFERENCES users(id) + ON DELETE CASCADE; + +-- Recreate exclusion constraint (required because barber_id type changed) + +ALTER TABLE reservations + ADD CONSTRAINT reservations_no_overlap + EXCLUDE USING gist ( + barber_id WITH =, + period WITH && + ); + +COMMIT; diff --git a/src/infrastructure/barber/barber.mapper.ts b/src/infrastructure/barber/barber.mapper.ts index 0e4c42b..e222062 100644 --- a/src/infrastructure/barber/barber.mapper.ts +++ b/src/infrastructure/barber/barber.mapper.ts @@ -1,15 +1,11 @@ import { Barber } from '@domain/barber/barber.entity'; -type BarberRow = { - id: string; - name: string; - bio: string | null; - active: boolean; - createdAt: Date; -}; +import { barbers } from '@infrastructure/database/schema'; +type BarberSelect = typeof barbers.$inferSelect; +type BarberInsert = typeof barbers.$inferInsert; export class BarberMapper { - static toDomain(row: BarberRow): Barber { + static toDomain(row: BarberSelect): Barber { return Barber.create({ userId: row.id, name: row.name, @@ -18,7 +14,7 @@ export class BarberMapper { }); } - static toPersistence(barber: Barber): BarberRow { + static toPersistence(barber: Barber): BarberInsert { return { id: barber.id, name: barber.name, diff --git a/src/infrastructure/database/schema/barber.ts b/src/infrastructure/database/schema/barber.ts index 641c183..78114a0 100644 --- a/src/infrastructure/database/schema/barber.ts +++ b/src/infrastructure/database/schema/barber.ts @@ -1,13 +1,11 @@ -import { boolean, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; +import { boolean, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; export const barbers = pgTable('barbers', { - id: text('id').primaryKey(), + id: uuid('id').primaryKey(), name: text('name').notNull(), bio: text('bio'), active: boolean('active').notNull().default(true), - createdAt: timestamp('created_at', { withTimezone: true }) - .notNull() - .defaultNow(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), }); diff --git a/src/infrastructure/database/schema/reservation.ts b/src/infrastructure/database/schema/reservation.ts index 004d45f..dd9185f 100644 --- a/src/infrastructure/database/schema/reservation.ts +++ b/src/infrastructure/database/schema/reservation.ts @@ -8,7 +8,5 @@ export const reservations = pgTable('reservations', { startTime: timestamp('start_time', { withTimezone: true }).notNull(), endTime: timestamp('end_time', { withTimezone: true }).notNull(), - createdAt: timestamp('created_at', { withTimezone: true }) - .notNull() - .defaultNow(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), }); diff --git a/src/infrastructure/database/schema/user.ts b/src/infrastructure/database/schema/user.ts index 49222c9..33f5f17 100644 --- a/src/infrastructure/database/schema/user.ts +++ b/src/infrastructure/database/schema/user.ts @@ -1,8 +1,10 @@ -import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'; +import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; export const users = pgTable('users', { - id: text('id').primaryKey(), + id: uuid('id').primaryKey(), + email: text('email').notNull().unique(), passwordHash: text('password_hash').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), }); diff --git a/src/infrastructure/reservation/reservation.drizzle-repository.ts b/src/infrastructure/reservation/reservation.drizzle-repository.ts index 42f69a5..a16a7b4 100644 --- a/src/infrastructure/reservation/reservation.drizzle-repository.ts +++ b/src/infrastructure/reservation/reservation.drizzle-repository.ts @@ -10,7 +10,7 @@ import { DATABASE } from '@infrastructure/database/database.token'; import { PostgresErrorMapper } from '@infrastructure/database/postgres-error.mapper'; import { reservations } from '@infrastructure/database/schema/reservation'; -import { ReservationMapper, ReservationRow } from './reservation.mapper'; +import { ReservationMapper } from './reservation.mapper'; export class ReservationDrizzleRepository implements ReservationRepository { constructor(@Inject(DATABASE) private readonly db: DrizzleClient) {} @@ -29,7 +29,7 @@ export class ReservationDrizzleRepository implements ReservationRepository { } async findById(id: string): Promise { - const [row]: ReservationRow[] = await this.db + const [row] = await this.db .select() .from(reservations) .where(eq(reservations.id, id)); diff --git a/src/infrastructure/reservation/reservation.mapper.ts b/src/infrastructure/reservation/reservation.mapper.ts index d7f5236..df96891 100644 --- a/src/infrastructure/reservation/reservation.mapper.ts +++ b/src/infrastructure/reservation/reservation.mapper.ts @@ -1,16 +1,11 @@ import { Reservation } from '@domain/reservation/reservation.entity'; -export type ReservationRow = { - id: string; - userId: string; - barberId: string; - startTime: Date; - endTime: Date; - createdAt: Date; -}; +import { reservations } from '@infrastructure/database/schema'; +type ReservationSelect = typeof reservations.$inferSelect; +type ReservationInsert = typeof reservations.$inferInsert; export class ReservationMapper { - static toDomain(row: ReservationRow): Reservation { + static toDomain(row: ReservationSelect): Reservation { return Reservation.create({ id: row.id, userId: row.userId, @@ -20,7 +15,7 @@ export class ReservationMapper { }); } - static toPersistence(reservation: Reservation): ReservationRow { + static toPersistence(reservation: Reservation): ReservationInsert { return { id: reservation.id, userId: reservation.userId, diff --git a/src/infrastructure/user/user.mapper.ts b/src/infrastructure/user/user.mapper.ts index e3db966..4a8256b 100644 --- a/src/infrastructure/user/user.mapper.ts +++ b/src/infrastructure/user/user.mapper.ts @@ -1,13 +1,12 @@ import { User } from '@domain/user/user.entity'; -type userRow = { - id: string; - email: string; - passwordHash: string; - createdAt: Date; -}; +import { users } from '@infrastructure/database/schema/user'; + +type UserSelect = typeof users.$inferSelect; +type UserInsert = typeof users.$inferInsert; + export class UserMapper { - static toDomain(row: userRow): User { + static toDomain(row: UserSelect): User { return User.create({ id: row.id, email: row.email, @@ -16,7 +15,7 @@ export class UserMapper { }); } - static toPersistence(user: User): userRow { + static toPersistence(user: User): UserInsert { return { id: user.id, email: user.email.value, diff --git a/test/factories/barber.factory.ts b/test/factories/barber.factory.ts index d96ae62..980f40e 100644 --- a/test/factories/barber.factory.ts +++ b/test/factories/barber.factory.ts @@ -1,24 +1,30 @@ -import { TEST_TIME } from '@test/utils/time'; import { randomUUID } from 'crypto'; -import { Barber } from '@domain/barber/barber.entity'; - import { Database } from '@infrastructure/database/database.provider'; import { barbers } from '@infrastructure/database/schema/barber'; -export function buildBarber(input: Partial = {}) { +type BarberInsert = typeof barbers.$inferInsert; + +export function buildBarber(input: Partial = {}) { const id = input.id ?? randomUUID(); + const name = input.name ?? 'John Doe'; + const bio = input.bio ?? null; + const active = input.active ?? true; + const createdAt = input.createdAt ?? new Date(); return { id, - name: input.name ?? 'John Barber', - bio: input.bio ?? null, - active: input.active ?? true, - createdAt: input.createdAt ?? TEST_TIME, + name, + bio, + active, + createdAt, }; } -export async function persistBarber(db: Database, input?: Partial) { +export async function persistBarber( + db: Database, + input?: Partial, +) { const data = buildBarber(input); await db.insert(barbers).values(data); diff --git a/test/factories/reservation.factory.ts b/test/factories/reservation.factory.ts index 83ed615..b6e6db6 100644 --- a/test/factories/reservation.factory.ts +++ b/test/factories/reservation.factory.ts @@ -1,29 +1,36 @@ import { TEST_TIME } from '@test/utils/time'; import { randomUUID } from 'crypto'; -import { Reservation } from '@domain/reservation/reservation.entity'; - import { Database } from '@infrastructure/database/database.provider'; import { reservations } from '@infrastructure/database/schema/reservation'; -export function buildReservation(input: Partial = {}) { +type ReservationInsert = typeof reservations.$inferInsert; +type ReservationFactoryInput = { userId: string; barberId: string } & Partial< + Omit +>; + +export function buildReservation( + input: ReservationFactoryInput, +): ReservationInsert { const id = input.id ?? randomUUID(); - const start = input.startTime ?? TEST_TIME; - const end = input.endTime ?? new Date(start.getTime() + 30 * 60 * 1000); + const startTime = input.startTime ?? TEST_TIME; + const endTime = + input.endTime ?? new Date(startTime.getTime() + 60 * 60 * 1000); + const createdAt = input.createdAt ?? TEST_TIME; return { id, - barberId: input.barberId ?? 'fixed-barber-id', - userId: input.userId ?? 'fixed-user-id', - startTime: start, - endTime: end, - createdAt: input.createdAt ?? start, + userId: input.userId, + barberId: input.barberId, + startTime, + endTime, + createdAt, }; } export async function persistReservation( db: Database, - input?: Partial, + input: ReservationFactoryInput, ) { const data = buildReservation(input); await db.insert(reservations).values(data); diff --git a/test/factories/user.factory.ts b/test/factories/user.factory.ts index 08c8ce5..39c73f5 100644 --- a/test/factories/user.factory.ts +++ b/test/factories/user.factory.ts @@ -1,27 +1,25 @@ -import { TEST_TIME } from '@test/utils/time'; import { randomUUID } from 'crypto'; import { Database } from '@infrastructure/database/database.provider'; import { users } from '@infrastructure/database/schema/user'; -type persistUserInput = { - id?: string; - email?: string; - passwordHash?: string; - createdAt?: Date; -}; +type UserInsert = typeof users.$inferInsert; -export function buildUser(input: persistUserInput = {}) { +export function buildUser(input: Partial = {}) { const id = input.id ?? randomUUID(); + const email = input.email ?? `user-${id}@example.com`; + const passwordHash = input.passwordHash ?? 'hashed-password'; + const createdAt = input.createdAt ?? new Date(); + return { id, - email: input.email ?? `user-${id}@example.com`, - passwordHash: input.passwordHash ?? 'hash', - createdAt: input.createdAt ?? TEST_TIME, + email, + passwordHash, + createdAt, }; } -export async function persistUser(db: Database, input?: persistUserInput) { +export async function persistUser(db: Database, input?: Partial) { const data = buildUser(input); await db.insert(users).values(data); return data; diff --git a/test/reservation/reservation-concurrency.int.spec.ts b/test/reservation/reservation-concurrency.int.spec.ts index b4ad9b1..a8c11ca 100644 --- a/test/reservation/reservation-concurrency.int.spec.ts +++ b/test/reservation/reservation-concurrency.int.spec.ts @@ -42,8 +42,6 @@ it('should prevent double booking under real concurrency', async () => { const results = await Promise.allSettled(tasks); - console.log(results); - const success = results.filter((r) => r.status === 'fulfilled'); const failed = results.filter((r) => r.status === 'rejected');