fix: mysql transactions do not work with run property of sqlite (#1974)

* fix: mysql transactions do not work with run property of sqlite

* fix: ci issues
This commit is contained in:
Meier Lukas
2025-01-17 12:53:01 +01:00
committed by GitHub
parent 7622f0a4d2
commit a1084f91e5
9 changed files with 677 additions and 267 deletions

View File

@@ -4,7 +4,7 @@ import superjson from "superjson";
import { constructBoardPermissions } from "@homarr/auth/shared"; import { constructBoardPermissions } from "@homarr/auth/shared";
import type { DeviceType } from "@homarr/common/server"; import type { DeviceType } from "@homarr/common/server";
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db"; import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
import { and, createId, eq, inArray, like, or } from "@homarr/db"; import { and, createId, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries"; import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { import {
boardGroupPermissions, boardGroupPermissions,
@@ -204,21 +204,49 @@ export const boardRouter = createTRPCRouter({
.input(validation.board.create) .input(validation.board.create)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const boardId = createId(); const boardId = createId();
await ctx.db.transaction(async (transaction) => { await handleTransactionsAsync(ctx.db, {
await transaction.insert(boards).values({ async handleAsync(db, schema) {
id: boardId, await db.transaction(async (transaction) => {
name: input.name, await transaction.insert(schema.boards).values({
isPublic: input.isPublic, id: boardId,
columnCount: input.columnCount, name: input.name,
creatorId: ctx.session.user.id, isPublic: input.isPublic,
}); columnCount: input.columnCount,
await transaction.insert(sections).values({ creatorId: ctx.session.user.id,
id: createId(), });
kind: "empty", await transaction.insert(schema.sections).values({
xOffset: 0, id: createId(),
yOffset: 0, kind: "empty",
boardId, xOffset: 0,
}); yOffset: 0,
boardId,
});
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction
.insert(boards)
.values({
id: boardId,
name: input.name,
isPublic: input.isPublic,
columnCount: input.columnCount,
creatorId: ctx.session.user.id,
})
.run();
transaction
.insert(sections)
.values({
id: createId(),
kind: "empty",
xOffset: 0,
yOffset: 0,
boardId,
})
.run();
});
},
}); });
}), }),
duplicateBoard: permissionRequiredProcedure duplicateBoard: permissionRequiredProcedure
@@ -302,28 +330,54 @@ export const boardRouter = createTRPCRouter({
})), })),
); );
ctx.db.transaction((transaction) => { await handleTransactionsAsync(ctx.db, {
transaction async handleAsync(db, schema) {
.insert(boards) await db.transaction(async (transaction) => {
.values({ transaction.insert(schema.boards).values({
...boardProps, ...boardProps,
id: newBoardId, id: newBoardId,
name: input.name, name: input.name,
creatorId: ctx.session.user.id, creatorId: ctx.session.user.id,
}) });
.run();
if (sectionsToInsert.length > 0) { if (sectionsToInsert.length > 0) {
transaction.insert(sections).values(sectionsToInsert).run(); await transaction.insert(schema.sections).values(sectionsToInsert);
} }
if (itemsToInsert.length > 0) { if (itemsToInsert.length > 0) {
transaction.insert(items).values(itemsToInsert).run(); await transaction.insert(schema.items).values(itemsToInsert);
} }
if (itemIntegrationsToInsert.length > 0) { if (itemIntegrationsToInsert.length > 0) {
transaction.insert(integrationItems).values(itemIntegrationsToInsert).run(); await transaction.insert(schema.integrationItems).values(itemIntegrationsToInsert);
} }
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction
.insert(boards)
.values({
...boardProps,
id: newBoardId,
name: input.name,
creatorId: ctx.session.user.id,
})
.run();
if (sectionsToInsert.length > 0) {
transaction.insert(sections).values(sectionsToInsert).run();
}
if (itemsToInsert.length > 0) {
transaction.insert(items).values(itemsToInsert).run();
}
if (itemIntegrationsToInsert.length > 0) {
transaction.insert(integrationItems).values(itemIntegrationsToInsert).run();
}
});
},
}); });
}), }),
renameBoard: protectedProcedure.input(validation.board.rename).mutation(async ({ ctx, input }) => { renameBoard: protectedProcedure.input(validation.board.rename).mutation(async ({ ctx, input }) => {
@@ -434,148 +488,313 @@ export const boardRouter = createTRPCRouter({
saveBoard: protectedProcedure.input(validation.board.save).mutation(async ({ input, ctx }) => { saveBoard: protectedProcedure.input(validation.board.save).mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify"); await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
await ctx.db.transaction(async (transaction) => { const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
const dbBoard = await getFullBoardWithWhereAsync(transaction, eq(boards.id, input.id), ctx.session.user.id);
const addedSections = filterAddedItems(input.sections, dbBoard.sections); await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
if (addedSections.length > 0) { if (addedSections.length > 0) {
await transaction.insert(sections).values( await transaction.insert(schema.sections).values(
addedSections.map((section) => ({ addedSections.map((section) => ({
id: section.id, id: section.id,
kind: section.kind, kind: section.kind,
yOffset: section.yOffset, yOffset: section.yOffset,
xOffset: section.kind === "dynamic" ? section.xOffset : 0, xOffset: section.kind === "dynamic" ? section.xOffset : 0,
height: "height" in section ? section.height : null, height: "height" in section ? section.height : null,
width: "width" in section ? section.width : null, width: "width" in section ? section.width : null,
parentSectionId: "parentSectionId" in section ? section.parentSectionId : null, parentSectionId: "parentSectionId" in section ? section.parentSectionId : null,
name: "name" in section ? section.name : null, name: "name" in section ? section.name : null,
boardId: dbBoard.id, boardId: dbBoard.id,
})), })),
); );
} }
const inputItems = input.sections.flatMap((section) => const inputItems = input.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })), section.items.map((item) => ({ ...item, sectionId: section.id })),
); );
const dbItems = dbBoard.sections.flatMap((section) => const dbItems = dbBoard.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })), section.items.map((item) => ({ ...item, sectionId: section.id })),
);
const addedItems = filterAddedItems(inputItems, dbItems);
if (addedItems.length > 0) {
await transaction.insert(items).values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})),
);
}
const inputIntegrationRelations = inputItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId && dbRelation.integrationId === inputRelation.integrationId,
),
);
if (addedIntegrationRelations.length > 0) {
await transaction.insert(integrationItems).values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
);
}
const updatedItems = filterUpdatedItems(inputItems, dbItems);
for (const item of updatedItems) {
await transaction
.update(items)
.set({
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})
.where(eq(items.id, item.id));
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
await transaction
.update(sections)
.set({
yOffset: section.yOffset,
xOffset: section.xOffset,
height: prev?.kind === "dynamic" && "height" in section ? section.height : null,
width: prev?.kind === "dynamic" && "width" in section ? section.width : null,
parentSectionId: prev?.kind === "dynamic" && "parentSectionId" in section ? section.parentSectionId : null,
name: prev?.kind === "category" && "name" in section ? section.name : null,
})
.where(eq(sections.id, section.id));
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId && dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
await transaction
.delete(integrationItems)
.where(
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
),
); );
}
const removedItems = filterRemovedItems(inputItems, dbItems); const addedItems = filterAddedItems(inputItems, dbItems);
const itemIds = removedItems.map((item) => item.id); if (addedItems.length > 0) {
if (itemIds.length > 0) { await transaction.insert(schema.items).values(
await transaction.delete(items).where(inArray(items.id, itemIds)); addedItems.map((item) => ({
} id: item.id,
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})),
);
}
const removedSections = filterRemovedItems(input.sections, dbBoard.sections); const inputIntegrationRelations = inputItems.flatMap(({ integrationIds, id: itemId }) =>
const sectionIds = removedSections.map((section) => section.id); integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
if (sectionIds.length > 0) { if (addedIntegrationRelations.length > 0) {
await transaction.delete(sections).where(inArray(sections.id, sectionIds)); await transaction.insert(schema.integrationItems).values(
} addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
);
}
const updatedItems = filterUpdatedItems(inputItems, dbItems);
for (const item of updatedItems) {
await transaction
.update(schema.items)
.set({
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})
.where(eq(items.id, item.id));
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
await transaction
.update(schema.sections)
.set({
yOffset: section.yOffset,
xOffset: section.xOffset,
height: prev?.kind === "dynamic" && "height" in section ? section.height : null,
width: prev?.kind === "dynamic" && "width" in section ? section.width : null,
parentSectionId:
prev?.kind === "dynamic" && "parentSectionId" in section ? section.parentSectionId : null,
name: prev?.kind === "category" && "name" in section ? section.name : null,
})
.where(eq(sections.id, section.id));
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
await transaction
.delete(schema.integrationItems)
.where(
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
),
);
}
const removedItems = filterRemovedItems(inputItems, dbItems);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
await transaction.delete(schema.items).where(inArray(items.id, itemIds));
}
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) {
await transaction.delete(schema.sections).where(inArray(sections.id, sectionIds));
}
});
},
handleSync(db) {
db.transaction((transaction) => {
const addedSections = filterAddedItems(input.sections, dbBoard.sections);
if (addedSections.length > 0) {
transaction
.insert(sections)
.values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
yOffset: section.yOffset,
xOffset: section.kind === "dynamic" ? section.xOffset : 0,
height: "height" in section ? section.height : null,
width: "width" in section ? section.width : null,
parentSectionId: "parentSectionId" in section ? section.parentSectionId : null,
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
})),
)
.run();
}
const inputItems = input.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })),
);
const dbItems = dbBoard.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })),
);
const addedItems = filterAddedItems(inputItems, dbItems);
if (addedItems.length > 0) {
transaction
.insert(items)
.values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})),
)
.run();
}
const inputIntegrationRelations = inputItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const dbIntegrationRelations = dbItems.flatMap(({ integrationIds, id: itemId }) =>
integrationIds.map((integrationId) => ({
integrationId,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
if (addedIntegrationRelations.length > 0) {
transaction
.insert(integrationItems)
.values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
)
.run();
}
const updatedItems = filterUpdatedItems(inputItems, dbItems);
for (const item of updatedItems) {
transaction
.update(items)
.set({
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
advancedOptions: superjson.stringify(item.advancedOptions),
sectionId: item.sectionId,
})
.where(eq(items.id, item.id))
.run();
}
const updatedSections = filterUpdatedItems(input.sections, dbBoard.sections);
for (const section of updatedSections) {
const prev = dbBoard.sections.find((dbSection) => dbSection.id === section.id);
transaction
.update(sections)
.set({
yOffset: section.yOffset,
xOffset: section.xOffset,
height: prev?.kind === "dynamic" && "height" in section ? section.height : null,
width: prev?.kind === "dynamic" && "width" in section ? section.width : null,
parentSectionId:
prev?.kind === "dynamic" && "parentSectionId" in section ? section.parentSectionId : null,
name: prev?.kind === "category" && "name" in section ? section.name : null,
})
.where(eq(sections.id, section.id))
.run();
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
transaction
.delete(integrationItems)
.where(
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
),
)
.run();
}
const removedItems = filterRemovedItems(inputItems, dbItems);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
transaction.delete(items).where(inArray(items.id, itemIds)).run();
}
const removedSections = filterRemovedItems(input.sections, dbBoard.sections);
const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) {
transaction.delete(sections).where(inArray(sections.id, sectionIds)).run();
}
});
},
}); });
}), }),
@@ -655,18 +874,42 @@ export const boardRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full"); await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full");
await ctx.db.transaction(async (transaction) => { await handleTransactionsAsync(ctx.db, {
await transaction.delete(boardUserPermissions).where(eq(boardUserPermissions.boardId, input.entityId)); async handleAsync(db, schema) {
if (input.permissions.length === 0) { await db.transaction(async (transaction) => {
return; await transaction
} .delete(schema.boardUserPermissions)
await transaction.insert(boardUserPermissions).values( .where(eq(boardUserPermissions.boardId, input.entityId));
input.permissions.map((permission) => ({ if (input.permissions.length === 0) {
userId: permission.principalId, return;
permission: permission.permission, }
boardId: input.entityId, await transaction.insert(schema.boardUserPermissions).values(
})), input.permissions.map((permission) => ({
); userId: permission.principalId,
permission: permission.permission,
boardId: input.entityId,
})),
);
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction.delete(boardUserPermissions).where(eq(boardUserPermissions.boardId, input.entityId)).run();
if (input.permissions.length === 0) {
return;
}
transaction
.insert(boardUserPermissions)
.values(
input.permissions.map((permission) => ({
userId: permission.principalId,
permission: permission.permission,
boardId: input.entityId,
})),
)
.run();
});
},
}); });
}), }),
saveGroupBoardPermissions: protectedProcedure saveGroupBoardPermissions: protectedProcedure
@@ -674,18 +917,42 @@ export const boardRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full"); await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.entityId), "full");
await ctx.db.transaction(async (transaction) => { await handleTransactionsAsync(ctx.db, {
await transaction.delete(boardGroupPermissions).where(eq(boardGroupPermissions.boardId, input.entityId)); async handleAsync(db, schema) {
if (input.permissions.length === 0) { await db.transaction(async (transaction) => {
return; await transaction
} .delete(schema.boardGroupPermissions)
await transaction.insert(boardGroupPermissions).values( .where(eq(boardGroupPermissions.boardId, input.entityId));
input.permissions.map((permission) => ({ if (input.permissions.length === 0) {
groupId: permission.principalId, return;
permission: permission.permission, }
boardId: input.entityId, await transaction.insert(schema.boardGroupPermissions).values(
})), input.permissions.map((permission) => ({
); groupId: permission.principalId,
permission: permission.permission,
boardId: input.entityId,
})),
);
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction.delete(boardGroupPermissions).where(eq(boardGroupPermissions.boardId, input.entityId)).run();
if (input.permissions.length === 0) {
return;
}
transaction
.insert(boardGroupPermissions)
.values(
input.permissions.map((permission) => ({
groupId: permission.principalId,
permission: permission.permission,
boardId: input.entityId,
})),
)
.run();
});
},
}); });
}), }),
importOldmarrConfig: permissionRequiredProcedure importOldmarrConfig: permissionRequiredProcedure

