diff --git a/server/src/db/repositories/DayNotes.repository.ts b/server/src/db/repositories/DayNotes.repository.ts index 109c75549..d52d64c0f 100644 --- a/server/src/db/repositories/DayNotes.repository.ts +++ b/server/src/db/repositories/DayNotes.repository.ts @@ -1,5 +1,5 @@ import type { DayNotes } from '../entities/DayNotes.entity'; -import { toRow } from './_shared/rows'; +import { toRow, type AssertRowKeys } from './_shared/rows'; import { EntityRepository } from '@mikro-orm/sql'; export interface DayNoteRow { @@ -14,37 +14,42 @@ export interface DayNoteRow { color: string | null; } +const _dayNoteRowKeys: AssertRowKeys = true; + export class DayNotesRepository extends EntityRepository { /** - * `INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order, color)` - * — `created_at` is left to `DEFAULT CURRENT_TIMESTAMP`, then the row is - * re-read so the returned row is the stored one. + * The column set of the legacy INSERT: + * `INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order, color)`. * - * `created_at` alone would not need the re-read: a `defaultRaw` column the - * insert does not name is in its `returning` clause, so it is already the - * stored text by the time `flush()` resolves. The re-read is there for the - * columns that are not — a nullable column this insert never named would - * otherwise be `undefined` on the entity and missing from the row. + * The caller passes already-coerced values — every field is required and is + * written verbatim. The legacy defaults (`icon || '📝'`, `sortOrder ?? 9999`, + * the trimmed text, `normalizeNoteColor(color)`) stay in + * `nest/day-notes/day-notes.service.ts`, which is where that rule lives. + * + * `created_at` is not in the column set: it is left to the column's + * `DEFAULT CURRENT_TIMESTAMP`. It alone would not need the re-read either — + * a `defaultRaw` column the insert does not name is in its `returning` + * clause, so it is already the stored text by the time `flush()` resolves. + * The re-read is there for the columns that are not: one this insert never + * names would otherwise be `undefined` on the entity and missing from the row. */ async createNote(input: { day_id: number; trip_id: number; text: string; - time?: string | null; - icon?: string | null; - sort_order?: number | null; - color?: string | null; + time: string | null; + icon: string | null; + sort_order: number | null; + color: string | null; }): Promise { const note = this.create({ day: input.day_id, - day_id: input.day_id, trip: input.trip_id, - trip_id: input.trip_id, text: input.text, - time: input.time ?? null, - icon: input.icon ?? '📝', - sort_order: input.sort_order ?? 0, - color: input.color ?? null, + time: input.time, + icon: input.icon, + sort_order: input.sort_order, + color: input.color, }); await this.getEntityManager().persist(note).flush(); await this.getEntityManager().refresh(note); diff --git a/server/src/db/repositories/Days.repository.ts b/server/src/db/repositories/Days.repository.ts index 5ff8c616e..8009bf472 100644 --- a/server/src/db/repositories/Days.repository.ts +++ b/server/src/db/repositories/Days.repository.ts @@ -1,5 +1,5 @@ import type { Days } from '../entities/Days.entity'; -import { toRow } from './_shared/rows'; +import { toRow, type AssertRowKeys } from './_shared/rows'; import { EntityRepository } from '@mikro-orm/sql'; /** A `days` row as the API emits it. */ @@ -13,6 +13,8 @@ export interface DayRow { default_transport_mode: string | null; } +const _dayRowKeys: AssertRowKeys = true; + export class DaysRepository extends EntityRepository { /** `SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC` */ async listByTrip(trip_id: number): Promise { @@ -45,27 +47,31 @@ export class DaysRepository extends EntityRepository { } /** - * `INSERT INTO days (trip_id, day_number, date, notes) VALUES (?, ?, ?, ?)` - * followed by the legacy re-select, so the caller gets the stored row (with - * every column default applied) and not what it asked to store. + * The column set of the legacy INSERT: + * `INSERT INTO days (trip_id, day_number, date, notes) VALUES (?, ?, ?, ?)`. * - * The re-select is not optional: the insert's `returning` clause carries only - * the generated and `defaultRaw` columns, so a column this insert never named - * (`title`, `default_transport_mode`) would still be `undefined` on the - * entity and would simply be missing from the row. + * The caller passes already-coerced values — every field is required and is + * written verbatim, because a default or a coercion belongs to the service + * that owns the rule, not to the statement that stores it. + * + * The insert is followed by the legacy re-select, so the caller gets the + * stored row rather than what it asked to store. That re-select is not + * optional: the insert's `returning` clause carries only the generated and + * `defaultRaw` columns, so a column this insert never names (`title`, + * `default_transport_mode`) would still be `undefined` on the entity and + * would simply be missing from the row. */ async createDay(input: { trip_id: number; day_number: number; - date?: string | null; - notes?: string | null; + date: string | null; + notes: string | null; }): Promise { const day = this.create({ trip: input.trip_id, - trip_id: input.trip_id, day_number: input.day_number, - date: input.date ?? null, - notes: input.notes ?? null, + date: input.date, + notes: input.notes, }); await this.getEntityManager().persist(day).flush(); await this.getEntityManager().refresh(day); diff --git a/server/src/db/repositories/_shared/rows.ts b/server/src/db/repositories/_shared/rows.ts index 1bcc20a62..4432e4881 100644 --- a/server/src/db/repositories/_shared/rows.ts +++ b/server/src/db/repositories/_shared/rows.ts @@ -1,4 +1,4 @@ -import { wrap, type EntityDTO } from '@mikro-orm/core'; +import { wrap, type Collection, type EntityDTO, type Reference } from '@mikro-orm/core'; /** * The plain row a service (and, through it, the API) sees. @@ -12,3 +12,22 @@ import { wrap, type EntityDTO } from '@mikro-orm/core'; export function toRow(entity: T): EntityDTO { return wrap(entity).toObject() as EntityDTO; } + +/** The scalar (column-backed) keys of an entity class: everything that is not a relation or a collection. */ +export type ScalarKeys = { + [K in keyof T]-?: NonNullable extends Collection | Reference ? never : K; +}[keyof T]; + +/** + * Compile-time parity between a hand-written row interface and the entity's + * scalar keys: a column added to or removed from the entity fails `tsc` here + * instead of leaking silently through `toRow`'s cast. Types (nullability) + * stay hand-maintained in the interface on purpose — that is where the API + * shape is documented. + */ +export type AssertRowKeys = [ + Exclude>, + Exclude, keyof Row>, +] extends [never, never] + ? true + : never; diff --git a/server/tests/unit/db/repositories/DayNotes.repository.test.ts b/server/tests/unit/db/repositories/DayNotes.repository.test.ts index 4ae35be76..0ab5bedf5 100644 --- a/server/tests/unit/db/repositories/DayNotes.repository.test.ts +++ b/server/tests/unit/db/repositories/DayNotes.repository.test.ts @@ -22,19 +22,19 @@ describe('DayNotesRepository timestamps', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); - const row = await notes.createNote({ day_id: day.id, trip_id: trip.id, text: 'Lunch' }); + const row = await notes.createNote({ day_id: day.id, trip_id: trip.id, text: 'Lunch', time: null, icon: '📝', sort_order: 0, color: null }); const stored = testDb.prepare('SELECT created_at, typeof(created_at) AS kind FROM day_notes WHERE id = ?').get(row.id) as { created_at: string; kind: string }; expect(stored.kind).toBe('text'); expect(stored.created_at).toMatch(DB_TIMESTAMP_RE); expect(row.created_at).toBe(stored.created_at); }); - it('NOTEREPO-002: the returned row is the SELECT * row and the defaults are the legacy ones', async () => { + it('NOTEREPO-002: the returned row is the SELECT * row, with the values the caller passed written verbatim', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); - const row = await notes.createNote({ day_id: day.id, trip_id: trip.id, text: 'Lunch' }); - expect(row).toEqual(testDb.prepare('SELECT * FROM day_notes WHERE id = ?').get(row.id)); + const row = await notes.createNote({ day_id: day.id, trip_id: trip.id, text: 'Lunch', time: null, icon: '📝', sort_order: 0, color: null }); + expect(row).toStrictEqual(testDb.prepare('SELECT * FROM day_notes WHERE id = ?').get(row.id)); expect(row.icon).toBe('📝'); expect(row.sort_order).toBe(0); }); @@ -43,7 +43,7 @@ describe('DayNotesRepository timestamps', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); - const row = await notes.createNote({ day_id: day.id, trip_id: trip.id, text: 'Lunch' }); + const row = await notes.createNote({ day_id: day.id, trip_id: trip.id, text: 'Lunch', time: null, icon: '📝', sort_order: 0, color: null }); const d = testDb.prepare('SELECT date(created_at) AS d FROM day_notes WHERE id = ?').get(row.id) as { d: string }; expect(d.d).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); diff --git a/server/tests/unit/db/repositories/Days.repository.test.ts b/server/tests/unit/db/repositories/Days.repository.test.ts index 4c010b401..89958de13 100644 --- a/server/tests/unit/db/repositories/Days.repository.test.ts +++ b/server/tests/unit/db/repositories/Days.repository.test.ts @@ -26,7 +26,7 @@ describe('DaysRepository', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id, { date: '2026-07-01', title: 'Arrival' }); const [row] = await days.listByTrip(trip.id); - expect(row).toEqual(rawDay(day.id)); + expect(row).toStrictEqual(rawDay(day.id)); }); it('DAYREPO-002: the scalar FK hydrates without loading the trip', async () => { @@ -60,15 +60,18 @@ describe('DaysRepository', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); expect(await days.maxDayNumber(trip.id)).toBe(0); - createDay(testDb, trip.id, { day_number: 3 }); - expect(await days.maxDayNumber(trip.id)).toBe(3); + createDay(testDb, trip.id, { day_number: 1 }); + createDay(testDb, trip.id, { day_number: 10 }); + createDay(testDb, trip.id, { day_number: 2 }); + // 10, not '2': the descending order has to be numeric, not lexicographic. + expect(await days.maxDayNumber(trip.id)).toBe(10); }); it('DAYREPO-006: createDay writes the legacy column set and returns the re-read row', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const row = await days.createDay({ trip_id: trip.id, day_number: 1, date: '2026-07-01' }); - expect(row).toEqual(rawDay(row.id)); + const row = await days.createDay({ trip_id: trip.id, day_number: 1, date: '2026-07-01', notes: null }); + expect(row).toStrictEqual(rawDay(row.id)); expect(row.notes).toBeNull(); expect(row.trip_id).toBe(trip.id); }); diff --git a/server/tests/unit/db/repositories/Trips.repository.test.ts b/server/tests/unit/db/repositories/Trips.repository.test.ts index 858f1c265..6eb7d330d 100644 --- a/server/tests/unit/db/repositories/Trips.repository.test.ts +++ b/server/tests/unit/db/repositories/Trips.repository.test.ts @@ -68,6 +68,6 @@ describe('TripsRepository.findAccessible — parity with canAccessTrip', () => { const entity = await t.repo(Trips).findOneOrFail({ id: trip.id }); const raw = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(trip.id) as Record; const { wrap } = await import('@mikro-orm/core'); - expect(wrap(entity).toObject()).toEqual(raw); + expect(wrap(entity).toObject()).toStrictEqual(raw); }); });