Revert "fix: harden automatic mobile boards"

This reverts commit d0b9348b86.
This commit is contained in:
ajnart committed 2026-07-29 20:06:32 +02:00
1 parent 92a2be2b65
commit dba9a250ff
37 files changed
+137 -637

No files matched your search

+21 -5
View File
@@ -5,10 +5,10 @@ import { and, eq, inArray } from "@homarr/db";
import {
getServerSettingByKeyAsync,
getServerSettingsAsync,
mergeServerSettingByKeyAsync,
insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync,
} from "@homarr/db/queries";
import { boards } from "@homarr/db/schema";
import { boards, serverSettings } from "@homarr/db/schema";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettingsKeys } from "@homarr/server-settings";
import { settingsInitSchema } from "@homarr/validation/settings";
@@ -82,9 +82,17 @@ export const serverSettingsRouter = createTRPCRouter({
const current = await getServerSettingByKeyAsync(ctx.db, "board");
const next = { ...current, ...input };
await mergeServerSettingByKeyAsync(ctx.db, "board", next, input);
const existing = await ctx.db.query.serverSettings.findFirst({
where: eq(serverSettings.settingKey, "board"),
});
return await getServerSettingByKeyAsync(ctx.db, "board");
if (existing) {
await updateServerSettingByKeyAsync(ctx.db, "board", next);
} else {
await insertServerSettingByKeyAsync(ctx.db, "board", next);
}
return next;
}),
saveSettings: permissionRequiredProcedure
.requiresPermission("admin")
@@ -100,7 +108,15 @@ export const serverSettingsRouter = createTRPCRouter({
...current,
...input.value,
} as ServerSettings[keyof ServerSettings];
await mergeServerSettingByKeyAsync(ctx.db, input.settingsKey, next, input.value);
const existing = await ctx.db.query.serverSettings.findFirst({
where: eq(serverSettings.settingKey, input.settingsKey),
});
if (existing) {
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, next);
} else {
await insertServerSettingByKeyAsync(ctx.db, input.settingsKey, next);
}
}),
initSettings: onboardingProcedure
.requiresStep("settings")
@@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { boards, layouts, serverSettings } from "@homarr/db/schema";
import { boards, serverSettings } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
@@ -116,33 +116,6 @@ describe("saveSettings", () => {
},
]);
});
test("saveSettings should preserve concurrent partial updates", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await Promise.all([
caller.saveSettings({
settingsKey: "analytics",
value: { enableGeneral: false },
}),
caller.saveSettings({
settingsKey: "analytics",
value: { instanceId: "concurrent-instance" },
}),
]);
await expect(caller.getAll()).resolves.toMatchObject({
analytics: {
enableGeneral: false,
instanceId: "concurrent-instance",
},
});
});
});
describe("board settings API", () => {
@@ -164,12 +137,6 @@ describe("board settings API", () => {
deviceType: undefined,
session: defaultSession,
});
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "legacy", isPublic: false });
await db.insert(layouts).values([
{ id: createId(), boardId, name: "Base", columnCount: 12, breakpoint: 0 },
{ id: createId(), boardId, name: "Mobile", columnCount: 4, breakpoint: 480 },
]);
const legacyBoardSettings = {
homeBoardId: defaultServerSettings.board.homeBoardId,
mobileHomeBoardId: defaultServerSettings.board.mobileHomeBoardId,
@@ -181,10 +148,7 @@ describe("board settings API", () => {
value: stringify(legacyBoardSettings),
});
await expect(caller.getBoardSettings()).resolves.toStrictEqual({
...defaultServerSettings.board,
enableAutomaticMobileLayout: false,
});
await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board);
});
test("updateBoardSettings should insert settings when defaults are not persisted", async () => {
@@ -249,25 +213,6 @@ describe("board settings API", () => {
]);
});
test("updateBoardSettings should preserve concurrent partial updates", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await Promise.all([
caller.updateBoardSettings({ forceDisableStatus: true }),
caller.updateBoardSettings({ enableStatusByDefault: false }),
]);
await expect(caller.getBoardSettings()).resolves.toMatchObject({
forceDisableStatus: true,
enableStatusByDefault: false,
});
});
test("updateBoardSettings should reject private home boards", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
@@ -2,7 +2,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueriesWithProvenance } from "../../settle-integrations";
import { getIntegrationQueryProvenance, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const mediaReleasesProcedure = publicProcedure.concat(
@@ -12,7 +12,7 @@ const mediaReleasesProcedure = publicProcedure.concat(
type MediaReleaseIntegration = Parameters<typeof mediaReleaseRequestHandler.handler>[0];
const getMediaReleasesWithProvenanceAsync = async (integrations: MediaReleaseIntegration[]) => {
const { results, ...provenance } = await settleIntegrationQueriesWithProvenance(
const results = await settleIntegrationQueries(
integrations,
async (integration) => {
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
@@ -40,7 +40,7 @@ const getMediaReleasesWithProvenanceAsync = async (integrations: MediaReleaseInt
return {
items,
...provenance,
...getIntegrationQueryProvenance(integrations.length, results),
};
};
@@ -11,7 +11,7 @@ import {
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueriesWithProvenance } from "../../settle-integrations";
import { getIntegrationQueryProvenance, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const latestRequestsProcedure = publicProcedure
@@ -28,7 +28,7 @@ const getLatestRequestsWithProvenanceAsync = async (
integrations: MediaRequestIntegration[],
input: MediaRequestListInput,
) => {
const { results, ...provenance } = await settleIntegrationQueriesWithProvenance(
const results = await settleIntegrationQueries(
integrations,
async (integration) => {
const { data, isStale } = await mediaRequestListRequestHandler
@@ -61,12 +61,12 @@ const getLatestRequestsWithProvenanceAsync = async (
return {
items,
...provenance,
...getIntegrationQueryProvenance(integrations.length, results),
};
};
const getRequestStatsWithProvenanceAsync = async (integrations: MediaRequestStatsIntegration[]) => {
const { results, ...provenance } = await settleIntegrationQueriesWithProvenance(
const results = await settleIntegrationQueries(
integrations,
async (integration) => {
const { data, isStale } = await mediaRequestStatsRequestHandler
@@ -92,7 +92,7 @@ const getRequestStatsWithProvenanceAsync = async (integrations: MediaRequestStat
)
.toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
integrations: results.map((result) => result.integration),
...provenance,
...getIntegrationQueryProvenance(integrations.length, results),
};
};
+12 -17
View File
@@ -2,7 +2,7 @@
import { describe, expect, it, vi } from "vitest";
import { settleIntegrationQueries, settleIntegrationQueriesWithProvenance } from "./settle-integrations";
import { getIntegrationQueryProvenance, settleIntegrationQueries } from "./settle-integrations";
const integrations = [
{ id: "first", name: "First", kind: "mock" },
@@ -77,23 +77,18 @@ describe("settleIntegrationQueries", () => {
});
});
describe("settleIntegrationQueriesWithProvenance", () => {
it("counts failed queries even when fallbacks preserve the result count", async () => {
const { results, ...provenance } = await settleIntegrationQueriesWithProvenance(
integrations,
vi.fn(async (integration) => {
if (integration.id === "first") throw new Error("offline");
return { integrationId: integration.id, isStale: true };
}),
{
fallback: (integration) => ({ integrationId: integration.id, isStale: false }),
},
);
expect(results).toHaveLength(2);
expect(provenance).toEqual({
describe("getIntegrationQueryProvenance", () => {
it("counts dropped and stale integration results independently", () => {
expect(getIntegrationQueryProvenance(4, [{ isStale: false }, { isStale: true }, { isStale: true }])).toEqual({
failedIntegrationCount: 1,
staleIntegrationCount: 1,
staleIntegrationCount: 2,
});
});
it("does not report failures for complete current results", () => {
expect(getIntegrationQueryProvenance(2, [{ isStale: false }, {}])).toEqual({
failedIntegrationCount: 0,
staleIntegrationCount: 0,
});
});
});
+4 -52
View File
@@ -11,24 +11,13 @@ interface IntegrationLike {
interface Options<TIntegration extends IntegrationLike, TResult> {
fallback?: (integration: TIntegration, error: unknown) => TResult;
throwOnAllFailure?: boolean;
}
export interface IntegrationQueryProvenance {
failedIntegrationCount: number;
staleIntegrationCount: number;
}
interface IntegrationQuerySettlement<TResult> {
results: TResult[];
failedIntegrationCount: number;
}
const settleIntegrationQueriesInternal = async <TIntegration extends IntegrationLike, TResult>(
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
integrations: TIntegration[],
fn: (integration: TIntegration) => Promise<TResult>,
options?: Options<TIntegration, TResult>,
): Promise<IntegrationQuerySettlement<TResult>> => {
): Promise<TResult[]> {
const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration)));
const results: TResult[] = [];
const errors: unknown[] = [];
@@ -56,46 +45,9 @@ const settleIntegrationQueriesInternal = async <TIntegration extends Integration
errors.push(result.reason);
});
const firstFailure = settled.find((result) => result.status === "rejected");
if (
options?.throwOnAllFailure === true &&
settled.length > 0 &&
settled.every((result) => result.status === "rejected") &&
firstFailure?.status === "rejected"
) {
throw firstFailure.reason;
}
if (options?.throwOnAllFailure !== false && results.length === 0 && errors.length > 0) {
if (results.length === 0 && errors.length > 0) {
throw errors[0];
}
return {
results,
failedIntegrationCount: settled.filter((result) => result.status === "rejected").length,
};
};
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
integrations: TIntegration[],
fn: (integration: TIntegration) => Promise<TResult>,
options?: Options<TIntegration, TResult>,
): Promise<TResult[]> {
return (await settleIntegrationQueriesInternal(integrations, fn, options)).results;
}
export async function settleIntegrationQueriesWithProvenance<
TIntegration extends IntegrationLike,
TResult extends { isStale?: boolean },
>(
integrations: TIntegration[],
fn: (integration: TIntegration) => Promise<TResult>,
options?: Options<TIntegration, TResult>,
): Promise<IntegrationQuerySettlement<TResult> & IntegrationQueryProvenance> {
const settlement = await settleIntegrationQueriesInternal(integrations, fn, options);
return {
...settlement,
staleIntegrationCount: settlement.results.filter((result) => result.isStale === true).length,
};
return results;
}