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>, 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 = { ...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(); 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 }; }