diff --git a/server/CLAUDE.md b/server/CLAUDE.md index b313668dd..54b3fbf4a 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -50,7 +50,7 @@ Nest owns everything. Every domain is a DI module under `src/nest//` (`c - **Thin controller → injectable provider.** Never resurrect a plain function-module service; don't grow god services — split a domain into several modules when it outgrows one class (trips and auth already are). - **Inject, don't reach for module globals.** No imports of the global `db` proxy from `nest/` layers at all any more — the last production importer converted onto a repository, and the export now survives only so ~190 test files' `vi.mock('…/db/database', …)` factories keep typechecking; no new code depending on the `ws` singleton or event-sink globals. There is no `DatabaseService` to inject either — take the repository(ies) you need plus `UnitOfWork` for transactions. Modules must be importable without side effects. -- **Transactions are not optional.** Multi-statement writes go in `await this.uow.transactional(async () => …)` (`UnitOfWork` from `nest/database`, provided by the global `OrmModule`); never hand-roll `BEGIN`/`COMMIT` and never call `db.transaction()` (the Phase 1 async sweep removes it). Every DB-touching method is `async`; `@typescript-eslint/no-floating-promises` and `no-misused-promises` are errors, so an unawaited call or a `Promise` in a condition fails lint. Bind values with `?`; identifiers only via a literal allow-list. In `src/db/repositories/` and `src/db/seeders/`, ESLint refuses raw SQL in any spelling: `raw()`, the `sql` tag, and SQL text handed to `execute`/`run`/`all`/`get`. Build the statement with the query builder; `MaintenanceRepository`, `DemoRepository` and the six existing seeders are the named exceptions in `eslint.config.mjs`, and `tests/unit/eslint-rules/restricted-syntax-selectors.test.ts` pins how many SQL strings each still holds, so they can only lose SQL, never gain it. +- **Transactions are not optional.** Multi-statement writes go in `await this.uow.transactional(async () => …)` (`UnitOfWork` from `nest/database`, provided by the global `OrmModule`); never hand-roll `BEGIN`/`COMMIT` and never call `db.transaction()` (the Phase 1 async sweep removes it). Every DB-touching method is `async`; `@typescript-eslint/no-floating-promises` and `no-misused-promises` are errors, so an unawaited call or a `Promise` in a condition fails lint. Bind values with `?`; identifiers only via a literal allow-list. In `src/db/repositories/` and `src/db/seeders/`, ESLint refuses raw SQL in any spelling: `raw()`, the `sql` tag, and SQL text handed to `execute`/`run`/`all`/`get`. Build the statement with the query builder; `MaintenanceRepository` and the six existing seeders are the named exceptions in `eslint.config.mjs`, and `tests/unit/eslint-rules/restricted-syntax-selectors.test.ts` pins how many SQL strings each still holds, so they can only lose SQL, never gain it. An insert that needs the new id reads it with `.returning('id')`: `InsertResult.insertId` is SQLite's `last_insert_rowid` and undefined on Postgres, and ESLint refuses it in `src/db/repositories/` and `src/db/seeders/`. - **Migrations are append-only.** A migration is identified by its class name in `mikro_orm_migrations`, and files sort by the timestamp in that name. Never rename or delete one that has shipped, and never edit its body; add a new one. No env interpolation in SQL. (The old positional `schema_version` array and the hand-kept `db/migrations.ts` test builder are both gone; the numbered migrations are the only schema history, for tests too.) A new foreign key comes with an index that leads with its column(s), or deleting the parent scans the child table (a partial index counts only when its WHERE clause is `IS NOT NULL` on those columns); `tests/unit/db/foreign-key-indexes.test.ts` fails on one without. - **Every write endpoint validates** through the Zod pipe with a schema from `@trek/shared` — no `Record` + ad-hoc checks. Also verify every referenced id exists and belongs to the same trip. - **Every outbound `fetch`** gets a timeout, a response-size cap and boundary validation: no `as`-casting provider responses, no silent `catch → null`. User-influenced URLs go through the SSRF guard. Never fall back to another user's API key. ESLint requires an init with a `signal` on `fetch`, `safeFetch`, `safeFetchFollow` and `safeFetchAdminConfigured` alike, or a literal `timeoutMs` in the options object of `safeFetch`/`safeFetchFollow`; an init spelled `undefined`, `null` or `void 0` counts as none (`safeFetchLlm` takes `LLM_TIMEOUT_MS` inside). Behind that, the guarded wrappers (`utils/ssrfGuard.ts`) cap every body at `DEFAULT_MAX_RESPONSE_BYTES` (64 MB) and, for a call without a signal, wait `DEFAULT_RESPONSE_TIMEOUT_MS` (30 s) for the headers. Pass a tighter `maxBytes` when you buffer a small answer, `timeoutMs` for a deadline over the whole exchange, and `maxBytes: null` only where the body is streamed on or counted by the caller (`pipeAsset`, the doc-sync transfers, the Ollama pull). diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index 9b6a22acd..62e62fd7e 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -46,6 +46,16 @@ const RAW_SQL_SELECTORS = [ }, ]; +// Kysely fills InsertResult.insertId from SQLite's last_insert_rowid. Postgres +// has no such value, so there it is undefined and Number(result.insertId) is +// NaN: an insert that needs the new id reads it with .returning('id'). +const INSERT_ID_MESSAGE = + "InsertResult.insertId is SQLite's last_insert_rowid and undefined on Postgres. Read the new id with .returning('id').executeTakeFirstOrThrow()."; +const INSERT_ID_SELECTORS = [ + { selector: "MemberExpression[property.name='insertId']", message: INSERT_ID_MESSAGE }, + { selector: "ObjectPattern > Property[key.name='insertId']", message: INSERT_ID_MESSAGE }, +]; + // A guarded call whose options object (third argument) sets `timeoutMs` has a // deadline over the whole exchange, so it does not need a signal in the init. // esquery only resolves `:nth-child` inside `:has` when each step is its own @@ -334,7 +344,13 @@ export default tseslint.config( ], }, ], - 'no-restricted-syntax': ['error', ...ENV_SELECTORS, ...RAW_SQL_SELECTORS, ...FETCH_SELECTORS], + 'no-restricted-syntax': [ + 'error', + ...ENV_SELECTORS, + ...RAW_SQL_SELECTORS, + ...FETCH_SELECTORS, + ...INSERT_ID_SELECTORS, + ], }, }, { @@ -344,8 +360,7 @@ export default tseslint.config( // same and passed unseen, in the seeders too, which no repository rule // reached. The files below already hold such strings and are named one by // one, so the list can only shrink: MaintenanceRepository is the SQLite - // maintenance adapter (PRAGMA, VACUUM INTO), DemoRepository is the - // documented escape hatch for the demo seed data, and every seeder but + // maintenance adapter (PRAGMA, VACUUM INTO), and every seeder but // DatabaseSeeder writes its rows as SQL text (four of them with the // SQLite-only INSERT OR IGNORE). A new seeder starts on the query builder. // tests/unit/eslint-rules/restricted-syntax-selectors.test.ts pins how many @@ -354,7 +369,6 @@ export default tseslint.config( files: ['src/db/repositories/**/*.ts', 'src/db/seeders/**/*.ts'], ignores: [ 'src/db/repositories/MaintenanceRepository.ts', - 'src/db/repositories/DemoRepository.ts', 'src/db/seeders/AddonSeeder.ts', 'src/db/seeders/AdminSeeder.ts', 'src/db/seeders/CategorySeeder.ts', @@ -369,6 +383,7 @@ export default tseslint.config( ...RAW_SQL_SELECTORS, ...FETCH_SELECTORS, ...STRING_SQL_SELECTORS, + ...INSERT_ID_SELECTORS, ], }, }, diff --git a/server/src/db/repositories/BucketList.repository.ts b/server/src/db/repositories/BucketList.repository.ts index 9cd602888..de111b92c 100644 --- a/server/src/db/repositories/BucketList.repository.ts +++ b/server/src/db/repositories/BucketList.repository.ts @@ -128,8 +128,8 @@ export class BucketListRepository extends TrekRepository { target_date: string | null; region_code: string | null; }): Promise { - const result = await this.insertDb().insertInto('bucket_list').values(row).executeTakeFirstOrThrow(); - return Number(result.insertId); + const inserted = await this.insertDb().insertInto('bucket_list').values(row).returning('id').executeTakeFirstOrThrow(); + return inserted.id; } /** AT33 (`createBucketItem`'s post-insert re-select, NOT user-scoped — the legacy statement's own shape) — `SELECT * FROM bucket_list WHERE id = ?`. */ diff --git a/server/src/db/repositories/BudgetItems.repository.ts b/server/src/db/repositories/BudgetItems.repository.ts index b3ac9b135..4bcdfae59 100644 --- a/server/src/db/repositories/BudgetItems.repository.ts +++ b/server/src/db/repositories/BudgetItems.repository.ts @@ -153,8 +153,8 @@ export class BudgetItemsRepository extends TrekRepository { exchange_rate: number; persons: number | null; days: number | null; note: string | null; ticket_json: string | null; sort_order: number; expense_date: string | null; reservation_id: number | null; place_id: number | null; }): Promise { - const result = await this.kysely().insertInto('budget_items').values({ ...row, trip_id: row.trip_id as number }).executeTakeFirstOrThrow(); - return Number(result.insertId); + const inserted = await this.kysely().insertInto('budget_items').values({ ...row, trip_id: row.trip_id as number }).returning('id').executeTakeFirstOrThrow(); + return inserted.id; } /** @@ -170,8 +170,8 @@ export class BudgetItemsRepository extends TrekRepository { note: string | null; sort_order: number | null; reservation_id: number | null; currency: string | null; exchange_rate: number; expense_date: string | null; ticket_json: string | null; paid_by_user_id: number | null; }): Promise { - const result = await this.kysely().insertInto('budget_items').values({ ...row, trip_id: row.trip_id as number }).executeTakeFirstOrThrow(); - return Number(result.insertId); + const inserted = await this.kysely().insertInto('budget_items').values({ ...row, trip_id: row.trip_id as number }).returning('id').executeTakeFirstOrThrow(); + return inserted.id; } /** diff --git a/server/src/db/repositories/BudgetSettlements.repository.ts b/server/src/db/repositories/BudgetSettlements.repository.ts index 5e1a53bd0..b84cfa8fb 100644 --- a/server/src/db/repositories/BudgetSettlements.repository.ts +++ b/server/src/db/repositories/BudgetSettlements.repository.ts @@ -101,14 +101,15 @@ export class BudgetSettlementsRepository extends TrekRepository { - const result = await this.kysely() + const inserted = await this.kysely() .insertInto('budget_settlements') .values({ trip_id: row.trip_id as number, from_user_id: row.from_user_id, to_user_id: row.to_user_id, amount: row.amount, currency: row.currency, exchange_rate: row.exchange_rate, settled_at: row.settled_at, note: row.note, created_by_user_id: row.created_by_user_id, }) + .returning('id') .executeTakeFirstOrThrow(); - return Number(result.insertId); + return inserted.id; } /** diff --git a/server/src/db/repositories/CollabNotes.repository.ts b/server/src/db/repositories/CollabNotes.repository.ts index b099394f7..2de93fda2 100644 --- a/server/src/db/repositories/CollabNotes.repository.ts +++ b/server/src/db/repositories/CollabNotes.repository.ts @@ -202,15 +202,17 @@ export class CollabNotesRepository extends TrekRepository { /** * CB18 (`addNoteFile`'s insert half) — `INSERT INTO trip_files (trip_id, * note_id, filename, original_name, file_size, mime_type) VALUES (?×6)`. - * Returns the new row's id (Kysely's `InsertResult.insertId`, this - * dialect's `lastInsertRowid` equivalent) for CB19's re-select. + * Returns the new row's id (read back with `RETURNING id`, which every + * supported engine answers, unlike `InsertResult.insertId`) for CB19's + * re-select. */ async insertAttachmentForNote(row: { trip_id: number | string; note_id: number | string; filename: string; original_name: string; file_size: number; mime_type: string }): Promise { - const result = await this.kysely() + const inserted = await this.kysely() .insertInto('trip_files') .values({ trip_id: row.trip_id as number, note_id: row.note_id as number, filename: row.filename, original_name: row.original_name, file_size: row.file_size, mime_type: row.mime_type }) + .returning('id') .executeTakeFirstOrThrow(); - return Number(result.insertId); + return inserted.id; } /** CB19 (`addNoteFile`'s re-select, immediately after {@link insertAttachmentForNote}) — `SELECT * FROM trip_files WHERE id = ?`, unscoped (the caller just wrote this exact id). */ diff --git a/server/src/db/repositories/CollectionLabels.repository.ts b/server/src/db/repositories/CollectionLabels.repository.ts index 216661c22..8a89ed5d5 100644 --- a/server/src/db/repositories/CollectionLabels.repository.ts +++ b/server/src/db/repositories/CollectionLabels.repository.ts @@ -61,8 +61,8 @@ export class CollectionLabelsRepository extends TrekRepository /** CL20 (`insertImportedLabel`) — `INSERT INTO collection_labels (collection_id, name, color, sort_order) VALUES (?,?,?,?)`. Returns the new row's id. Dup text also `createLabel` (Task 2's own call site). */ async insertLabel(row: { collection_id: number; name: string; color: string; sort_order: number }): Promise { - const result = await this.kysely().insertInto('collection_labels').values(row).executeTakeFirstOrThrow(); - return Number(result.insertId); + const inserted = await this.kysely().insertInto('collection_labels').values(row).returning('id').executeTakeFirstOrThrow(); + return inserted.id; } // ------------------------------------------------------------------------- diff --git a/server/src/db/repositories/Collections.repository.ts b/server/src/db/repositories/Collections.repository.ts index ecaeca42c..07f294159 100644 --- a/server/src/db/repositories/Collections.repository.ts +++ b/server/src/db/repositories/Collections.repository.ts @@ -188,8 +188,8 @@ export class CollectionsRepository extends TrekRepository { links: string | null; sort_order: number; }): Promise { - const result = await this.insertDb().insertInto('collections').values(row).executeTakeFirstOrThrow(); - return Number(result.insertId); + const inserted = await this.insertDb().insertInto('collections').values(row).returning('id').executeTakeFirstOrThrow(); + return inserted.id; } /** @@ -394,8 +394,8 @@ export class CollectionsRepository extends TrekRepository { links: string | null; sort_order: number; }): Promise { - const result = await this.placesWriteDb().insertInto('collection_places').values(row).executeTakeFirstOrThrow(); - return Number(result.insertId); + const inserted = await this.placesWriteDb().insertInto('collection_places').values(row).returning('id').executeTakeFirstOrThrow(); + return inserted.id; } /** CL24 (`writeFilePlaces`'s `assignLabel` prepared statement) — `INSERT OR IGNORE INTO collection_place_labels (collection_place_id, label_id) VALUES (?,?)`. */ diff --git a/server/src/db/repositories/DemoRepository.ts b/server/src/db/repositories/DemoRepository.ts index 6190a99ff..8a0628695 100644 --- a/server/src/db/repositories/DemoRepository.ts +++ b/server/src/db/repositories/DemoRepository.ts @@ -1,8 +1,17 @@ import type { EntityManager } from '@mikro-orm/core'; +import type { SqlEntityManager } from '@mikro-orm/sql'; import { Users } from '../entities/Users.entity'; import type { NewAdminUserRow } from './Users.repository'; import { AppSettings } from '../entities/AppSettings.entity'; import { Trips } from '../entities/Trips.entity'; +import { currentTimestampKysely } from '../dialect/sql-functions'; +import type { DB } from '../kysely/db'; + +/** The tables the example-trip seed writes through Kysely. */ +type DemoSeedKyselyDB = Pick< + DB, + 'trip_members' | 'trips' | 'days' | 'places' | 'day_assignments' | 'packing_items' | 'budget_items' | 'reservations' | 'day_notes' +>; /** DMR1's projection (`resetDemoUser`'s pre-close credential read). */ export interface DemoAdminCredentialsRow { @@ -60,14 +69,11 @@ export type NewDemoPlaceRow = readonly [ * repository composes it via `this.em.getRepository(Entity)` rather than * duplicating the statement — `Users.repository.ts`/`AppSettings.repository.ts` * are Plan 3i Task 1's territory this task and never edits. The remaining - * shapes (a `trips` count/list, the shared `trip_members` "insert or ignore" - * membership row, and the seven bulk INSERT loops for the three example - * trips) go through `connection.execute()` — rule 4's last-tier escape - * hatch, reserved for exactly this repository — because a prepared - * statement run in a loop over hand-written seed data is the natural shape - * here, and forcing ~90 rows of literal data through per-entity `insert()` - * calls (importing eight more entity classes this task does not own) buys - * nothing a raw, parameterised statement doesn't already give. + * shapes (the shared `trip_members` "insert or ignore" membership row and + * the seven bulk INSERT loops for the three example trips) are Kysely + * statements on the generated table types: one query-builder insert per row, + * with `RETURNING id` where the caller needs the new id, so the seed runs on + * any engine the dialect layer supports and never spells SQL text. * * Constructed directly (`new DemoRepository(em)`), never through Nest DI: * its only two callers — `demo/demo-seed.ts` and `demo/demo-reset.ts` — are @@ -86,11 +92,14 @@ export class DemoRepository { this.em.getContext(); } - /** `this.em.getConnection().execute(sql, params, 'run')`, validated first — the one place every raw statement below funnels through. */ - private async run(sql: string, params: readonly unknown[]): Promise<{ insertId: number }> { + /** + * `em.getKysely()`, validated first: the one place every seed insert below + * starts from. The callers hand in the SQL driver's EntityManager under + * core's type, which does not declare `getKysely`. + */ + private kysely() { this.validateRequestContext(); - const result = await this.em.getConnection().execute<{ id: number }>(sql, [...params], 'run'); - return { insertId: Number(result.insertId) }; + return (this.em as SqlEntityManager).getKysely(); } // --------------------------------------------------------------------- @@ -148,46 +157,66 @@ export class DemoRepository { * the legacy code's own dup-text shape. */ async addTripMember(tripId: number, userId: number, invitedBy: number): Promise { - await this.run('INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)', [tripId, userId, invitedBy]); + await this.kysely() + .insertInto('trip_members') + .values({ trip_id: tripId, user_id: userId, invited_by: invitedBy }) + .onConflict((oc) => oc.columns(['trip_id', 'user_id']).doNothing()) + .execute(); } /** DMS7 — `INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)`. Returns the generated trip id. */ async insertTrip(userId: number, title: string, description: string, startDate: string, endDate: string, currency: string): Promise { - const { insertId } = await this.run( - 'INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)', - [userId, title, description, startDate, endDate, currency], - ); - return insertId; + const inserted = await this.kysely() + .insertInto('trips') + .values({ user_id: userId, title, description, start_date: startDate, end_date: endDate, currency }) + .returning('id') + .executeTakeFirstOrThrow(); + return inserted.id; } /** DMS8 — `INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)`. Returns the generated day id. */ async insertDay(tripId: number, dayNumber: number, date: string): Promise { - const { insertId } = await this.run('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)', [tripId, dayNumber, date]); - return insertId; + const inserted = await this.kysely() + .insertInto('days') + .values({ trip_id: tripId, day_number: dayNumber, date }) + .returning('id') + .executeTakeFirstOrThrow(); + return inserted.id; } /** DMS9 — the 13-column `places` insert (see {@link NewDemoPlaceRow}). Returns the generated place id. */ async insertPlace(row: NewDemoPlaceRow): Promise { - const { insertId } = await this.run( - 'INSERT INTO places (trip_id, name, lat, lng, address, category_id, place_time, duration_minutes, notes, image_url, google_place_id, website, phone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - row, - ); - return insertId; + const [trip_id, name, lat, lng, address, category_id, place_time, duration_minutes, notes, image_url, google_place_id, website, phone] = row; + const inserted = await this.kysely() + .insertInto('places') + .values({ + trip_id, name, lat, lng, address, category_id, place_time, duration_minutes, notes, image_url, google_place_id, website, phone, + }) + .returning('id') + .executeTakeFirstOrThrow(); + return inserted.id; } /** DMS10 — `INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, ?)`. */ async insertDayAssignment(dayId: number, placeId: number, orderIndex: number): Promise { - await this.run('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, ?)', [dayId, placeId, orderIndex]); + await this.kysely().insertInto('day_assignments').values({ day_id: dayId, place_id: placeId, order_index: orderIndex }).execute(); } /** DMS11 — `INSERT INTO packing_items (trip_id, name, checked, category, sort_order, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`. */ async insertPackingItem(tripId: number, name: string, checked: number, category: string, sortOrder: number): Promise { - await this.run('INSERT INTO packing_items (trip_id, name, checked, category, sort_order, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)', [tripId, name, checked, category, sortOrder]); + const platform = this.em.getPlatform(); + await this.kysely() + .insertInto('packing_items') + .values({ trip_id: tripId, name, checked, category, sort_order: sortOrder, updated_at: currentTimestampKysely(platform) }) + .execute(); } /** DMS12 — `INSERT INTO budget_items (trip_id, category, name, total_price, persons, note) VALUES (?, ?, ?, ?, ?, ?)`. */ async insertBudgetItem(tripId: number, category: string, name: string, totalPrice: number, persons: number, note: string | null): Promise { - await this.run('INSERT INTO budget_items (trip_id, category, name, total_price, persons, note) VALUES (?, ?, ?, ?, ?, ?)', [tripId, category, name, totalPrice, persons, note]); + await this.kysely() + .insertInto('budget_items') + .values({ trip_id: tripId, category, name, total_price: totalPrice, persons, note }) + .execute(); } /** @@ -206,15 +235,20 @@ export class DemoRepository { type: string, location: string, ): Promise { - await this.run( - 'INSERT INTO reservations (trip_id, day_id, title, reservation_time, confirmation_number, status, type, location) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - [tripId, dayId, title, reservationTime, confirmationNumber, status, type, location], - ); + await this.kysely() + .insertInto('reservations') + .values({ + trip_id: tripId, day_id: dayId, title, reservation_time: reservationTime, confirmation_number: confirmationNumber, status, type, location, + }) + .execute(); } /** DMS14 — `INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)`. */ async insertDayNote(dayId: number, tripId: number, text: string, time: string, icon: string, sortOrder: number): Promise { - await this.run('INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)', [dayId, tripId, text, time, icon, sortOrder]); + await this.kysely() + .insertInto('day_notes') + .values({ day_id: dayId, trip_id: tripId, text, time, icon, sort_order: sortOrder }) + .execute(); } // --------------------------------------------------------------------- diff --git a/server/src/db/repositories/PackingBags.repository.ts b/server/src/db/repositories/PackingBags.repository.ts index 8d169c6ee..f5d2844d4 100644 --- a/server/src/db/repositories/PackingBags.repository.ts +++ b/server/src/db/repositories/PackingBags.repository.ts @@ -112,8 +112,8 @@ export class PackingBagsRepository extends TrekRepository { * the new row's id. */ async insertMinimal(trip_id: number | string, name: string, color: string): Promise { - const result = await this.kysely().insertInto('packing_bags').values({ trip_id: trip_id as number, name, color }).executeTakeFirstOrThrow(); - return Number(result.insertId); + const inserted = await this.kysely().insertInto('packing_bags').values({ trip_id: trip_id as number, name, color }).returning('id').executeTakeFirstOrThrow(); + return inserted.id; } /** @@ -122,8 +122,8 @@ export class PackingBagsRepository extends TrekRepository { * set from {@link insertMinimal}'s. Returns the new row's id. */ async insertBag(row: { trip_id: number | string; name: string; color: string; sort_order: number | null; weight_limit_grams: number | null }): Promise { - const result = await this.kysely().insertInto('packing_bags').values({ ...row, trip_id: row.trip_id as number }).executeTakeFirstOrThrow(); - return Number(result.insertId); + const inserted = await this.kysely().insertInto('packing_bags').values({ ...row, trip_id: row.trip_id as number }).returning('id').executeTakeFirstOrThrow(); + return inserted.id; } /** PK43 (`createBag`'s post-insert re-select) — `SELECT * FROM packing_bags WHERE id = ?`. */ diff --git a/server/src/db/repositories/PackingTemplateCategories.repository.ts b/server/src/db/repositories/PackingTemplateCategories.repository.ts index c059bba5c..2377b979c 100644 --- a/server/src/db/repositories/PackingTemplateCategories.repository.ts +++ b/server/src/db/repositories/PackingTemplateCategories.repository.ts @@ -37,8 +37,8 @@ export class PackingTemplateCategoriesRepository extends TrekRepository { - const result = await this.kysely().insertInto('packing_template_categories').values({ template_id: template_id as number, name, sort_order }).executeTakeFirstOrThrow(); - return Number(result.insertId); + const inserted = await this.kysely().insertInto('packing_template_categories').values({ template_id: template_id as number, name, sort_order }).returning('id').executeTakeFirstOrThrow(); + return inserted.id; } /** PK76 (`createTemplateCategory`) — `SELECT MAX(sort_order) as max FROM packing_template_categories WHERE template_id = ?`. */ diff --git a/server/src/db/repositories/PackingTemplateItems.repository.ts b/server/src/db/repositories/PackingTemplateItems.repository.ts index 2fae4b4a3..39a90960c 100644 --- a/server/src/db/repositories/PackingTemplateItems.repository.ts +++ b/server/src/db/repositories/PackingTemplateItems.repository.ts @@ -92,7 +92,7 @@ export class PackingTemplateItemsRepository extends TrekRepository { - const result = await this.kysely() + const inserted = await this.kysely() .insertInto('packing_template_items') .values({ category_id: row.category_id as number, @@ -102,8 +102,9 @@ export class PackingTemplateItemsRepository extends TrekRepository * .insertItem`'s precedent). */ async insertTemplate(name: string, created_by: number): Promise { - const result = await this.kysely().insertInto('packing_templates').values({ name, created_by }).executeTakeFirstOrThrow(); - return Number(result.insertId); + const inserted = await this.kysely().insertInto('packing_templates').values({ name, created_by }).returning('id').executeTakeFirstOrThrow(); + return inserted.id; } /** PK71 (`updatePackingTemplate`) — `UPDATE packing_templates SET name = ? WHERE id = ?`. */ diff --git a/server/src/db/repositories/Places.repository.ts b/server/src/db/repositories/Places.repository.ts index 1496c4669..0cdc1b06f 100644 --- a/server/src/db/repositories/Places.repository.ts +++ b/server/src/db/repositories/Places.repository.ts @@ -1265,8 +1265,8 @@ export class PlacesRepository extends TrekRepository { phone: string | null; osm_id: string | null; }): Promise { - const result = await this.kysely().insertInto('places').values(row).executeTakeFirstOrThrow(); - return Number(result.insertId); + const inserted = await this.kysely().insertInto('places').values(row).returning('id').executeTakeFirstOrThrow(); + return inserted.id; } // --------------------------------------------------------------------------- diff --git a/server/src/db/repositories/TodoItems.repository.ts b/server/src/db/repositories/TodoItems.repository.ts index 871ee2d3f..bdfa5620d 100644 --- a/server/src/db/repositories/TodoItems.repository.ts +++ b/server/src/db/repositories/TodoItems.repository.ts @@ -75,11 +75,12 @@ export class TodoItemsRepository extends TrekRepository { trip_id: number | string; name: string; category: string | null; sort_order: number; due_date: string | null; description: string | null; assigned_user_id: number | null; priority: number; }): Promise { - const result = await this.kysely() + const inserted = await this.kysely() .insertInto('todo_items') .values({ ...row, trip_id: row.trip_id as number, checked: 0 }) + .returning('id') .executeTakeFirstOrThrow(); - return Number(result.insertId); + return inserted.id; } /** TD4/TD7 (`createItem`/`updateItem`'s post-write re-select) — `SELECT * FROM todo_items WHERE id = ?` (no trip filter — the caller already knows `id` is in-scope, having just written it). */ @@ -157,11 +158,12 @@ export class TodoItemsRepository extends TrekRepository { trip_id: number | string; name: string; category: string | null; sort_order: number | null; due_date: string | null; description: string | null; priority: number | null; }): Promise { - const result = await this.kysely() + const inserted = await this.kysely() .insertInto('todo_items') .values({ ...row, trip_id: row.trip_id as number, checked: 0, assigned_user_id: null }) + .returning('id') .executeTakeFirstOrThrow(); - return Number(result.insertId); + return inserted.id; } // --------------------------------------------------------------------------- diff --git a/server/tests/unit/db/repositories/DemoRepository.test.ts b/server/tests/unit/db/repositories/DemoRepository.test.ts index 7221d1e6c..acd3e3181 100644 --- a/server/tests/unit/db/repositories/DemoRepository.test.ts +++ b/server/tests/unit/db/repositories/DemoRepository.test.ts @@ -91,6 +91,22 @@ describe('DemoRepository', () => { expect(note).toEqual({ day_id: dayId, trip_id: tripId, text: 'Note text', time: '13:00', icon: 'Info', sort_order: 0.5 }); }); + it('DEMOREPO-007: the inserts read the new id back with RETURNING, and a packing item gets its updated_at stamp', async () => { + const { user } = createAdmin(testDb); + const tripId = await demo.insertTrip(user.id, 'Ids', 'd', '2026-05-01', '2026-05-02', 'EUR'); + expect(typeof tripId).toBe('number'); + const latest = testDb.prepare('SELECT MAX(id) AS id FROM trips').get() as { id: number }; + expect(tripId).toBe(latest.id); + + const dayId = await demo.insertDay(tripId, 1, '2026-05-01'); + const secondDayId = await demo.insertDay(tripId, 2, '2026-05-02'); + expect(secondDayId).toBeGreaterThan(dayId); + + await demo.insertPackingItem(tripId, 'Charger', 0, 'Tech', 1); + const packing = testDb.prepare('SELECT updated_at FROM packing_items WHERE trip_id = ?').get(tripId) as { updated_at: string | null }; + expect(packing.updated_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + }); + it('DEMOREPO-CRED-001 (DMR1/DMR5 round trip): getAdminCredentials reads exactly what restoreAdminCredentials wrote', async () => { const { user } = createAdmin(testDb, { email: 'admin@nomad.app' }); testDb.prepare('UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, avatar = ? WHERE id = ?') diff --git a/server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts b/server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts index f3360ef91..2d6743138 100644 --- a/server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts +++ b/server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts @@ -141,7 +141,6 @@ declare const repo: { run(sql: string, params?: unknown[]): Promise; ge // the ignores entry) has to follow. const EXEMPT_SQL_STRINGS: Record = { 'src/db/repositories/MaintenanceRepository.ts': 4, - 'src/db/repositories/DemoRepository.ts': 9, 'src/db/seeders/AddonSeeder.ts': 1, 'src/db/seeders/AdminSeeder.ts': 2, 'src/db/seeders/CategorySeeder.ts': 2, @@ -202,3 +201,29 @@ void safeFetch(url);`, expect(hits(options, "void connection.execute('SELECT 1');")).toEqual([]); }); }); + +describe('insertId selectors', () => { + const INSERT = ` +declare const result: { insertId: bigint | undefined }; +declare const query: { executeTakeFirstOrThrow(): Promise<{ insertId: bigint | undefined }> }; +`; + const insertIdHits = (options: unknown[], code: string) => + messages(options, `${INSERT}\n${code}`).filter((m) => m.startsWith('InsertResult.insertId')); + + it('flags a read of insertId in every repository, the SQL-exempt one included, and in a seeder', async () => { + for (const file of [ + 'src/db/repositories/Probe.repository.ts', + 'src/db/repositories/MaintenanceRepository.ts', + 'src/db/seeders/ProbeSeeder.ts', + ]) { + const options = await selectorsFor(file); + expect(insertIdHits(options, 'void Number(result.insertId);'), file).toHaveLength(1); + expect(insertIdHits(options, 'void query.executeTakeFirstOrThrow().then(({ insertId }) => insertId);'), file).toHaveLength(1); + } + }); + + it('leaves a returning() read alone', async () => { + const options = await selectorsFor('src/db/repositories/Probe.repository.ts'); + expect(insertIdHits(options, 'declare const row: { id: number };\nvoid row.id;')).toEqual([]); + }); +});