Files
homarr/packages/api/src/router/custom-widget/package/migrations.ts
T

125 lines
5.4 KiB
TypeScript

import { isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import { TRPCError } from "@trpc/server";
import { parse, stringify } from "superjson";
import { z } from "zod/v4";
import { isRecord } from "@homarr/common";
import { getCustomWidgetDefaultOptions, validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import type { CustomWidgetArtifact, CustomWidgetPackage } from "@homarr/custom-widgets/package";
import type { customWidgetStorage } from "@homarr/db/schema";
import { getBoundConnection } from "./connections";
import { getWidgetPackageSupervisor } from "./invocations";
import { getArtifact, parseBindings } from "./records";
import type { getPackagePlacements } from "./records";
import type { PackageContext, PackageInstallation } from "./types";
type StorageRow = typeof customWidgetStorage.$inferSelect;
export async function prepareWidgetMigration(
ctx: PackageContext,
installation: PackageInstallation,
source: CustomWidgetPackage,
artifact: CustomWidgetArtifact,
placements: Awaited<ReturnType<typeof getPackagePlacements>>,
storage: StorageRow[],
signal?: AbortSignal,
) {
const previous = installation.activeArtifactId ? await getArtifact(ctx, installation.activeArtifactId) : null;
const configurationChanged =
previous && previous.source.manifest.configurationVersion !== source.manifest.configurationVersion;
const storageChanged = previous && previous.source.manifest.storageVersion !== source.manifest.storageVersion;
const bindings = parseBindings(installation.bindings);
const changes = [];
for (const placement of placements) {
// Materialize existing defaults so a release cannot change a placement's saved choices implicitly.
let configuration: Record<string, unknown> = {
...getCustomWidgetDefaultOptions(source.options),
...getCustomWidgetDefaultOptions(previous?.source.options ?? {}),
...placement.packageOptions.configuration,
};
if (configurationChanged && source.manifest.migrations?.configuration) {
configuration = z.record(z.string(), z.unknown()).parse(
await getWidgetPackageSupervisor().invoke({
artifact,
instanceId: placement.id,
boardId: placement.boardId,
userId: ctx.session?.user.id,
signal,
handler: source.manifest.migrations.configuration,
input: {
fromVersion: previous.source.manifest.configurationVersion,
toVersion: source.manifest.configurationVersion,
configuration,
},
}),
);
}
const issues = validateCustomWidgetOptions(source.options, configuration);
if (issues.length)
throw new TRPCError({ code: "PRECONDITION_FAILED", message: `Placement ${placement.id}: ${issues[0]?.message}` });
const mergedBindings = { ...bindings, ...placement.packageOptions.connectionBindings };
for (const [name, requirement] of Object.entries(source.connections)) {
if (requirement.optional && !mergedBindings[name]) continue;
const connection = await getBoundConnection(ctx, mergedBindings, name);
if (!isWidgetConnectionCompatible(requirement, connection.configuration))
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Connection '${name}' requires ${requirement.serviceType ?? requirement.kind}`,
});
}
const raw: unknown = parse(placement.options);
if (!isRecord(raw)) throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Placement options are invalid" });
changes.push({ id: placement.id, options: stringify({ ...raw, configuration }) });
}
const managedStorage = storage.filter((row) => row.scope !== "system");
let migratedStorage = storage;
if (storageChanged && managedStorage.length) {
const handler = source.manifest.migrations?.storage;
if (!handler)
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "A storage migration is required for the new storage version",
});
const result = z.array(z.object({ id: z.string(), value: z.unknown() })).parse(
await getWidgetPackageSupervisor().invoke({
artifact,
instanceId: installation.id,
boardId: "migration",
userId: ctx.session?.user.id,
handler,
signal,
input: {
fromVersion: previous.source.manifest.storageVersion,
toVersion: source.manifest.storageVersion,
records: managedStorage.map(({ id, scope, ownerKey, key, value }) => ({
id,
scope,
ownerKey,
key,
value: JSON.parse(value) as unknown,
})),
},
}),
);
const byId = new Map(managedStorage.map((row) => [row.id, row]));
const seen = new Set<string>();
migratedStorage = result
.map(({ id, value }) => {
const row = byId.get(id);
if (!row || seen.has(id))
throw new TRPCError({
code: "BAD_REQUEST",
message: "Storage migration returned an unknown or duplicate record",
});
seen.add(id);
const serialized = JSON.stringify(value);
if (serialized === undefined || Buffer.byteLength(serialized) > 1_048_576)
throw new TRPCError({ code: "BAD_REQUEST", message: "Migrated storage exceeds 1 MiB per record" });
return { ...row, value: serialized, updatedAt: new Date() };
})
.concat(storage.filter((row) => row.scope === "system"));
}
return { placements: changes, storage: migratedStorage };
}