feat(db): added similar checks for duplicate indices for cards (#191)
This commit is contained in:
@@ -95,9 +95,32 @@ export const create = async (
|
|||||||
.having(gt(countExpr, 1));
|
.having(gt(countExpr, 1));
|
||||||
|
|
||||||
if (duplicateIndices.length > 0) {
|
if (duplicateIndices.length > 0) {
|
||||||
throw new Error(
|
// Compact indices for this list to sequential values (0..n-1) preserving order
|
||||||
`Duplicate indices found after creating card ${result[0].id}`,
|
await tx.execute(sql`
|
||||||
);
|
WITH ordered AS (
|
||||||
|
SELECT id, ROW_NUMBER() OVER (ORDER BY "index", id) - 1 AS new_index
|
||||||
|
FROM "card"
|
||||||
|
WHERE "listId" = ${result[0].listId} AND "deletedAt" IS NULL
|
||||||
|
)
|
||||||
|
UPDATE "card" c
|
||||||
|
SET "index" = o.new_index
|
||||||
|
FROM ordered o
|
||||||
|
WHERE c.id = o.id;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Last resort: verify fix; rollback if duplicates persist
|
||||||
|
const postFixDupes = await tx
|
||||||
|
.select({ index: cards.index, count: countExpr })
|
||||||
|
.from(cards)
|
||||||
|
.where(and(eq(cards.listId, result[0].listId), isNull(cards.deletedAt)))
|
||||||
|
.groupBy(cards.listId, cards.index)
|
||||||
|
.having(gt(countExpr, 1));
|
||||||
|
|
||||||
|
if (postFixDupes.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Invariant violation: duplicate card indices remain after compaction in list ${result[0].listId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result[0];
|
return result[0];
|
||||||
@@ -219,11 +242,95 @@ export const bulkCreate = async (
|
|||||||
importId?: number;
|
importId?: number;
|
||||||
}[],
|
}[],
|
||||||
) => {
|
) => {
|
||||||
const result = await db.insert(cards).values(cardInput).returning({
|
if (cardInput.length === 0) return [];
|
||||||
id: cards.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
return db.transaction(async (tx) => {
|
||||||
|
// Group incoming cards by list to compute safe, sequential indices per list
|
||||||
|
const byList = new Map<number, typeof cardInput>();
|
||||||
|
for (const item of cardInput) {
|
||||||
|
const arr = byList.get(item.listId) ?? [];
|
||||||
|
arr.push(item);
|
||||||
|
byList.set(item.listId, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allValuesToInsert: {
|
||||||
|
publicId: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
createdBy: string;
|
||||||
|
listId: number;
|
||||||
|
index: number;
|
||||||
|
importId?: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
// For each list, append incoming cards after current max index, preserving incoming order
|
||||||
|
for (const [listId, items] of byList.entries()) {
|
||||||
|
const last = await tx.query.cards.findFirst({
|
||||||
|
columns: { index: true },
|
||||||
|
where: and(eq(cards.listId, listId), isNull(cards.deletedAt)),
|
||||||
|
orderBy: [desc(cards.index)],
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextIndex = last ? last.index + 1 : 0;
|
||||||
|
const sorted = [...items].sort((a, b) => a.index - b.index);
|
||||||
|
for (const it of sorted) {
|
||||||
|
allValuesToInsert.push({
|
||||||
|
publicId: it.publicId,
|
||||||
|
title: it.title,
|
||||||
|
description: it.description,
|
||||||
|
createdBy: it.createdBy,
|
||||||
|
listId: it.listId,
|
||||||
|
index: nextIndex++,
|
||||||
|
importId: it.importId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = await tx
|
||||||
|
.insert(cards)
|
||||||
|
.values(allValuesToInsert)
|
||||||
|
.returning({ id: cards.id });
|
||||||
|
|
||||||
|
// Post-insert: compact per list if duplicates exist; then verify
|
||||||
|
const countExpr = sql<number>`COUNT(*)`.mapWith(Number);
|
||||||
|
for (const listId of byList.keys()) {
|
||||||
|
const duplicateIndices = await tx
|
||||||
|
.select({ index: cards.index, count: countExpr })
|
||||||
|
.from(cards)
|
||||||
|
.where(and(eq(cards.listId, listId), isNull(cards.deletedAt)))
|
||||||
|
.groupBy(cards.listId, cards.index)
|
||||||
|
.having(gt(countExpr, 1));
|
||||||
|
|
||||||
|
if (duplicateIndices.length > 0) {
|
||||||
|
await tx.execute(sql`
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT id, ROW_NUMBER() OVER (ORDER BY "index", id) - 1 AS new_index
|
||||||
|
FROM "card"
|
||||||
|
WHERE "listId" = ${listId} AND "deletedAt" IS NULL
|
||||||
|
)
|
||||||
|
UPDATE "card" c
|
||||||
|
SET "index" = o.new_index
|
||||||
|
FROM ordered o
|
||||||
|
WHERE c.id = o.id;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const postFixDupes = await tx
|
||||||
|
.select({ index: cards.index, count: countExpr })
|
||||||
|
.from(cards)
|
||||||
|
.where(and(eq(cards.listId, listId), isNull(cards.deletedAt)))
|
||||||
|
.groupBy(cards.listId, cards.index)
|
||||||
|
.having(gt(countExpr, 1));
|
||||||
|
|
||||||
|
if (postFixDupes.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Invariant violation: duplicate card indices remain after compaction in list ${listId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inserted;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createCardLabelRelationship = async (
|
export const createCardLabelRelationship = async (
|
||||||
@@ -606,9 +713,53 @@ export const reorder = async (
|
|||||||
.having(gt(countExpr, 1));
|
.having(gt(countExpr, 1));
|
||||||
|
|
||||||
if (duplicateIndices.length > 0) {
|
if (duplicateIndices.length > 0) {
|
||||||
throw new Error(
|
// Auto-heal by compacting indices for the affected list(s)
|
||||||
`Duplicate indices found after reordering card ${card.id}`,
|
const affectedListIds = [currentList.id, newList?.id].filter(
|
||||||
|
(id): id is number => id !== undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (affectedListIds.length === 1) {
|
||||||
|
await tx.execute(sql`
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT id, ROW_NUMBER() OVER (ORDER BY "index", id) - 1 AS new_index
|
||||||
|
FROM "card"
|
||||||
|
WHERE "listId" = ${affectedListIds[0]} AND "deletedAt" IS NULL
|
||||||
|
)
|
||||||
|
UPDATE "card" c
|
||||||
|
SET "index" = o.new_index
|
||||||
|
FROM ordered o
|
||||||
|
WHERE c.id = o.id;
|
||||||
|
`);
|
||||||
|
} else if (affectedListIds.length === 2) {
|
||||||
|
await tx.execute(sql`
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT id,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY "listId" ORDER BY "index", id) - 1 AS new_index
|
||||||
|
FROM "card"
|
||||||
|
WHERE "listId" IN (${sql.join(affectedListIds, sql`,`)}) AND "deletedAt" IS NULL
|
||||||
|
)
|
||||||
|
UPDATE "card" c
|
||||||
|
SET "index" = o.new_index
|
||||||
|
FROM ordered o
|
||||||
|
WHERE c.id = o.id;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify fix and rollback if necessary
|
||||||
|
const postFixDupes = await tx
|
||||||
|
.select({ index: cards.index, count: countExpr })
|
||||||
|
.from(cards)
|
||||||
|
.where(
|
||||||
|
and(inArray(cards.listId, affectedListIds), isNull(cards.deletedAt)),
|
||||||
|
)
|
||||||
|
.groupBy(cards.listId, cards.index)
|
||||||
|
.having(gt(countExpr, 1));
|
||||||
|
|
||||||
|
if (postFixDupes.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Invariant violation: duplicate card indices remain after compaction for card ${card.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedCard = await tx.query.cards.findFirst({
|
const updatedCard = await tx.query.cards.findFirst({
|
||||||
|
|||||||
Reference in New Issue
Block a user