fix(server): take MAX+1 positions inside the transaction that inserts

Days, todo items, packing items and bags, packing template categories and
items, collections, collection labels and road-trip vias took their next
position as MAX(...) + 1 in one statement and inserted in the next, with
any other request free to run in between: two adds at once read the same
MAX and landed on the same position (the road-trip test pinned exactly
that duplicate). The read now sits in the transaction that inserts, and
the transaction holds the connection, so the second add waits and takes
the next position. A collection label's limit and name check move into
the same transaction, so two labels of one name added at once end in one
label and a 409.

Each site gets a test that adds several rows at once and reads back one
position each.
This commit is contained in:
Maurice
2026-10-08 15:35:36 +02:00
parent e015e5e5e8
commit 9dd2e1b983
10 changed files with 137 additions and 73 deletions
@@ -133,11 +133,8 @@ export class CollectionsService {
@InjectRepository(CollectionMembers) private readonly members: CollectionMembersRepository,
@InjectRepository(CollectionLabels) private readonly labels: CollectionLabelsRepository,
@InjectRepository(Categories) private readonly categories: CategoriesRepository,
// Plan 3h Task 2 (part B) — savePlace onward. `collectionPlaces`/
// `collectionPlaceRatings` own this service's own two remaining tables;
// `trips`/`tripMembers`/`tripPlaces`/`tripPlaceRatings`/`tags`/`users`
// are 3b/3c's, injected directly (AP1-AP6 each become a
// `TripsRepository.findAccessible` call, replacing `this.db.canAccessTrip`).
// The saved places and their ratings are this service's own; the trip-side
// repositories below are injected directly for the copy-to-trip paths.
@InjectRepository(CollectionPlaces) private readonly collectionPlaces: CollectionPlacesRepository,
@InjectRepository(CollectionPlaceRatings) private readonly collectionPlaceRatings: CollectionPlaceRatingsRepository,
@InjectRepository(Trips) private readonly trips: TripsRepository,
@@ -594,8 +591,9 @@ export class CollectionsService {
}
async createCollection(userId: number, body: CollectionCreateRequest): Promise<Collection> {
const max = await this.collectionsRepo.maxSortOrder(userId);
const id = await this.collectionsRepo.insertCollection({
// The MAX read and the insert in one transaction: two lists created at once
// cannot take the same position.
const id = await this.uow.transactional(async () => this.collectionsRepo.insertCollection({
owner_id: userId,
name: body.name,
description: body.description ?? null,
@@ -603,8 +601,8 @@ export class CollectionsService {
icon: body.icon ?? 'Bookmark',
cover_image: body.cover_image ?? null,
links: serializeLinks(body.links),
sort_order: max + 1,
});
sort_order: (await this.collectionsRepo.maxSortOrder(userId)) + 1,
}));
const col = await this.getCollectionRow(id);
return { ...col, is_owner: true };
}
@@ -1286,13 +1284,15 @@ export class CollectionsService {
await this.assertCanEdit(userId, collectionId);
const trimmed = name.trim();
if (!trimmed) httpError(400, 'Label name is required');
const count = await this.labels.countByCollection(collectionId);
if (count >= MAX_LABELS_PER_COLLECTION) httpError(400, `A list can have at most ${MAX_LABELS_PER_COLLECTION} labels`);
if (await this.labels.nameExists(collectionId, trimmed)) {
httpError(409, 'A label with this name already exists');
}
const nextSort = (await this.labels.maxSortOrder(collectionId)) + 1;
const newId = await this.labels.insertLabel({ collection_id: collectionId, name: trimmed, color: color ?? '#6366f1', sort_order: nextSort });
// The limit, the name check, the position and the insert in one transaction,
// so two labels added at once can neither both pass the checks nor share a position.
const newId = await this.uow.transactional(async () => {
const count = await this.labels.countByCollection(collectionId);
if (count >= MAX_LABELS_PER_COLLECTION) httpError(400, `A list can have at most ${MAX_LABELS_PER_COLLECTION} labels`);
if (await this.labels.nameExists(collectionId, trimmed)) httpError(409, 'A label with this name already exists');
const nextSort = (await this.labels.maxSortOrder(collectionId)) + 1;
return await this.labels.insertLabel({ collection_id: collectionId, name: trimmed, color: color ?? '#6366f1', sort_order: nextSort });
});
await this.notifyCollectionUsers(collectionId, socketId, 'collections:updated');
return this.getLabelById(newId);
}
+6 -12
View File
@@ -304,18 +304,12 @@ export class DaysService {
// of defect `PlacesService.create` was fixed for (A-M1).
const tripIdNum = toRowId(tripId);
if (tripIdNum === null) throw new HttpException({ error: 'Trip not found' }, 404);
// DY5: `DaysRepository.maxDayNumber` already folds the no-rows case to 0
// (`?? 0`); the `|| 0` here is the legacy expression kept verbatim so a
// stored 0 still becomes 1 — the same outcome either fold produces, but
// the ruling is to keep the literal formula in the service.
const maxDayNumber = await this.daysRepo.maxDayNumber(tripIdNum);
const dayNumber = (maxDayNumber || 0) + 1;
const day = await this.daysRepo.createDay({
trip_id: tripIdNum,
day_number: dayNumber,
date: date || null,
notes: notes || null,
// DY5: the next day number and the row that takes it in one transaction, so
// two days added at once cannot both read the same MAX. `|| 0` keeps a stored
// 0 becoming 1, as the legacy formula did.
const day = await this.uow.transactional(async () => {
const dayNumber = ((await this.daysRepo.maxDayNumber(tripIdNum)) || 0) + 1;
return await this.daysRepo.createDay({ trip_id: tripIdNum, day_number: dayNumber, date: date || null, notes: notes || null });
});
return { ...day, assignments: [] };
}
+24 -18
View File
@@ -277,12 +277,13 @@ export class PackingService {
ownerId?: number,
) {
if (data.bag_id != null && !(await this.bagInTrip(tripId, data.bag_id))) return { invalidBag: true } as const;
const maxOrder = await this.itemsRepo.maxSortOrder(tripId);
const sortOrder = (maxOrder !== null ? maxOrder : -1) + 1;
const qty = Math.max(1, Math.min(999, Number(data.quantity) || 1));
const isPrivate = this.visibilityToPrivate(data.visibility, data.is_private);
// The MAX read and the insert share the transaction, so two items added at
// once cannot take the same position.
const itemId = await this.uow.transactional(async () => {
const sortOrder = ((await this.itemsRepo.maxSortOrder(tripId)) ?? -1) + 1;
const id = await this.itemsRepo.insertItem({
trip_id: tripId,
name: data.name,
@@ -490,12 +491,11 @@ export class PackingService {
// ── Bulk Import ────────────────────────────────────────────────────────────
async bulkImport(tripId: string | number, items: ImportItem[], ownerId?: number) {
const maxOrder = await this.itemsRepo.maxSortOrder(tripId);
let sortOrder = (maxOrder !== null ? maxOrder : -1) + 1;
const created: any[] = [];
await this.uow.transactional(async () => {
// Read inside the transaction, like createItem.
let sortOrder = ((await this.itemsRepo.maxSortOrder(tripId)) ?? -1) + 1;
for (const item of items) {
if (!item.name?.trim()) continue;
const checked = item.checked ? 1 : 0;
@@ -615,13 +615,15 @@ export class PackingService {
}
async createBag(tripId: string | number, data: { name: string; color?: string; weight_limit_grams?: number | null }) {
const maxOrder = await this.bagsRepo.maxSortOrder(tripId);
const newId = await this.bagsRepo.insertBag({
trip_id: tripId,
name: data.name.trim(),
color: data.color || '#6366f1',
sort_order: (maxOrder ?? -1) + 1,
weight_limit_grams: data.weight_limit_grams ?? null,
const newId = await this.uow.transactional(async () => {
const maxOrder = await this.bagsRepo.maxSortOrder(tripId);
return await this.bagsRepo.insertBag({
trip_id: tripId,
name: data.name.trim(),
color: data.color || '#6366f1',
sort_order: (maxOrder ?? -1) + 1,
weight_limit_grams: data.weight_limit_grams ?? null,
});
});
return await this.bagsRepo.findById(newId);
}
@@ -682,13 +684,13 @@ export class PackingService {
if (templateItems.length === 0) return null;
const maxOrder = await this.itemsRepo.maxSortOrder(tripId);
let sortOrder = (maxOrder !== null ? maxOrder : -1) + 1;
const isPrivate = ownerId != null ? this.visibilityToPrivate(visibility) : 0;
const owner = isPrivate ? ownerId! : null;
const added: any[] = [];
await this.uow.transactional(async () => {
// Read inside the transaction, like createItem.
let sortOrder = ((await this.itemsRepo.maxSortOrder(tripId)) ?? -1) + 1;
for (const ti of templateItems) {
// Weight, count and bag ride along since #1131; a bag the trip lacks is
// created, the way the import does it.
@@ -837,8 +839,10 @@ export class PackingService {
if (!name?.trim()) return { error: 'Category name is required', status: 400 };
const template = await this.templatesRepo.findById(templateId);
if (!template) return { error: 'Template not found', status: 404 };
const maxOrder = await this.templateCategoriesRepo.maxSortOrder(templateId);
const newId = await this.templateCategoriesRepo.insertCategory(templateId, name.trim(), (maxOrder ?? -1) + 1);
const newId = await this.uow.transactional(async () => {
const maxOrder = await this.templateCategoriesRepo.maxSortOrder(templateId);
return await this.templateCategoriesRepo.insertCategory(templateId, name.trim(), (maxOrder ?? -1) + 1);
});
return { category: await this.templateCategoriesRepo.findById(newId) };
}
@@ -863,8 +867,10 @@ export class PackingService {
if (!name?.trim()) return { error: 'Item name is required', status: 400 };
const cat = await this.templateCategoriesRepo.findInTemplate(catId, templateId);
if (!cat) return { error: 'Category not found', status: 404 };
const maxOrder = await this.templateItemsRepo.maxSortOrder(catId);
const newId = await this.templateItemsRepo.insertTemplateItem({ category_id: catId, name: name.trim(), sort_order: (maxOrder ?? -1) + 1 });
const newId = await this.uow.transactional(async () => {
const maxOrder = await this.templateItemsRepo.maxSortOrder(catId);
return await this.templateItemsRepo.insertTemplateItem({ category_id: catId, name: name.trim(), sort_order: (maxOrder ?? -1) + 1 });
});
return { item: await this.templateItemsRepo.findById(newId) };
}
+11 -9
View File
@@ -107,15 +107,17 @@ export class RoadtripService {
async create(dayId: string | number, input: { after_order_index: number; lat: number; lng: number; sequence?: number }): Promise<RoadtripVia> {
const dayIdNum = this.requireDayId(dayId);
// Appended after whatever already follows that stop, unless the caller says where.
// RT4 (`nextSequence`) then RT5 (`insertVia`), un-transacted (R7 — pin, don't fix):
// two concurrent adds on one leg can read the same next sequence.
const sequence = input.sequence ?? (await this.viasRepo.nextSequence(dayIdNum, input.after_order_index));
const id = await this.viasRepo.insertVia({
day_id: dayIdNum,
after_order_index: input.after_order_index,
sequence,
lat: input.lat,
lng: input.lng,
// RT4 (`nextSequence`) and RT5 (`insertVia`) in one transaction, so two adds on
// one leg at once cannot read the same next sequence.
const id = await this.uow.transactional(async () => {
const sequence = input.sequence ?? (await this.viasRepo.nextSequence(dayIdNum, input.after_order_index));
return await this.viasRepo.insertVia({
day_id: dayIdNum,
after_order_index: input.after_order_index,
sequence,
lat: input.lat,
lng: input.lng,
});
});
return (await this.viasRepo.findById(id))!;
}
+9 -7
View File
@@ -64,13 +64,15 @@ export class TodoService {
async createItem(tripId: string | number, data: {
name: string; category?: string | null; due_date?: string | null; description?: string | null; assigned_user_id?: number | null; priority?: number;
}) {
const maxOrder = await this.todoItemsRepo.maxSortOrder(tripId);
const sortOrder = (maxOrder !== null ? maxOrder : -1) + 1;
const id = await this.todoItemsRepo.insertItem({
trip_id: tripId, name: data.name, category: data.category || null, sort_order: sortOrder,
due_date: data.due_date || null, description: data.description || null,
assigned_user_id: data.assigned_user_id || null, priority: data.priority || 0,
// The next position and the row that takes it in one transaction, so two items
// added at once cannot both read the same MAX.
const id = await this.uow.transactional(async () => {
const maxOrder = await this.todoItemsRepo.maxSortOrder(tripId);
return await this.todoItemsRepo.insertItem({
trip_id: tripId, name: data.name, category: data.category || null, sort_order: (maxOrder ?? -1) + 1,
due_date: data.due_date || null, description: data.description || null,
assigned_user_id: data.assigned_user_id || null, priority: data.priority || 0,
});
});
return this.todoItemsRepo.findById(id);