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
+1 -1
View File
@@ -50,7 +50,7 @@ Nest owns everything. Every domain is a DI module under `src/nest/<domain>/` (`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<string, unknown>` + 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).
+19 -4
View File
@@ -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,
],
},
},
@@ -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;
}
// ---------------------------------------------------------------------------
@@ -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 = ?')
@@ -141,7 +141,6 @@ declare const repo: { run(sql: string, params?: unknown[]): Promise<unknown>; ge
// the ignores entry) has to follow.
const EXEMPT_SQL_STRINGS: Record<string, number> = {
'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([]);
});
});