View File

@@ -3,7 +3,7 @@ import { TRPCError } from "@trpc/server";
import { objectEntries } from "@homarr/common"; import { objectEntries } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { decryptSecret, encryptSecret } from "@homarr/common/server";
import type { Database } from "@homarr/db"; import type { Database } from "@homarr/db";
import { and, asc, createId, eq, inArray, like } from "@homarr/db"; import { and, asc, createId, eq, handleTransactionsAsync, inArray, like } from "@homarr/db";
import { import {
groupMembers, groupMembers,
groupPermissions, groupPermissions,
@@ -360,20 +360,45 @@ export const integrationRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.entityId), "full"); await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.entityId), "full");
await ctx.db.transaction(async (transaction) => { await handleTransactionsAsync(ctx.db, {
await transaction async handleAsync(db, schema) {
.delete(integrationUserPermissions) await ctx.db.transaction(async (transaction) => {
.where(eq(integrationUserPermissions.integrationId, input.entityId)); await transaction
if (input.permissions.length === 0) { .delete(schema.integrationUserPermissions)
return; .where(eq(schema.integrationUserPermissions.integrationId, input.entityId));
} if (input.permissions.length === 0) {
await transaction.insert(integrationUserPermissions).values( return;
input.permissions.map((permission) => ({ }
userId: permission.principalId, await transaction.insert(schema.integrationUserPermissions).values(
permission: permission.permission, input.permissions.map((permission) => ({
integrationId: input.entityId, userId: permission.principalId,
})), permission: permission.permission,
); integrationId: input.entityId,
})),
);
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction
.delete(integrationUserPermissions)
.where(eq(integrationUserPermissions.integrationId, input.entityId))
.run();
if (input.permissions.length === 0) {
return;
}
transaction
.insert(integrationUserPermissions)
.values(
input.permissions.map((permission) => ({
userId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
)
.run();
});
},
}); });
}), }),
saveGroupIntegrationPermissions: protectedProcedure saveGroupIntegrationPermissions: protectedProcedure
@@ -381,20 +406,45 @@ export const integrationRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.entityId), "full"); await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.entityId), "full");
await ctx.db.transaction(async (transaction) => { await handleTransactionsAsync(ctx.db, {
await transaction async handleAsync(db, schema) {
.delete(integrationGroupPermissions) await db.transaction(async (transaction) => {
.where(eq(integrationGroupPermissions.integrationId, input.entityId)); await transaction
if (input.permissions.length === 0) { .delete(schema.integrationGroupPermissions)
return; .where(eq(schema.integrationGroupPermissions.integrationId, input.entityId));
} if (input.permissions.length === 0) {
await transaction.insert(integrationGroupPermissions).values( return;
input.permissions.map((permission) => ({ }
groupId: permission.principalId, await transaction.insert(schema.integrationGroupPermissions).values(
permission: permission.permission, input.permissions.map((permission) => ({
integrationId: input.entityId, groupId: permission.principalId,
})), permission: permission.permission,
); integrationId: input.entityId,
})),
);
});
},
handleSync(db) {
db.transaction((transaction) => {
transaction
.delete(integrationGroupPermissions)
.where(eq(integrationGroupPermissions.integrationId, input.entityId))
.run();
if (input.permissions.length === 0) {
return;
}
transaction
.insert(integrationGroupPermissions)
.values(
input.permissions.map((permission) => ({
groupId: permission.principalId,
permission: permission.permission,
integrationId: input.entityId,
})),
)
.run();
});
},
}); });
}), }),
searchInIntegration: protectedProcedure searchInIntegration: protectedProcedure

