chore(release): automatic release v1.35.0

This commit is contained in:
homarr-releases[bot]
2025-08-29 19:13:35 +00:00
committed by GitHub
39 changed files with 4162 additions and 1117 deletions

View File

@@ -34,6 +34,10 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
# DB_PASSWORD='password'
# DB_NAME='name-of-database'
# The following is an example on how to use the node-postgres driver:
# DB_DRIVER='node-postgres'
# DB_URL='postgres://user:password@host:port/database'
# The below path can be used to store trusted certificates, it is not required and can be left empty.
# If it is empty, it will default to `/appdata/trusted-certificates` in production.
# If it is used, please use the full path to the directory where the certificates are stored.

View File

@@ -33,6 +33,7 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.34.0
- 1.33.0
- 1.32.0
- 1.31.0

View File

@@ -0,0 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="db:migration:postgresql:generate" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="db:migration:postgresql:generate" />
</scripts>
<node-interpreter value="project" />
<package-manager value="pnpm" />
<envs />
<method v="2" />
</configuration>
</component>

View File

@@ -26,5 +26,29 @@ services:
volumes:
- mysql_data:/var/lib/mysql
postgresql:
image: postgres
restart: always
# set shared memory limit when using docker compose
shm_size: 128mb
# or set shared memory limit when deploy via swarm stack
#volumes:
# - type: tmpfs
# target: /dev/shm
# tmpfs:
# size: 134217728 # 128*2^20 bytes = 128Mb
environment:
POSTGRES_PASSWORD: homarr
POSTGRES_USER: homarr
POSTGRES_DB: homarrdb
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- postgresql_data:/var/lib/postgresql/data
ports:
- 5432:5432
# if already run PostgreSQL, change port number to use container's service
# - 2345:5432
volumes:
mysql_data:
mysql_data:
postgresql_data:

View File

@@ -9,6 +9,8 @@
"cli": "pnpm with-env tsx packages/cli/index.ts",
"db:migration:mysql:generate": "pnpm -F db migration:mysql:generate",
"db:migration:mysql:run": "pnpm -F db migration:mysql:run",
"db:migration:postgresql:generate": "pnpm -F db migration:postgresql:generate",
"db:migration:postgresql:run": "pnpm -F db migration:postgresql:run",
"db:migration:sqlite:generate": "pnpm -F db migration:sqlite:generate",
"db:migration:sqlite:run": "pnpm -F db migration:sqlite:run",
"db:push": "pnpm -F db push:sqlite",
@@ -73,10 +75,28 @@
"tree-sitter-json"
],
"overrides": {
"proxmox-api>undici": "7.14.0"
"@babel/helpers@<7.26.10": ">=7.26.10",
"@babel/runtime@<7.26.10": ">=7.26.10",
"axios@>=1.0.0 <1.8.2": ">=1.8.2",
"brace-expansion@>=2.0.0 <=2.0.1": ">=2.0.2",
"brace-expansion@>=1.0.0 <=1.1.11": ">=1.1.12",
"esbuild@<=0.24.2": ">=0.25.0",
"form-data@>=4.0.0 <4.0.4": ">=4.0.4",
"hono@<4.6.5": ">=4.6.5",
"linkifyjs@<4.3.2": ">=4.3.2",
"nanoid@>=4.0.0 <5.0.9": ">=5.0.9",
"prismjs@<1.30.0": ">=1.30.0",
"proxmox-api>undici": "7.14.0",
"rollup@>=4.0.0 <4.22.4": ">=4.22.4",
"sha.js@<=2.4.11": ">=2.4.12",
"tar-fs@>=3.0.0 <3.0.9": ">=3.0.9",
"tar-fs@>=2.0.0 <2.1.3": ">=2.1.3",
"tmp@<=0.2.3": ">=0.2.4",
"vite@>=5.0.0 <=5.4.18": ">=5.4.19"
},
"patchedDependencies": {
"@types/node-unifi": "patches/@types__node-unifi.patch"
"@types/node-unifi": "patches/@types__node-unifi.patch",
"trpc-to-openapi": "patches/trpc-to-openapi.patch"
},
"allowUnusedPatches": true,
"ignoredBuiltDependencies": [

View File

@@ -1,7 +1,7 @@
import { randomBytes } from "crypto";
import { z } from "zod/v4";
import { createEnv } from "@homarr/core/infrastructure/env";
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
const errorSuffix = `, please generate a 64 character secret in hex format or use the following: "${randomBytes(32).toString("hex")}"`;
@@ -23,9 +23,11 @@ export const env = createEnv({
.regex(/^[0-9a-fA-F]{64}$/, {
message: `SECRET_ENCRYPTION_KEY must only contain hex characters${errorSuffix}`,
}),
NO_EXTERNAL_CONNECTION: createBooleanSchema(false),
},
runtimeEnv: {
SECRET_ENCRYPTION_KEY: process.env.SECRET_ENCRYPTION_KEY,
NODE_ENV: process.env.NODE_ENV,
NO_EXTERNAL_CONNECTION: process.env.NO_EXTERNAL_CONNECTION,
},
});

View File

