diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..a678821 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,31 @@ +services: + mariadb: + image: mariadb:10.11.16-jammy + ports: + - 3306:3306 + environment: + MARIADB_DATABASE: qonstellation + MARIADB_ROOT_PASSWORD: password + volumes: + - type: volume + source: mariadb_data + target: /var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + start_period: 10s + interval: 5s + timeout: 10s + retries: 10 + + adminer: + image: adminer:5.4.2 + ports: + - 8080:8080 + environment: + ADMINER_DEFAULT_SERVER: mariadb + depends_on: + mariadb: + condition: service_healthy + +volumes: + mariadb_data: diff --git a/database/db.ts b/database/db.ts new file mode 100644 index 0000000..c65ede3 --- /dev/null +++ b/database/db.ts @@ -0,0 +1,27 @@ +import { Kysely, MysqlDialect } from "@kysely/kysely" +import { createPool } from "mysql2" +import type { PostsTable } from "./posts.ts" +import type { SystemStatesTable } from "./systemStates.ts" +import type { UsersTable } from "./users.ts" +import type { UserSettingsTable } from "./userSettings.ts" +import type { UserTokensTable } from "./userTokens.ts" + +export interface Database { + posts: PostsTable + users: UsersTable + systemStates: SystemStatesTable + userSettings: UserSettingsTable + userTokens: UserTokensTable +} + +const dialect = new MysqlDialect({ + pool: createPool({ + host: Deno.env.get("DB_HOST") ?? "localhost", + port: Number(Deno.env.get("DB_PORT") ?? 3306), + user: Deno.env.get("DB_USER") ?? "root", + password: Deno.env.get("DB_PASSWORD") ?? "password", + database: Deno.env.get("DB_NAME") ?? "qonstellation", + }), +}) + +export const db = new Kysely({ dialect }) diff --git a/database/migrations/001_initial.ts b/database/migrations/001_initial.ts new file mode 100644 index 0000000..d4c807b --- /dev/null +++ b/database/migrations/001_initial.ts @@ -0,0 +1,61 @@ +import type { Kysely } from "@kysely/kysely" + +export const up = async (db: Kysely) => { + await db.schema + .createTable("users") + .addColumn("id", "uuid", (col) => col.primaryKey()) + .execute() + + await db.schema + .createTable("posts") + .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement()) + .addColumn("at_proto_uri", "varchar(8192)", (col) => col.notNull()) + .addColumn("traq_message_id", "uuid", (col) => col.notNull()) + .execute() + + await db.schema + .createTable("system_states") + .addColumn("key", "varchar(16)", (col) => col.primaryKey()) + .addColumn("value", "integer", (col) => col.notNull()) + .execute() + + await db.schema + .createTable("user_settings") + .addColumn( + "id", + "integer", + (col) => col.primaryKey().autoIncrement(), + ) + .addColumn("did", "varchar(2048)", (col) => col.notNull()) + .addColumn("target_channel_id", "uuid", (col) => col.notNull()) + .addColumn( + "user_id", + "uuid", + (col) => col.notNull().unique().references("users.id"), + ) + .execute() + + await db.schema + .createTable("user_tokens") + .addColumn( + "id", + "integer", + (col) => col.primaryKey().autoIncrement(), + ) + .addColumn("access_token", "varchar(36)", (col) => col.notNull()) + .addColumn("refresh_token", "varchar(36)", (col) => col.notNull()) + .addColumn( + "user_id", + "uuid", + (col) => col.notNull().unique().references("users.id"), + ) + .execute() +} + +export const down = async (db: Kysely) => { + await db.schema.dropTable("user_tokens").execute() + await db.schema.dropTable("user_settings").execute() + await db.schema.dropTable("system_states").execute() + await db.schema.dropTable("posts").execute() + await db.schema.dropTable("users").execute() +} diff --git a/database/migrator.ts b/database/migrator.ts new file mode 100644 index 0000000..1ae4883 --- /dev/null +++ b/database/migrator.ts @@ -0,0 +1,12 @@ +import { type Migration, type MigrationProvider } from "@kysely/kysely" +import { down, up } from "./migrations/001_initial.ts" + +const migrations = { + "001_initial": { up, down }, +} satisfies Record + +export const migrationProvider = { + getMigrations() { + return Promise.resolve(migrations) + }, +} satisfies MigrationProvider diff --git a/database/posts.ts b/database/posts.ts new file mode 100644 index 0000000..83291c3 --- /dev/null +++ b/database/posts.ts @@ -0,0 +1,7 @@ +import { Generated } from "@kysely/kysely" + +export interface PostsTable { + id: Generated + traqMessageId: string + atProtoUri: string +} diff --git a/database/systemStates.ts b/database/systemStates.ts new file mode 100644 index 0000000..37930f0 --- /dev/null +++ b/database/systemStates.ts @@ -0,0 +1,4 @@ +export interface SystemStatesTable { + key: string + value: number +} diff --git a/database/userSettings.ts b/database/userSettings.ts new file mode 100644 index 0000000..a1399a7 --- /dev/null +++ b/database/userSettings.ts @@ -0,0 +1,8 @@ +import type { Generated } from "@kysely/kysely" + +export interface UserSettingsTable { + id: Generated + did: string + targetChannelId: string + userId: string +} diff --git a/database/userTokens.ts b/database/userTokens.ts new file mode 100644 index 0000000..dc5edf8 --- /dev/null +++ b/database/userTokens.ts @@ -0,0 +1,8 @@ +import type { Generated } from "@kysely/kysely" + +export interface UserTokensTable { + id: Generated + accessToken: string + refreshToken: string + userId: string +} diff --git a/database/users.ts b/database/users.ts new file mode 100644 index 0000000..827ae55 --- /dev/null +++ b/database/users.ts @@ -0,0 +1,3 @@ +export interface UsersTable { + id: string +} diff --git a/deno.jsonc b/deno.jsonc index 8a82d21..3f7b192 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -8,7 +8,9 @@ }, "imports": { "@fresh/plugin-vite": "jsr:@fresh/plugin-vite@^1.1.2", + "@kysely/kysely": "jsr:@kysely/kysely@^0.28.16", "fresh": "jsr:@fresh/core@^2.3.3", + "mysql2": "npm:mysql2@^3.22.3", "preact": "npm:preact@^10.29.1", "vite": "npm:vite@^7.3.2" }, @@ -21,6 +23,6 @@ } }, "tasks": { - "dev": "deno run -A npm:vite" + "dev": "deno run -A vite" } } diff --git a/deno.lock b/deno.lock index 70e6110..6079e87 100644 --- a/deno.lock +++ b/deno.lock @@ -8,6 +8,7 @@ "jsr:@fresh/core@2": "2.3.3", "jsr:@fresh/core@^2.3.3": "2.3.3", "jsr:@fresh/plugin-vite@^1.1.2": "1.1.2", + "jsr:@kysely/kysely@~0.28.16": "0.28.16", "jsr:@std/bytes@^1.0.6": "1.0.6", "jsr:@std/dotenv@~0.225.5": "0.225.6", "jsr:@std/encoding@^1.0.10": "1.0.10", @@ -32,20 +33,19 @@ "npm:@babel/preset-react@^7.27.1": "7.28.5_@babel+core@7.29.0", "npm:@opentelemetry/api@^1.9.0": "1.9.1", "npm:@preact/signals@^2.5.1": "2.9.0_preact@10.29.1", - "npm:@prefresh/vite@^2.4.8": "2.4.12_preact@10.29.1_vite@7.3.2", + "npm:@prefresh/vite@^2.4.8": "2.4.12_preact@10.29.1_vite@7.3.2__@types+node@25.6.0_@types+node@25.6.0", "npm:@remix-run/node-fetch-server@0.12": "0.12.0", "npm:@types/babel__core@^7.20.5": "7.20.5", "npm:esbuild-wasm@~0.25.11": "0.25.12", "npm:esbuild@0.25.7": "0.25.7", "npm:esbuild@~0.25.5": "0.25.12", + "npm:mysql2@^3.22.3": "3.22.3_@types+node@25.6.0", "npm:preact-render-to-string@^6.6.3": "6.6.7_preact@10.29.1", "npm:preact@^10.28.2": "10.29.1", "npm:preact@^10.29.1": "10.29.1", "npm:rollup@^4.55.1": "4.60.2", - "npm:vite@*": "7.3.2", - "npm:vite@7.3.2": "7.3.2", - "npm:vite@^7.1.4": "7.3.2", - "npm:vite@^7.3.2": "7.3.2" + "npm:vite@^7.1.4": "7.3.2_@types+node@25.6.0", + "npm:vite@^7.3.2": "7.3.2_@types+node@25.6.0" }, "jsr": { "@deno/esbuild-plugin@1.2.1": { @@ -112,6 +112,9 @@ "npm:vite@^7.1.4" ] }, + "@kysely/kysely@0.28.16": { + "integrity": "b620bf5d8dc892cb30419aaaef62f0f1ba1846a30eaa45f882ec20183b88a18b" + }, "@std/bytes@1.0.6": { "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" }, @@ -127,7 +130,6 @@ "@std/fs@1.0.23": { "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", "dependencies": [ - "jsr:@std/internal", "jsr:@std/path@^1.1.4" ] }, @@ -798,7 +800,7 @@ "@prefresh/utils@1.2.1": { "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==" }, - "@prefresh/vite@2.4.12_preact@10.29.1_vite@7.3.2": { + "@prefresh/vite@2.4.12_preact@10.29.1_vite@7.3.2__@types+node@25.6.0_@types+node@25.6.0": { "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==", "dependencies": [ "@babel/core", @@ -977,6 +979,15 @@ "@types/estree@1.0.8": { "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" }, + "@types/node@25.6.0": { + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dependencies": [ + "undici-types" + ] + }, + "aws-ssl-profiles@1.1.2": { + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==" + }, "baseline-browser-mapping@2.10.24": { "integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==", "bin": true @@ -1004,6 +1015,9 @@ "ms" ] }, + "denque@2.1.0": { + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==" + }, "electron-to-chromium@1.5.344": { "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==" }, @@ -1130,9 +1144,24 @@ "os": ["darwin"], "scripts": true }, + "generate-function@2.3.1": { + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "dependencies": [ + "is-property" + ] + }, "gensync@1.0.0-beta.2": { "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" }, + "iconv-lite@0.7.2": { + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dependencies": [ + "safer-buffer" + ] + }, + "is-property@1.0.2": { + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" + }, "js-tokens@4.0.0": { "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, @@ -1144,15 +1173,41 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "bin": true }, + "long@5.3.2": { + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" + }, "lru-cache@5.1.1": { "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dependencies": [ "yallist" ] }, + "lru.min@1.1.4": { + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==" + }, "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "mysql2@3.22.3_@types+node@25.6.0": { + "integrity": "sha512-uWWxvZSRvRhtBdh2CdcuK83YcOfPdmEeEYB069bAmPnV93QApDGVPuvCQOLjlh7tYHEWdgQPrn6kosDxHBVLkA==", + "dependencies": [ + "@types/node", + "aws-ssl-profiles", + "denque", + "generate-function", + "iconv-lite", + "long", + "lru.min", + "named-placeholders", + "sql-escaper" + ] + }, + "named-placeholders@1.1.6": { + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "dependencies": [ + "lru.min" + ] + }, "nanoid@3.3.11": { "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "bin": true @@ -1221,6 +1276,9 @@ ], "bin": true }, + "safer-buffer@2.1.2": { + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, "semver@6.3.1": { "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "bin": true @@ -1228,6 +1286,9 @@ "source-map-js@1.2.1": { "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" }, + "sql-escaper@1.3.3": { + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==" + }, "tinyglobby@0.2.16": { "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dependencies": [ @@ -1235,6 +1296,9 @@ "picomatch@4.0.4" ] }, + "undici-types@7.19.2": { + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==" + }, "update-browserslist-db@1.2.3_browserslist@4.28.2": { "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dependencies": [ @@ -1244,9 +1308,10 @@ ], "bin": true }, - "vite@7.3.2": { + "vite@7.3.2_@types+node@25.6.0": { "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dependencies": [ + "@types/node", "esbuild@0.27.7", "fdir", "picomatch@4.0.4", @@ -1257,6 +1322,9 @@ "optionalDependencies": [ "fsevents" ], + "optionalPeers": [ + "@types/node" + ], "bin": true }, "yallist@3.1.1": { @@ -1267,6 +1335,8 @@ "dependencies": [ "jsr:@fresh/core@^2.3.3", "jsr:@fresh/plugin-vite@^1.1.2", + "jsr:@kysely/kysely@~0.28.16", + "npm:mysql2@^3.22.3", "npm:preact@^10.29.1", "npm:vite@^7.3.2" ] diff --git a/main.ts b/main.ts index 6ceb88f..911a422 100644 --- a/main.ts +++ b/main.ts @@ -1,3 +1,15 @@ +import { Migrator } from "@kysely/kysely" import { App, staticFiles } from "fresh" +import { db } from "./database/db.ts" +import { migrationProvider } from "./database/migrator.ts" + +const migrator = new Migrator({ db, provider: migrationProvider }) +const { error } = await migrator.migrateToLatest() + +if (error) { + throw error +} + +console.log("Database migrated") export const app = new App().use(staticFiles()).fsRoutes()