View File

@@ -1,7 +1,7 @@
import { splitToNChunks, Stopwatch } from "@homarr/common"; import { splitToNChunks, Stopwatch } from "@homarr/common";
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions"; import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
import type { InferInsertModel } from "@homarr/db"; import type { InferInsertModel } from "@homarr/db";
import { db, inArray, sql } from "@homarr/db"; import { db, handleTransactionsAsync, inArray, sql } from "@homarr/db";
import { createId } from "@homarr/db/client"; import { createId } from "@homarr/db/client";
import { iconRepositories, icons } from "@homarr/db/schema"; import { iconRepositories, icons } from "@homarr/db/schema";
import { fetchIconsAsync } from "@homarr/icons"; import { fetchIconsAsync } from "@homarr/icons";
@@ -83,43 +83,81 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
(iconRepository) => !repositoryIconGroups.some((group) => group.slug === iconRepository.slug), (iconRepository) => !repositoryIconGroups.some((group) => group.slug === iconRepository.slug),
); );
db.transaction((transaction) => { await handleTransactionsAsync(db, {
if (newIconRepositories.length >= 1) { async handleAsync(db, schema) {
transaction.insert(iconRepositories).values(newIconRepositories).run(); await db.transaction(async (transaction) => {
} if (newIconRepositories.length >= 1) {
await transaction.insert(schema.iconRepositories).values(newIconRepositories);
}
if (newIcons.length >= 1) { if (newIcons.length >= 1) {
// We only insert 5000 icons at a time to avoid SQLite limitations // We only insert 5000 icons at a time to avoid SQLite limitations
for (const chunck of splitToNChunks(newIcons, Math.ceil(newIcons.length / 5000))) { for (const chunck of splitToNChunks(newIcons, Math.ceil(newIcons.length / 5000))) {
transaction.insert(icons).values(chunck).run(); await transaction.insert(schema.icons).values(chunck);
} }
} }
if (deadIcons.length >= 1) { if (deadIcons.length >= 1) {
transaction await transaction.delete(schema.icons).where(
.delete(icons) inArray(
.where( // Combine iconRepositoryId and checksum to allow same icons on different repositories
inArray( sql`concat(${icons.iconRepositoryId}, '.', ${icons.checksum})`,
// Combine iconRepositoryId and checksum to allow same icons on different repositories deadIcons.map((icon) => `${icon.iconRepositoryId}.${icon.checksum}`),
sql`concat(${icons.iconRepositoryId}, '.', ${icons.checksum})`, ),
deadIcons.map((icon) => `${icon.iconRepositoryId}.${icon.checksum}`), );
), }
)
.run();
}
if (deadIconRepositories.length >= 1) { if (deadIconRepositories.length >= 1) {
transaction await transaction.delete(schema.iconRepositories).where(
.delete(iconRepositories) inArray(
.where( iconRepositories.id,
inArray( deadIconRepositories.map((iconRepository) => iconRepository.id),
iconRepositories.id, ),
deadIconRepositories.map((iconRepository) => iconRepository.id), );
), }
)
.run();
}
countDeleted += deadIcons.length; countDeleted += deadIcons.length;
});
},
handleSync() {
db.transaction((transaction) => {
if (newIconRepositories.length >= 1) {
transaction.insert(iconRepositories).values(newIconRepositories).run();
}
if (newIcons.length >= 1) {
// We only insert 5000 icons at a time to avoid SQLite limitations
for (const chunck of splitToNChunks(newIcons, Math.ceil(newIcons.length / 5000))) {
transaction.insert(icons).values(chunck).run();
}
}
if (deadIcons.length >= 1) {
transaction
.delete(icons)
.where(
inArray(
// Combine iconRepositoryId and checksum to allow same icons on different repositories
sql`concat(${icons.iconRepositoryId}, '.', ${icons.checksum})`,
deadIcons.map((icon) => `${icon.iconRepositoryId}.${icon.checksum}`),
),
)
.run();
}
if (deadIconRepositories.length >= 1) {
transaction
.delete(iconRepositories)
.where(
inArray(
iconRepositories.id,
deadIconRepositories.map((iconRepository) => iconRepository.id),
),
)
.run();
}
countDeleted += deadIcons.length;
});
},
}); });
logger.info(`Updated database within ${stopWatch.getElapsedInHumanWords()} (-${countDeleted}, +${countInserted})`); logger.info(`Updated database within ${stopWatch.getElapsedInHumanWords()} (-${countDeleted}, +${countInserted})`);