@@ -1,4 +1,5 @@
import { sendServerAnalyticsAsync } from "@homarr/analytics";
import { env } from "@homarr/common/env";
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
@@ -9,6 +10,7 @@ export const analyticsJob = createCronJob("analytics", EVERY_WEEK, {
runOnStart: true,
preventManualExecution: true,
}).withCallback(async () => {
if (env.NO_EXTERNAL_CONNECTION) return;
const analyticSetting = await getServerSettingByKeyAsync(db, "analytics");
if (!analyticSetting.enableGeneral) {

View File

@@ -1,4 +1,5 @@
import { createId, splitToNChunks, Stopwatch } from "@homarr/common";
import { env } from "@homarr/common/env";
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
import type { InferInsertModel } from "@homarr/db";
import { db, handleTransactionsAsync, inArray, sql } from "@homarr/db";
@@ -12,6 +13,8 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
runOnStart: true,
expectedMaximumDurationInMillis: 10 * 1000,
}).withCallback(async () => {
if (env.NO_EXTERNAL_CONNECTION) return;
logger.info("Updating icon repository cache...");
const stopWatch = new Stopwatch();
const repositoryIconGroups = await fetchIconsAsync();

View File

@@ -2,7 +2,7 @@ import type { InferInsertModel } from "drizzle-orm";
import { objectEntries } from "@homarr/common";
import type { HomarrDatabase, HomarrDatabaseMysql } from "./driver";
import type { HomarrDatabase, HomarrDatabaseMysql, HomarrDatabasePostgresql } from "./driver";
import { env } from "./env";
import * as schema from "./schema";
@@ -10,6 +10,14 @@ type TableKey = {
[K in keyof typeof schema]: (typeof schema)[K] extends { _: { brand: "Table" } } ? K : never;
}[keyof typeof schema];
export function isMysql(): boolean {
return env.DB_DRIVER === "mysql2";
}
export function isPostgresql(): boolean {
return env.DB_DRIVER === "node-postgres";
}
export const createDbInsertCollectionForTransaction = <TTableKey extends TableKey>(
tablesInInsertOrder: TTableKey[],
) => {
@@ -35,8 +43,10 @@ export const createDbInsertCollectionForTransaction = <TTableKey extends TableKe
}
});
},
insertAllAsync: async (db: HomarrDatabaseMysql) => {
await db.transaction(async (transaction) => {
// We allow any database that supports async passed here but then fallback to mysql to prevent typescript errors
insertAllAsync: async (db: HomarrDatabaseMysql | HomarrDatabasePostgresql) => {
const innerDb = db as HomarrDatabaseMysql;
await innerDb.transaction(async (transaction) => {
for (const [key, values] of objectEntries(context)) {
if (values.length >= 1) {
// Below is actually the mysqlSchema when the driver is mysql
@@ -56,12 +66,18 @@ export const createDbInsertCollectionWithoutTransaction = <TTableKey extends Tab
return {
...collection,
insertAllAsync: async (db: HomarrDatabase) => {
if (env.DB_DRIVER !== "mysql2") {
insertAll(db);
return;
switch (env.DB_DRIVER) {
case "mysql2":
case "node-postgres":
// For mysql2 and node-postgres, we can use the async insertAllAsync method
await insertAllAsync(db as unknown as HomarrDatabaseMysql | HomarrDatabasePostgresql);
return;
default:
// For better-sqlite3, we need to use the synchronous insertAll method
// default assumes better-sqlite3. It's original implementation.
insertAll(db);
break;
}
await insertAllAsync(db as unknown as HomarrDatabaseMysql);
},
};
};

View File

@@ -0,0 +1,20 @@
import type { Config } from "drizzle-kit";
import { env } from "../env";
export default {
dialect: "postgresql",
schema: "./schema",
casing: "snake_case",
dbCredentials: env.DB_URL
? { url: env.DB_URL }
: {
host: env.DB_HOST,
port: env.DB_PORT,
user: env.DB_USER,
password: env.DB_PASSWORD,
database: env.DB_NAME,
},
out: "./migrations/postgresql",
} satisfies Config;

View File

@@ -5,17 +5,22 @@ import type { BetterSQLite3Database } 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 type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
import type { Pool as MysqlConnectionPool } from "mysql2";
import mysql from "mysql2";
import { Pool as PostgresPool } from "pg";
import { logger } from "@homarr/log";
import { env } from "./env";
import * as mysqlSchema from "./schema/mysql";
import * as pgSchema from "./schema/postgresql";
import * as sqliteSchema from "./schema/sqlite";
export type HomarrDatabase = BetterSQLite3Database<typeof sqliteSchema>;
export type HomarrDatabaseMysql = MySql2Database<typeof mysqlSchema>;
export type HomarrDatabasePostgresql = NodePgDatabase<typeof pgSchema>;
const init = () => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -24,6 +29,9 @@ const init = () => {
case "mysql2":
initMySQL2();
break;
case "node-postgres":
initNodePostgres();
break;
default:
initBetterSqlite();
break;
@@ -31,7 +39,7 @@ const init = () => {
}
};
export let connection: BetterSqlite3Connection | MysqlConnectionPool;
export let connection: BetterSqlite3Connection | MysqlConnectionPool | PostgresPool;
export let database: HomarrDatabase;
class WinstonDrizzleLogger implements Logger {
@@ -73,4 +81,33 @@ const initMySQL2 = () => {
}) as unknown as HomarrDatabase;
};
const initNodePostgres = () => {
if (!env.DB_HOST) {
connection = new PostgresPool({
connectionString: env.DB_URL,
max: 0,
idleTimeoutMillis: 60000,
allowExitOnIdle: false,
});
} else {
connection = new PostgresPool({
host: env.DB_HOST,
database: env.DB_NAME,
port: env.DB_PORT,
user: env.DB_USER,
password: env.DB_PASSWORD,
max: 0,
idleTimeoutMillis: 60000,
allowExitOnIdle: false,
});
}
database = drizzlePg({
logger: new WinstonDrizzleLogger(),
schema: pgSchema,
casing: "snake_case",
client: connection,
}) as unknown as HomarrDatabase;
};
init();

View File

@@ -6,6 +6,7 @@ import { createEnv } from "@homarr/core/infrastructure/env";
const drivers = {
betterSqlite3: "better-sqlite3",
mysql2: "mysql2",
nodePostgres: "node-postgres",
} as const;
const isDriver = (driver: (typeof drivers)[keyof typeof drivers]) => process.env.DB_DRIVER === driver;
@@ -21,7 +22,7 @@ export const env = createEnv({
*/
server: {
DB_DRIVER: z
.union([z.literal(drivers.betterSqlite3), z.literal(drivers.mysql2)], {
.union([z.literal(drivers.betterSqlite3), z.literal(drivers.mysql2), z.literal(drivers.nodePostgres)], {
message: `Invalid database driver, supported are ${Object.keys(drivers).join(", ")}`,
})
.default(drivers.betterSqlite3),
@@ -42,7 +43,7 @@ export const env = createEnv({
.regex(/\d+/)
.transform(Number)
.refine((number) => number >= 1)
.default(3306),
.default(isDriver(drivers.mysql2) ? 3306 : 5432),
DB_USER: z.string(),
DB_PASSWORD: z.string(),
DB_NAME: z.string(),

View File

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

View File

@@ -0,0 +1,43 @@
import SuperJSON from "superjson";
import { eq } from "../..";
import type { Database } from "../..";
import { items } from "../../schema";
/**
* To support showing the description in the widget itself we replaced
* the tooltip show option with display mode.
*/
export async function migrateAppWidgetShowDescriptionTooltipToDisplayModeAsync(db: Database) {
const existingAppItems = await db.query.items.findMany({
where: (table, { eq }) => eq(table.kind, "app"),
});
const itemsToUpdate = existingAppItems
.map((item) => ({
id: item.id,
options: SuperJSON.parse<{ showDescriptionTooltip?: boolean }>(item.options),
}))
.filter((item) => item.options.showDescriptionTooltip !== undefined);
console.log(
`Migrating app items with showDescriptionTooltip to descriptionDisplayMode count=${itemsToUpdate.length}`,
);
await Promise.all(
itemsToUpdate.map(async (item) => {
const { showDescriptionTooltip, ...options } = item.options;
await db
.update(items)
.set({
options: SuperJSON.stringify({
...options,
descriptionDisplayMode: showDescriptionTooltip ? "tooltip" : "hidden",
}),
})
.where(eq(items.id, item.id));
}),
);
console.log(`Migrated app items with showDescriptionTooltip to descriptionDisplayMode count=${itemsToUpdate.length}`);
}

View File

@@ -1,8 +1,10 @@
import type { Database } from "../..";
import { migrateReleaseWidgetProviderToOptionsAsync } from "./0000_release_widget_provider_to_options";
import { migrateOpnsenseCredentialsAsync } from "./0001_opnsense_credentials";
import { migrateAppWidgetShowDescriptionTooltipToDisplayModeAsync } from "./0002_app_widget_show_description_tooltip_to_display_mode";
export const applyCustomMigrationsAsync = async (db: Database) => {
await migrateReleaseWidgetProviderToOptionsAsync(db);
await migrateOpnsenseCredentialsAsync(db);
await migrateAppWidgetShowDescriptionTooltipToDisplayModeAsync(db);
};

View File

@@ -0,0 +1,322 @@
CREATE TABLE "account" (
"user_id" varchar(64) NOT NULL,
"type" text NOT NULL,
"provider" varchar(64) NOT NULL,
"provider_account_id" varchar(64) NOT NULL,
"refresh_token" text,
"access_token" text,
"expires_at" integer,
"token_type" text,
"scope" text,
"id_token" text,
"session_state" text,
CONSTRAINT "account_provider_provider_account_id_pk" PRIMARY KEY("provider","provider_account_id")
);
--> statement-breakpoint
CREATE TABLE "apiKey" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"api_key" text NOT NULL,
"salt" text NOT NULL,
"user_id" varchar(64) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "app" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"description" text,
"icon_url" text NOT NULL,
"href" text,
"ping_url" text
);
--> statement-breakpoint
CREATE TABLE "boardGroupPermission" (
"board_id" varchar(64) NOT NULL,
"group_id" varchar(64) NOT NULL,
"permission" varchar(128) NOT NULL,
CONSTRAINT "boardGroupPermission_board_id_group_id_permission_pk" PRIMARY KEY("board_id","group_id","permission")
);
--> statement-breakpoint
CREATE TABLE "boardUserPermission" (
"board_id" varchar(64) NOT NULL,
"user_id" varchar(64) NOT NULL,
"permission" varchar(128) NOT NULL,
CONSTRAINT "boardUserPermission_board_id_user_id_permission_pk" PRIMARY KEY("board_id","user_id","permission")
);
--> statement-breakpoint
CREATE TABLE "board" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"name" varchar(256) NOT NULL,
"is_public" boolean DEFAULT false NOT NULL,
"creator_id" varchar(64),
"page_title" text,
"meta_title" text,
"logo_image_url" text,
"favicon_image_url" text,
"background_image_url" text,
"background_image_attachment" text DEFAULT 'fixed' NOT NULL,
"background_image_repeat" text DEFAULT 'no-repeat' NOT NULL,
"background_image_size" text DEFAULT 'cover' NOT NULL,
"primary_color" text DEFAULT '#fa5252' NOT NULL,
"secondary_color" text DEFAULT '#fd7e14' NOT NULL,
"opacity" integer DEFAULT 100 NOT NULL,
"custom_css" text,
"icon_color" text,
"item_radius" text DEFAULT 'lg' NOT NULL,
"disable_status" boolean DEFAULT false NOT NULL,
CONSTRAINT "board_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "cron_job_configuration" (
"name" varchar(256) PRIMARY KEY NOT NULL,
"cron_expression" varchar(32) NOT NULL,
"is_enabled" boolean DEFAULT true NOT NULL
);
--> statement-breakpoint
CREATE TABLE "groupMember" (
"group_id" varchar(64) NOT NULL,
"user_id" varchar(64) NOT NULL,
CONSTRAINT "groupMember_group_id_user_id_pk" PRIMARY KEY("group_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "groupPermission" (
"group_id" varchar(64) NOT NULL,
"permission" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "group" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"name" varchar(64) NOT NULL,
"owner_id" varchar(64),
"home_board_id" varchar(64),
"mobile_home_board_id" varchar(64),
"position" smallint NOT NULL,
CONSTRAINT "group_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "iconRepository" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"slug" varchar(150) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "icon" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"name" varchar(250) NOT NULL,
"url" text NOT NULL,
"checksum" text NOT NULL,
"icon_repository_id" varchar(64) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "integrationGroupPermissions" (
"integration_id" varchar(64) NOT NULL,
"group_id" varchar(64) NOT NULL,
"permission" varchar(128) NOT NULL,
CONSTRAINT "integration_group_permission__pk" PRIMARY KEY("integration_id","group_id","permission")
);
--> statement-breakpoint
CREATE TABLE "integration_item" (
"item_id" varchar(64) NOT NULL,
"integration_id" varchar(64) NOT NULL,
CONSTRAINT "integration_item_item_id_integration_id_pk" PRIMARY KEY("item_id","integration_id")
);
--> statement-breakpoint
CREATE TABLE "integrationSecret" (
"kind" varchar(16) NOT NULL,
"value" text NOT NULL,
"updated_at" timestamp NOT NULL,
"integration_id" varchar(64) NOT NULL,
CONSTRAINT "integrationSecret_integration_id_kind_pk" PRIMARY KEY("integration_id","kind")
);
--> statement-breakpoint
CREATE TABLE "integrationUserPermission" (
"integration_id" varchar(64) NOT NULL,
"user_id" varchar(64) NOT NULL,
"permission" varchar(128) NOT NULL,
CONSTRAINT "integrationUserPermission_integration_id_user_id_permission_pk" PRIMARY KEY("integration_id","user_id","permission")
);
--> statement-breakpoint
CREATE TABLE "integration" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"url" text NOT NULL,
"kind" varchar(128) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "invite" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"token" varchar(512) NOT NULL,
"expiration_date" timestamp NOT NULL,
"creator_id" varchar(64) NOT NULL,
CONSTRAINT "invite_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "item_layout" (
"item_id" varchar(64) NOT NULL,
"section_id" varchar(64) NOT NULL,
"layout_id" varchar(64) NOT NULL,
"x_offset" integer NOT NULL,
"y_offset" integer NOT NULL,
"width" integer NOT NULL,
"height" integer NOT NULL,
CONSTRAINT "item_layout_item_id_section_id_layout_id_pk" PRIMARY KEY("item_id","section_id","layout_id")
);
--> statement-breakpoint
CREATE TABLE "item" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"board_id" varchar(64) NOT NULL,
"kind" text NOT NULL,
"options" text DEFAULT '{"json": {}}' NOT NULL,
"advanced_options" text DEFAULT '{"json": {}}' NOT NULL
);
--> statement-breakpoint
CREATE TABLE "layout" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"name" varchar(32) NOT NULL,
"board_id" varchar(64) NOT NULL,
"column_count" smallint NOT NULL,
"breakpoint" smallint DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "media" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"name" varchar(512) NOT NULL,
"content" "bytea" NOT NULL,
"content_type" text NOT NULL,
"size" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"creator_id" varchar(64)
);
--> statement-breakpoint
CREATE TABLE "onboarding" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"step" varchar(64) NOT NULL,
"previous_step" varchar(64)
);
--> statement-breakpoint
CREATE TABLE "search_engine" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"icon_url" text NOT NULL,
"name" varchar(64) NOT NULL,
"short" varchar(8) NOT NULL,
"description" text,
"url_template" text,
"type" varchar(64) DEFAULT 'generic' NOT NULL,
"integration_id" varchar(64),
CONSTRAINT "search_engine_short_unique" UNIQUE("short")
);
--> statement-breakpoint
CREATE TABLE "section_collapse_state" (
"user_id" varchar(64) NOT NULL,
"section_id" varchar(64) NOT NULL,
"collapsed" boolean DEFAULT false NOT NULL,
CONSTRAINT "section_collapse_state_user_id_section_id_pk" PRIMARY KEY("user_id","section_id")
);
--> statement-breakpoint
CREATE TABLE "section_layout" (
"section_id" varchar(64) NOT NULL,
"layout_id" varchar(64) NOT NULL,
"parent_section_id" varchar(64),
"x_offset" integer NOT NULL,
"y_offset" integer NOT NULL,
"width" integer NOT NULL,
"height" integer NOT NULL,
CONSTRAINT "section_layout_section_id_layout_id_pk" PRIMARY KEY("section_id","layout_id")
);
--> statement-breakpoint
CREATE TABLE "section" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"board_id" varchar(64) NOT NULL,
"kind" text NOT NULL,
"x_offset" integer,
"y_offset" integer,
"name" text,
"options" text DEFAULT '{"json": {}}'
);
--> statement-breakpoint
CREATE TABLE "serverSetting" (
"setting_key" varchar(64) PRIMARY KEY NOT NULL,
"value" text DEFAULT '{"json": {}}' NOT NULL,
CONSTRAINT "serverSetting_settingKey_unique" UNIQUE("setting_key")
);
--> statement-breakpoint
CREATE TABLE "session" (
"session_token" varchar(512) PRIMARY KEY NOT NULL,
"user_id" varchar(64) NOT NULL,
"expires" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "trusted_certificate_hostname" (
"hostname" varchar(256) NOT NULL,
"thumbprint" varchar(128) NOT NULL,
"certificate" text NOT NULL,
CONSTRAINT "trusted_certificate_hostname_hostname_thumbprint_pk" PRIMARY KEY("hostname","thumbprint")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"name" text,
"email" text,
"email_verified" timestamp,
"image" text,
"password" text,
"salt" text,
"provider" varchar(64) DEFAULT 'credentials' NOT NULL,
"home_board_id" varchar(64),
"mobile_home_board_id" varchar(64),
"default_search_engine_id" varchar(64),
"open_search_in_new_tab" boolean DEFAULT false NOT NULL,
"color_scheme" varchar(5) DEFAULT 'dark' NOT NULL,
"first_day_of_week" smallint DEFAULT 1 NOT NULL,
"ping_icons_enabled" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
CREATE TABLE "verificationToken" (
"identifier" varchar(64) NOT NULL,
"token" varchar(512) NOT NULL,
"expires" timestamp NOT NULL,
CONSTRAINT "verificationToken_identifier_token_pk" PRIMARY KEY("identifier","token")
);
--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "apiKey" ADD CONSTRAINT "apiKey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "boardGroupPermission" ADD CONSTRAINT "boardGroupPermission_board_id_board_id_fk" FOREIGN KEY ("board_id") REFERENCES "public"."board"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "boardGroupPermission" ADD CONSTRAINT "boardGroupPermission_group_id_group_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."group"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "boardUserPermission" ADD CONSTRAINT "boardUserPermission_board_id_board_id_fk" FOREIGN KEY ("board_id") REFERENCES "public"."board"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "boardUserPermission" ADD CONSTRAINT "boardUserPermission_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "board" ADD CONSTRAINT "board_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "groupMember" ADD CONSTRAINT "groupMember_group_id_group_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."group"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "groupMember" ADD CONSTRAINT "groupMember_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "groupPermission" ADD CONSTRAINT "groupPermission_group_id_group_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."group"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "group" ADD CONSTRAINT "group_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "group" ADD CONSTRAINT "group_home_board_id_board_id_fk" FOREIGN KEY ("home_board_id") REFERENCES "public"."board"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "group" ADD CONSTRAINT "group_mobile_home_board_id_board_id_fk" FOREIGN KEY ("mobile_home_board_id") REFERENCES "public"."board"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "icon" ADD CONSTRAINT "icon_icon_repository_id_iconRepository_id_fk" FOREIGN KEY ("icon_repository_id") REFERENCES "public"."iconRepository"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "integrationGroupPermissions" ADD CONSTRAINT "integrationGroupPermissions_integration_id_integration_id_fk" FOREIGN KEY ("integration_id") REFERENCES "public"."integration"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "integrationGroupPermissions" ADD CONSTRAINT "integrationGroupPermissions_group_id_group_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."group"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "integration_item" ADD CONSTRAINT "integration_item_item_id_item_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."item"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "integration_item" ADD CONSTRAINT "integration_item_integration_id_integration_id_fk" FOREIGN KEY ("integration_id") REFERENCES "public"."integration"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "integrationSecret" ADD CONSTRAINT "integrationSecret_integration_id_integration_id_fk" FOREIGN KEY ("integration_id") REFERENCES "public"."integration"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "integrationUserPermission" ADD CONSTRAINT "integrationUserPermission_integration_id_integration_id_fk" FOREIGN KEY ("integration_id") REFERENCES "public"."integration"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "integrationUserPermission" ADD CONSTRAINT "integrationUserPermission_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invite" ADD CONSTRAINT "invite_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "item_layout" ADD CONSTRAINT "item_layout_item_id_item_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."item"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "item_layout" ADD CONSTRAINT "item_layout_section_id_section_id_fk" FOREIGN KEY ("section_id") REFERENCES "public"."section"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "item_layout" ADD CONSTRAINT "item_layout_layout_id_layout_id_fk" FOREIGN KEY ("layout_id") REFERENCES "public"."layout"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "item" ADD CONSTRAINT "item_board_id_board_id_fk" FOREIGN KEY ("board_id") REFERENCES "public"."board"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "layout" ADD CONSTRAINT "layout_board_id_board_id_fk" FOREIGN KEY ("board_id") REFERENCES "public"."board"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "media" ADD CONSTRAINT "media_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "search_engine" ADD CONSTRAINT "search_engine_integration_id_integration_id_fk" FOREIGN KEY ("integration_id") REFERENCES "public"."integration"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "section_collapse_state" ADD CONSTRAINT "section_collapse_state_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "section_collapse_state" ADD CONSTRAINT "section_collapse_state_section_id_section_id_fk" FOREIGN KEY ("section_id") REFERENCES "public"."section"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "section_layout" ADD CONSTRAINT "section_layout_section_id_section_id_fk" FOREIGN KEY ("section_id") REFERENCES "public"."section"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "section_layout" ADD CONSTRAINT "section_layout_layout_id_layout_id_fk" FOREIGN KEY ("layout_id") REFERENCES "public"."layout"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "section_layout" ADD CONSTRAINT "section_layout_parent_section_id_section_id_fk" FOREIGN KEY ("parent_section_id") REFERENCES "public"."section"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "section" ADD CONSTRAINT "section_board_id_board_id_fk" FOREIGN KEY ("board_id") REFERENCES "public"."board"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user" ADD CONSTRAINT "user_home_board_id_board_id_fk" FOREIGN KEY ("home_board_id") REFERENCES "public"."board"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user" ADD CONSTRAINT "user_mobile_home_board_id_board_id_fk" FOREIGN KEY ("mobile_home_board_id") REFERENCES "public"."board"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user" ADD CONSTRAINT "user_default_search_engine_id_search_engine_id_fk" FOREIGN KEY ("default_search_engine_id") REFERENCES "public"."search_engine"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "integration_secret__kind_idx" ON "integrationSecret" USING btree ("kind");--> statement-breakpoint
CREATE INDEX "integration_secret__updated_at_idx" ON "integrationSecret" USING btree ("updated_at");--> statement-breakpoint
CREATE INDEX "integration__kind_idx" ON "integration" USING btree ("kind");--> statement-breakpoint
CREATE INDEX "user_id_idx" ON "session" USING btree ("user_id");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1754853510707,
"tag": "0000_initial",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,45 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { Pool } from "pg";
import type { Database } from "../..";
import { env } from "../../env";
import * as pgSchema from "../../schema/postgresql";
import { applyCustomMigrationsAsync } from "../custom";
import { seedDataAsync } from "../seed";
const migrationsFolder = process.argv[2] ?? ".";
const migrateAsync = async () => {
const pool = new Pool(
env.DB_URL
? { connectionString: env.DB_URL }
: {
host: env.DB_HOST,
database: env.DB_NAME,
port: env.DB_PORT,
user: env.DB_USER,
password: env.DB_PASSWORD,
},
);
const db = drizzle({
schema: pgSchema,
casing: "snake_case",
client: pool,
});
await migrate(db, { migrationsFolder });
await seedDataAsync(db as unknown as Database);
await applyCustomMigrationsAsync(db as unknown as Database);
};
migrateAsync()
.then(() => {
console.log("Migration complete");
process.exit(0);
})
.catch((err) => {
console.log("Migration failed", err);
process.exit(1);
});

View File

@@ -15,9 +15,8 @@ import {
insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync,
} from "../queries/server-setting";
import { integrations, onboarding, searchEngines } from "../schema";
import { groups, integrations, onboarding, searchEngines } from "../schema";
import type { Integration } from "../schema";
import { groups } from "../schema/mysql";
export const seedDataAsync = async (db: Database) => {
await seedEveryoneGroupAsync(db);

View File

@@ -16,8 +16,9 @@
"main": "./index.ts",
"types": "./index.ts",
"scripts": {
"build": "pnpm run build:sqlite && pnpm run build:mysql",
"build": "pnpm run build:sqlite && pnpm run build:mysql && pnpm run build:postgresql",
"build:mysql": "esbuild migrations/mysql/migrate.ts --bundle --platform=node --outfile=migrations/mysql/migrate.cjs",
"build:postgresql": "esbuild migrations/postgresql/migrate.ts --bundle --platform=node --outfile=migrations/postgresql/migrate.cjs",
"build:sqlite": "esbuild migrations/sqlite/migrate.ts --bundle --platform=node --outfile=migrations/sqlite/migrate.cjs",
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
@@ -26,10 +27,14 @@
"migration:mysql:drop": "pnpm with-env drizzle-kit drop --config ./configs/mysql.config.ts",
"migration:mysql:generate": "pnpm with-env drizzle-kit generate --config ./configs/mysql.config.ts",
"migration:mysql:run": "pnpm with-env drizzle-kit migrate --config ./configs/mysql.config.ts && pnpm run seed && pnpm run migration:custom",
"migration:postgresql:drop": "pnpm with-env drizzle-kit drop --config ./configs/postgresql.config.ts",
"migration:postgresql:generate": "pnpm with-env drizzle-kit generate --config ./configs/postgresql.config.ts",
"migration:postgresql:run": "pnpm with-env drizzle-kit migrate --config ./configs/postgresql.config.ts && pnpm run seed && pnpm run migration:custom",
"migration:sqlite:drop": "pnpm with-env drizzle-kit drop --config ./configs/sqlite.config.ts",
"migration:sqlite:generate": "pnpm with-env drizzle-kit generate --config ./configs/sqlite.config.ts",
"migration:sqlite:run": "pnpm with-env drizzle-kit migrate --config ./configs/sqlite.config.ts && pnpm run seed && pnpm run migration:custom",
"push:mysql": "pnpm with-env drizzle-kit push --config ./configs/mysql.config.ts",
"push:postgresql": "pnpm with-env drizzle-kit push --config ./configs/postgresql.config.ts",
"push:sqlite": "pnpm with-env drizzle-kit push --config ./configs/sqlite.config.ts",
"seed": "pnpm with-env tsx ./migrations/run-seed.ts",
"studio": "pnpm with-env drizzle-kit studio --config ./configs/sqlite.config.ts",
@@ -47,12 +52,14 @@
"@mantine/core": "^8.2.5",
"@paralleldrive/cuid2": "^2.2.2",
"@testcontainers/mysql": "^11.5.1",
"@testcontainers/postgresql": "^11.4.0",
"better-sqlite3": "^12.2.0",
"dotenv": "^17.2.1",
"drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.4",
"drizzle-zod": "^0.8.3",
"mysql2": "3.14.3",
"pg": "^8.16.3",
"superjson": "2.2.2"
},
"devDependencies": {
@@ -60,6 +67,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "7.6.13",
"@types/pg": "^8.15.4",
"dotenv-cli": "^10.0.0",
"esbuild": "^0.25.9",
"eslint": "^9.33.0",

View File

@@ -1,11 +1,20 @@
import type { InferSelectModel } from "drizzle-orm";
import { env } from "../env";
import * as mysqlSchema from "./mysql";
import * as pgSchema from "./postgresql";
import * as sqliteSchema from "./sqlite";
export type PostgreSqlSchema = typeof pgSchema;
export type MySqlSchema = typeof mysqlSchema;
type Schema = typeof sqliteSchema;
const schema = process.env.DB_DRIVER === "mysql2" ? (mysqlSchema as unknown as Schema) : sqliteSchema;
const schema =
env.DB_DRIVER === "mysql2"
? (mysqlSchema as unknown as Schema)
: env.DB_DRIVER === "node-postgres"
? (pgSchema as unknown as Schema)
: sqliteSchema;
// Sadly we can't use export * from here as we have multiple possible exports
export const {

View File

@@ -528,6 +528,7 @@ export const userRelations = relations(users, ({ one, many }) => ({
groups: many(groupMembers),
ownedGroups: many(groups),
invites: many(invites),
medias: many(medias),
defaultSearchEngine: one(searchEngines, {
fields: [users.defaultSearchEngineId],
references: [searchEngines.id],

View File

@@ -0,0 +1,775 @@
import type { AdapterAccount } from "@auth/core/adapters";
import type { MantineSize } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates";
import { relations } from "drizzle-orm";
import type { AnyPgColumn } from "drizzle-orm/pg-core";
import {
boolean,
customType,
index,
integer,
pgTable,
primaryKey,
smallint,
text,
timestamp,
varchar,
} from "drizzle-orm/pg-core";
import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
emptySuperJSON,
} from "@homarr/definitions";
import type {
BackgroundImageAttachment,
BackgroundImageRepeat,
BackgroundImageSize,
BoardPermission,
ColorScheme,
GroupPermissionKey,
IntegrationKind,
IntegrationPermission,
IntegrationSecretKind,
OnboardingStep,
SearchEngineType,
SectionKind,
SupportedAuthProvider,
WidgetKind,
} from "@homarr/definitions";
const customBlob = customType<{ data: Buffer }>({
dataType() {
return "bytea";
},
});
export const apiKeys = pgTable("apiKey", {
id: varchar({ length: 64 }).notNull().primaryKey(),
apiKey: text().notNull(),
salt: text().notNull(),
userId: varchar({ length: 64 })
.notNull()
.references((): AnyPgColumn => users.id, {
onDelete: "cascade",
}),
});
export const users = pgTable("user", {
id: varchar({ length: 64 }).notNull().primaryKey(),
name: text(),
email: text(),
emailVerified: timestamp(),
image: text(),
password: text(),
salt: text(),
provider: varchar({ length: 64 }).$type<SupportedAuthProvider>().default("credentials").notNull(),
homeBoardId: varchar({ length: 64 }).references((): AnyPgColumn => boards.id, {
onDelete: "set null",
}),
mobileHomeBoardId: varchar({ length: 64 }).references((): AnyPgColumn => boards.id, {
onDelete: "set null",
}),
defaultSearchEngineId: varchar({ length: 64 }).references(() => searchEngines.id, {
onDelete: "set null",
}),
openSearchInNewTab: boolean().default(false).notNull(),
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: smallint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(),
});
export const accounts = pgTable(
"account",
{
userId: varchar({ length: 64 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text().$type<AdapterAccount["type"]>().notNull(),
provider: varchar({ length: 64 }).notNull(),
providerAccountId: varchar({ length: 64 }).notNull(),
refresh_token: text(),
access_token: text(),
expires_at: integer(),
token_type: text(),
scope: text(),
id_token: text(),
session_state: text(),
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId],
}),
userIdIdx: index("userId_idx").on(account.userId),
}),
);
export const sessions = pgTable(
"session",
{
sessionToken: varchar({ length: 512 }).notNull().primaryKey(),
userId: varchar({ length: 64 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: timestamp().notNull(),
},
(session) => ({
userIdIdx: index("user_id_idx").on(session.userId),
}),
);
export const verificationTokens = pgTable(
"verificationToken",
{
identifier: varchar({ length: 64 }).notNull(),
token: varchar({ length: 512 }).notNull(),
expires: timestamp().notNull(),
},
(verificationToken) => ({
compoundKey: primaryKey({
columns: [verificationToken.identifier, verificationToken.token],
}),
}),
);
export const groupMembers = pgTable(
"groupMember",
{
groupId: varchar({ length: 64 })
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
userId: varchar({ length: 64 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
},
(groupMember) => ({
compoundKey: primaryKey({
columns: [groupMember.groupId, groupMember.userId],
}),
}),
);
export const groups = pgTable("group", {
id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar({ length: 64 }).unique().notNull(),
ownerId: varchar({ length: 64 }).references(() => users.id, {
onDelete: "set null",
}),
homeBoardId: varchar({ length: 64 }).references(() => boards.id, {
onDelete: "set null",
}),
mobileHomeBoardId: varchar({ length: 64 }).references(() => boards.id, {
onDelete: "set null",
}),
position: smallint().notNull(),
});
export const groupPermissions = pgTable("groupPermission", {
groupId: varchar({ length: 64 })
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
permission: text().$type<GroupPermissionKey>().notNull(),
});
export const invites = pgTable("invite", {
id: varchar({ length: 64 }).notNull().primaryKey(),
token: varchar({ length: 512 }).notNull().unique(),
expirationDate: timestamp().notNull(),
creatorId: varchar({ length: 64 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
});
export const medias = pgTable("media", {
id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar({ length: 512 }).notNull(),
content: customBlob().notNull(),
contentType: text().notNull(),
size: integer().notNull(),
createdAt: timestamp({ mode: "date" }).notNull().defaultNow(),
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
});
export const integrations = pgTable(
"integration",
{
id: varchar({ length: 64 }).notNull().primaryKey(),
name: text().notNull(),
url: text().notNull(),
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
},
(integrations) => ({
kindIdx: index("integration__kind_idx").on(integrations.kind),
}),
);
export const integrationSecrets = pgTable(
"integrationSecret",
{
kind: varchar({ length: 16 }).$type<IntegrationSecretKind>().notNull(),
value: text().$type<`${string}.${string}`>().notNull(),
updatedAt: timestamp()
.$onUpdateFn(() => new Date())
.notNull(),
integrationId: varchar({ length: 64 })
.notNull()
.references(() => integrations.id, { onDelete: "cascade" }),
},
(integrationSecret) => ({
compoundKey: primaryKey({
columns: [integrationSecret.integrationId, integrationSecret.kind],
}),
kindIdx: index("integration_secret__kind_idx").on(integrationSecret.kind),
updatedAtIdx: index("integration_secret__updated_at_idx").on(integrationSecret.updatedAt),
}),
);
export const integrationUserPermissions = pgTable(
"integrationUserPermission",
{
integrationId: varchar({ length: 64 })
.notNull()
.references(() => integrations.id, { onDelete: "cascade" }),
userId: varchar({ length: 64 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permission: varchar({ length: 128 }).$type<IntegrationPermission>().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.integrationId, table.userId, table.permission],
}),
}),
);
export const integrationGroupPermissions = pgTable(
"integrationGroupPermissions",
{
integrationId: varchar({ length: 64 })
.notNull()
.references(() => integrations.id, { onDelete: "cascade" }),
groupId: varchar({ length: 64 })
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
permission: varchar({ length: 128 }).$type<IntegrationPermission>().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.integrationId, table.groupId, table.permission],
name: "integration_group_permission__pk",
}),
}),
);
export const boards = pgTable("board", {
id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar({ length: 256 }).unique().notNull(),
isPublic: boolean().default(false).notNull(),
creatorId: varchar({ length: 64 }).references(() => users.id, {
onDelete: "set null",
}),
pageTitle: text(),
metaTitle: text(),
logoImageUrl: text(),
faviconImageUrl: text(),
backgroundImageUrl: text(),
backgroundImageAttachment: text()
.$type<BackgroundImageAttachment>()
.default(backgroundImageAttachments.defaultValue)
.notNull(),
backgroundImageRepeat: text().$type<BackgroundImageRepeat>().default(backgroundImageRepeats.defaultValue).notNull(),
backgroundImageSize: text().$type<BackgroundImageSize>().default(backgroundImageSizes.defaultValue).notNull(),
primaryColor: text().default("#fa5252").notNull(),
secondaryColor: text().default("#fd7e14").notNull(),
opacity: integer().default(100).notNull(),
customCss: text(),
iconColor: text(),
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
disableStatus: boolean().default(false).notNull(),
});
export const boardUserPermissions = pgTable(
"boardUserPermission",
{
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
userId: varchar({ length: 64 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permission: varchar({ length: 128 }).$type<BoardPermission>().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.boardId, table.userId, table.permission],
}),
}),
);
export const boardGroupPermissions = pgTable(
"boardGroupPermission",
{
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
groupId: varchar({ length: 64 })
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
permission: varchar({ length: 128 }).$type<BoardPermission>().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.boardId, table.groupId, table.permission],
}),
}),
);
export const layouts = pgTable("layout", {
id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar({ length: 32 }).notNull(),
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
columnCount: smallint().notNull(),
breakpoint: smallint().notNull().default(0),
});
export const itemLayouts = pgTable(
"item_layout",
{
itemId: varchar({ length: 64 })
.notNull()
.references(() => items.id, { onDelete: "cascade" }),
sectionId: varchar({ length: 64 })
.notNull()
.references(() => sections.id, { onDelete: "cascade" }),
layoutId: varchar({ length: 64 })
.notNull()
.references(() => layouts.id, { onDelete: "cascade" }),
xOffset: integer().notNull(),
yOffset: integer().notNull(),
width: integer().notNull(),
height: integer().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.itemId, table.sectionId, table.layoutId],
}),
}),
);
export const sectionLayouts = pgTable(
"section_layout",
{
sectionId: varchar({ length: 64 })
.notNull()
.references(() => sections.id, { onDelete: "cascade" }),
layoutId: varchar({ length: 64 })
.notNull()
.references(() => layouts.id, { onDelete: "cascade" }),
parentSectionId: varchar({ length: 64 }).references((): AnyPgColumn => sections.id, {
onDelete: "cascade",
}),
xOffset: integer().notNull(),
yOffset: integer().notNull(),
width: integer().notNull(),
height: integer().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.sectionId, table.layoutId],
}),
}),
);
export const sections = pgTable("section", {
id: varchar({ length: 64 }).notNull().primaryKey(),
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
kind: text().$type<SectionKind>().notNull(),
xOffset: integer(),
yOffset: integer(),
name: text(),
options: text().default(emptySuperJSON),
});
export const sectionCollapseStates = pgTable(
"section_collapse_state",
{
userId: varchar({ length: 64 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
sectionId: varchar({ length: 64 })
.notNull()
.references(() => sections.id, { onDelete: "cascade" }),
collapsed: boolean().default(false).notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.userId, table.sectionId],
}),
}),
);
export const items = pgTable("item", {
id: varchar({ length: 64 }).notNull().primaryKey(),
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
kind: text().$type<WidgetKind>().notNull(),
options: text().default(emptySuperJSON).notNull(),
advancedOptions: text().default(emptySuperJSON).notNull(),
});
export const apps = pgTable("app", {
id: varchar({ length: 64 }).notNull().primaryKey(),
name: text().notNull(),
description: text(),
iconUrl: text().notNull(),
href: text(),
pingUrl: text(),
});
export const integrationItems = pgTable(
"integration_item",
{
itemId: varchar({ length: 64 })
.notNull()
.references(() => items.id, { onDelete: "cascade" }),
integrationId: varchar({ length: 64 })
.notNull()
.references(() => integrations.id, { onDelete: "cascade" }),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.itemId, table.integrationId],
}),
}),
);
export const icons = pgTable("icon", {
id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar({ length: 250 }).notNull(),
url: text().notNull(),
checksum: text().notNull(),
iconRepositoryId: varchar({ length: 64 })
.notNull()
.references(() => iconRepositories.id, { onDelete: "cascade" }),
});
export const iconRepositories = pgTable("iconRepository", {
id: varchar({ length: 64 }).notNull().primaryKey(),
slug: varchar({ length: 150 }).notNull(),
});
export const serverSettings = pgTable("serverSetting", {
settingKey: varchar({ length: 64 }).notNull().unique().primaryKey(),
value: text().default(emptySuperJSON).notNull(),
});
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
user: one(users, {
fields: [apiKeys.userId],
references: [users.id],
}),
}));
export const searchEngines = pgTable("search_engine", {
id: varchar({ length: 64 }).notNull().primaryKey(),
iconUrl: text().notNull(),
name: varchar({ length: 64 }).notNull(),
short: varchar({ length: 8 }).unique().notNull(),
description: text(),
urlTemplate: text(),
type: varchar({ length: 64 }).$type<SearchEngineType>().notNull().default("generic"),
integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "cascade" }),
});
export const onboarding = pgTable("onboarding", {
id: varchar({ length: 64 }).notNull().primaryKey(),
step: varchar({ length: 64 }).$type<OnboardingStep>().notNull(),
previousStep: varchar({ length: 64 }).$type<OnboardingStep>(),
});
export const trustedCertificateHostnames = pgTable(
"trusted_certificate_hostname",
{
hostname: varchar({ length: 256 }).notNull(),
thumbprint: varchar({ length: 128 }).notNull(),
certificate: text().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.hostname, table.thumbprint],
}),
}),
);
export const cronJobConfigurations = pgTable("cron_job_configuration", {
name: varchar({ length: 256 }).notNull().primaryKey(),
cronExpression: varchar({ length: 32 }).notNull(),
isEnabled: boolean().default(true).notNull(),
});
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
references: [users.id],
}),
}));
export const userRelations = relations(users, ({ one, many }) => ({
accounts: many(accounts),
boards: many(boards),
boardPermissions: many(boardUserPermissions),
groups: many(groupMembers),
ownedGroups: many(groups),
invites: many(invites),
medias: many(medias),
defaultSearchEngine: one(searchEngines, {
fields: [users.defaultSearchEngineId],
references: [searchEngines.id],
}),
}));
export const mediaRelations = relations(medias, ({ one }) => ({
creator: one(users, {
fields: [medias.creatorId],
references: [users.id],
}),
}));
export const iconRelations = relations(icons, ({ one }) => ({
repository: one(iconRepositories, {
fields: [icons.iconRepositoryId],
references: [iconRepositories.id],
}),
}));
export const iconRepositoryRelations = relations(iconRepositories, ({ many }) => ({
icons: many(icons),
}));
export const inviteRelations = relations(invites, ({ one }) => ({
creator: one(users, {
fields: [invites.creatorId],
references: [users.id],
}),
}));
export const sessionRelations = relations(sessions, ({ one }) => ({
user: one(users, {
fields: [sessions.userId],
references: [users.id],
}),
}));
export const groupMemberRelations = relations(groupMembers, ({ one }) => ({
group: one(groups, {
fields: [groupMembers.groupId],
references: [groups.id],
}),
user: one(users, {
fields: [groupMembers.userId],
references: [users.id],
}),
}));
export const groupRelations = relations(groups, ({ one, many }) => ({
permissions: many(groupPermissions),
boardPermissions: many(boardGroupPermissions),
members: many(groupMembers),
owner: one(users, {
fields: [groups.ownerId],
references: [users.id],
}),
homeBoard: one(boards, {
fields: [groups.homeBoardId],
references: [boards.id],
relationName: "groupRelations__board__homeBoardId",
}),
mobileHomeBoard: one(boards, {
fields: [groups.mobileHomeBoardId],
references: [boards.id],
relationName: "groupRelations__board__mobileHomeBoardId",
}),
}));
export const groupPermissionRelations = relations(groupPermissions, ({ one }) => ({
group: one(groups, {
fields: [groupPermissions.groupId],
references: [groups.id],
}),
}));
export const boardUserPermissionRelations = relations(boardUserPermissions, ({ one }) => ({
user: one(users, {
fields: [boardUserPermissions.userId],
references: [users.id],
}),
board: one(boards, {
fields: [boardUserPermissions.boardId],
references: [boards.id],
}),
}));
export const boardGroupPermissionRelations = relations(boardGroupPermissions, ({ one }) => ({
group: one(groups, {
fields: [boardGroupPermissions.groupId],
references: [groups.id],
}),
board: one(boards, {
fields: [boardGroupPermissions.boardId],
references: [boards.id],
}),
}));
export const integrationRelations = relations(integrations, ({ many }) => ({
secrets: many(integrationSecrets),
items: many(integrationItems),
userPermissions: many(integrationUserPermissions),
groupPermissions: many(integrationGroupPermissions),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
user: one(users, {
fields: [integrationUserPermissions.userId],
references: [users.id],
}),
integration: one(integrations, {
fields: [integrationUserPermissions.integrationId],
references: [integrations.id],
}),
}));
export const integrationGroupPermissionRelations = relations(integrationGroupPermissions, ({ one }) => ({
group: one(groups, {
fields: [integrationGroupPermissions.groupId],
references: [groups.id],
}),
integration: one(integrations, {
fields: [integrationGroupPermissions.integrationId],
references: [integrations.id],
}),
}));
export const integrationSecretRelations = relations(integrationSecrets, ({ one }) => ({
integration: one(integrations, {
fields: [integrationSecrets.integrationId],
references: [integrations.id],
}),
}));
export const boardRelations = relations(boards, ({ many, one }) => ({
sections: many(sections),
items: many(items),
creator: one(users, {
fields: [boards.creatorId],
references: [users.id],
}),
userPermissions: many(boardUserPermissions),
groupPermissions: many(boardGroupPermissions),
layouts: many(layouts),
groupHomes: many(groups, {
relationName: "groupRelations__board__homeBoardId",
}),
mobileHomeBoard: many(groups, {
relationName: "groupRelations__board__mobileHomeBoardId",
}),
}));
export const sectionRelations = relations(sections, ({ many, one }) => ({
board: one(boards, {
fields: [sections.boardId],
references: [boards.id],
}),
collapseStates: many(sectionCollapseStates),
layouts: many(sectionLayouts, {
relationName: "sectionLayoutRelations__section__sectionId",
}),
children: many(sectionLayouts, {
relationName: "sectionLayoutRelations__section__parentSectionId",
}),
}));
export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({ one }) => ({
user: one(users, {
fields: [sectionCollapseStates.userId],
references: [users.id],
}),
section: one(sections, {
fields: [sectionCollapseStates.sectionId],
references: [sections.id],
}),
}));
export const itemRelations = relations(items, ({ one, many }) => ({
integrations: many(integrationItems),
layouts: many(itemLayouts),
board: one(boards, {
fields: [items.boardId],
references: [boards.id],
}),
}));
export const integrationItemRelations = relations(integrationItems, ({ one }) => ({
integration: one(integrations, {
fields: [integrationItems.integrationId],
references: [integrations.id],
}),
item: one(items, {
fields: [integrationItems.itemId],
references: [items.id],
}),
}));
export const searchEngineRelations = relations(searchEngines, ({ one, many }) => ({
integration: one(integrations, {
fields: [searchEngines.integrationId],
references: [integrations.id],
}),
usersWithDefault: many(users),
}));
export const itemLayoutRelations = relations(itemLayouts, ({ one }) => ({
item: one(items, {
fields: [itemLayouts.itemId],
references: [items.id],
}),
section: one(sections, {
fields: [itemLayouts.sectionId],
references: [sections.id],
}),
layout: one(layouts, {
fields: [itemLayouts.layoutId],
references: [layouts.id],
}),
}));
export const sectionLayoutRelations = relations(sectionLayouts, ({ one }) => ({
section: one(sections, {
fields: [sectionLayouts.sectionId],
references: [sections.id],
relationName: "sectionLayoutRelations__section__sectionId",
}),
layout: one(layouts, {
fields: [sectionLayouts.layoutId],
references: [layouts.id],
}),
parentSection: one(sections, {
fields: [sectionLayouts.parentSectionId],
references: [sections.id],
relationName: "sectionLayoutRelations__section__parentSectionId",
}),
}));
export const layoutRelations = relations(layouts, ({ one, many }) => ({
items: many(itemLayouts),
sections: many(sectionLayouts),
board: one(boards, {
fields: [layouts.boardId],
references: [boards.id],
}),
}));

