refactor(db): read new ids with RETURNING instead of insertId

Kysely fills InsertResult.insertId from SQLite's last_insert_rowid. On
Postgres it is undefined, so the sixteen repository inserts that returned
Number(result.insertId) would hand NaN ids to their services without an
error. They now add .returning('id') and read the id from the row, which
SQLite (3.35 and later, bundled by better-sqlite3) and Postgres both answer
with the same value.

DemoRepository ran its example-trip seed as nine SQL strings through
connection.execute, including INSERT OR IGNORE and a bare CURRENT_TIMESTAMP.
Those are Kysely inserts on the generated table types now: ON CONFLICT
(trip_id, user_id) DO NOTHING for the membership row, the dialect helper for
the timestamp, RETURNING id where the caller needs it. With no SQL text left
it drops off the ESLint exemption list and its pin.

ESLint now refuses any read of insertId in src/db/repositories and
src/db/seeders, so the pattern cannot come back.
This commit is contained in:
Maurice
2026-10-08 15:14:08 +02:00
parent 5f17a5df50
commit c524454f47
17 changed files with 169 additions and 73 deletions
@@ -128,8 +128,8 @@ export class BucketListRepository extends TrekRepository<BucketList> {
target_date: string | null;
region_code: string | null;
}): Promise<number> {
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 = ?`. */
@@ -153,8 +153,8 @@ export class BudgetItemsRepository extends TrekRepository<BudgetItems> {
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<number> {
const result = await this.kysely<BudgetItemsInsertKyselyDB>().insertInto('budget_items').values({ ...row, trip_id: row.trip_id as number }).executeTakeFirstOrThrow();
return Number(result.insertId);
const inserted = await this.kysely<BudgetItemsInsertKyselyDB>().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<BudgetItems> {
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<number> {
const result = await this.kysely<BudgetItemsCopyInsertKyselyDB>().insertInto('budget_items').values({ ...row, trip_id: row.trip_id as number }).executeTakeFirstOrThrow();
return Number(result.insertId);
const inserted = await this.kysely<BudgetItemsCopyInsertKyselyDB>().insertInto('budget_items').values({ ...row, trip_id: row.trip_id as number }).returning('id').executeTakeFirstOrThrow();
return inserted.id;
}
/**
@@ -101,14 +101,15 @@ export class BudgetSettlementsRepository extends TrekRepository<BudgetSettlement
trip_id: number | string; from_user_id: number; to_user_id: number; amount: number;
currency: string | null; exchange_rate: number; settled_at: string | null; note: string | null; created_by_user_id: number | null;
}): Promise<number> {
const result = await this.kysely<BudgetSettlementsWriteKyselyDB>()
const inserted = await this.kysely<BudgetSettlementsWriteKyselyDB>()
.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;
}
/**
@@ -202,15 +202,17 @@ export class CollabNotesRepository extends TrekRepository<CollabNotes> {
/**
* 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<number> {
const result = await this.kysely<NoteAttachmentInsertKyselyDB>()
const inserted = await this.kysely<NoteAttachmentInsertKyselyDB>()
.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). */
@@ -61,8 +61,8 @@ export class CollectionLabelsRepository extends TrekRepository<CollectionLabels>
/** 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<number> {
const result = await this.kysely<CollectionLabelsInsertKyselyDB>().insertInto('collection_labels').values(row).executeTakeFirstOrThrow();
return Number(result.insertId);
const inserted = await this.kysely<CollectionLabelsInsertKyselyDB>().insertInto('collection_labels').values(row).returning('id').executeTakeFirstOrThrow();
return inserted.id;
}
// -------------------------------------------------------------------------
@@ -188,8 +188,8 @@ export class CollectionsRepository extends TrekRepository<Collections> {
links: string | null;
sort_order: number;
}): Promise<number> {
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<Collections> {
links: string | null;
sort_order: number;
}): Promise<number> {
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 (?,?)`. */
+67 -33
View File
@@ -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<DemoSeedKyselyDB>();
}
// ---------------------------------------------------------------------
@@ -148,46 +157,66 @@ export class DemoRepository {
* the legacy code's own dup-text shape.
*/
async addTripMember(tripId: number, userId: number, invitedBy: number): Promise<void> {
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<number> {
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<number> {
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<number> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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();
}
// ---------------------------------------------------------------------
@@ -112,8 +112,8 @@ export class PackingBagsRepository extends TrekRepository<PackingBags> {
* the new row's id.
*/
async insertMinimal(trip_id: number | string, name: string, color: string): Promise<number> {
const result = await this.kysely<PackingBagsMinimalInsertKyselyDB>().insertInto('packing_bags').values({ trip_id: trip_id as number, name, color }).executeTakeFirstOrThrow();
return Number(result.insertId);
const inserted = await this.kysely<PackingBagsMinimalInsertKyselyDB>().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<PackingBags> {
* 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<number> {
const result = await this.kysely<PackingBagsInsertKyselyDB>().insertInto('packing_bags').values({ ...row, trip_id: row.trip_id as number }).executeTakeFirstOrThrow();
return Number(result.insertId);
const inserted = await this.kysely<PackingBagsInsertKyselyDB>().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 = ?`. */
@@ -37,8 +37,8 @@ export class PackingTemplateCategoriesRepository extends TrekRepository<PackingT
* with `TrekRepository`/`EntityRepository`'s own same-named method.
*/
async insertCategory(template_id: number | string, name: string, sort_order: number): Promise<number> {
const result = await this.kysely<PackingTemplateCategoriesInsertKyselyDB>().insertInto('packing_template_categories').values({ template_id: template_id as number, name, sort_order }).executeTakeFirstOrThrow();
return Number(result.insertId);
const inserted = await this.kysely<PackingTemplateCategoriesInsertKyselyDB>().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 = ?`. */
@@ -92,7 +92,7 @@ export class PackingTemplateItemsRepository extends TrekRepository<PackingTempla
quantity?: number;
bag_name?: string | null;
}): Promise<number> {
const result = await this.kysely<PackingTemplateItemsInsertKyselyDB>()
const inserted = await this.kysely<PackingTemplateItemsInsertKyselyDB>()
.insertInto('packing_template_items')
.values({
category_id: row.category_id as number,
@@ -102,8 +102,9 @@ export class PackingTemplateItemsRepository extends TrekRepository<PackingTempla
quantity: row.quantity,
bag_name: row.bag_name,
})
.returning('id')
.executeTakeFirstOrThrow();
return Number(result.insertId);
return inserted.id;
}
/** PK85 (`createTemplateItem`) — `SELECT MAX(sort_order) as max FROM packing_template_items WHERE category_id = ?`. */
@@ -118,8 +118,8 @@ export class PackingTemplatesRepository extends TrekRepository<PackingTemplates>
* .insertItem`'s precedent).
*/
async insertTemplate(name: string, created_by: number): Promise<number> {
const result = await this.kysely<PackingTemplatesInsertKyselyDB>().insertInto('packing_templates').values({ name, created_by }).executeTakeFirstOrThrow();
return Number(result.insertId);
const inserted = await this.kysely<PackingTemplatesInsertKyselyDB>().insertInto('packing_templates').values({ name, created_by }).returning('id').executeTakeFirstOrThrow();
return inserted.id;
}
/** PK71 (`updatePackingTemplate`) — `UPDATE packing_templates SET name = ? WHERE id = ?`. */
@@ -1265,8 +1265,8 @@ export class PlacesRepository extends TrekRepository<Places> {
phone: string | null;
osm_id: string | null;
}): Promise<number> {
const result = await this.kysely<PlacesNarrowInsertKyselyDB>().insertInto('places').values(row).executeTakeFirstOrThrow();
return Number(result.insertId);
const inserted = await this.kysely<PlacesNarrowInsertKyselyDB>().insertInto('places').values(row).returning('id').executeTakeFirstOrThrow();
return inserted.id;
}
// ---------------------------------------------------------------------------
@@ -75,11 +75,12 @@ export class TodoItemsRepository extends TrekRepository<TodoItems> {
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<number> {
const result = await this.kysely<TodoItemsInsertKyselyDB>()
const inserted = await this.kysely<TodoItemsInsertKyselyDB>()
.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<TodoItems> {
trip_id: number | string; name: string; category: string | null; sort_order: number | null;
due_date: string | null; description: string | null; priority: number | null;
}): Promise<number> {
const result = await this.kysely<TodoItemsCopyInsertKyselyDB>()
const inserted = await this.kysely<TodoItemsCopyInsertKyselyDB>()
.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;
}
// ---------------------------------------------------------------------------