View File

@@ -2,6 +2,7 @@ import Database from "better-sqlite3";
import type { Logger } from "drizzle-orm"; import type { Logger } from "drizzle-orm";
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import { drizzle as drizzleSqlite } from "drizzle-orm/better-sqlite3"; import { drizzle as drizzleSqlite } from "drizzle-orm/better-sqlite3";
import type { MySql2Database } from "drizzle-orm/mysql2";
import { drizzle as drizzleMysql } from "drizzle-orm/mysql2"; import { drizzle as drizzleMysql } from "drizzle-orm/mysql2";
import mysql from "mysql2"; import mysql from "mysql2";
@@ -11,7 +12,8 @@ import { env } from "./env";
import * as mysqlSchema from "./schema/mysql"; import * as mysqlSchema from "./schema/mysql";
import * as sqliteSchema from "./schema/sqlite"; import * as sqliteSchema from "./schema/sqlite";
type HomarrDatabase = BetterSQLite3Database<typeof sqliteSchema>; export type HomarrDatabase = BetterSQLite3Database<typeof sqliteSchema>;
export type HomarrDatabaseMysql = MySql2Database<typeof mysqlSchema>;
const init = () => { const init = () => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition

View File

@@ -7,5 +7,7 @@ export * from "drizzle-orm";
export const db = database; export const db = database;
export type Database = typeof db; export type Database = typeof db;
export type { HomarrDatabaseMysql } from "./driver";
export { createId } from "@paralleldrive/cuid2"; export { createId } from "@paralleldrive/cuid2";
export { handleDiffrentDbDriverOperationsAsync as handleTransactionsAsync } from "./transactions";

View File

@@ -0,0 +1,24 @@
import type { HomarrDatabase, HomarrDatabaseMysql } from "./driver";
import { env } from "./env";
import * as mysqlSchema from "./schema/mysql";
type MysqlSchema = typeof mysqlSchema;
interface HandleTransactionInput {
handleAsync: (db: HomarrDatabaseMysql, schema: MysqlSchema) => Promise<void>;
handleSync: (db: HomarrDatabase) => void;
}
/**
* The below method is mostly used to handle transactions in different database drivers.
* As better-sqlite3 transactions have to be synchronous, we have to implement them in a different way.
* But it can also generally be used when dealing with different database drivers.
*/
export const handleDiffrentDbDriverOperationsAsync = async (db: HomarrDatabase, input: HandleTransactionInput) => {
if (env.DB_DRIVER !== "mysql2") {
input.handleSync(db);
return;
}
await input.handleAsync(db as unknown as HomarrDatabaseMysql, mysqlSchema);
};

View File

@@ -1,5 +1,5 @@
import { objectEntries } from "@homarr/common"; import { objectEntries } from "@homarr/common";
import type { Database, InferInsertModel } from "@homarr/db"; import type { Database, HomarrDatabaseMysql, InferInsertModel } from "@homarr/db";
import * as schema from "@homarr/db/schema"; import * as schema from "@homarr/db/schema";
type TableKey = { type TableKey = {
@@ -29,5 +29,15 @@ export const createDbInsertCollection = <TTableKey extends TableKey>(tablesInIns
} }
}); });
}, },
insertAllAsync: async (db: HomarrDatabaseMysql) => {
await db.transaction(async (transaction) => {
for (const [key, values] of objectEntries(context)) {
if (values.length >= 1) {
// Below is actually the mysqlSchema when the driver is mysql
await transaction.insert(schema[key] as never).values(values as never);
}
}
});
},
}; };
}; };