View File

@@ -0,0 +1,46 @@
import path from "path";
import { PostgreSqlContainer } from "@testcontainers/postgresql";
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { Pool } from "pg";
import { describe, test } from "vitest";
import * as pgSchema from "../schema/postgresql";
describe("PostgreSql Migration", () => {
test("should add all tables and keys specified in migration files", async () => {
const container = new PostgreSqlContainer("postgres:latest");
const postgreSqlContainer = await container.start();
const pool = new Pool({
user: postgreSqlContainer.getUsername(),
database: postgreSqlContainer.getDatabase(),
password: postgreSqlContainer.getPassword(),
port: postgreSqlContainer.getPort(),
host: postgreSqlContainer.getHost(),
keepAlive: true,
max: 0,
idleTimeoutMillis: 60000,
allowExitOnIdle: false,
});
const database = drizzle({
schema: pgSchema,
casing: "snake_case",
client: pool,
});
// Run migrations and check if it works
await migrate(database, {
migrationsFolder: path.join(__dirname, "..", "migrations", "postgresql"),
});
// Check if users table exists
await database.query.users.findMany();
// Close the pool to release resources
await pool.end();
// Stop the container
await postgreSqlContainer.stop();
}, 40_000);
});

View File

@@ -1,15 +1,17 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type { Column, InferSelectModel } from "drizzle-orm";
import type { ForeignKey as MysqlForeignKey, MySqlTableWithColumns } from "drizzle-orm/mysql-core";
import type { PgTableWithColumns, ForeignKey as PostgresqlForeignKey } from "drizzle-orm/pg-core";
import type { ForeignKey as SqliteForeignKey, SQLiteTableWithColumns } from "drizzle-orm/sqlite-core";
import { expect, expectTypeOf, test } from "vitest";
import { objectEntries } from "@homarr/common";
import * as mysqlSchema from "../schema/mysql";
import * as postgresqlSchema from "../schema/postgresql";
import * as sqliteSchema from "../schema/sqlite";
// We need the following two types as there is currently no support for Buffer in mysql and
// We need the following three types as there is currently no support for Buffer in mysql & pg and
// so we use a custom type which results in the config beeing different
type FixedMysqlConfig = {
[key in keyof MysqlConfig]: {
@@ -22,6 +24,22 @@ type FixedMysqlConfig = {
};
};
type FixedPostgresqlConfig = {
[key in keyof PostgreisqlConfig]: {
[column in keyof PostgreisqlConfig[key]]: {
[property in Exclude<
keyof PostgreisqlConfig[key][column],
"dataType" | "data"
>]: PostgreisqlConfig[key][column][property];
} & {
dataType: PostgreisqlConfig[key][column]["data"] extends Buffer
? "buffer"
: PostgreisqlConfig[key][column]["dataType"];
data: PostgreisqlConfig[key][column]["data"] extends Buffer ? Buffer : PostgreisqlConfig[key][column]["data"];
};
};
};
type FixedSqliteConfig = {
[key in keyof SqliteConfig]: {
[column in keyof SqliteConfig[key]]: {
@@ -117,6 +135,91 @@ test("schemas should match", () => {
});
});
test("schemas should match for postgresql", () => {
expectTypeOf<SqliteTables>().toEqualTypeOf<PostgresqlTables>();
expectTypeOf<PostgresqlTables>().toEqualTypeOf<SqliteTables>();
expectTypeOf<FixedSqliteConfig>().toEqualTypeOf<FixedPostgresqlConfig>();
expectTypeOf<FixedPostgresqlConfig>().toEqualTypeOf<FixedSqliteConfig>();
objectEntries(sqliteSchema).forEach(([tableName, sqliteTable]) => {
// keys of sqliteSchema and postgresqlSchema are the same, so we can safely use tableName as key
// skipcq: JS-E1007
const postgresqlTable = postgresqlSchema[tableName];
Object.entries(sqliteTable).forEach(([columnName, sqliteColumn]: [string, object]) => {
if (!("isUnique" in sqliteColumn)) return;
if (!("uniqueName" in sqliteColumn)) return;
if (!("primary" in sqliteColumn)) return;
const postgresqlColumn = postgresqlTable[columnName as keyof typeof postgresqlTable] as object;
if (!("isUnique" in postgresqlColumn)) return;
if (!("uniqueName" in postgresqlColumn)) return;
if (!("primary" in postgresqlColumn)) return;
expect(
sqliteColumn.isUnique,
`expect unique of column ${columnName} in table ${tableName} to be the same for both schemas`,
).toEqual(postgresqlColumn.isUnique);
expect(
sqliteColumn.uniqueName,
`expect uniqueName of column ${columnName} in table ${tableName} to be the same for both schemas`,
).toEqual(postgresqlColumn.uniqueName);
expect(
sqliteColumn.primary,
`expect primary of column ${columnName} in table ${tableName} to be the same for both schemas`,
).toEqual(postgresqlColumn.primary);
});
const sqliteForeignKeys = sqliteTable[Symbol.for("drizzle:SQLiteInlineForeignKeys") as keyof typeof sqliteTable] as
| SqliteForeignKey[]
| undefined;
const postgresqlForeignKeys = postgresqlTable[
Symbol.for("drizzle:PgInlineForeignKeys") as keyof typeof postgresqlTable
] as PostgresqlForeignKey[] | undefined;
if (!sqliteForeignKeys && !postgresqlForeignKeys) return;
expect(postgresqlForeignKeys, `postgresql foreign key for ${tableName} to be defined`).toBeDefined();
expect(sqliteForeignKeys, `sqlite foreign key for ${tableName} to be defined`).toBeDefined();
expect(
sqliteForeignKeys!.length,
`expect number of foreign keys in table ${tableName} to be the same for both schemas`,
).toEqual(postgresqlForeignKeys?.length);
sqliteForeignKeys?.forEach((sqliteForeignKey) => {
sqliteForeignKey.getName();
const postgresqlForeignKey = postgresqlForeignKeys!.find((key) => key.getName() === sqliteForeignKey.getName());
expect(
postgresqlForeignKey,
`expect foreign key ${sqliteForeignKey.getName()} to be defined in postgresql schema`,
).toBeDefined();
// In PostgreSql, onDelete is "no action" by default, so it is treated as undefined to match Sqlite.
expect(
sqliteForeignKey.onDelete,
`expect foreign key (${sqliteForeignKey.getName()}) onDelete to be the same for both schemas`,
).toEqual(postgresqlForeignKey!.onDelete === "no action" ? undefined : postgresqlForeignKey!.onDelete);
// In PostgreSql, onUpdate is "no action" by default, so it is treated as undefined to match Sqlite.
expect(
sqliteForeignKey.onUpdate,
`expect foreign key (${sqliteForeignKey.getName()}) onUpdate to be the same for both schemas`,
).toEqual(postgresqlForeignKey!.onUpdate === "no action" ? undefined : postgresqlForeignKey!.onUpdate);
sqliteForeignKey.reference().foreignColumns.forEach((column) => {
expect(
postgresqlForeignKey!.reference().foreignColumns.map((column) => column.name),
`expect foreign key (${sqliteForeignKey.getName()}) columns to be the same for both schemas`,
).toContainEqual(column.name);
});
expect(
Object.keys(sqliteForeignKey.reference().foreignTable),
`expect foreign key (${sqliteForeignKey.getName()}) table to be the same for both schemas`,
).toEqual(Object.keys(postgresqlForeignKey!.reference().foreignTable).filter((key) => key !== "enableRLS"));
});
});
});
type SqliteTables = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[K in keyof typeof sqliteSchema]: (typeof sqliteSchema)[K] extends SQLiteTableWithColumns<any>
@@ -130,6 +233,13 @@ type MysqlTables = {
: never;
};
type PostgresqlTables = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[K in keyof typeof postgresqlSchema]: (typeof postgresqlSchema)[K] extends PgTableWithColumns<any>
? InferSelectModel<(typeof postgresqlSchema)[K]>
: never;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type InferColumnConfig<T extends Column<any, object>> =
T extends Column<infer C, object> ? Omit<C, "columnType" | "enumValues" | "driverParam"> : never;
@@ -155,3 +265,14 @@ type MysqlConfig = {
}
: never;
};
type PostgreisqlConfig = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[K in keyof typeof postgresqlSchema]: (typeof postgresqlSchema)[K] extends PgTableWithColumns<any>
? {
[C in keyof (typeof postgresqlSchema)[K]["_"]["config"]["columns"]]: InferColumnConfig<
(typeof postgresqlSchema)[K]["_"]["config"]["columns"][C]
>;
}
: never;
};

