diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 617d7263a..d7cbebb39 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -696,25 +696,12 @@ export const boardRouter = createTRPCRouter({ saveLayouts: protectedProcedure.input(boardSaveLayoutsSchema).mutation(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify"); - let board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id); - const missingGutterRoots = (["left", "right"] as const).filter( - (lane) => - input.layouts.some((layout) => getBoardLaneColumnCount(layout, lane) > 0) && - !getRootSectionForLane(board, lane), + const requiredGutterRoots = (["left", "right"] as const).filter((lane) => + input.layouts.some((layout) => getBoardLaneColumnCount(layout, lane) > 0), ); - if (missingGutterRoots.length > 0) { - await ctx.db.insert(sections).values( - missingGutterRoots.map((lane) => ({ - id: createId(), - boardId: board.id, - kind: "empty" as const, - xOffset: rootSectionOffsets[lane], - yOffset: 0, - options: emptySuperJSON, - })), - ); - board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id); - } + await ensureGutterRootSectionsAsync(ctx.db, input.id, requiredGutterRoots); + + const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id); const addedLayouts = filterAddedItems(input.layouts, board.layouts); @@ -1709,7 +1696,9 @@ const getUpdatedBoardLayout = ( const mainRoot = rootByLane.get("main"); if (!mainRoot) throw new Error(`Board "${board.id}" has no main canvas root`); - const sourceRootById = new Map(emptyRoots.map((section) => [section.id, getRootSectionLane(section.xOffset)] as const)); + const sourceRootById = new Map( + emptyRoots.map((section) => [section.id, getRootSectionLane(section.xOffset)] as const), + ); const targetLaneBySourceLane = new Map( boardLanes.map((lane) => [ lane, @@ -1797,15 +1786,77 @@ interface BoardLayoutGeometry { rightGutterColumnCount: number; } -const getRootSectionForLane = ( - board: Awaited>, - lane: BoardLane, -) => { - const roots = board.sections.filter( - (section) => section.kind === "empty" && getRootSectionLane(section.xOffset) === lane, - ); - if (roots.length > 1) throw new Error(`Board "${board.id}" has multiple ${lane} canvas roots`); - return roots[0]; +type GutterLane = Exclude; + +const ensureGutterRootSectionsAsync = async (db: Database, boardId: string, lanes: readonly GutterLane[]) => { + if (lanes.length === 0) return; + + const getMissingLanes = (existingOffsets: readonly (number | null)[]) => + lanes.filter((lane) => { + const rootCount = existingOffsets.filter((offset) => offset === rootSectionOffsets[lane]).length; + if (rootCount > 1) throw new Error(`Board "${boardId}" has multiple ${lane} canvas roots`); + return rootCount === 0; + }); + const getRows = (missingLanes: readonly GutterLane[]) => + missingLanes.map((lane) => ({ + id: createId(), + boardId, + kind: "empty" as const, + xOffset: rootSectionOffsets[lane], + yOffset: 0, + options: emptySuperJSON, + })); + + await handleTransactionsAsync(db, { + async handleAsync(database, schema) { + await database.transaction(async (transaction) => { + await transaction + .select({ id: schema.boards.id }) + .from(schema.boards) + .where(eq(schema.boards.id, boardId)) + .for("update"); + + const existingRoots = await transaction + .select({ xOffset: schema.sections.xOffset }) + .from(schema.sections) + .where( + and( + eq(schema.sections.boardId, boardId), + eq(schema.sections.kind, "empty"), + inArray( + schema.sections.xOffset, + lanes.map((lane) => rootSectionOffsets[lane]), + ), + ), + ); + const rows = getRows(getMissingLanes(existingRoots.map(({ xOffset }) => xOffset))); + if (rows.length > 0) await transaction.insert(schema.sections).values(rows); + }); + }, + handleSync(database) { + database.transaction( + (transaction) => { + const existingRoots = transaction + .select({ xOffset: sections.xOffset }) + .from(sections) + .where( + and( + eq(sections.boardId, boardId), + eq(sections.kind, "empty"), + inArray( + sections.xOffset, + lanes.map((lane) => rootSectionOffsets[lane]), + ), + ), + ) + .all(); + const rows = getRows(getMissingLanes(existingRoots.map(({ xOffset }) => xOffset))); + if (rows.length > 0) transaction.insert(sections).values(rows).run(); + }, + { behavior: "immediate" }, + ); + }, + }); }; const getElementsForLayout = (board: Awaited>, layoutId: string) => { diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index 2281c2f94..5fdfd191e 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -1694,6 +1694,27 @@ describe("saveLayouts should save layout changes", () => { ); expect(returnedItem.sectionId).toBe(sectionId); }); + test("should create one gutter root when layouts are saved concurrently", async () => { + const db = createDb(); + const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); + const { boardId, layoutId } = await createFullBoardAsync(db, "concurrent-gutters"); + const input = { + id: boardId, + layouts: [ + { + ...createExistingLayout(layoutId), + leftGutterColumnCount: 2, + }, + ], + }; + + await Promise.all([caller.saveLayouts(input), caller.saveLayouts(input)]); + + const leftRoots = await db.query.sections.findMany({ + where: and(eq(sections.boardId, boardId), eq(sections.kind, "empty"), eq(sections.xOffset, -1)), + }); + expect(leftRoots).toHaveLength(1); + }); test("should remove layout when not present in input", async () => { // Arrange const db = createDb();