View File

@@ -1,6 +1,7 @@
import type { z } from "zod"; import type { z } from "zod";
import { Stopwatch } from "@homarr/common"; import { Stopwatch } from "@homarr/common";
import { handleTransactionsAsync } from "@homarr/db";
import type { Database } from "@homarr/db"; import type { Database } from "@homarr/db";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
@@ -34,11 +35,21 @@ export const importInitialOldmarrAsync = async (
logger.info("Inserting import data to database"); logger.info("Inserting import data to database");
// Due to a limitation with better-sqlite it's only possible to use it synchronously await handleTransactionsAsync(db, {
db.transaction((transaction) => { async handleAsync(db) {
boardInsertCollection.insertAll(transaction); await db.transaction(async (transaction) => {
userInsertCollection.insertAll(transaction); await boardInsertCollection.insertAllAsync(transaction);
integrationInsertCollection.insertAll(transaction); await userInsertCollection.insertAllAsync(transaction);
await integrationInsertCollection.insertAllAsync(transaction);
});
},
handleSync(db) {
db.transaction((transaction) => {
boardInsertCollection.insertAll(transaction);
userInsertCollection.insertAll(transaction);
integrationInsertCollection.insertAll(transaction);
});
},
}); });
logger.info(`Import successful (in ${stopwatch.getElapsedInHumanWords()})`); logger.info(`Import successful (in ${stopwatch.getElapsedInHumanWords()})`);

View File

@@ -1,4 +1,4 @@
import { inArray } from "@homarr/db"; import { handleTransactionsAsync, inArray } from "@homarr/db";
import type { Database } from "@homarr/db"; import type { Database } from "@homarr/db";
import { apps } from "@homarr/db/schema"; import { apps } from "@homarr/db/schema";
import type { OldmarrConfig } from "@homarr/old-schema"; import type { OldmarrConfig } from "@homarr/old-schema";
@@ -31,6 +31,12 @@ export const importSingleOldmarrConfigAsync = async (
const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, settings); const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, settings);
// Due to a limitation with better-sqlite it's only possible to use it synchronously await handleTransactionsAsync(db, {
boardInsertCollection.insertAll(db); async handleAsync(db) {
await boardInsertCollection.insertAllAsync(db);
},
handleSync(db) {
boardInsertCollection.insertAll(db);
},
});
}; };