View File

@@ -1,11 +1,10 @@
import { isMysql, isPostgresql } from "./collection";
import type { HomarrDatabase, HomarrDatabaseMysql } from "./driver";
import { env } from "./env";
import * as mysqlSchema from "./schema/mysql";
type MysqlSchema = typeof mysqlSchema;
import type { MySqlSchema } from "./schema";
import * as schema from "./schema";
interface HandleTransactionInput {
handleAsync: (db: HomarrDatabaseMysql, schema: MysqlSchema) => Promise<void>;
handleAsync: (db: HomarrDatabaseMysql, schema: MySqlSchema) => Promise<void>;
handleSync: (db: HomarrDatabase) => void;
}
@@ -15,10 +14,10 @@ interface HandleTransactionInput {
* 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") {
if (isMysql() || isPostgresql()) {
// Schema type is always the correct one based on env variables
await input.handleAsync(db as unknown as HomarrDatabaseMysql, schema as unknown as MySqlSchema);
} else {
input.handleSync(db);
return;
}
await input.handleAsync(db as unknown as HomarrDatabaseMysql, mysqlSchema);
};

View File

@@ -37,9 +37,9 @@ export const mapApp = (
appId: appsMap.get(app.id)?.id!,
openInNewTab: app.behaviour.isOpeningNewTab,
pingEnabled: app.network.enabledStatusChecker,
showDescriptionTooltip: app.behaviour.tooltipDescription !== "",
showTitle: app.appearance.appNameStatus === "normal",
layout: app.appearance.positionAppName,
descriptionDisplayMode: app.behaviour.tooltipDescription !== "" ? "tooltip" : "hidden",
} satisfies WidgetComponentProps<"app">["options"]),
layouts: boardSizes.map((size) => {
const shapeForSize = app.shape[size];

View File

@@ -3,6 +3,7 @@ import { Octokit } from "octokit";
import { compareSemVer, isValidSemVer } from "semver-parser";
import { fetchWithTimeout } from "@homarr/common";
import { env } from "@homarr/common/env";
import { logger } from "@homarr/log";
import { createChannelWithLatestAndEvents } from "@homarr/redis";
import { createCachedRequestHandler } from "@homarr/request-handler/lib/cached-request-handler";
@@ -13,6 +14,11 @@ export const updateCheckerRequestHandler = createCachedRequestHandler({
queryKey: "homarr-update-checker",
cacheDuration: dayjs.duration(1, "hour"),
async requestAsync(_) {
if (env.NO_EXTERNAL_CONNECTION)
return {
availableUpdates: [],
};
const octokit = new Octokit({
request: {
fetch: fetchWithTimeout,

View File

@@ -44,7 +44,7 @@
"apps": "Aplikace",
"boards": "Plochy",
"integrations": "Integrace",
"credentialUsers": ""
"credentialUsers": "Uživatelské oprávnění"
}
},
"tokenModal": {
@@ -52,7 +52,7 @@
"field": {
"token": {
"label": "Token",
"description": ""
"description": "Zadejte zobrazený importní token z předchozí instance homarr"
}
},
"notification": {

View File

@@ -1266,9 +1266,6 @@
"showTitle": {
"label": "Show app name"
},
"showDescriptionTooltip": {
"label": "Show description tooltip"
},
"pingEnabled": {
"label": "Enable status check"
},
@@ -1280,6 +1277,15 @@
"column": "Vertical",
"column-reverse": "Vertical (reversed)"
}
},
"descriptionDisplayMode": {
"label": "Description display mode",
"description": "Choose how to display the app description",
"option": {
"normal": "Within widget",
"tooltip": "As tooltip",
"hidden": "Hidden"
}
}
},
"error": {

View File

@@ -1958,7 +1958,7 @@
"label": "Columnas a mostrar"
},
"enableRowSorting": {
"label": ""
"label": "Habilitar ordenar elementos"
},
"defaultSort": {
"label": ""

View File

@@ -3,18 +3,18 @@
"step": {
"start": {
"title": "Chào mừng đến với Homarr",
"subtitle": "Hãy bắt đầu cài đặt phiên bản Homarr của bạn.",
"description": "Để bắt đầu, hãy chọn cách bạn muốn cài đặt phiên bản Homarr của mình.",
"subtitle": "Hãy bắt đầu cài đặt ứng dụng Homarr của bạn.",
"description": "Để bắt đầu, hãy chọn cách bạn muốn cài đặt ứng dụng Homarr của mình.",
"action": {
"scratch": "Bắt đầu từ đầu",
"importOldmarr": "Nhập từ Homarr trước phiên bản 1.0"
"importOldmarr": "Nhập dữ liệu từ Homarr phiên bản trước 1.0"
}
},
"import": {
"title": "Nhập dữ liệu",
"subtitle": "Bạn có thể nhập dữ liệu từ một phiên bản Homarr có sẵn.",
"dropzone": {
"title": "Kéo tệp tin zip vào đây hoặc nhấp để chọn",
"title": "Kéo tệp tin zip vào đây hoặc nhấp chuột để chọn",
"description": "Tệp tin zip đã tải lên sẽ được xử lý và bạn sẽ có thể chọn những gì bạn muốn nhập"
},
"fileInfo": {
@@ -92,13 +92,13 @@
},
"finish": {
"title": "Hoàn tất cài đặt",
"subtitle": "Đã sẵn sàng để tiếp tục!",
"subtitle": "Bạn đã sẵn sàng để tiếp tục!",
"description": "",
"action": {
"goToBoard": "",
"createBoard": "",
"inviteUser": "",
"docs": "Hãy đọc tài liệu này"
"docs": "Đọc tài liệu hướng dẫn"
}
}
},
@@ -156,7 +156,7 @@
"label": "Công cụ tìm kiếm mặc định"
},
"openSearchInNewTab": {
"label": ""
"label": "Mở kết quả tìm kiếm trong thẻ mới"
}
},
"error": {
@@ -169,7 +169,7 @@
"notification": {
"success": {
"title": "Đăng nhập thành công",
"message": "Bạn đã đăng nhập"
"message": "Bạn đã được đăng nhập"
},
"error": {
"title": "Đăng nhập không thành công",
@@ -177,7 +177,7 @@
}
},
"forgotPassword": {
"label": "Quên mật khẩu?",
"label": "Bạn đã quên mật khẩu?",
"description": ""
}
},
@@ -189,8 +189,8 @@
"message": "Vui lòng đăng nhập để tiếp tục"
},
"error": {
"title": "Tạo tài khoản thất bại",
"message": "Tài khoản của bạn không thể được tạo"
"title": "Tạo tài khoản không thành công",
"message": "Không thể tạo tài khoản bạn được"
}
}
},
@@ -287,7 +287,7 @@
},
"delete": {
"label": "Xóa người dùng vĩnh viễn",
"description": "Xóa người dùng này bao gồm cài đặt của họ. Sẽ không xóa các bảng. Người dùng sẽ không được thông báo.",
"description": "Xóa người dùng này và những cài đặt cá nhân của họ. Hành động này sẽ không xóa các bảng. Người dùng bị xóa sẽ không nhận thông báo.",
"confirm": "Bạn có chắc chắn muốn xóa tài khoản {username} cùng với các cài đặt?"
},
"select": {
@@ -409,8 +409,8 @@
"title": "Khác",
"item": {
"view-logs": {
"label": "Xem nhật ký",
"description": "Cho phép thành viên xem nhật ký"
"label": "Xem nhật ký máy",
"description": "Cho phép thành viên xem nhật ký máy"
}
}
},
@@ -419,11 +419,11 @@
"item": {
"create": {
"label": "Tạo công cụ tìm kiếm",
"description": ""
"description": "Cho phép thành viên nhóm này tạo công cụ tìm kiếm"
},
"modify-all": {
"label": "",
"description": ""
"label": "Sửa đổi tất cả công cụ tìm kiếm",
"description": "Cho phép thành viên nhóm này sửa đổi tất cả công cụ tìm kiếm"
},
"full-all": {
"label": "",
@@ -441,23 +441,23 @@
},
"action": {
"create": {
"label": "",
"label": "Nhóm mới",
"notification": {
"success": {
"message": ""
"message": "Đã tạo nhóm thành công"
},
"error": {
"message": ""
"message": "Nhóm này không thể tạo được"
}
}
},
"transfer": {
"label": "",
"description": "",
"confirm": "",
"label": "Chuyển quyền sở hữu",
"description": "Chuyển quyền sở hữu nhóm này cho một người dùng khác",
"confirm": "Bạn chắc chắn muốn chuyển quyền sở hữu nhóm {name} cho {username}?",
"notification": {
"success": {
"message": ""
"message": "Đã chuyển thành công quyền sở hữu nhóm {group} cho {username}"
},
"error": {
"message": ""
@@ -465,19 +465,19 @@
}
},
"addMember": {
"label": "Thêm thành viên"
"label": "Thêm thành viên vào nhóm"
},
"removeMember": {
"label": "Xóa thành viên",
"label": "Xóa thành viên khỏi nhóm",
"confirm": ""
},
"delete": {
"label": "Xóa nhóm",
"description": "",
"confirm": "",
"confirm": "Bạn chắc chắn muốn xóa nhóm {name}?",
"notification": {
"success": {
"message": ""
"message": "Đã xóa nhóm {name} thành công"
},
"error": {
"message": ""
@@ -537,7 +537,7 @@
},
"defaultGroup": {
"name": "Nhóm mặc định",
"description": ""
"description": "{name} - Tất cả những người dùng đã đăng nhập"
}
},
"app": {
@@ -546,7 +546,7 @@
"list": {
"title": "Ứng dụng",
"noResults": {
"title": "",
"title": "Chưa có ứng dụng nào cả",
"action": "Tạo ứng dụng của bạn trước"
}
},
@@ -596,7 +596,7 @@
"label": "Tên"
},
"description": {
"label": "Thông tin chi tiết"
"label": "Mô tả"
},
"url": {
"label": "Url"
@@ -612,14 +612,14 @@
"select": {
"label": "Chọn ứng dụng",
"notFound": "Không tìm thấy ứng dụng",
"search": "",
"search": "Tìm một ứng dụng",
"noResults": "Không có kết quả",
"action": "Chọn {app}",
"title": ""
},
"create": {
"title": "",
"description": "",
"title": "Tạo ứng dụng mới",
"description": "Tạo ứng dụng mới",
"action": ""
},
"add": ""
@@ -887,7 +887,7 @@
"message": ""
},
"invalidJson": {
"title": "",
"title": "JSON không hợp lệ",
"message": ""
},
"wrongPath": {
@@ -943,7 +943,7 @@
},
"topic": {
"label": "",
"newLabel": ""
"newLabel": "Chủ đề mới"
},
"opnsenseApiKey": {
"label": "",
@@ -1009,7 +1009,7 @@
"action": {
"add": "Thêm",
"apply": "Áp dụng",
"backToOverview": "",
"backToOverview": "Trở về mục tổng quan",
"create": "Tạo nên",
"createAnother": "",
"edit": "Sửa",
@@ -1083,7 +1083,7 @@
"userAvatar": {
"menu": {
"switchToDarkMode": "",
"switchToLightMode": "",
"switchToLightMode": "Đổi qua chế độ nền sáng",
"management": "",
"preferences": "Cá nhân hoá",
"logout": "",
@@ -2856,7 +2856,7 @@
"boards": "Bảng",
"apps": "Ứng dụng",
"integrations": "",
"searchEngies": "",
"searchEngies": "Công cụ tìm kiếm",
"medias": "",
"users": {
"label": "Người dùng",
@@ -2976,7 +2976,7 @@
"mobile": ""
}
},
"search": "",
"search": "Tìm",
"firstDayOfWeek": "Ngày đầu tiên trong tuần",
"accessibility": "Trợ năng"
}
@@ -3077,7 +3077,7 @@
},
"members": {
"title": "",
"search": "",
"search": "Tìm một thành viên",
"notFound": ""
},
"permissions": {
@@ -3158,9 +3158,9 @@
}
},
"search": {
"title": "",
"title": "Tìm",
"defaultSearchEngine": {
"label": "",
"label": "Công cụ tìm kiếm mặc định cho toàn hệ thống",
"description": ""
}
},
@@ -3895,7 +3895,7 @@
},
"search": {
"placeholder": "",
"nothingFound": "",
"nothingFound": "Không tìm được gì cả",
"error": {
"fetch": ""
},

View File

@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react";
import { Fragment, Suspense } from "react";
import { Flex, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconLoader } from "@tabler/icons-react";
import combineClasses from "clsx";
@@ -74,7 +74,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
))}
position="right-start"
multiline
disabled={!options.showDescriptionTooltip || !app.description}
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
styles={{ tooltip: { maxWidth: 300 } }}
>
<Flex
@@ -87,16 +87,34 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
align="center"
gap={isColumnLayout ? 0 : "sm"}
>
{options.showTitle && (
<Text
className="app-title"
fw={700}
size={isTiny ? "8px" : "sm"}
ta={isColumnLayout ? "center" : undefined}
>
{app.name}
</Text>
)}
<Stack gap={0}>
{options.showTitle && (
<Text
className="app-title"
fw={700}
size={isTiny ? rem(8) : "sm"}
ta={isColumnLayout ? "center" : undefined}
>
{app.name}
</Text>
)}
{options.descriptionDisplayMode === "normal" && (
<Text
className="app-description"
size={isTiny ? rem(8) : "sm"}
ta={isColumnLayout ? "center" : undefined}
c="dimmed"
lineClamp={4}
>
{app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</Text>
)}
</Stack>
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={board.iconColor !== null}

View File

@@ -1,10 +1,13 @@
import {
IconApps,
IconDeviceDesktopX,
IconEyeOff,
IconLayoutBottombarExpand,
IconLayoutNavbarExpand,
IconLayoutSidebarLeftExpand,
IconLayoutSidebarRightExpand,
IconTextScan2,
IconTooltip,
} from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
@@ -18,7 +21,34 @@ export const { definition, componentLoader } = createWidgetDefinition("app", {
appId: factory.app(),
openInNewTab: factory.switch({ defaultValue: true }),
showTitle: factory.switch({ defaultValue: true }),
showDescriptionTooltip: factory.switch({ defaultValue: false }),
descriptionDisplayMode: factory.select({
options: [
{
label(t) {
return t("widget.app.option.descriptionDisplayMode.option.normal");
},
value: "normal",
icon: IconTextScan2,
},
{
label(t) {
return t("widget.app.option.descriptionDisplayMode.option.tooltip");
},
value: "tooltip",
icon: IconTooltip,
},
{
label(t) {
return t("widget.app.option.descriptionDisplayMode.option.hidden");
},
value: "hidden",
icon: IconEyeOff,
},
],
defaultValue: "hidden",
searchable: true,
withDescription: true,
}),
layout: factory.select({
options: [
{

View File

@@ -42,7 +42,7 @@ export const CommonChart = ({
pos={"relative"}
style={{ overflow: "visible" }}
p={0}
bg={data.length <= 1 ? backgroundColor : undefined}
bg={backgroundColor}
radius={board.itemRadius}
>
{data.length > 1 && height > 40 && !hovered && (

View File

@@ -0,0 +1,13 @@
diff --git a/dist/esm/utils/zod.mjs b/dist/esm/utils/zod.mjs
index a3fda1a5b5403a659bfc70653a94102e607f8b80..070d9728f9b41add50e5d30d3aa559be91018ccd 100644
--- a/dist/esm/utils/zod.mjs
+++ b/dist/esm/utils/zod.mjs
@@ -62,7 +62,7 @@ export const instanceofZodTypeLikeString = (_type) => {
}
return instanceofZodTypeKind(type, 'string');
};
-export const zodSupportsCoerce = 'coerce' in z;
+export const zodSupportsCoerce = true //'coerce' in z;
export const instanceofZodTypeCoercible = (_type) => {
const type = unwrapZodType(_type, false);
return (instanceofZodTypeKind(type, 'number') ||

1497
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long