diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 123f4dcb..cfed886b 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -24,8 +24,10 @@ jobs: - name: Install dependencies run: npm ci - - name: Run ESLint - run: npx eslint . + - name: Lint + # npm run lint, not npx eslint — the script also checks that the + # generated dialect schemas match schema.ts, which eslint cannot see. + run: npm run lint - name: Run Prettier check run: npx prettier --check . @@ -35,3 +37,76 @@ jobs: - name: Build run: npm run build + + database-dialects: + name: Postgres and MySQL + runs-on: blacksmith-2vcpu-ubuntu-2404 + + # The test suite only ever sees SQLite. Everything that differs per engine — + # the RETURNING replacements, the read-then-write transactions, the + # migrations themselves — is only covered here, against real servers. + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: termix + POSTGRES_PASSWORD: termix + POSTGRES_DB: termix_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + mysql: + image: mysql:8 + env: + MYSQL_ROOT_PASSWORD: termix + MYSQL_DATABASE: termix_test + MYSQL_USER: termix + MYSQL_PASSWORD: termix + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1 -ptermix" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: ".nvmrc" + cache: "npm" + + - name: Install dependencies + run: npm ci + + # Each run applies the migrations to an empty database first, so a + # migration that does not apply cleanly fails the build. + - name: Verify Postgres + run: npm run verify:dialect -- postgres://termix:termix@127.0.0.1:5432/termix_test + + - name: Verify MySQL + run: npm run verify:dialect -- mysql://termix:termix@127.0.0.1:3306/termix_test + + # The same repository suite the SQLite run executes, pointed at each + # engine. This is where a dialect difference in a query shows up as a + # failing assertion rather than as a bug report. + - name: Repository tests on Postgres + env: + TEST_DIALECT: postgres + TEST_DATABASE_URL: postgres://termix:termix@127.0.0.1:5432/termix_test + run: npx vitest run src/backend/tests/database/repositories --no-file-parallelism + + - name: Repository tests on MySQL + env: + TEST_DIALECT: mysql + TEST_DATABASE_URL: mysql://termix:termix@127.0.0.1:3306/termix_test + run: npx vitest run src/backend/tests/database/repositories --no-file-parallelism diff --git a/.prettierignore b/.prettierignore index befe2e1f..8cf32b0d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -17,3 +17,6 @@ db *.min.js *.min.css openapi.json + +# Generated by drizzle-kit; formatting is the tool's own +drizzle/ diff --git a/docker/Dockerfile b/docker/Dockerfile index f4028795..a0bfa6b2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -76,6 +76,9 @@ COPY --chown=node:node --from=frontend-builder /app/dist /app/html COPY --chown=node:node --from=production-deps /app/node_modules /app/node_modules COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend COPY --chown=node:node package.json ./ +# Schema for Postgres and MySQL. Unused by the default SQLite deployment, which +# builds its tables at startup instead. +COPY --chown=node:node drizzle ./drizzle VOLUME ["/app/data"] diff --git a/docs/database-backends.md b/docs/database-backends.md new file mode 100644 index 00000000..fbf5ff5a --- /dev/null +++ b/docs/database-backends.md @@ -0,0 +1,227 @@ +# Database backends + +Termix runs on SQLite by default. Postgres and MySQL are supported for +self-hosted deployments; this document records how the three differ, because the +differences are not only about SQL. + +## This is multi-backend, not a migration + +SQLite is not going away. The desktop app embeds its own backend and cannot ship +a database server, so it will always run on SQLite. Postgres and MySQL exist for +self-hosted deployments that need more than one process to reach the data — +multiple replicas, an external backup story, or an existing database estate. + +Anything that assumes a single engine is wrong. + +## Where the schema comes from + +`src/backend/database/db/schema.ts` is the single source of truth, written +against `drizzle-orm/sqlite-core`. + +`schema.pg.ts` and `schema.mysql.ts` are **generated** from it: + +```bash +npm run schema:generate # rewrite the generated modules +npm run schema:check # fail if they are out of date (runs as part of lint) +``` + +Never edit the generated files. `npm run lint` fails if they drift from the +source, so a schema change that forgets to regenerate cannot reach main. + +The transforms are mechanical: + +| sqlite | postgres | mysql | +| ------------------------------------------------ | ----------------- | ----------------------- | +| `integer(…, { mode: "boolean" })` | `boolean` | `boolean` | +| `integer(…).primaryKey({ autoIncrement: true })` | `serial` | `int().autoincrement()` | +| `integer` | `integer` | `int` | +| `real` | `doublePrecision` | `double` | +| `text` used as a key | `varchar(255)` | `varchar(255)` | + +A column becomes `varchar` if it is a primary key, is unique, or sits on either +end of a foreign key — MySQL cannot index an unbounded `TEXT`, and both sides of +a foreign key must agree. + +## Durability + +On SQLite the database is loaded into memory and serialised back to an encrypted +file, so every write needs an explicit flush. That is what the `onWrite` hook +each repository receives is for. + +On Postgres and MySQL a committed write is already durable. No hook is installed +at all — see `needsExplicitPersist` in `db/dialect.ts`. + +## Encryption: what changes, and what does not + +This is the part most likely to be misread, so it is spelled out. + +### Unchanged on every backend + +**Field-level encryption still applies.** Credentials and other sensitive values +are encrypted in the application before they reach the database, under a +per-user data key: + +- `ssh_data` — passwords, private keys, key passphrases, sudo/RDP/VNC/Telnet + secrets +- `ssh_credentials` — passwords, private and public keys +- `users` — TOTP secret and backup codes +- `vault_tokens`, `opkssh_tokens`, `termix_identity_ca` — certificates and keys +- `shared_host_secrets` — re-encrypted per recipient + +Installation-level secrets — the OIDC client secret and LDAP bind password — +are encrypted under the system key, since they have no owning user and must be +readable during login. + +This is the protection that matters most, and it is identical on all three +engines. + +### Different on Postgres and MySQL + +**Whole-file encryption does not exist.** On SQLite the database file itself is +encrypted at rest. There is no equivalent for a client-server engine: the data +lives in the server's storage, not in a file Termix owns. + +Concretely, on Postgres/MySQL the following are readable by anyone with database +access, where on SQLite they were covered by the file encryption: + +- host names, addresses, ports and usernames +- folder and snippet names, and **snippet contents** +- audit log entries +- session recording metadata and paths +- user names, roles and API key hashes + +None of these are credentials — those stay encrypted — but together they +describe your estate. + +**If you run Postgres or MySQL, encryption at rest is your responsibility**: +transparent data encryption, an encrypted volume, or an encrypted filesystem. +Termix does not provide it and cannot. + +### Threat model, side by side + +| | SQLite | Postgres / MySQL | +| ----------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------ | +| Stolen database file / volume | credentials encrypted, everything else encrypted | credentials encrypted, **rest depends on your storage encryption** | +| Database access without app access | credentials unreadable | credentials unreadable | +| Application compromise while a user is unlocked | that user's secrets readable | same | +| Backups | inherit file encryption | **plain unless you encrypt them** | + +The second row is the point of field-level encryption, and it holds everywhere. +The first and last rows are where the backends genuinely differ. + +## Running on Postgres or MySQL + +Two variables. Unset, nothing changes and SQLite is used exactly as before. + +``` +DATABASE_DIALECT=postgres +DATABASE_URL=postgres://user:password@host:5432/termix +``` + +``` +DATABASE_DIALECT=mysql +DATABASE_URL=mysql://user:password@host:3306/termix +``` + +`mariadb://` is accepted for MySQL. The scheme is checked against the dialect +before a connection is attempted, so a mismatch fails with a readable message +rather than a driver error deep in a stack. + +Point it at an **empty** database. Migrations are applied at startup, from +`drizzle/postgres` or `drizzle/mysql`, and drizzle records what it has applied — +so several instances against one database are safe, and so is restarting. + +There is no migration path from an existing SQLite database. Exporting one and +importing it into Postgres is not something this branch does. + +### Docker + +`drizzle/` ships in the image. A compose service needs only the two variables: + +Added to the compose file in the README, that is one service and two variables: + +```yaml +services: + termix: + image: ghcr.io/lukegus/termix:latest + environment: + PORT: "8080" + DATABASE_DIALECT: postgres + DATABASE_URL: postgres://termix:termix@db:5432/termix + depends_on: + - db + + db: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_USER: termix + POSTGRES_PASSWORD: termix + POSTGRES_DB: termix + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: +``` + +`DATA_DIR` is still used for uploads and recordings on every backend. Only the +database itself moves. + +## What is verified, and how + +`npm run verify:dialect -- ` applies the migrations to an empty database and +drives the real repository classes against it, asserting values rather than the +absence of exceptions. + +The repository test suite also runs against each engine: + +``` +TEST_DIALECT=postgres TEST_DATABASE_URL= npx vitest run \ + src/backend/tests/database/repositories --no-file-parallelism +``` + +CI runs both, against PostgreSQL 16 and MySQL 8 service containers. Eighteen +tests assert on bytes stored by the SQLite driver and skip on other engines; +they still run in the SQLite pass. + +Tested against PostgreSQL 16 and MySQL 8. **MariaDB is not a substitute for +MySQL when testing** — it accepts DDL that MySQL 8 rejects, which has hidden a +real defect here more than once. + +## Known limits + +- The desktop app always uses SQLite. It embeds its own backend and cannot ship + a database server. +- Repositories import the SQLite table definitions on every engine. That is + correct — the query builder needs identifiers and value encoders, and those + agree — but it means `PortableDatabase` is a named approximation rather than a + guarantee. See `repositories/database-context.ts`. +- `getCurrentSettingValue` is a synchronous read. On Postgres and MySQL it comes + from a cache primed at startup and kept current by `SettingsRepository`, + because those drivers have no synchronous query. + + That cache is per-process, so on a **multi-replica** deployment a setting + changed on one instance does not reach the others through the write path. Each + replica re-reads the settings table every 30 seconds + (`SETTINGS_CACHE_REFRESH_SECONDS`, 0 to disable), which does not make settings + immediately consistent — it bounds how long they can disagree. Changing a + setting takes effect on the replica that made the change at once, and on the + others within the interval. + +- **Importing a backup is SQLite-only.** The restore writes tables in an order + that is not dependency-safe and relies on `PRAGMA foreign_keys = OFF`, which + has no equivalent here: Postgres needs superuser to disable triggers, and + MySQL's session-scoped switch is not guaranteed across a pool. It refuses with + a message rather than failing partway through and leaving a half-restored + database. Restore into Postgres or MySQL with their own tooling. +- **`LIKE` is case-insensitive on SQLite and case-sensitive on Postgres.** The + four places that use it match folder path prefixes and settings keys, so the + practical effect is that renaming a folder `prod` on SQLite also catches + `PROD / api` and on Postgres does not. Postgres is arguably the more correct + of the two; nothing was changed to make them agree, because that would alter + SQLite behaviour for existing deployments. +- The SQLite-era data migrations — legacy shared-credential cleanup, the + shared-host-secrets rebuild, per-user field-encryption backfill — do not run on + the other engines. A database created by the drizzle migrations never had the + shapes they repair. diff --git a/drizzle.config.mysql.ts b/drizzle.config.mysql.ts new file mode 100644 index 00000000..5665bf7b --- /dev/null +++ b/drizzle.config.mysql.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "mysql", + schema: "./src/backend/database/db/schema.mysql.ts", + out: "./drizzle/mysql", +}); diff --git a/drizzle.config.pg.ts b/drizzle.config.pg.ts new file mode 100644 index 00000000..91292349 --- /dev/null +++ b/drizzle.config.pg.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "postgresql", + schema: "./src/backend/database/db/schema.pg.ts", + out: "./drizzle/postgres", +}); diff --git a/drizzle.config.sqlite.ts b/drizzle.config.sqlite.ts new file mode 100644 index 00000000..7941897c --- /dev/null +++ b/drizzle.config.sqlite.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "sqlite", + schema: "./src/backend/database/db/schema.ts", + out: "./drizzle/sqlite", +}); diff --git a/drizzle/mysql/0000_clean_pretty_boy.sql b/drizzle/mysql/0000_clean_pretty_boy.sql new file mode 100644 index 00000000..ea4d196a --- /dev/null +++ b/drizzle/mysql/0000_clean_pretty_boy.sql @@ -0,0 +1,876 @@ +CREATE TABLE `alert_firings` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `rule_id` int NOT NULL, + `host_id` int NOT NULL, + `host_name` text NOT NULL, + `fired_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `resolved_at` text, + `value` double, + `message` text NOT NULL, + `severity` text NOT NULL DEFAULT ('warning'), + `acknowledged` boolean NOT NULL DEFAULT false, + CONSTRAINT `alert_firings_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `alert_rule_channels` ( + `id` int AUTO_INCREMENT NOT NULL, + `rule_id` int NOT NULL, + `channel_id` int NOT NULL, + CONSTRAINT `alert_rule_channels_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `alert_rules` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `host_id` int, + `name` varchar(255) NOT NULL, + `enabled` boolean NOT NULL DEFAULT true, + `trigger_type` text NOT NULL, + `threshold_value` double, + `threshold_duration_seconds` int, + `cooldown_minutes` int NOT NULL DEFAULT 15, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `alert_rules_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `api_keys` ( + `id` varchar(255) NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `token_hash` text NOT NULL, + `token_prefix` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `expires_at` text, + `last_used_at` text, + `is_active` boolean NOT NULL DEFAULT true, + CONSTRAINT `api_keys_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `audit_logs` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255), + `username` text NOT NULL, + `action` text NOT NULL, + `resource_type` text NOT NULL, + `resource_id` text, + `resource_name` text, + `details` text, + `ip_address` text, + `user_agent` text, + `success` boolean NOT NULL, + `error_message` text, + `timestamp` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `audit_logs_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `c2s_tunnel_presets` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `config` text NOT NULL, + `platform` text, + `computer_name` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `c2s_tunnel_presets_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `command_history` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `command` text NOT NULL, + `executed_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `command_history_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `dashboard_service_links` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `label` text NOT NULL, + `url` text NOT NULL, + `order` int NOT NULL DEFAULT 0, + `sync_id` varchar(255), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `dashboard_service_links_id` PRIMARY KEY(`id`), + CONSTRAINT `dashboard_service_links_sync_id_unique` UNIQUE(`sync_id`) +); +--> statement-breakpoint +CREATE TABLE `dismissed_alerts` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `alert_id` text NOT NULL, + `dismissed_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `dismissed_alerts_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `file_manager_pinned` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `name` varchar(255) NOT NULL, + `path` text NOT NULL, + `pinned_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `file_manager_pinned_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `file_manager_recent` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `name` varchar(255) NOT NULL, + `path` text NOT NULL, + `last_opened` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `file_manager_recent_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `file_manager_shortcuts` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `name` varchar(255) NOT NULL, + `path` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `file_manager_shortcuts_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `homepage_items` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `type_id` text NOT NULL, + `title` text, + `config` text NOT NULL DEFAULT ('{}'), + `folder_id` int, + `sync_id` varchar(255), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `homepage_items_id` PRIMARY KEY(`id`), + CONSTRAINT `homepage_items_sync_id_unique` UNIQUE(`sync_id`) +); +--> statement-breakpoint +CREATE TABLE `homepage_layouts` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `layout` text NOT NULL DEFAULT ('{}'), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `homepage_layouts_id` PRIMARY KEY(`id`), + CONSTRAINT `homepage_layouts_user_id_unique` UNIQUE(`user_id`) +); +--> statement-breakpoint +CREATE TABLE `host_access` ( + `id` int AUTO_INCREMENT NOT NULL, + `host_id` int NOT NULL, + `user_id` varchar(255), + `role_id` int, + `granted_by` varchar(255) NOT NULL, + `permission_level` text NOT NULL DEFAULT ('connect'), + `expires_at` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `last_accessed_at` text, + `access_count` int NOT NULL DEFAULT 0, + `override_credential_id` int, + CONSTRAINT `host_access_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `host_health_checks` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `checks` text NOT NULL, + `interval_seconds` int NOT NULL DEFAULT 300, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `host_health_checks_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_host_health_checks_user_host` UNIQUE(`user_id`,`host_id`) +); +--> statement-breakpoint +CREATE TABLE `host_health_history` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `check_id` text NOT NULL, + `ts` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `ok` boolean NOT NULL, + `latency_ms` int, + `detail` text, + CONSTRAINT `host_health_history_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `host_metrics_history` ( + `id` int AUTO_INCREMENT NOT NULL, + `host_id` int NOT NULL, + `ts` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `cpu_percent` double, + `mem_percent` double, + `disk_percent` double, + `net_rx_bytes` int, + `net_tx_bytes` int, + CONSTRAINT `host_metrics_history_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `host_metrics_preferences` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `layout` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `host_metrics_preferences_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_host_metrics_prefs_user_host` UNIQUE(`user_id`,`host_id`) +); +--> statement-breakpoint +CREATE TABLE `ssh_data` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `connection_type` text NOT NULL DEFAULT ('ssh'), + `name` varchar(255), + `ip` text NOT NULL, + `port` int NOT NULL, + `username` text NOT NULL, + `folder` text, + `tags` text, + `pin` boolean NOT NULL DEFAULT false, + `auth_type` text NOT NULL, + `use_warpgate` boolean NOT NULL DEFAULT false, + `force_keyboard_interactive` text, + `password` text, + `key` text, + `key_password` text, + `key_type` text, + `sudo_password` text, + `autostart_password` text, + `autostart_key` text, + `autostart_key_password` text, + `credential_id` int, + `override_credential_username` boolean, + `vault_profile_id` int, + `enable_terminal` boolean NOT NULL DEFAULT true, + `enable_session_logging` boolean NOT NULL DEFAULT true, + `allow_session_sharing` boolean NOT NULL DEFAULT true, + `enable_command_history` boolean NOT NULL DEFAULT true, + `enable_tunnel` boolean NOT NULL DEFAULT true, + `tunnel_connections` text, + `jump_hosts` text, + `enable_file_manager` boolean NOT NULL DEFAULT true, + `scp_legacy` boolean NOT NULL DEFAULT false, + `enable_docker` boolean NOT NULL DEFAULT false, + `enable_tmux_monitor` boolean NOT NULL DEFAULT false, + `show_terminal_in_sidebar` boolean NOT NULL DEFAULT true, + `show_file_manager_in_sidebar` boolean NOT NULL DEFAULT false, + `show_tunnel_in_sidebar` boolean NOT NULL DEFAULT false, + `show_docker_in_sidebar` boolean NOT NULL DEFAULT false, + `show_server_stats_in_sidebar` boolean NOT NULL DEFAULT false, + `default_path` text, + `stats_config` text, + `docker_config` text, + `enable_proxmox` boolean NOT NULL DEFAULT false, + `proxmox_config` text, + `terminal_config` text, + `quick_actions` text, + `notes` text, + `enable_ssh` boolean NOT NULL DEFAULT true, + `enable_rdp` boolean NOT NULL DEFAULT false, + `enable_vnc` boolean NOT NULL DEFAULT false, + `enable_telnet` boolean NOT NULL DEFAULT false, + `ssh_port` int DEFAULT 22, + `rdp_port` int DEFAULT 3389, + `vnc_port` int DEFAULT 5900, + `telnet_port` int DEFAULT 23, + `rdp_credential_id` int, + `rdp_user` text, + `rdp_password` text, + `rdp_domain` text, + `rdp_security` text, + `rdp_ignore_cert` boolean DEFAULT false, + `vnc_credential_id` int, + `vnc_password` text, + `vnc_user` text, + `telnet_user` text, + `telnet_password` text, + `telnet_credential_id` int, + `rdp_auth_type` text, + `vnc_auth_type` text, + `telnet_auth_type` text, + `domain` text, + `security` text, + `ignore_cert` boolean DEFAULT false, + `guacamole_config` text, + `use_socks5` boolean, + `socks5_host` text, + `socks5_port` int, + `socks5_username` text, + `socks5_password` text, + `socks5_proxy_chain` text, + `connection_origin` text, + `mac_address` text, + `wol_broadcast_address` text, + `port_knock_sequence` text, + `host_key_fingerprint` text, + `host_key_type` text, + `host_key_algorithm` text DEFAULT ('sha256'), + `host_key_first_seen` text, + `host_key_last_verified` text, + `host_key_changed_count` int DEFAULT 0, + `sync_id` varchar(255), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `ssh_data_id` PRIMARY KEY(`id`), + CONSTRAINT `ssh_data_sync_id_unique` UNIQUE(`sync_id`) +); +--> statement-breakpoint +CREATE TABLE `network_topology` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `topology` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `network_topology_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `notification_channels` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `type` text NOT NULL, + `config` text NOT NULL, + `enabled` boolean NOT NULL DEFAULT true, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `notification_channels_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `opkssh_tokens` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `ssh_cert` text NOT NULL, + `private_key` text NOT NULL, + `email` text, + `sub` text, + `issuer` text, + `audience` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `expires_at` text NOT NULL, + `last_used` text, + CONSTRAINT `opkssh_tokens_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_opkssh_tokens_user_host` UNIQUE(`user_id`,`host_id`) +); +--> statement-breakpoint +CREATE TABLE `recent_activity` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `type` text NOT NULL, + `host_id` int NOT NULL, + `host_name` text, + `timestamp` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `recent_activity_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `roles` ( + `id` int AUTO_INCREMENT NOT NULL, + `name` varchar(255) NOT NULL, + `display_name` text NOT NULL, + `description` text, + `is_system` boolean NOT NULL DEFAULT false, + `permissions` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `roles_id` PRIMARY KEY(`id`), + CONSTRAINT `roles_name_unique` UNIQUE(`name`) +); +--> statement-breakpoint +CREATE TABLE `session_recordings` ( + `id` int AUTO_INCREMENT NOT NULL, + `host_id` int NOT NULL, + `user_id` varchar(255), + `username` text, + `access_id` int, + `started_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `ended_at` text, + `duration` int, + `commands` text, + `dangerous_actions` text, + `recording_path` text, + `protocol` varchar(255) NOT NULL DEFAULT 'ssh', + `format` text NOT NULL DEFAULT ('text'), + `terminated_by_owner` boolean DEFAULT false, + `termination_reason` text, + CONSTRAINT `session_recordings_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `session_share_participants` ( + `id` int AUTO_INCREMENT NOT NULL, + `share_id` varchar(255) NOT NULL, + `user_id` varchar(255), + `guest_label` text, + `joined_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `left_at` text, + CONSTRAINT `session_share_participants_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `session_shares` ( + `id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `owner_user_id` varchar(255) NOT NULL, + `protocol` varchar(255) NOT NULL, + `session_id` text NOT NULL, + `tab_instance_id` text, + `share_type` text NOT NULL, + `target_user_id` varchar(255), + `link_token` varchar(255), + `permission_level` text NOT NULL DEFAULT ('read-only'), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `expires_at` text NOT NULL, + `revoked_at` text, + `last_joined_at` text, + `join_count` int NOT NULL DEFAULT 0, + CONSTRAINT `session_shares_id` PRIMARY KEY(`id`), + CONSTRAINT `session_shares_link_token_unique` UNIQUE(`link_token`) +); +--> statement-breakpoint +CREATE TABLE `sessions` ( + `id` varchar(255) NOT NULL, + `user_id` varchar(255) NOT NULL, + `jwt_token` text NOT NULL, + `device_type` text NOT NULL, + `device_info` text NOT NULL, + `oidc_sub` text, + `oidc_sid` text, + `sso_provider_id` int, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `expires_at` text NOT NULL, + `last_active_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `sessions_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `settings` ( + `key` varchar(255) NOT NULL, + `value` text NOT NULL, + CONSTRAINT `settings_key` PRIMARY KEY(`key`) +); +--> statement-breakpoint +CREATE TABLE `shared_host_secrets` ( + `id` int AUTO_INCREMENT NOT NULL, + `host_access_id` int NOT NULL, + `target_user_id` varchar(255) NOT NULL, + `protocol` varchar(255) NOT NULL DEFAULT 'ssh', + `source_type` text NOT NULL DEFAULT ('credential'), + `original_credential_id` int, + `encrypted_username` text, + `encrypted_auth_type` text, + `encrypted_password` text, + `encrypted_key` text, + `encrypted_key_password` text, + `encrypted_key_type` text, + `encrypted_domain` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `shared_host_secrets_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_shared_host_secrets_scope` UNIQUE(`host_access_id`,`target_user_id`,`protocol`) +); +--> statement-breakpoint +CREATE TABLE `snippet_access` ( + `id` int AUTO_INCREMENT NOT NULL, + `snippet_id` int NOT NULL, + `user_id` varchar(255), + `role_id` int, + `granted_by` varchar(255) NOT NULL, + `permission_level` text NOT NULL DEFAULT ('view'), + `expires_at` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `snippet_access_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `snippet_folders` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `color` text, + `icon` text, + `sync_id` varchar(255), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `snippet_folders_id` PRIMARY KEY(`id`), + CONSTRAINT `snippet_folders_sync_id_unique` UNIQUE(`sync_id`) +); +--> statement-breakpoint +CREATE TABLE `snippets` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `content` text NOT NULL, + `description` text, + `folder` text, + `order` int NOT NULL DEFAULT 0, + `sync_id` varchar(255), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `host_filter` text, + CONSTRAINT `snippets_id` PRIMARY KEY(`id`), + CONSTRAINT `snippets_sync_id_unique` UNIQUE(`sync_id`) +); +--> statement-breakpoint +CREATE TABLE `ssh_credential_usage` ( + `id` int AUTO_INCREMENT NOT NULL, + `credential_id` int NOT NULL, + `host_id` int NOT NULL, + `user_id` varchar(255) NOT NULL, + `used_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `ssh_credential_usage_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `ssh_credentials` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `description` text, + `folder` text, + `tags` text, + `auth_type` text NOT NULL, + `username` text, + `password` text, + `key` text, + `private_key` text, + `public_key` text, + `key_password` text, + `key_type` text, + `detected_key_type` text, + `cert_public_key` text, + `usage_count` int NOT NULL DEFAULT 0, + `last_used` text, + `sync_id` varchar(255), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `ssh_credentials_id` PRIMARY KEY(`id`), + CONSTRAINT `ssh_credentials_sync_id_unique` UNIQUE(`sync_id`) +); +--> statement-breakpoint +CREATE TABLE `ssh_folders` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `color` text, + `icon` text, + `credential_id` int, + `sync_id` varchar(255), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `ssh_folders_id` PRIMARY KEY(`id`), + CONSTRAINT `ssh_folders_sync_id_unique` UNIQUE(`sync_id`) +); +--> statement-breakpoint +CREATE TABLE `sso_providers` ( + `id` int AUTO_INCREMENT NOT NULL, + `name` varchar(255) NOT NULL, + `type` text NOT NULL, + `enabled` boolean NOT NULL DEFAULT true, + `display_order` int NOT NULL DEFAULT 0, + `config` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `sso_providers_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `sync_tombstones` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `entity_type` text NOT NULL, + `sync_id` varchar(255) NOT NULL, + `deleted_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `sync_tombstones_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `termix_identities` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `handle` varchar(255) NOT NULL, + `description` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `termix_identities_id` PRIMARY KEY(`id`), + CONSTRAINT `termix_identities_user_id_unique` UNIQUE(`user_id`), + CONSTRAINT `termix_identities_handle_unique` UNIQUE(`handle`) +); +--> statement-breakpoint +CREATE TABLE `termix_identity_ca` ( + `id` int AUTO_INCREMENT NOT NULL, + `identity_id` int NOT NULL, + `user_id` varchar(255) NOT NULL, + `public_key` text NOT NULL, + `private_key` text NOT NULL, + `validity_days` int NOT NULL DEFAULT 90, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `termix_identity_ca_id` PRIMARY KEY(`id`), + CONSTRAINT `termix_identity_ca_identity_id_unique` UNIQUE(`identity_id`) +); +--> statement-breakpoint +CREATE TABLE `termix_identity_keys` ( + `id` int AUTO_INCREMENT NOT NULL, + `identity_id` int NOT NULL, + `user_id` varchar(255) NOT NULL, + `public_key` text NOT NULL, + `key_type` text NOT NULL, + `algorithm` text NOT NULL, + `label` text, + `comment` text, + `source` text NOT NULL DEFAULT ('manual'), + `credential_id` int, + `enabled` boolean NOT NULL DEFAULT true, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `termix_identity_keys_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `tmux_session_tags` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `host_id` int NOT NULL, + `session_name` text NOT NULL, + `tag` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `tmux_session_tags_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `transfer_recent` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `source_host_id` int NOT NULL, + `dest_host_id` int NOT NULL, + `dest_path` text NOT NULL, + `dest_path_label` text NOT NULL, + `last_used` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `transfer_recent_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `trusted_devices` ( + `id` varchar(255) NOT NULL, + `user_id` varchar(255) NOT NULL, + `device_fingerprint` text NOT NULL, + `device_type` text NOT NULL, + `device_info` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `expires_at` text NOT NULL, + `last_used_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `trusted_devices_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `user_open_tabs` ( + `id` varchar(255) NOT NULL, + `user_id` varchar(255) NOT NULL, + `tab_type` text NOT NULL, + `host_id` int, + `label` text NOT NULL, + `tab_order` int NOT NULL DEFAULT 0, + `backend_session_id` text, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `user_open_tabs_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `user_preferences` ( + `user_id` varchar(255) NOT NULL, + `reopen_tabs_on_login` boolean NOT NULL DEFAULT false, + `theme` text, + `font_size` text, + `accent_color` text, + `language` text, + `storage_mode` text, + `command_autocomplete` boolean, + `command_palette_enabled` boolean, + `show_host_tags` boolean, + `host_tray_on_click` boolean, + `pin_app_rail` boolean, + `expand_app_rail_on_hover` boolean, + `folders_collapsed` boolean, + `confirm_snippet_execution` boolean, + `disable_update_check` boolean, + `confirm_tab_close` boolean, + `hidden_rail_tabs` text, + `compact_host_view` boolean, + `status_color_scheme` text, + `custom_themes` text, + `custom_keybindings` text, + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `user_preferences_user_id` PRIMARY KEY(`user_id`) +); +--> statement-breakpoint +CREATE TABLE `user_roles` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `role_id` int NOT NULL, + `granted_by` varchar(255), + `granted_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `user_roles_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_user_roles_user_role` UNIQUE(`user_id`,`role_id`) +); +--> statement-breakpoint +CREATE TABLE `users` ( + `id` varchar(255) NOT NULL, + `username` text NOT NULL, + `password_hash` text NOT NULL, + `is_admin` boolean NOT NULL DEFAULT false, + `is_oidc` boolean NOT NULL DEFAULT false, + `oidc_identifier` text, + `sso_provider_id` int, + `client_id` text, + `client_secret` text, + `issuer_url` text, + `authorization_url` text, + `token_url` text, + `identifier_path` text, + `name_path` text, + `scopes` text DEFAULT ('openid email profile'), + `totp_secret` text, + `totp_enabled` boolean NOT NULL DEFAULT false, + `totp_backup_codes` text, + `registered_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `donation_modal_dismissed` boolean NOT NULL DEFAULT false, + CONSTRAINT `users_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `vault_profiles` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `description` text, + `folder` text, + `tags` text, + `vault_addr` text NOT NULL, + `vault_namespace` text, + `oidc_mount` text, + `oidc_role` text, + `ssh_mount` text, + `ssh_role` text NOT NULL, + `valid_principals` text, + `key_type` text, + `shared` boolean NOT NULL DEFAULT false, + `sync_id` varchar(255), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT `vault_profiles_id` PRIMARY KEY(`id`), + CONSTRAINT `vault_profiles_sync_id_unique` UNIQUE(`sync_id`) +); +--> statement-breakpoint +CREATE TABLE `vault_tokens` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` varchar(255) NOT NULL, + `profile_id` int NOT NULL, + `ssh_cert` text NOT NULL, + `private_key` text NOT NULL, + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `expires_at` text NOT NULL, + `last_used` text, + CONSTRAINT `vault_tokens_id` PRIMARY KEY(`id`), + CONSTRAINT `idx_vault_tokens_user_profile` UNIQUE(`user_id`,`profile_id`) +); +--> statement-breakpoint +CREATE TABLE `webauthn_credentials` ( + `id` varchar(255) NOT NULL, + `user_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `credential_id` text NOT NULL, + `public_key` text NOT NULL, + `counter` int NOT NULL DEFAULT 0, + `device_type` text, + `backed_up` boolean NOT NULL DEFAULT false, + `transports` text, + `user_verification` text NOT NULL DEFAULT ('preferred'), + `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), + `last_used_at` text, + CONSTRAINT `webauthn_credentials_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +ALTER TABLE `alert_firings` ADD CONSTRAINT `alert_firings_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `alert_firings` ADD CONSTRAINT `alert_firings_rule_id_alert_rules_id_fk` FOREIGN KEY (`rule_id`) REFERENCES `alert_rules`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `alert_rule_channels` ADD CONSTRAINT `alert_rule_channels_rule_id_alert_rules_id_fk` FOREIGN KEY (`rule_id`) REFERENCES `alert_rules`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `alert_rule_channels` ADD CONSTRAINT `alert_rule_channels_channel_id_notification_channels_id_fk` FOREIGN KEY (`channel_id`) REFERENCES `notification_channels`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `alert_rules` ADD CONSTRAINT `alert_rules_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `alert_rules` ADD CONSTRAINT `alert_rules_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `api_keys` ADD CONSTRAINT `api_keys_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `audit_logs` ADD CONSTRAINT `audit_logs_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `c2s_tunnel_presets` ADD CONSTRAINT `c2s_tunnel_presets_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `command_history` ADD CONSTRAINT `command_history_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `command_history` ADD CONSTRAINT `command_history_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `dashboard_service_links` ADD CONSTRAINT `dashboard_service_links_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `dismissed_alerts` ADD CONSTRAINT `dismissed_alerts_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `file_manager_pinned` ADD CONSTRAINT `file_manager_pinned_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `file_manager_pinned` ADD CONSTRAINT `file_manager_pinned_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `file_manager_recent` ADD CONSTRAINT `file_manager_recent_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `file_manager_recent` ADD CONSTRAINT `file_manager_recent_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `file_manager_shortcuts` ADD CONSTRAINT `file_manager_shortcuts_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `file_manager_shortcuts` ADD CONSTRAINT `file_manager_shortcuts_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `homepage_items` ADD CONSTRAINT `homepage_items_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `homepage_layouts` ADD CONSTRAINT `homepage_layouts_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_access` ADD CONSTRAINT `host_access_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_access` ADD CONSTRAINT `host_access_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_access` ADD CONSTRAINT `host_access_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_access` ADD CONSTRAINT `host_access_granted_by_users_id_fk` FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_access` ADD CONSTRAINT `host_access_override_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`override_credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_health_checks` ADD CONSTRAINT `host_health_checks_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_health_checks` ADD CONSTRAINT `host_health_checks_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_health_history` ADD CONSTRAINT `host_health_history_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_health_history` ADD CONSTRAINT `host_health_history_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_metrics_history` ADD CONSTRAINT `host_metrics_history_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_metrics_preferences` ADD CONSTRAINT `host_metrics_preferences_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `host_metrics_preferences` ADD CONSTRAINT `host_metrics_preferences_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_data` ADD CONSTRAINT `ssh_data_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_data` ADD CONSTRAINT `ssh_data_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_data` ADD CONSTRAINT `ssh_data_vault_profile_id_vault_profiles_id_fk` FOREIGN KEY (`vault_profile_id`) REFERENCES `vault_profiles`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_data` ADD CONSTRAINT `ssh_data_rdp_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`rdp_credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_data` ADD CONSTRAINT `ssh_data_vnc_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`vnc_credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_data` ADD CONSTRAINT `ssh_data_telnet_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`telnet_credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `network_topology` ADD CONSTRAINT `network_topology_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `notification_channels` ADD CONSTRAINT `notification_channels_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `opkssh_tokens` ADD CONSTRAINT `opkssh_tokens_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `opkssh_tokens` ADD CONSTRAINT `opkssh_tokens_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `recent_activity` ADD CONSTRAINT `recent_activity_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `recent_activity` ADD CONSTRAINT `recent_activity_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `session_recordings` ADD CONSTRAINT `session_recordings_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `session_recordings` ADD CONSTRAINT `session_recordings_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `session_recordings` ADD CONSTRAINT `session_recordings_access_id_host_access_id_fk` FOREIGN KEY (`access_id`) REFERENCES `host_access`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `session_share_participants` ADD CONSTRAINT `session_share_participants_share_id_session_shares_id_fk` FOREIGN KEY (`share_id`) REFERENCES `session_shares`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `session_share_participants` ADD CONSTRAINT `session_share_participants_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `session_shares` ADD CONSTRAINT `session_shares_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `session_shares` ADD CONSTRAINT `session_shares_owner_user_id_users_id_fk` FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `session_shares` ADD CONSTRAINT `session_shares_target_user_id_users_id_fk` FOREIGN KEY (`target_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `sessions` ADD CONSTRAINT `sessions_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `shared_host_secrets` ADD CONSTRAINT `shared_host_secrets_host_access_id_host_access_id_fk` FOREIGN KEY (`host_access_id`) REFERENCES `host_access`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `shared_host_secrets` ADD CONSTRAINT `shared_host_secrets_target_user_id_users_id_fk` FOREIGN KEY (`target_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `shared_host_secrets` ADD CONSTRAINT `shared_host_secrets_original_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`original_credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `snippet_access` ADD CONSTRAINT `snippet_access_snippet_id_snippets_id_fk` FOREIGN KEY (`snippet_id`) REFERENCES `snippets`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `snippet_access` ADD CONSTRAINT `snippet_access_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `snippet_access` ADD CONSTRAINT `snippet_access_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `snippet_access` ADD CONSTRAINT `snippet_access_granted_by_users_id_fk` FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `snippet_folders` ADD CONSTRAINT `snippet_folders_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `snippets` ADD CONSTRAINT `snippets_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_credential_usage` ADD CONSTRAINT `ssh_credential_usage_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_credential_usage` ADD CONSTRAINT `ssh_credential_usage_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_credential_usage` ADD CONSTRAINT `ssh_credential_usage_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_credentials` ADD CONSTRAINT `ssh_credentials_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_folders` ADD CONSTRAINT `ssh_folders_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `ssh_folders` ADD CONSTRAINT `ssh_folders_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `sync_tombstones` ADD CONSTRAINT `sync_tombstones_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `termix_identities` ADD CONSTRAINT `termix_identities_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `termix_identity_ca` ADD CONSTRAINT `termix_identity_ca_identity_id_termix_identities_id_fk` FOREIGN KEY (`identity_id`) REFERENCES `termix_identities`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `termix_identity_ca` ADD CONSTRAINT `termix_identity_ca_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `termix_identity_keys` ADD CONSTRAINT `termix_identity_keys_identity_id_termix_identities_id_fk` FOREIGN KEY (`identity_id`) REFERENCES `termix_identities`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `termix_identity_keys` ADD CONSTRAINT `termix_identity_keys_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `termix_identity_keys` ADD CONSTRAINT `termix_identity_keys_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `tmux_session_tags` ADD CONSTRAINT `tmux_session_tags_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `tmux_session_tags` ADD CONSTRAINT `tmux_session_tags_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `transfer_recent` ADD CONSTRAINT `transfer_recent_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `transfer_recent` ADD CONSTRAINT `transfer_recent_source_host_id_ssh_data_id_fk` FOREIGN KEY (`source_host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `transfer_recent` ADD CONSTRAINT `transfer_recent_dest_host_id_ssh_data_id_fk` FOREIGN KEY (`dest_host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `trusted_devices` ADD CONSTRAINT `trusted_devices_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `user_open_tabs` ADD CONSTRAINT `user_open_tabs_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `user_open_tabs` ADD CONSTRAINT `user_open_tabs_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `user_preferences` ADD CONSTRAINT `user_preferences_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_granted_by_users_id_fk` FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `vault_profiles` ADD CONSTRAINT `vault_profiles_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `vault_tokens` ADD CONSTRAINT `vault_tokens_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `vault_tokens` ADD CONSTRAINT `vault_tokens_profile_id_vault_profiles_id_fk` FOREIGN KEY (`profile_id`) REFERENCES `vault_profiles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `webauthn_credentials` ADD CONSTRAINT `webauthn_credentials_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/mysql/meta/0000_snapshot.json b/drizzle/mysql/meta/0000_snapshot.json new file mode 100644 index 00000000..6ab3cf67 --- /dev/null +++ b/drizzle/mysql/meta/0000_snapshot.json @@ -0,0 +1,6330 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "4d859368-b714-4d2c-aa81-109dd9569380", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "alert_firings": { + "name": "alert_firings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rule_id": { + "name": "rule_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fired_at": { + "name": "fired_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "resolved_at": { + "name": "resolved_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('warning')" + }, + "acknowledged": { + "name": "acknowledged", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "alert_firings_user_id_users_id_fk": { + "name": "alert_firings_user_id_users_id_fk", + "tableFrom": "alert_firings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_firings_rule_id_alert_rules_id_fk": { + "name": "alert_firings_rule_id_alert_rules_id_fk", + "tableFrom": "alert_firings", + "tableTo": "alert_rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "alert_firings_id": { + "name": "alert_firings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "alert_rule_channels": { + "name": "alert_rule_channels", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "rule_id": { + "name": "rule_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "alert_rule_channels_rule_id_alert_rules_id_fk": { + "name": "alert_rule_channels_rule_id_alert_rules_id_fk", + "tableFrom": "alert_rule_channels", + "tableTo": "alert_rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_rule_channels_channel_id_notification_channels_id_fk": { + "name": "alert_rule_channels_channel_id_notification_channels_id_fk", + "tableFrom": "alert_rule_channels", + "tableTo": "notification_channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "alert_rule_channels_id": { + "name": "alert_rule_channels_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "alert_rules": { + "name": "alert_rules", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "threshold_value": { + "name": "threshold_value", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "threshold_duration_seconds": { + "name": "threshold_duration_seconds", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cooldown_minutes": { + "name": "cooldown_minutes", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 15 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "alert_rules_user_id_users_id_fk": { + "name": "alert_rules_user_id_users_id_fk", + "tableFrom": "alert_rules", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_rules_host_id_ssh_data_id_fk": { + "name": "alert_rules_host_id_ssh_data_id_fk", + "tableFrom": "alert_rules", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "alert_rules_id": { + "name": "alert_rules_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "api_keys_id": { + "name": "api_keys_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audit_logs_id": { + "name": "audit_logs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "c2s_tunnel_presets": { + "name": "c2s_tunnel_presets", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "computer_name": { + "name": "computer_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "c2s_tunnel_presets_user_id_users_id_fk": { + "name": "c2s_tunnel_presets_user_id_users_id_fk", + "tableFrom": "c2s_tunnel_presets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "c2s_tunnel_presets_id": { + "name": "c2s_tunnel_presets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "command_history": { + "name": "command_history", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "executed_at": { + "name": "executed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "command_history_user_id_users_id_fk": { + "name": "command_history_user_id_users_id_fk", + "tableFrom": "command_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "command_history_host_id_ssh_data_id_fk": { + "name": "command_history_host_id_ssh_data_id_fk", + "tableFrom": "command_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "command_history_id": { + "name": "command_history_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "dashboard_service_links": { + "name": "dashboard_service_links", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_service_links_user_id_users_id_fk": { + "name": "dashboard_service_links_user_id_users_id_fk", + "tableFrom": "dashboard_service_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dashboard_service_links_id": { + "name": "dashboard_service_links_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "dashboard_service_links_sync_id_unique": { + "name": "dashboard_service_links_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "dismissed_alerts": { + "name": "dismissed_alerts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "dismissed_alerts_user_id_users_id_fk": { + "name": "dismissed_alerts_user_id_users_id_fk", + "tableFrom": "dismissed_alerts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dismissed_alerts_id": { + "name": "dismissed_alerts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "file_manager_pinned": { + "name": "file_manager_pinned", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "file_manager_pinned_user_id_users_id_fk": { + "name": "file_manager_pinned_user_id_users_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_pinned_host_id_ssh_data_id_fk": { + "name": "file_manager_pinned_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "file_manager_pinned_id": { + "name": "file_manager_pinned_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "file_manager_recent": { + "name": "file_manager_recent", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened": { + "name": "last_opened", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "file_manager_recent_user_id_users_id_fk": { + "name": "file_manager_recent_user_id_users_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_recent_host_id_ssh_data_id_fk": { + "name": "file_manager_recent_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "file_manager_recent_id": { + "name": "file_manager_recent_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "file_manager_shortcuts": { + "name": "file_manager_shortcuts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "file_manager_shortcuts_user_id_users_id_fk": { + "name": "file_manager_shortcuts_user_id_users_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_shortcuts_host_id_ssh_data_id_fk": { + "name": "file_manager_shortcuts_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "file_manager_shortcuts_id": { + "name": "file_manager_shortcuts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "homepage_items": { + "name": "homepage_items", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_id": { + "name": "type_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{}')" + }, + "folder_id": { + "name": "folder_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "homepage_items_user_id_users_id_fk": { + "name": "homepage_items_user_id_users_id_fk", + "tableFrom": "homepage_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homepage_items_id": { + "name": "homepage_items_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "homepage_items_sync_id_unique": { + "name": "homepage_items_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "homepage_layouts": { + "name": "homepage_layouts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{}')" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "homepage_layouts_user_id_users_id_fk": { + "name": "homepage_layouts_user_id_users_id_fk", + "tableFrom": "homepage_layouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "homepage_layouts_id": { + "name": "homepage_layouts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "homepage_layouts_user_id_unique": { + "name": "homepage_layouts_user_id_unique", + "columns": [ + "user_id" + ] + } + }, + "checkConstraint": {} + }, + "host_access": { + "name": "host_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('connect')" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_count": { + "name": "access_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "override_credential_id": { + "name": "override_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_override_credential_id_ssh_credentials_id_fk": { + "name": "host_access_override_credential_id_ssh_credentials_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "override_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_access_id": { + "name": "host_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_health_checks": { + "name": "host_health_checks", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checks": { + "name": "checks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 300 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_host_health_checks_user_host": { + "name": "idx_host_health_checks_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "host_health_checks_user_id_users_id_fk": { + "name": "host_health_checks_user_id_users_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_checks_host_id_ssh_data_id_fk": { + "name": "host_health_checks_host_id_ssh_data_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_health_checks_id": { + "name": "host_health_checks_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_health_history": { + "name": "host_health_history", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "check_id": { + "name": "check_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_health_history_user_id_users_id_fk": { + "name": "host_health_history_user_id_users_id_fk", + "tableFrom": "host_health_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_history_host_id_ssh_data_id_fk": { + "name": "host_health_history_host_id_ssh_data_id_fk", + "tableFrom": "host_health_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_health_history_id": { + "name": "host_health_history_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_metrics_history": { + "name": "host_metrics_history", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_percent": { + "name": "mem_percent", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disk_percent": { + "name": "disk_percent", + "type": "double", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_rx_bytes": { + "name": "net_rx_bytes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_tx_bytes": { + "name": "net_tx_bytes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_metrics_history_host_id_ssh_data_id_fk": { + "name": "host_metrics_history_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_metrics_history_id": { + "name": "host_metrics_history_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_metrics_preferences": { + "name": "host_metrics_preferences", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_host_metrics_prefs_user_host": { + "name": "idx_host_metrics_prefs_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "host_metrics_preferences_user_id_users_id_fk": { + "name": "host_metrics_preferences_user_id_users_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_metrics_preferences_host_id_ssh_data_id_fk": { + "name": "host_metrics_preferences_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_metrics_preferences_id": { + "name": "host_metrics_preferences_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_data": { + "name": "ssh_data", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('ssh')" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "use_warpgate": { + "name": "use_warpgate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_password": { + "name": "autostart_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key": { + "name": "autostart_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key_password": { + "name": "autostart_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vault_profile_id": { + "name": "vault_profile_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_terminal": { + "name": "enable_terminal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_session_logging": { + "name": "enable_session_logging", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "allow_session_sharing": { + "name": "allow_session_sharing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_command_history": { + "name": "enable_command_history", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_tunnel": { + "name": "enable_tunnel", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "tunnel_connections": { + "name": "tunnel_connections", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_file_manager": { + "name": "enable_file_manager", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "scp_legacy": { + "name": "scp_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_docker": { + "name": "enable_docker", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_tmux_monitor": { + "name": "enable_tmux_monitor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_terminal_in_sidebar": { + "name": "show_terminal_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_file_manager_in_sidebar": { + "name": "show_file_manager_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_tunnel_in_sidebar": { + "name": "show_tunnel_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_docker_in_sidebar": { + "name": "show_docker_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_server_stats_in_sidebar": { + "name": "show_server_stats_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_path": { + "name": "default_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats_config": { + "name": "stats_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "docker_config": { + "name": "docker_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_proxmox": { + "name": "enable_proxmox", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "proxmox_config": { + "name": "proxmox_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_config": { + "name": "terminal_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quick_actions": { + "name": "quick_actions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_rdp": { + "name": "enable_rdp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_vnc": { + "name": "enable_vnc", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_telnet": { + "name": "enable_telnet", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ssh_port": { + "name": "ssh_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "rdp_port": { + "name": "rdp_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 3389 + }, + "vnc_port": { + "name": "vnc_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 5900 + }, + "telnet_port": { + "name": "telnet_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 23 + }, + "rdp_credential_id": { + "name": "rdp_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_user": { + "name": "rdp_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_password": { + "name": "rdp_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_domain": { + "name": "rdp_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_security": { + "name": "rdp_security", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_ignore_cert": { + "name": "rdp_ignore_cert", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "vnc_credential_id": { + "name": "vnc_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_password": { + "name": "vnc_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_user": { + "name": "vnc_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_user": { + "name": "telnet_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_password": { + "name": "telnet_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_credential_id": { + "name": "telnet_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_auth_type": { + "name": "rdp_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_auth_type": { + "name": "vnc_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_auth_type": { + "name": "telnet_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "security": { + "name": "security", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ignore_cert": { + "name": "ignore_cert", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "guacamole_config": { + "name": "guacamole_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_socks5": { + "name": "use_socks5", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wol_broadcast_address": { + "name": "wol_broadcast_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('sha256')" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vault_profile_id_vault_profiles_id_fk": { + "name": "ssh_data_vault_profile_id_vault_profiles_id_fk", + "tableFrom": "ssh_data", + "tableTo": "vault_profiles", + "columnsFrom": [ + "vault_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_rdp_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_rdp_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "rdp_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vnc_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_vnc_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "vnc_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_telnet_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_telnet_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "telnet_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_data_id": { + "name": "ssh_data_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "network_topology": { + "name": "network_topology", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "topology": { + "name": "topology", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "network_topology_user_id_users_id_fk": { + "name": "network_topology_user_id_users_id_fk", + "tableFrom": "network_topology", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "network_topology_id": { + "name": "network_topology_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "notification_channels": { + "name": "notification_channels", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channels_user_id_users_id_fk": { + "name": "notification_channels_user_id_users_id_fk", + "tableFrom": "notification_channels", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notification_channels_id": { + "name": "notification_channels_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "opkssh_tokens": { + "name": "opkssh_tokens", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_opkssh_tokens_user_host": { + "name": "idx_opkssh_tokens_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "opkssh_tokens_user_id_users_id_fk": { + "name": "opkssh_tokens_user_id_users_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opkssh_tokens_host_id_ssh_data_id_fk": { + "name": "opkssh_tokens_host_id_ssh_data_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "opkssh_tokens_id": { + "name": "opkssh_tokens_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "recent_activity": { + "name": "recent_activity", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "recent_activity_user_id_users_id_fk": { + "name": "recent_activity_user_id_users_id_fk", + "tableFrom": "recent_activity", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recent_activity_host_id_ssh_data_id_fk": { + "name": "recent_activity_host_id_ssh_data_id_fk", + "tableFrom": "recent_activity", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "recent_activity_id": { + "name": "recent_activity_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "roles_id": { + "name": "roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "session_recordings": { + "name": "session_recordings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_id": { + "name": "access_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commands": { + "name": "commands", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dangerous_actions": { + "name": "dangerous_actions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recording_path": { + "name": "recording_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('text')" + }, + "terminated_by_owner": { + "name": "terminated_by_owner", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "termination_reason": { + "name": "termination_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_recordings_host_id_ssh_data_id_fk": { + "name": "session_recordings_host_id_ssh_data_id_fk", + "tableFrom": "session_recordings", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_recordings_user_id_users_id_fk": { + "name": "session_recordings_user_id_users_id_fk", + "tableFrom": "session_recordings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "session_recordings_access_id_host_access_id_fk": { + "name": "session_recordings_access_id_host_access_id_fk", + "tableFrom": "session_recordings", + "tableTo": "host_access", + "columnsFrom": [ + "access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_recordings_id": { + "name": "session_recordings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "session_share_participants": { + "name": "session_share_participants", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "share_id": { + "name": "share_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guest_label": { + "name": "guest_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "left_at": { + "name": "left_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_share_participants_share_id_session_shares_id_fk": { + "name": "session_share_participants_share_id_session_shares_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "session_shares", + "columnsFrom": [ + "share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_share_participants_user_id_users_id_fk": { + "name": "session_share_participants_user_id_users_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_share_participants_id": { + "name": "session_share_participants_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "session_shares": { + "name": "session_shares", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_instance_id": { + "name": "tab_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_type": { + "name": "share_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "link_token": { + "name": "link_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('read-only')" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_joined_at": { + "name": "last_joined_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "join_count": { + "name": "join_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "session_shares_host_id_ssh_data_id_fk": { + "name": "session_shares_host_id_ssh_data_id_fk", + "tableFrom": "session_shares", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_owner_user_id_users_id_fk": { + "name": "session_shares_owner_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_target_user_id_users_id_fk": { + "name": "session_shares_target_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_shares_id": { + "name": "session_shares_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "session_shares_link_token_unique": { + "name": "session_shares_link_token_unique", + "columns": [ + "link_token" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_sid": { + "name": "oidc_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "settings_key": { + "name": "settings_key", + "columns": [ + "key" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_host_secrets": { + "name": "shared_host_secrets", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('credential')" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + "host_access_id", + "target_user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_host_secrets_id": { + "name": "shared_host_secrets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "snippet_access": { + "name": "snippet_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "snippet_id": { + "name": "snippet_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('view')" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "snippet_access_snippet_id_snippets_id_fk": { + "name": "snippet_access_snippet_id_snippets_id_fk", + "tableFrom": "snippet_access", + "tableTo": "snippets", + "columnsFrom": [ + "snippet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_user_id_users_id_fk": { + "name": "snippet_access_user_id_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_role_id_roles_id_fk": { + "name": "snippet_access_role_id_roles_id_fk", + "tableFrom": "snippet_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_granted_by_users_id_fk": { + "name": "snippet_access_granted_by_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "snippet_access_id": { + "name": "snippet_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "snippet_folders": { + "name": "snippet_folders", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "snippet_folders_user_id_users_id_fk": { + "name": "snippet_folders_user_id_users_id_fk", + "tableFrom": "snippet_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "snippet_folders_id": { + "name": "snippet_folders_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "snippet_folders_sync_id_unique": { + "name": "snippet_folders_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "snippets": { + "name": "snippets", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "host_filter": { + "name": "host_filter", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "snippets_user_id_users_id_fk": { + "name": "snippets_user_id_users_id_fk", + "tableFrom": "snippets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "snippets_id": { + "name": "snippets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "snippets_sync_id_unique": { + "name": "snippets_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "ssh_credential_usage": { + "name": "ssh_credential_usage", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_credential_usage_id": { + "name": "ssh_credential_usage_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_credentials": { + "name": "ssh_credentials", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage_count": { + "name": "usage_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_credentials_id": { + "name": "ssh_credentials_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "ssh_folders": { + "name": "ssh_folders", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_folders_credential_id_ssh_credentials_id_fk": { + "name": "ssh_folders_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_folders_id": { + "name": "ssh_folders_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "sso_providers": { + "name": "sso_providers", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sso_providers_id": { + "name": "sso_providers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sync_tombstones": { + "name": "sync_tombstones", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_tombstones_user_id_users_id_fk": { + "name": "sync_tombstones_user_id_users_id_fk", + "tableFrom": "sync_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_tombstones_id": { + "name": "sync_tombstones_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "termix_identities": { + "name": "termix_identities", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "handle": { + "name": "handle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identities_user_id_users_id_fk": { + "name": "termix_identities_user_id_users_id_fk", + "tableFrom": "termix_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "termix_identities_id": { + "name": "termix_identities_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "termix_identities_user_id_unique": { + "name": "termix_identities_user_id_unique", + "columns": [ + "user_id" + ] + }, + "termix_identities_handle_unique": { + "name": "termix_identities_handle_unique", + "columns": [ + "handle" + ] + } + }, + "checkConstraint": {} + }, + "termix_identity_ca": { + "name": "termix_identity_ca", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validity_days": { + "name": "validity_days", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_ca_identity_id_termix_identities_id_fk": { + "name": "termix_identity_ca_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_ca_user_id_users_id_fk": { + "name": "termix_identity_ca_user_id_users_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "termix_identity_ca_id": { + "name": "termix_identity_ca_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "termix_identity_ca_identity_id_unique": { + "name": "termix_identity_ca_identity_id_unique", + "columns": [ + "identity_id" + ] + } + }, + "checkConstraint": {} + }, + "termix_identity_keys": { + "name": "termix_identity_keys", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('manual')" + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_keys_identity_id_termix_identities_id_fk": { + "name": "termix_identity_keys_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_user_id_users_id_fk": { + "name": "termix_identity_keys_user_id_users_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_credential_id_ssh_credentials_id_fk": { + "name": "termix_identity_keys_credential_id_ssh_credentials_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "termix_identity_keys_id": { + "name": "termix_identity_keys_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "tmux_session_tags": { + "name": "tmux_session_tags", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_name": { + "name": "session_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "tmux_session_tags_user_id_users_id_fk": { + "name": "tmux_session_tags_user_id_users_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tmux_session_tags_host_id_ssh_data_id_fk": { + "name": "tmux_session_tags_host_id_ssh_data_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tmux_session_tags_id": { + "name": "tmux_session_tags_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "transfer_recent": { + "name": "transfer_recent", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_host_id": { + "name": "source_host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_host_id": { + "name": "dest_host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_path": { + "name": "dest_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_path_label": { + "name": "dest_path_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "transfer_recent_user_id_users_id_fk": { + "name": "transfer_recent_user_id_users_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_source_host_id_ssh_data_id_fk": { + "name": "transfer_recent_source_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "source_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_dest_host_id_ssh_data_id_fk": { + "name": "transfer_recent_dest_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "dest_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "transfer_recent_id": { + "name": "transfer_recent_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "trusted_devices": { + "name": "trusted_devices", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "trusted_devices_id": { + "name": "trusted_devices_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_open_tabs": { + "name": "user_open_tabs", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_open_tabs_id": { + "name": "user_open_tabs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_autocomplete": { + "name": "command_autocomplete", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_palette_enabled": { + "name": "command_palette_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_host_tags": { + "name": "show_host_tags", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_tray_on_click": { + "name": "host_tray_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_app_rail": { + "name": "pin_app_rail", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expand_app_rail_on_hover": { + "name": "expand_app_rail_on_hover", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_snippet_execution": { + "name": "confirm_snippet_execution", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_themes": { + "name": "custom_themes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_preferences_user_id": { + "name": "user_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + "user_id", + "role_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_id": { + "name": "user_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_oidc": { + "name": "is_oidc", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "oidc_identifier": { + "name": "oidc_identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identifier_path": { + "name": "identifier_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_path": { + "name": "name_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('openid email profile')" + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "totp_backup_codes": { + "name": "totp_backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "donation_modal_dismissed": { + "name": "donation_modal_dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "vault_profiles": { + "name": "vault_profiles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vault_addr": { + "name": "vault_addr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vault_namespace": { + "name": "vault_namespace", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_mount": { + "name": "oidc_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_role": { + "name": "oidc_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_mount": { + "name": "ssh_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_role": { + "name": "ssh_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "valid_principals": { + "name": "valid_principals", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "vault_profiles_user_id_users_id_fk": { + "name": "vault_profiles_user_id_users_id_fk", + "tableFrom": "vault_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "vault_profiles_id": { + "name": "vault_profiles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "vault_profiles_sync_id_unique": { + "name": "vault_profiles_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "vault_tokens": { + "name": "vault_tokens", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_vault_tokens_user_profile": { + "name": "idx_vault_tokens_user_profile", + "columns": [ + "user_id", + "profile_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_tokens_user_id_users_id_fk": { + "name": "vault_tokens_user_id_users_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_tokens_profile_id_vault_profiles_id_fk": { + "name": "vault_tokens_profile_id_vault_profiles_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "vault_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "vault_tokens_id": { + "name": "vault_tokens_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "webauthn_credentials": { + "name": "webauthn_credentials", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_verification": { + "name": "user_verification", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('preferred')" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "webauthn_credentials_user_id_users_id_fk": { + "name": "webauthn_credentials_user_id_users_id_fk", + "tableFrom": "webauthn_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "webauthn_credentials_id": { + "name": "webauthn_credentials_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/mysql/meta/_journal.json b/drizzle/mysql/meta/_journal.json new file mode 100644 index 00000000..67c4da96 --- /dev/null +++ b/drizzle/mysql/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "mysql", + "entries": [ + { + "idx": 0, + "version": "5", + "when": 1785276659909, + "tag": "0000_clean_pretty_boy", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/drizzle/postgres/0000_jazzy_infant_terrible.sql b/drizzle/postgres/0000_jazzy_infant_terrible.sql new file mode 100644 index 00000000..9f73c3de --- /dev/null +++ b/drizzle/postgres/0000_jazzy_infant_terrible.sql @@ -0,0 +1,824 @@ +CREATE TABLE "alert_firings" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "rule_id" integer NOT NULL, + "host_id" integer NOT NULL, + "host_name" text NOT NULL, + "fired_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "resolved_at" text, + "value" double precision, + "message" text NOT NULL, + "severity" text DEFAULT 'warning' NOT NULL, + "acknowledged" boolean DEFAULT false NOT NULL +); +--> statement-breakpoint +CREATE TABLE "alert_rule_channels" ( + "id" serial PRIMARY KEY NOT NULL, + "rule_id" integer NOT NULL, + "channel_id" integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE "alert_rules" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "host_id" integer, + "name" varchar(255) NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "trigger_type" text NOT NULL, + "threshold_value" double precision, + "threshold_duration_seconds" integer, + "cooldown_minutes" integer DEFAULT 15 NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "api_keys" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "token_hash" text NOT NULL, + "token_prefix" text NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "expires_at" text, + "last_used_at" text, + "is_active" boolean DEFAULT true NOT NULL +); +--> statement-breakpoint +CREATE TABLE "audit_logs" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255), + "username" text NOT NULL, + "action" text NOT NULL, + "resource_type" text NOT NULL, + "resource_id" text, + "resource_name" text, + "details" text, + "ip_address" text, + "user_agent" text, + "success" boolean NOT NULL, + "error_message" text, + "timestamp" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "c2s_tunnel_presets" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "config" text NOT NULL, + "platform" text, + "computer_name" text, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "command_history" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "command" text NOT NULL, + "executed_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "dashboard_service_links" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "label" text NOT NULL, + "url" text NOT NULL, + "order" integer DEFAULT 0 NOT NULL, + "sync_id" varchar(255), + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "dashboard_service_links_sync_id_unique" UNIQUE("sync_id") +); +--> statement-breakpoint +CREATE TABLE "dismissed_alerts" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "alert_id" text NOT NULL, + "dismissed_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "file_manager_pinned" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "name" varchar(255) NOT NULL, + "path" text NOT NULL, + "pinned_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "file_manager_recent" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "name" varchar(255) NOT NULL, + "path" text NOT NULL, + "last_opened" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "file_manager_shortcuts" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "name" varchar(255) NOT NULL, + "path" text NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "homepage_items" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "type_id" text NOT NULL, + "title" text, + "config" text DEFAULT '{}' NOT NULL, + "folder_id" integer, + "sync_id" varchar(255), + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "homepage_items_sync_id_unique" UNIQUE("sync_id") +); +--> statement-breakpoint +CREATE TABLE "homepage_layouts" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "layout" text DEFAULT '{}' NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "homepage_layouts_user_id_unique" UNIQUE("user_id") +); +--> statement-breakpoint +CREATE TABLE "host_access" ( + "id" serial PRIMARY KEY NOT NULL, + "host_id" integer NOT NULL, + "user_id" varchar(255), + "role_id" integer, + "granted_by" varchar(255) NOT NULL, + "permission_level" text DEFAULT 'connect' NOT NULL, + "expires_at" text, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "last_accessed_at" text, + "access_count" integer DEFAULT 0 NOT NULL, + "override_credential_id" integer +); +--> statement-breakpoint +CREATE TABLE "host_health_checks" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "checks" text NOT NULL, + "interval_seconds" integer DEFAULT 300 NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "host_health_history" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "check_id" text NOT NULL, + "ts" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "ok" boolean NOT NULL, + "latency_ms" integer, + "detail" text +); +--> statement-breakpoint +CREATE TABLE "host_metrics_history" ( + "id" serial PRIMARY KEY NOT NULL, + "host_id" integer NOT NULL, + "ts" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "cpu_percent" double precision, + "mem_percent" double precision, + "disk_percent" double precision, + "net_rx_bytes" integer, + "net_tx_bytes" integer +); +--> statement-breakpoint +CREATE TABLE "host_metrics_preferences" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "layout" text NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "ssh_data" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "connection_type" text DEFAULT 'ssh' NOT NULL, + "name" varchar(255), + "ip" text NOT NULL, + "port" integer NOT NULL, + "username" text NOT NULL, + "folder" text, + "tags" text, + "pin" boolean DEFAULT false NOT NULL, + "auth_type" text NOT NULL, + "use_warpgate" boolean DEFAULT false NOT NULL, + "force_keyboard_interactive" text, + "password" text, + "key" text, + "key_password" text, + "key_type" text, + "sudo_password" text, + "autostart_password" text, + "autostart_key" text, + "autostart_key_password" text, + "credential_id" integer, + "override_credential_username" boolean, + "vault_profile_id" integer, + "enable_terminal" boolean DEFAULT true NOT NULL, + "enable_session_logging" boolean DEFAULT true NOT NULL, + "allow_session_sharing" boolean DEFAULT true NOT NULL, + "enable_command_history" boolean DEFAULT true NOT NULL, + "enable_tunnel" boolean DEFAULT true NOT NULL, + "tunnel_connections" text, + "jump_hosts" text, + "enable_file_manager" boolean DEFAULT true NOT NULL, + "scp_legacy" boolean DEFAULT false NOT NULL, + "enable_docker" boolean DEFAULT false NOT NULL, + "enable_tmux_monitor" boolean DEFAULT false NOT NULL, + "show_terminal_in_sidebar" boolean DEFAULT true NOT NULL, + "show_file_manager_in_sidebar" boolean DEFAULT false NOT NULL, + "show_tunnel_in_sidebar" boolean DEFAULT false NOT NULL, + "show_docker_in_sidebar" boolean DEFAULT false NOT NULL, + "show_server_stats_in_sidebar" boolean DEFAULT false NOT NULL, + "default_path" text, + "stats_config" text, + "docker_config" text, + "enable_proxmox" boolean DEFAULT false NOT NULL, + "proxmox_config" text, + "terminal_config" text, + "quick_actions" text, + "notes" text, + "enable_ssh" boolean DEFAULT true NOT NULL, + "enable_rdp" boolean DEFAULT false NOT NULL, + "enable_vnc" boolean DEFAULT false NOT NULL, + "enable_telnet" boolean DEFAULT false NOT NULL, + "ssh_port" integer DEFAULT 22, + "rdp_port" integer DEFAULT 3389, + "vnc_port" integer DEFAULT 5900, + "telnet_port" integer DEFAULT 23, + "rdp_credential_id" integer, + "rdp_user" text, + "rdp_password" text, + "rdp_domain" text, + "rdp_security" text, + "rdp_ignore_cert" boolean DEFAULT false, + "vnc_credential_id" integer, + "vnc_password" text, + "vnc_user" text, + "telnet_user" text, + "telnet_password" text, + "telnet_credential_id" integer, + "rdp_auth_type" text, + "vnc_auth_type" text, + "telnet_auth_type" text, + "domain" text, + "security" text, + "ignore_cert" boolean DEFAULT false, + "guacamole_config" text, + "use_socks5" boolean, + "socks5_host" text, + "socks5_port" integer, + "socks5_username" text, + "socks5_password" text, + "socks5_proxy_chain" text, + "connection_origin" text, + "mac_address" text, + "wol_broadcast_address" text, + "port_knock_sequence" text, + "host_key_fingerprint" text, + "host_key_type" text, + "host_key_algorithm" text DEFAULT 'sha256', + "host_key_first_seen" text, + "host_key_last_verified" text, + "host_key_changed_count" integer DEFAULT 0, + "sync_id" varchar(255), + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "ssh_data_sync_id_unique" UNIQUE("sync_id") +); +--> statement-breakpoint +CREATE TABLE "network_topology" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "topology" text, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "notification_channels" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "type" text NOT NULL, + "config" text NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "opkssh_tokens" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "ssh_cert" text NOT NULL, + "private_key" text NOT NULL, + "email" text, + "sub" text, + "issuer" text, + "audience" text, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "expires_at" text NOT NULL, + "last_used" text +); +--> statement-breakpoint +CREATE TABLE "recent_activity" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "type" text NOT NULL, + "host_id" integer NOT NULL, + "host_name" text, + "timestamp" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "roles" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar(255) NOT NULL, + "display_name" text NOT NULL, + "description" text, + "is_system" boolean DEFAULT false NOT NULL, + "permissions" text, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "roles_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE "session_recordings" ( + "id" serial PRIMARY KEY NOT NULL, + "host_id" integer NOT NULL, + "user_id" varchar(255), + "username" text, + "access_id" integer, + "started_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "ended_at" text, + "duration" integer, + "commands" text, + "dangerous_actions" text, + "recording_path" text, + "protocol" varchar(255) DEFAULT 'ssh' NOT NULL, + "format" text DEFAULT 'text' NOT NULL, + "terminated_by_owner" boolean DEFAULT false, + "termination_reason" text +); +--> statement-breakpoint +CREATE TABLE "session_share_participants" ( + "id" serial PRIMARY KEY NOT NULL, + "share_id" varchar(255) NOT NULL, + "user_id" varchar(255), + "guest_label" text, + "joined_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "left_at" text +); +--> statement-breakpoint +CREATE TABLE "session_shares" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "host_id" integer NOT NULL, + "owner_user_id" varchar(255) NOT NULL, + "protocol" varchar(255) NOT NULL, + "session_id" text NOT NULL, + "tab_instance_id" text, + "share_type" text NOT NULL, + "target_user_id" varchar(255), + "link_token" varchar(255), + "permission_level" text DEFAULT 'read-only' NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "expires_at" text NOT NULL, + "revoked_at" text, + "last_joined_at" text, + "join_count" integer DEFAULT 0 NOT NULL, + CONSTRAINT "session_shares_link_token_unique" UNIQUE("link_token") +); +--> statement-breakpoint +CREATE TABLE "sessions" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "jwt_token" text NOT NULL, + "device_type" text NOT NULL, + "device_info" text NOT NULL, + "oidc_sub" text, + "oidc_sid" text, + "sso_provider_id" integer, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "expires_at" text NOT NULL, + "last_active_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "settings" ( + "key" varchar(255) PRIMARY KEY NOT NULL, + "value" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "shared_host_secrets" ( + "id" serial PRIMARY KEY NOT NULL, + "host_access_id" integer NOT NULL, + "target_user_id" varchar(255) NOT NULL, + "protocol" varchar(255) DEFAULT 'ssh' NOT NULL, + "source_type" text DEFAULT 'credential' NOT NULL, + "original_credential_id" integer, + "encrypted_username" text, + "encrypted_auth_type" text, + "encrypted_password" text, + "encrypted_key" text, + "encrypted_key_password" text, + "encrypted_key_type" text, + "encrypted_domain" text, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "snippet_access" ( + "id" serial PRIMARY KEY NOT NULL, + "snippet_id" integer NOT NULL, + "user_id" varchar(255), + "role_id" integer, + "granted_by" varchar(255) NOT NULL, + "permission_level" text DEFAULT 'view' NOT NULL, + "expires_at" text, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "snippet_folders" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "color" text, + "icon" text, + "sync_id" varchar(255), + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "snippet_folders_sync_id_unique" UNIQUE("sync_id") +); +--> statement-breakpoint +CREATE TABLE "snippets" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "content" text NOT NULL, + "description" text, + "folder" text, + "order" integer DEFAULT 0 NOT NULL, + "sync_id" varchar(255), + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "host_filter" text, + CONSTRAINT "snippets_sync_id_unique" UNIQUE("sync_id") +); +--> statement-breakpoint +CREATE TABLE "ssh_credential_usage" ( + "id" serial PRIMARY KEY NOT NULL, + "credential_id" integer NOT NULL, + "host_id" integer NOT NULL, + "user_id" varchar(255) NOT NULL, + "used_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "ssh_credentials" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "description" text, + "folder" text, + "tags" text, + "auth_type" text NOT NULL, + "username" text, + "password" text, + "key" text, + "private_key" text, + "public_key" text, + "key_password" text, + "key_type" text, + "detected_key_type" text, + "cert_public_key" text, + "usage_count" integer DEFAULT 0 NOT NULL, + "last_used" text, + "sync_id" varchar(255), + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "ssh_credentials_sync_id_unique" UNIQUE("sync_id") +); +--> statement-breakpoint +CREATE TABLE "ssh_folders" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "color" text, + "icon" text, + "credential_id" integer, + "sync_id" varchar(255), + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "ssh_folders_sync_id_unique" UNIQUE("sync_id") +); +--> statement-breakpoint +CREATE TABLE "sso_providers" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar(255) NOT NULL, + "type" text NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "display_order" integer DEFAULT 0 NOT NULL, + "config" text NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sync_tombstones" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "entity_type" text NOT NULL, + "sync_id" varchar(255) NOT NULL, + "deleted_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "termix_identities" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "handle" varchar(255) NOT NULL, + "description" text, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "termix_identities_user_id_unique" UNIQUE("user_id"), + CONSTRAINT "termix_identities_handle_unique" UNIQUE("handle") +); +--> statement-breakpoint +CREATE TABLE "termix_identity_ca" ( + "id" serial PRIMARY KEY NOT NULL, + "identity_id" integer NOT NULL, + "user_id" varchar(255) NOT NULL, + "public_key" text NOT NULL, + "private_key" text NOT NULL, + "validity_days" integer DEFAULT 90 NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "termix_identity_ca_identity_id_unique" UNIQUE("identity_id") +); +--> statement-breakpoint +CREATE TABLE "termix_identity_keys" ( + "id" serial PRIMARY KEY NOT NULL, + "identity_id" integer NOT NULL, + "user_id" varchar(255) NOT NULL, + "public_key" text NOT NULL, + "key_type" text NOT NULL, + "algorithm" text NOT NULL, + "label" text, + "comment" text, + "source" text DEFAULT 'manual' NOT NULL, + "credential_id" integer, + "enabled" boolean DEFAULT true NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "tmux_session_tags" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "host_id" integer NOT NULL, + "session_name" text NOT NULL, + "tag" text NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "transfer_recent" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "source_host_id" integer NOT NULL, + "dest_host_id" integer NOT NULL, + "dest_path" text NOT NULL, + "dest_path_label" text NOT NULL, + "last_used" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "trusted_devices" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "device_fingerprint" text NOT NULL, + "device_type" text NOT NULL, + "device_info" text NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "expires_at" text NOT NULL, + "last_used_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_open_tabs" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "tab_type" text NOT NULL, + "host_id" integer, + "label" text NOT NULL, + "tab_order" integer DEFAULT 0 NOT NULL, + "backend_session_id" text, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_preferences" ( + "user_id" varchar(255) PRIMARY KEY NOT NULL, + "reopen_tabs_on_login" boolean DEFAULT false NOT NULL, + "theme" text, + "font_size" text, + "accent_color" text, + "language" text, + "storage_mode" text, + "command_autocomplete" boolean, + "command_palette_enabled" boolean, + "show_host_tags" boolean, + "host_tray_on_click" boolean, + "pin_app_rail" boolean, + "expand_app_rail_on_hover" boolean, + "folders_collapsed" boolean, + "confirm_snippet_execution" boolean, + "disable_update_check" boolean, + "confirm_tab_close" boolean, + "hidden_rail_tabs" text, + "compact_host_view" boolean, + "status_color_scheme" text, + "custom_themes" text, + "custom_keybindings" text, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_roles" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "role_id" integer NOT NULL, + "granted_by" varchar(255), + "granted_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "username" text NOT NULL, + "password_hash" text NOT NULL, + "is_admin" boolean DEFAULT false NOT NULL, + "is_oidc" boolean DEFAULT false NOT NULL, + "oidc_identifier" text, + "sso_provider_id" integer, + "client_id" text, + "client_secret" text, + "issuer_url" text, + "authorization_url" text, + "token_url" text, + "identifier_path" text, + "name_path" text, + "scopes" text DEFAULT 'openid email profile', + "totp_secret" text, + "totp_enabled" boolean DEFAULT false NOT NULL, + "totp_backup_codes" text, + "registered_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "donation_modal_dismissed" boolean DEFAULT false NOT NULL +); +--> statement-breakpoint +CREATE TABLE "vault_profiles" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "description" text, + "folder" text, + "tags" text, + "vault_addr" text NOT NULL, + "vault_namespace" text, + "oidc_mount" text, + "oidc_role" text, + "ssh_mount" text, + "ssh_role" text NOT NULL, + "valid_principals" text, + "key_type" text, + "shared" boolean DEFAULT false NOT NULL, + "sync_id" varchar(255), + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "vault_profiles_sync_id_unique" UNIQUE("sync_id") +); +--> statement-breakpoint +CREATE TABLE "vault_tokens" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "profile_id" integer NOT NULL, + "ssh_cert" text NOT NULL, + "private_key" text NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "expires_at" text NOT NULL, + "last_used" text +); +--> statement-breakpoint +CREATE TABLE "webauthn_credentials" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "credential_id" text NOT NULL, + "public_key" text NOT NULL, + "counter" integer DEFAULT 0 NOT NULL, + "device_type" text, + "backed_up" boolean DEFAULT false NOT NULL, + "transports" text, + "user_verification" text DEFAULT 'preferred' NOT NULL, + "created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL, + "last_used_at" text +); +--> statement-breakpoint +ALTER TABLE "alert_firings" ADD CONSTRAINT "alert_firings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "alert_firings" ADD CONSTRAINT "alert_firings_rule_id_alert_rules_id_fk" FOREIGN KEY ("rule_id") REFERENCES "public"."alert_rules"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "alert_rule_channels" ADD CONSTRAINT "alert_rule_channels_rule_id_alert_rules_id_fk" FOREIGN KEY ("rule_id") REFERENCES "public"."alert_rules"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "alert_rule_channels" ADD CONSTRAINT "alert_rule_channels_channel_id_notification_channels_id_fk" FOREIGN KEY ("channel_id") REFERENCES "public"."notification_channels"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "alert_rules" ADD CONSTRAINT "alert_rules_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "alert_rules" ADD CONSTRAINT "alert_rules_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "c2s_tunnel_presets" ADD CONSTRAINT "c2s_tunnel_presets_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "command_history" ADD CONSTRAINT "command_history_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "command_history" ADD CONSTRAINT "command_history_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "dashboard_service_links" ADD CONSTRAINT "dashboard_service_links_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "dismissed_alerts" ADD CONSTRAINT "dismissed_alerts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "file_manager_pinned" ADD CONSTRAINT "file_manager_pinned_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "file_manager_pinned" ADD CONSTRAINT "file_manager_pinned_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "file_manager_recent" ADD CONSTRAINT "file_manager_recent_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "file_manager_recent" ADD CONSTRAINT "file_manager_recent_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "file_manager_shortcuts" ADD CONSTRAINT "file_manager_shortcuts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "file_manager_shortcuts" ADD CONSTRAINT "file_manager_shortcuts_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "homepage_items" ADD CONSTRAINT "homepage_items_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "homepage_layouts" ADD CONSTRAINT "homepage_layouts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_access" ADD CONSTRAINT "host_access_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_access" ADD CONSTRAINT "host_access_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_access" ADD CONSTRAINT "host_access_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_access" ADD CONSTRAINT "host_access_granted_by_users_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_access" ADD CONSTRAINT "host_access_override_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("override_credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_health_checks" ADD CONSTRAINT "host_health_checks_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_health_checks" ADD CONSTRAINT "host_health_checks_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_health_history" ADD CONSTRAINT "host_health_history_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_health_history" ADD CONSTRAINT "host_health_history_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_metrics_history" ADD CONSTRAINT "host_metrics_history_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_metrics_preferences" ADD CONSTRAINT "host_metrics_preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "host_metrics_preferences" ADD CONSTRAINT "host_metrics_preferences_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_data" ADD CONSTRAINT "ssh_data_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_data" ADD CONSTRAINT "ssh_data_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_data" ADD CONSTRAINT "ssh_data_vault_profile_id_vault_profiles_id_fk" FOREIGN KEY ("vault_profile_id") REFERENCES "public"."vault_profiles"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_data" ADD CONSTRAINT "ssh_data_rdp_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("rdp_credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_data" ADD CONSTRAINT "ssh_data_vnc_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("vnc_credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_data" ADD CONSTRAINT "ssh_data_telnet_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("telnet_credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "network_topology" ADD CONSTRAINT "network_topology_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notification_channels" ADD CONSTRAINT "notification_channels_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "opkssh_tokens" ADD CONSTRAINT "opkssh_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "opkssh_tokens" ADD CONSTRAINT "opkssh_tokens_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "recent_activity" ADD CONSTRAINT "recent_activity_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "recent_activity" ADD CONSTRAINT "recent_activity_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_recordings" ADD CONSTRAINT "session_recordings_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_recordings" ADD CONSTRAINT "session_recordings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_recordings" ADD CONSTRAINT "session_recordings_access_id_host_access_id_fk" FOREIGN KEY ("access_id") REFERENCES "public"."host_access"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_share_participants" ADD CONSTRAINT "session_share_participants_share_id_session_shares_id_fk" FOREIGN KEY ("share_id") REFERENCES "public"."session_shares"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_share_participants" ADD CONSTRAINT "session_share_participants_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_shares" ADD CONSTRAINT "session_shares_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_shares" ADD CONSTRAINT "session_shares_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session_shares" ADD CONSTRAINT "session_shares_target_user_id_users_id_fk" FOREIGN KEY ("target_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shared_host_secrets" ADD CONSTRAINT "shared_host_secrets_host_access_id_host_access_id_fk" FOREIGN KEY ("host_access_id") REFERENCES "public"."host_access"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shared_host_secrets" ADD CONSTRAINT "shared_host_secrets_target_user_id_users_id_fk" FOREIGN KEY ("target_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shared_host_secrets" ADD CONSTRAINT "shared_host_secrets_original_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("original_credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "snippet_access" ADD CONSTRAINT "snippet_access_snippet_id_snippets_id_fk" FOREIGN KEY ("snippet_id") REFERENCES "public"."snippets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "snippet_access" ADD CONSTRAINT "snippet_access_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "snippet_access" ADD CONSTRAINT "snippet_access_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "snippet_access" ADD CONSTRAINT "snippet_access_granted_by_users_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "snippet_folders" ADD CONSTRAINT "snippet_folders_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "snippets" ADD CONSTRAINT "snippets_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_credential_usage" ADD CONSTRAINT "ssh_credential_usage_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_credential_usage" ADD CONSTRAINT "ssh_credential_usage_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_credential_usage" ADD CONSTRAINT "ssh_credential_usage_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_credentials" ADD CONSTRAINT "ssh_credentials_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_folders" ADD CONSTRAINT "ssh_folders_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ssh_folders" ADD CONSTRAINT "ssh_folders_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sync_tombstones" ADD CONSTRAINT "sync_tombstones_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "termix_identities" ADD CONSTRAINT "termix_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "termix_identity_ca" ADD CONSTRAINT "termix_identity_ca_identity_id_termix_identities_id_fk" FOREIGN KEY ("identity_id") REFERENCES "public"."termix_identities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "termix_identity_ca" ADD CONSTRAINT "termix_identity_ca_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "termix_identity_keys" ADD CONSTRAINT "termix_identity_keys_identity_id_termix_identities_id_fk" FOREIGN KEY ("identity_id") REFERENCES "public"."termix_identities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "termix_identity_keys" ADD CONSTRAINT "termix_identity_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "termix_identity_keys" ADD CONSTRAINT "termix_identity_keys_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tmux_session_tags" ADD CONSTRAINT "tmux_session_tags_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tmux_session_tags" ADD CONSTRAINT "tmux_session_tags_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "transfer_recent" ADD CONSTRAINT "transfer_recent_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "transfer_recent" ADD CONSTRAINT "transfer_recent_source_host_id_ssh_data_id_fk" FOREIGN KEY ("source_host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "transfer_recent" ADD CONSTRAINT "transfer_recent_dest_host_id_ssh_data_id_fk" FOREIGN KEY ("dest_host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "trusted_devices" ADD CONSTRAINT "trusted_devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_open_tabs" ADD CONSTRAINT "user_open_tabs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_open_tabs" ADD CONSTRAINT "user_open_tabs_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_granted_by_users_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "vault_profiles" ADD CONSTRAINT "vault_profiles_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "vault_tokens" ADD CONSTRAINT "vault_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "vault_tokens" ADD CONSTRAINT "vault_tokens_profile_id_vault_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."vault_profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "webauthn_credentials" ADD CONSTRAINT "webauthn_credentials_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "idx_host_health_checks_user_host" ON "host_health_checks" USING btree ("user_id","host_id");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_host_metrics_prefs_user_host" ON "host_metrics_preferences" USING btree ("user_id","host_id");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_opkssh_tokens_user_host" ON "opkssh_tokens" USING btree ("user_id","host_id");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_shared_host_secrets_scope" ON "shared_host_secrets" USING btree ("host_access_id","target_user_id","protocol");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_user_roles_user_role" ON "user_roles" USING btree ("user_id","role_id");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_vault_tokens_user_profile" ON "vault_tokens" USING btree ("user_id","profile_id"); \ No newline at end of file diff --git a/drizzle/postgres/meta/0000_snapshot.json b/drizzle/postgres/meta/0000_snapshot.json new file mode 100644 index 00000000..ba8fc46e --- /dev/null +++ b/drizzle/postgres/meta/0000_snapshot.json @@ -0,0 +1,5664 @@ +{ + "id": "2919cc9b-a491-4161-a03f-1ee4846be915", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.alert_firings": { + "name": "alert_firings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fired_at": { + "name": "fired_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "resolved_at": { + "name": "resolved_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'warning'" + }, + "acknowledged": { + "name": "acknowledged", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "alert_firings_user_id_users_id_fk": { + "name": "alert_firings_user_id_users_id_fk", + "tableFrom": "alert_firings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_firings_rule_id_alert_rules_id_fk": { + "name": "alert_firings_rule_id_alert_rules_id_fk", + "tableFrom": "alert_firings", + "tableTo": "alert_rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_rule_channels": { + "name": "alert_rule_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "alert_rule_channels_rule_id_alert_rules_id_fk": { + "name": "alert_rule_channels_rule_id_alert_rules_id_fk", + "tableFrom": "alert_rule_channels", + "tableTo": "alert_rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_rule_channels_channel_id_notification_channels_id_fk": { + "name": "alert_rule_channels_channel_id_notification_channels_id_fk", + "tableFrom": "alert_rule_channels", + "tableTo": "notification_channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_rules": { + "name": "alert_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "threshold_value": { + "name": "threshold_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "threshold_duration_seconds": { + "name": "threshold_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cooldown_minutes": { + "name": "cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "alert_rules_user_id_users_id_fk": { + "name": "alert_rules_user_id_users_id_fk", + "tableFrom": "alert_rules", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_rules_host_id_ssh_data_id_fk": { + "name": "alert_rules_host_id_ssh_data_id_fk", + "tableFrom": "alert_rules", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.c2s_tunnel_presets": { + "name": "c2s_tunnel_presets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "computer_name": { + "name": "computer_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "c2s_tunnel_presets_user_id_users_id_fk": { + "name": "c2s_tunnel_presets_user_id_users_id_fk", + "tableFrom": "c2s_tunnel_presets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.command_history": { + "name": "command_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "command_history_user_id_users_id_fk": { + "name": "command_history_user_id_users_id_fk", + "tableFrom": "command_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "command_history_host_id_ssh_data_id_fk": { + "name": "command_history_host_id_ssh_data_id_fk", + "tableFrom": "command_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_service_links": { + "name": "dashboard_service_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_service_links_user_id_users_id_fk": { + "name": "dashboard_service_links_user_id_users_id_fk", + "tableFrom": "dashboard_service_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dashboard_service_links_sync_id_unique": { + "name": "dashboard_service_links_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dismissed_alerts": { + "name": "dismissed_alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "dismissed_alerts_user_id_users_id_fk": { + "name": "dismissed_alerts_user_id_users_id_fk", + "tableFrom": "dismissed_alerts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_manager_pinned": { + "name": "file_manager_pinned", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "file_manager_pinned_user_id_users_id_fk": { + "name": "file_manager_pinned_user_id_users_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_pinned_host_id_ssh_data_id_fk": { + "name": "file_manager_pinned_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_manager_recent": { + "name": "file_manager_recent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_opened": { + "name": "last_opened", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "file_manager_recent_user_id_users_id_fk": { + "name": "file_manager_recent_user_id_users_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_recent_host_id_ssh_data_id_fk": { + "name": "file_manager_recent_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_manager_shortcuts": { + "name": "file_manager_shortcuts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "file_manager_shortcuts_user_id_users_id_fk": { + "name": "file_manager_shortcuts_user_id_users_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_shortcuts_host_id_ssh_data_id_fk": { + "name": "file_manager_shortcuts_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.homepage_items": { + "name": "homepage_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type_id": { + "name": "type_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "folder_id": { + "name": "folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "homepage_items_user_id_users_id_fk": { + "name": "homepage_items_user_id_users_id_fk", + "tableFrom": "homepage_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "homepage_items_sync_id_unique": { + "name": "homepage_items_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.homepage_layouts": { + "name": "homepage_layouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "homepage_layouts_user_id_users_id_fk": { + "name": "homepage_layouts_user_id_users_id_fk", + "tableFrom": "homepage_layouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "homepage_layouts_user_id_unique": { + "name": "homepage_layouts_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_access": { + "name": "host_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "override_credential_id": { + "name": "override_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_override_credential_id_ssh_credentials_id_fk": { + "name": "host_access_override_credential_id_ssh_credentials_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "override_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_health_checks": { + "name": "host_health_checks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "checks": { + "name": "checks", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 300 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_health_checks_user_host": { + "name": "idx_host_health_checks_user_host", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_health_checks_user_id_users_id_fk": { + "name": "host_health_checks_user_id_users_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_checks_host_id_ssh_data_id_fk": { + "name": "host_health_checks_host_id_ssh_data_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_health_history": { + "name": "host_health_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "check_id": { + "name": "check_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_health_history_user_id_users_id_fk": { + "name": "host_health_history_user_id_users_id_fk", + "tableFrom": "host_health_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_history_host_id_ssh_data_id_fk": { + "name": "host_health_history_host_id_ssh_data_id_fk", + "tableFrom": "host_health_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_metrics_history": { + "name": "host_metrics_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "mem_percent": { + "name": "mem_percent", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "disk_percent": { + "name": "disk_percent", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "net_rx_bytes": { + "name": "net_rx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "net_tx_bytes": { + "name": "net_tx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_metrics_history_host_id_ssh_data_id_fk": { + "name": "host_metrics_history_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_metrics_preferences": { + "name": "host_metrics_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_metrics_prefs_user_host": { + "name": "idx_host_metrics_prefs_user_host", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_metrics_preferences_user_id_users_id_fk": { + "name": "host_metrics_preferences_user_id_users_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_metrics_preferences_host_id_ssh_data_id_fk": { + "name": "host_metrics_preferences_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_data": { + "name": "ssh_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "use_warpgate": { + "name": "use_warpgate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autostart_password": { + "name": "autostart_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autostart_key": { + "name": "autostart_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "autostart_key_password": { + "name": "autostart_key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "vault_profile_id": { + "name": "vault_profile_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "enable_terminal": { + "name": "enable_terminal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_session_logging": { + "name": "enable_session_logging", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_session_sharing": { + "name": "allow_session_sharing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_command_history": { + "name": "enable_command_history", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_tunnel": { + "name": "enable_tunnel", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "tunnel_connections": { + "name": "tunnel_connections", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable_file_manager": { + "name": "enable_file_manager", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "scp_legacy": { + "name": "scp_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_docker": { + "name": "enable_docker", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_tmux_monitor": { + "name": "enable_tmux_monitor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_terminal_in_sidebar": { + "name": "show_terminal_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_file_manager_in_sidebar": { + "name": "show_file_manager_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_tunnel_in_sidebar": { + "name": "show_tunnel_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_docker_in_sidebar": { + "name": "show_docker_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_server_stats_in_sidebar": { + "name": "show_server_stats_in_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_path": { + "name": "default_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stats_config": { + "name": "stats_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "docker_config": { + "name": "docker_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable_proxmox": { + "name": "enable_proxmox", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "proxmox_config": { + "name": "proxmox_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_config": { + "name": "terminal_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quick_actions": { + "name": "quick_actions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_rdp": { + "name": "enable_rdp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_vnc": { + "name": "enable_vnc", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_telnet": { + "name": "enable_telnet", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ssh_port": { + "name": "ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 22 + }, + "rdp_port": { + "name": "rdp_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3389 + }, + "vnc_port": { + "name": "vnc_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5900 + }, + "telnet_port": { + "name": "telnet_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 23 + }, + "rdp_credential_id": { + "name": "rdp_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rdp_user": { + "name": "rdp_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rdp_password": { + "name": "rdp_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rdp_domain": { + "name": "rdp_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rdp_security": { + "name": "rdp_security", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rdp_ignore_cert": { + "name": "rdp_ignore_cert", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "vnc_credential_id": { + "name": "vnc_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vnc_password": { + "name": "vnc_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vnc_user": { + "name": "vnc_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnet_user": { + "name": "telnet_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnet_password": { + "name": "telnet_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnet_credential_id": { + "name": "telnet_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rdp_auth_type": { + "name": "rdp_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vnc_auth_type": { + "name": "vnc_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telnet_auth_type": { + "name": "telnet_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "security": { + "name": "security", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ignore_cert": { + "name": "ignore_cert", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "guacamole_config": { + "name": "guacamole_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_socks5": { + "name": "use_socks5", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wol_broadcast_address": { + "name": "wol_broadcast_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'sha256'" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vault_profile_id_vault_profiles_id_fk": { + "name": "ssh_data_vault_profile_id_vault_profiles_id_fk", + "tableFrom": "ssh_data", + "tableTo": "vault_profiles", + "columnsFrom": [ + "vault_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_rdp_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_rdp_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "rdp_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vnc_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_vnc_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "vnc_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_telnet_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_telnet_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "telnet_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.network_topology": { + "name": "network_topology", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "topology": { + "name": "topology", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "network_topology_user_id_users_id_fk": { + "name": "network_topology_user_id_users_id_fk", + "tableFrom": "network_topology", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channels": { + "name": "notification_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channels_user_id_users_id_fk": { + "name": "notification_channels_user_id_users_id_fk", + "tableFrom": "notification_channels", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.opkssh_tokens": { + "name": "opkssh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_opkssh_tokens_user_host": { + "name": "idx_opkssh_tokens_user_host", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "opkssh_tokens_user_id_users_id_fk": { + "name": "opkssh_tokens_user_id_users_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opkssh_tokens_host_id_ssh_data_id_fk": { + "name": "opkssh_tokens_host_id_ssh_data_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recent_activity": { + "name": "recent_activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "recent_activity_user_id_users_id_fk": { + "name": "recent_activity_user_id_users_id_fk", + "tableFrom": "recent_activity", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recent_activity_host_id_ssh_data_id_fk": { + "name": "recent_activity_host_id_ssh_data_id_fk", + "tableFrom": "recent_activity", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_recordings": { + "name": "session_recordings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_id": { + "name": "access_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "commands": { + "name": "commands", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dangerous_actions": { + "name": "dangerous_actions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recording_path": { + "name": "recording_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "terminated_by_owner": { + "name": "terminated_by_owner", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "termination_reason": { + "name": "termination_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_recordings_host_id_ssh_data_id_fk": { + "name": "session_recordings_host_id_ssh_data_id_fk", + "tableFrom": "session_recordings", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_recordings_user_id_users_id_fk": { + "name": "session_recordings_user_id_users_id_fk", + "tableFrom": "session_recordings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "session_recordings_access_id_host_access_id_fk": { + "name": "session_recordings_access_id_host_access_id_fk", + "tableFrom": "session_recordings", + "tableTo": "host_access", + "columnsFrom": [ + "access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_share_participants": { + "name": "session_share_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "share_id": { + "name": "share_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "guest_label": { + "name": "guest_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "left_at": { + "name": "left_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_share_participants_share_id_session_shares_id_fk": { + "name": "session_share_participants_share_id_session_shares_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "session_shares", + "columnsFrom": [ + "share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_share_participants_user_id_users_id_fk": { + "name": "session_share_participants_user_id_users_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_shares": { + "name": "session_shares", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tab_instance_id": { + "name": "tab_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "share_type": { + "name": "share_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "link_token": { + "name": "link_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'read-only'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_joined_at": { + "name": "last_joined_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "join_count": { + "name": "join_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "session_shares_host_id_ssh_data_id_fk": { + "name": "session_shares_host_id_ssh_data_id_fk", + "tableFrom": "session_shares", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_owner_user_id_users_id_fk": { + "name": "session_shares_owner_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_target_user_id_users_id_fk": { + "name": "session_shares_target_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_shares_link_token_unique": { + "name": "session_shares_link_token_unique", + "nullsNotDistinct": false, + "columns": [ + "link_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_sid": { + "name": "oidc_sid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_host_secrets": { + "name": "shared_host_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'credential'" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + { + "expression": "host_access_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "protocol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.snippet_access": { + "name": "snippet_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "snippet_id": { + "name": "snippet_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'view'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "snippet_access_snippet_id_snippets_id_fk": { + "name": "snippet_access_snippet_id_snippets_id_fk", + "tableFrom": "snippet_access", + "tableTo": "snippets", + "columnsFrom": [ + "snippet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_user_id_users_id_fk": { + "name": "snippet_access_user_id_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_role_id_roles_id_fk": { + "name": "snippet_access_role_id_roles_id_fk", + "tableFrom": "snippet_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_granted_by_users_id_fk": { + "name": "snippet_access_granted_by_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.snippet_folders": { + "name": "snippet_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "snippet_folders_user_id_users_id_fk": { + "name": "snippet_folders_user_id_users_id_fk", + "tableFrom": "snippet_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "snippet_folders_sync_id_unique": { + "name": "snippet_folders_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.snippets": { + "name": "snippets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "host_filter": { + "name": "host_filter", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "snippets_user_id_users_id_fk": { + "name": "snippets_user_id_users_id_fk", + "tableFrom": "snippets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "snippets_sync_id_unique": { + "name": "snippets_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_credential_usage": { + "name": "ssh_credential_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_credentials": { + "name": "ssh_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_folders": { + "name": "ssh_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_folders_credential_id_ssh_credentials_id_fk": { + "name": "ssh_folders_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_tombstones": { + "name": "sync_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_tombstones_user_id_users_id_fk": { + "name": "sync_tombstones_user_id_users_id_fk", + "tableFrom": "sync_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termix_identities": { + "name": "termix_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identities_user_id_users_id_fk": { + "name": "termix_identities_user_id_users_id_fk", + "tableFrom": "termix_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "termix_identities_user_id_unique": { + "name": "termix_identities_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "termix_identities_handle_unique": { + "name": "termix_identities_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termix_identity_ca": { + "name": "termix_identity_ca", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "validity_days": { + "name": "validity_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 90 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_ca_identity_id_termix_identities_id_fk": { + "name": "termix_identity_ca_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_ca_user_id_users_id_fk": { + "name": "termix_identity_ca_user_id_users_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "termix_identity_ca_identity_id_unique": { + "name": "termix_identity_ca_identity_id_unique", + "nullsNotDistinct": false, + "columns": [ + "identity_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.termix_identity_keys": { + "name": "termix_identity_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_keys_identity_id_termix_identities_id_fk": { + "name": "termix_identity_keys_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_user_id_users_id_fk": { + "name": "termix_identity_keys_user_id_users_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_credential_id_ssh_credentials_id_fk": { + "name": "termix_identity_keys_credential_id_ssh_credentials_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tmux_session_tags": { + "name": "tmux_session_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "session_name": { + "name": "session_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "tmux_session_tags_user_id_users_id_fk": { + "name": "tmux_session_tags_user_id_users_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tmux_session_tags_host_id_ssh_data_id_fk": { + "name": "tmux_session_tags_host_id_ssh_data_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transfer_recent": { + "name": "transfer_recent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source_host_id": { + "name": "source_host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dest_host_id": { + "name": "dest_host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dest_path": { + "name": "dest_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dest_path_label": { + "name": "dest_path_label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "transfer_recent_user_id_users_id_fk": { + "name": "transfer_recent_user_id_users_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_source_host_id_ssh_data_id_fk": { + "name": "transfer_recent_source_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "source_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_dest_host_id_ssh_data_id_fk": { + "name": "transfer_recent_dest_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "dest_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trusted_devices": { + "name": "trusted_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_open_tabs": { + "name": "user_open_tabs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command_autocomplete": { + "name": "command_autocomplete", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "command_palette_enabled": { + "name": "command_palette_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "show_host_tags": { + "name": "show_host_tags", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "host_tray_on_click": { + "name": "host_tray_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "pin_app_rail": { + "name": "pin_app_rail", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "expand_app_rail_on_hover": { + "name": "expand_app_rail_on_hover", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "confirm_snippet_execution": { + "name": "confirm_snippet_execution", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_themes": { + "name": "custom_themes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_oidc": { + "name": "is_oidc", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "oidc_identifier": { + "name": "oidc_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identifier_path": { + "name": "identifier_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name_path": { + "name": "name_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'openid email profile'" + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_backup_codes": { + "name": "totp_backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "donation_modal_dismissed": { + "name": "donation_modal_dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vault_profiles": { + "name": "vault_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vault_addr": { + "name": "vault_addr", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vault_namespace": { + "name": "vault_namespace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_mount": { + "name": "oidc_mount", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_role": { + "name": "oidc_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ssh_mount": { + "name": "ssh_mount", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ssh_role": { + "name": "ssh_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "valid_principals": { + "name": "valid_principals", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "vault_profiles_user_id_users_id_fk": { + "name": "vault_profiles_user_id_users_id_fk", + "tableFrom": "vault_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "vault_profiles_sync_id_unique": { + "name": "vault_profiles_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vault_tokens": { + "name": "vault_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "profile_id": { + "name": "profile_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_vault_tokens_user_profile": { + "name": "idx_vault_tokens_user_profile", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vault_tokens_user_id_users_id_fk": { + "name": "vault_tokens_user_id_users_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_tokens_profile_id_vault_profiles_id_fk": { + "name": "vault_tokens_profile_id_vault_profiles_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "vault_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webauthn_credentials": { + "name": "webauthn_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_verification": { + "name": "user_verification", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preferred'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "webauthn_credentials_user_id_users_id_fk": { + "name": "webauthn_credentials_user_id_users_id_fk", + "tableFrom": "webauthn_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json new file mode 100644 index 00000000..8e2a42dd --- /dev/null +++ b/drizzle/postgres/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1785276657789, + "tag": "0000_jazzy_infant_terrible", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/drizzle/sqlite/0000_clever_hercules.sql b/drizzle/sqlite/0000_clever_hercules.sql new file mode 100644 index 00000000..71c556d7 --- /dev/null +++ b/drizzle/sqlite/0000_clever_hercules.sql @@ -0,0 +1,823 @@ +CREATE TABLE `alert_firings` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `rule_id` integer NOT NULL, + `host_id` integer NOT NULL, + `host_name` text NOT NULL, + `fired_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `resolved_at` text, + `value` real, + `message` text NOT NULL, + `severity` text DEFAULT 'warning' NOT NULL, + `acknowledged` integer DEFAULT false NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`rule_id`) REFERENCES `alert_rules`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `alert_rule_channels` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `rule_id` integer NOT NULL, + `channel_id` integer NOT NULL, + FOREIGN KEY (`rule_id`) REFERENCES `alert_rules`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`channel_id`) REFERENCES `notification_channels`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `alert_rules` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `host_id` integer, + `name` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `trigger_type` text NOT NULL, + `threshold_value` real, + `threshold_duration_seconds` integer, + `cooldown_minutes` integer DEFAULT 15 NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `api_keys` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `token_hash` text NOT NULL, + `token_prefix` text NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `expires_at` text, + `last_used_at` text, + `is_active` integer DEFAULT true NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `audit_logs` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text, + `username` text NOT NULL, + `action` text NOT NULL, + `resource_type` text NOT NULL, + `resource_id` text, + `resource_name` text, + `details` text, + `ip_address` text, + `user_agent` text, + `success` integer NOT NULL, + `error_message` text, + `timestamp` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE TABLE `c2s_tunnel_presets` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `config` text NOT NULL, + `platform` text, + `computer_name` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `command_history` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `host_id` integer NOT NULL, + `command` text NOT NULL, + `executed_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `dashboard_service_links` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `label` text NOT NULL, + `url` text NOT NULL, + `order` integer DEFAULT 0 NOT NULL, + `sync_id` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `dashboard_service_links_sync_id_unique` ON `dashboard_service_links` (`sync_id`);--> statement-breakpoint +CREATE TABLE `dismissed_alerts` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `alert_id` text NOT NULL, + `dismissed_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `file_manager_pinned` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `host_id` integer NOT NULL, + `name` text NOT NULL, + `path` text NOT NULL, + `pinned_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `file_manager_recent` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `host_id` integer NOT NULL, + `name` text NOT NULL, + `path` text NOT NULL, + `last_opened` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `file_manager_shortcuts` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `host_id` integer NOT NULL, + `name` text NOT NULL, + `path` text NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `homepage_items` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `type_id` text NOT NULL, + `title` text, + `config` text DEFAULT '{}' NOT NULL, + `folder_id` integer, + `sync_id` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `homepage_items_sync_id_unique` ON `homepage_items` (`sync_id`);--> statement-breakpoint +CREATE TABLE `homepage_layouts` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `layout` text DEFAULT '{}' NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `homepage_layouts_user_id_unique` ON `homepage_layouts` (`user_id`);--> statement-breakpoint +CREATE TABLE `host_access` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `host_id` integer NOT NULL, + `user_id` text, + `role_id` integer, + `granted_by` text NOT NULL, + `permission_level` text DEFAULT 'connect' NOT NULL, + `expires_at` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `last_accessed_at` text, + `access_count` integer DEFAULT 0 NOT NULL, + `override_credential_id` integer, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`override_credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE TABLE `host_health_checks` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `host_id` integer NOT NULL, + `checks` text NOT NULL, + `interval_seconds` integer DEFAULT 300 NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_host_health_checks_user_host` ON `host_health_checks` (`user_id`,`host_id`);--> statement-breakpoint +CREATE TABLE `host_health_history` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `host_id` integer NOT NULL, + `check_id` text NOT NULL, + `ts` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `ok` integer NOT NULL, + `latency_ms` integer, + `detail` text, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `host_metrics_history` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `host_id` integer NOT NULL, + `ts` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `cpu_percent` real, + `mem_percent` real, + `disk_percent` real, + `net_rx_bytes` integer, + `net_tx_bytes` integer, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `host_metrics_preferences` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `host_id` integer NOT NULL, + `layout` text NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_host_metrics_prefs_user_host` ON `host_metrics_preferences` (`user_id`,`host_id`);--> statement-breakpoint +CREATE TABLE `ssh_data` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `connection_type` text DEFAULT 'ssh' NOT NULL, + `name` text, + `ip` text NOT NULL, + `port` integer NOT NULL, + `username` text NOT NULL, + `folder` text, + `tags` text, + `pin` integer DEFAULT false NOT NULL, + `auth_type` text NOT NULL, + `use_warpgate` integer DEFAULT false NOT NULL, + `force_keyboard_interactive` text, + `password` text, + `key` text(8192), + `key_password` text, + `key_type` text, + `sudo_password` text, + `autostart_password` text, + `autostart_key` text(8192), + `autostart_key_password` text, + `credential_id` integer, + `override_credential_username` integer, + `vault_profile_id` integer, + `enable_terminal` integer DEFAULT true NOT NULL, + `enable_session_logging` integer DEFAULT true NOT NULL, + `allow_session_sharing` integer DEFAULT true NOT NULL, + `enable_command_history` integer DEFAULT true NOT NULL, + `enable_tunnel` integer DEFAULT true NOT NULL, + `tunnel_connections` text, + `jump_hosts` text, + `enable_file_manager` integer DEFAULT true NOT NULL, + `scp_legacy` integer DEFAULT false NOT NULL, + `enable_docker` integer DEFAULT false NOT NULL, + `enable_tmux_monitor` integer DEFAULT false NOT NULL, + `show_terminal_in_sidebar` integer DEFAULT true NOT NULL, + `show_file_manager_in_sidebar` integer DEFAULT false NOT NULL, + `show_tunnel_in_sidebar` integer DEFAULT false NOT NULL, + `show_docker_in_sidebar` integer DEFAULT false NOT NULL, + `show_server_stats_in_sidebar` integer DEFAULT false NOT NULL, + `default_path` text, + `stats_config` text, + `docker_config` text, + `enable_proxmox` integer DEFAULT false NOT NULL, + `proxmox_config` text, + `terminal_config` text, + `quick_actions` text, + `notes` text, + `enable_ssh` integer DEFAULT true NOT NULL, + `enable_rdp` integer DEFAULT false NOT NULL, + `enable_vnc` integer DEFAULT false NOT NULL, + `enable_telnet` integer DEFAULT false NOT NULL, + `ssh_port` integer DEFAULT 22, + `rdp_port` integer DEFAULT 3389, + `vnc_port` integer DEFAULT 5900, + `telnet_port` integer DEFAULT 23, + `rdp_credential_id` integer, + `rdp_user` text, + `rdp_password` text, + `rdp_domain` text, + `rdp_security` text, + `rdp_ignore_cert` integer DEFAULT false, + `vnc_credential_id` integer, + `vnc_password` text, + `vnc_user` text, + `telnet_user` text, + `telnet_password` text, + `telnet_credential_id` integer, + `rdp_auth_type` text, + `vnc_auth_type` text, + `telnet_auth_type` text, + `domain` text, + `security` text, + `ignore_cert` integer DEFAULT false, + `guacamole_config` text, + `use_socks5` integer, + `socks5_host` text, + `socks5_port` integer, + `socks5_username` text, + `socks5_password` text, + `socks5_proxy_chain` text, + `connection_origin` text, + `mac_address` text, + `wol_broadcast_address` text, + `port_knock_sequence` text, + `host_key_fingerprint` text, + `host_key_type` text, + `host_key_algorithm` text DEFAULT 'sha256', + `host_key_first_seen` text, + `host_key_last_verified` text, + `host_key_changed_count` integer DEFAULT 0, + `sync_id` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`vault_profile_id`) REFERENCES `vault_profiles`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`rdp_credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`vnc_credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`telnet_credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX `ssh_data_sync_id_unique` ON `ssh_data` (`sync_id`);--> statement-breakpoint +CREATE TABLE `network_topology` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `topology` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `notification_channels` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `type` text NOT NULL, + `config` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `opkssh_tokens` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `host_id` integer NOT NULL, + `ssh_cert` text(8192) NOT NULL, + `private_key` text(8192) NOT NULL, + `email` text, + `sub` text, + `issuer` text, + `audience` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `expires_at` text NOT NULL, + `last_used` text, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_opkssh_tokens_user_host` ON `opkssh_tokens` (`user_id`,`host_id`);--> statement-breakpoint +CREATE TABLE `recent_activity` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `type` text NOT NULL, + `host_id` integer NOT NULL, + `host_name` text, + `timestamp` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `roles` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `display_name` text NOT NULL, + `description` text, + `is_system` integer DEFAULT false NOT NULL, + `permissions` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `roles_name_unique` ON `roles` (`name`);--> statement-breakpoint +CREATE TABLE `session_recordings` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `host_id` integer NOT NULL, + `user_id` text, + `username` text, + `access_id` integer, + `started_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `ended_at` text, + `duration` integer, + `commands` text, + `dangerous_actions` text, + `recording_path` text, + `protocol` text DEFAULT 'ssh' NOT NULL, + `format` text DEFAULT 'text' NOT NULL, + `terminated_by_owner` integer DEFAULT false, + `termination_reason` text, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`access_id`) REFERENCES `host_access`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE TABLE `session_share_participants` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `share_id` text NOT NULL, + `user_id` text, + `guest_label` text, + `joined_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `left_at` text, + FOREIGN KEY (`share_id`) REFERENCES `session_shares`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `session_shares` ( + `id` text PRIMARY KEY NOT NULL, + `host_id` integer NOT NULL, + `owner_user_id` text NOT NULL, + `protocol` text NOT NULL, + `session_id` text NOT NULL, + `tab_instance_id` text, + `share_type` text NOT NULL, + `target_user_id` text, + `link_token` text, + `permission_level` text DEFAULT 'read-only' NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `expires_at` text NOT NULL, + `revoked_at` text, + `last_joined_at` text, + `join_count` integer DEFAULT 0 NOT NULL, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`target_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `session_shares_link_token_unique` ON `session_shares` (`link_token`);--> statement-breakpoint +CREATE TABLE `sessions` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `jwt_token` text NOT NULL, + `device_type` text NOT NULL, + `device_info` text NOT NULL, + `oidc_sub` text, + `oidc_sid` text, + `sso_provider_id` integer, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `expires_at` text NOT NULL, + `last_active_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `settings` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `shared_host_secrets` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `host_access_id` integer NOT NULL, + `target_user_id` text NOT NULL, + `protocol` text DEFAULT 'ssh' NOT NULL, + `source_type` text DEFAULT 'credential' NOT NULL, + `original_credential_id` integer, + `encrypted_username` text, + `encrypted_auth_type` text, + `encrypted_password` text, + `encrypted_key` text(16384), + `encrypted_key_password` text, + `encrypted_key_type` text, + `encrypted_domain` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`host_access_id`) REFERENCES `host_access`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`target_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`original_credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_shared_host_secrets_scope` ON `shared_host_secrets` (`host_access_id`,`target_user_id`,`protocol`);--> statement-breakpoint +CREATE TABLE `snippet_access` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `snippet_id` integer NOT NULL, + `user_id` text, + `role_id` integer, + `granted_by` text NOT NULL, + `permission_level` text DEFAULT 'view' NOT NULL, + `expires_at` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`snippet_id`) REFERENCES `snippets`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `snippet_folders` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `color` text, + `icon` text, + `sync_id` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `snippet_folders_sync_id_unique` ON `snippet_folders` (`sync_id`);--> statement-breakpoint +CREATE TABLE `snippets` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `content` text NOT NULL, + `description` text, + `folder` text, + `order` integer DEFAULT 0 NOT NULL, + `sync_id` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `host_filter` text, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `snippets_sync_id_unique` ON `snippets` (`sync_id`);--> statement-breakpoint +CREATE TABLE `ssh_credential_usage` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `credential_id` integer NOT NULL, + `host_id` integer NOT NULL, + `user_id` text NOT NULL, + `used_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `ssh_credentials` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `description` text, + `folder` text, + `tags` text, + `auth_type` text NOT NULL, + `username` text, + `password` text, + `key` text(16384), + `private_key` text(16384), + `public_key` text(4096), + `key_password` text, + `key_type` text, + `detected_key_type` text, + `cert_public_key` text(8192), + `usage_count` integer DEFAULT 0 NOT NULL, + `last_used` text, + `sync_id` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `ssh_credentials_sync_id_unique` ON `ssh_credentials` (`sync_id`);--> statement-breakpoint +CREATE TABLE `ssh_folders` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `color` text, + `icon` text, + `credential_id` integer, + `sync_id` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX `ssh_folders_sync_id_unique` ON `ssh_folders` (`sync_id`);--> statement-breakpoint +CREATE TABLE `sso_providers` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `type` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `display_order` integer DEFAULT 0 NOT NULL, + `config` text NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE TABLE `sync_tombstones` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `entity_type` text NOT NULL, + `sync_id` text NOT NULL, + `deleted_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `termix_identities` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `handle` text NOT NULL, + `description` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `termix_identities_user_id_unique` ON `termix_identities` (`user_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `termix_identities_handle_unique` ON `termix_identities` (`handle`);--> statement-breakpoint +CREATE TABLE `termix_identity_ca` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `identity_id` integer NOT NULL, + `user_id` text NOT NULL, + `public_key` text(4096) NOT NULL, + `private_key` text(8192) NOT NULL, + `validity_days` integer DEFAULT 90 NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`identity_id`) REFERENCES `termix_identities`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `termix_identity_ca_identity_id_unique` ON `termix_identity_ca` (`identity_id`);--> statement-breakpoint +CREATE TABLE `termix_identity_keys` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `identity_id` integer NOT NULL, + `user_id` text NOT NULL, + `public_key` text(8192) NOT NULL, + `key_type` text NOT NULL, + `algorithm` text NOT NULL, + `label` text, + `comment` text, + `source` text DEFAULT 'manual' NOT NULL, + `credential_id` integer, + `enabled` integer DEFAULT true NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`identity_id`) REFERENCES `termix_identities`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE TABLE `tmux_session_tags` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `host_id` integer NOT NULL, + `session_name` text NOT NULL, + `tag` text NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `transfer_recent` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `source_host_id` integer NOT NULL, + `dest_host_id` integer NOT NULL, + `dest_path` text NOT NULL, + `dest_path_label` text NOT NULL, + `last_used` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`source_host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`dest_host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `trusted_devices` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `device_fingerprint` text NOT NULL, + `device_type` text NOT NULL, + `device_info` text NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `expires_at` text NOT NULL, + `last_used_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `user_open_tabs` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `tab_type` text NOT NULL, + `host_id` integer, + `label` text NOT NULL, + `tab_order` integer DEFAULT 0 NOT NULL, + `backend_session_id` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `user_preferences` ( + `user_id` text PRIMARY KEY NOT NULL, + `reopen_tabs_on_login` integer DEFAULT false NOT NULL, + `theme` text, + `font_size` text, + `accent_color` text, + `language` text, + `storage_mode` text, + `command_autocomplete` integer, + `command_palette_enabled` integer, + `show_host_tags` integer, + `host_tray_on_click` integer, + `pin_app_rail` integer, + `expand_app_rail_on_hover` integer, + `folders_collapsed` integer, + `confirm_snippet_execution` integer, + `disable_update_check` integer, + `confirm_tab_close` integer, + `hidden_rail_tabs` text, + `compact_host_view` integer, + `status_color_scheme` text, + `custom_themes` text, + `custom_keybindings` text, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `user_roles` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `role_id` integer NOT NULL, + `granted_by` text, + `granted_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_user_roles_user_role` ON `user_roles` (`user_id`,`role_id`);--> statement-breakpoint +CREATE TABLE `users` ( + `id` text PRIMARY KEY NOT NULL, + `username` text NOT NULL, + `password_hash` text NOT NULL, + `is_admin` integer DEFAULT false NOT NULL, + `is_oidc` integer DEFAULT false NOT NULL, + `oidc_identifier` text, + `sso_provider_id` integer, + `client_id` text, + `client_secret` text, + `issuer_url` text, + `authorization_url` text, + `token_url` text, + `identifier_path` text, + `name_path` text, + `scopes` text DEFAULT 'openid email profile', + `totp_secret` text, + `totp_enabled` integer DEFAULT false NOT NULL, + `totp_backup_codes` text, + `registered_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `donation_modal_dismissed` integer DEFAULT false NOT NULL +); +--> statement-breakpoint +CREATE TABLE `vault_profiles` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `description` text, + `folder` text, + `tags` text, + `vault_addr` text NOT NULL, + `vault_namespace` text, + `oidc_mount` text, + `oidc_role` text, + `ssh_mount` text, + `ssh_role` text NOT NULL, + `valid_principals` text, + `key_type` text, + `shared` integer DEFAULT false NOT NULL, + `sync_id` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `vault_profiles_sync_id_unique` ON `vault_profiles` (`sync_id`);--> statement-breakpoint +CREATE TABLE `vault_tokens` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `profile_id` integer NOT NULL, + `ssh_cert` text(8192) NOT NULL, + `private_key` text(8192) NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `expires_at` text NOT NULL, + `last_used` text, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`profile_id`) REFERENCES `vault_profiles`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_vault_tokens_user_profile` ON `vault_tokens` (`user_id`,`profile_id`);--> statement-breakpoint +CREATE TABLE `webauthn_credentials` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `credential_id` text NOT NULL, + `public_key` text NOT NULL, + `counter` integer DEFAULT 0 NOT NULL, + `device_type` text, + `backed_up` integer DEFAULT false NOT NULL, + `transports` text, + `user_verification` text DEFAULT 'preferred' NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `last_used_at` text, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/drizzle/sqlite/meta/0000_snapshot.json b/drizzle/sqlite/meta/0000_snapshot.json new file mode 100644 index 00000000..ab533e79 --- /dev/null +++ b/drizzle/sqlite/meta/0000_snapshot.json @@ -0,0 +1,5980 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "170ededd-8277-4445-b7b6-a67ac8491b18", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "alert_firings": { + "name": "alert_firings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rule_id": { + "name": "rule_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fired_at": { + "name": "fired_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "resolved_at": { + "name": "resolved_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'warning'" + }, + "acknowledged": { + "name": "acknowledged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "alert_firings_user_id_users_id_fk": { + "name": "alert_firings_user_id_users_id_fk", + "tableFrom": "alert_firings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_firings_rule_id_alert_rules_id_fk": { + "name": "alert_firings_rule_id_alert_rules_id_fk", + "tableFrom": "alert_firings", + "tableTo": "alert_rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "alert_rule_channels": { + "name": "alert_rule_channels", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "rule_id": { + "name": "rule_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "alert_rule_channels_rule_id_alert_rules_id_fk": { + "name": "alert_rule_channels_rule_id_alert_rules_id_fk", + "tableFrom": "alert_rule_channels", + "tableTo": "alert_rules", + "columnsFrom": [ + "rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_rule_channels_channel_id_notification_channels_id_fk": { + "name": "alert_rule_channels_channel_id_notification_channels_id_fk", + "tableFrom": "alert_rule_channels", + "tableTo": "notification_channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "alert_rules": { + "name": "alert_rules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "threshold_value": { + "name": "threshold_value", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "threshold_duration_seconds": { + "name": "threshold_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cooldown_minutes": { + "name": "cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 15 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "alert_rules_user_id_users_id_fk": { + "name": "alert_rules_user_id_users_id_fk", + "tableFrom": "alert_rules", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_rules_host_id_ssh_data_id_fk": { + "name": "alert_rules_host_id_ssh_data_id_fk", + "tableFrom": "alert_rules", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "c2s_tunnel_presets": { + "name": "c2s_tunnel_presets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "computer_name": { + "name": "computer_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "c2s_tunnel_presets_user_id_users_id_fk": { + "name": "c2s_tunnel_presets_user_id_users_id_fk", + "tableFrom": "c2s_tunnel_presets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "command_history": { + "name": "command_history", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "executed_at": { + "name": "executed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "command_history_user_id_users_id_fk": { + "name": "command_history_user_id_users_id_fk", + "tableFrom": "command_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "command_history_host_id_ssh_data_id_fk": { + "name": "command_history_host_id_ssh_data_id_fk", + "tableFrom": "command_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dashboard_service_links": { + "name": "dashboard_service_links", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "dashboard_service_links_sync_id_unique": { + "name": "dashboard_service_links_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "dashboard_service_links_user_id_users_id_fk": { + "name": "dashboard_service_links_user_id_users_id_fk", + "tableFrom": "dashboard_service_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dismissed_alerts": { + "name": "dismissed_alerts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "dismissed_alerts_user_id_users_id_fk": { + "name": "dismissed_alerts_user_id_users_id_fk", + "tableFrom": "dismissed_alerts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_manager_pinned": { + "name": "file_manager_pinned", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "file_manager_pinned_user_id_users_id_fk": { + "name": "file_manager_pinned_user_id_users_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_pinned_host_id_ssh_data_id_fk": { + "name": "file_manager_pinned_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_pinned", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_manager_recent": { + "name": "file_manager_recent", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened": { + "name": "last_opened", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "file_manager_recent_user_id_users_id_fk": { + "name": "file_manager_recent_user_id_users_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_recent_host_id_ssh_data_id_fk": { + "name": "file_manager_recent_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "file_manager_shortcuts": { + "name": "file_manager_shortcuts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "file_manager_shortcuts_user_id_users_id_fk": { + "name": "file_manager_shortcuts_user_id_users_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_manager_shortcuts_host_id_ssh_data_id_fk": { + "name": "file_manager_shortcuts_host_id_ssh_data_id_fk", + "tableFrom": "file_manager_shortcuts", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "homepage_items": { + "name": "homepage_items", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_id": { + "name": "type_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "folder_id": { + "name": "folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "homepage_items_sync_id_unique": { + "name": "homepage_items_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "homepage_items_user_id_users_id_fk": { + "name": "homepage_items_user_id_users_id_fk", + "tableFrom": "homepage_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "homepage_layouts": { + "name": "homepage_layouts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "homepage_layouts_user_id_unique": { + "name": "homepage_layouts_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "homepage_layouts_user_id_users_id_fk": { + "name": "homepage_layouts_user_id_users_id_fk", + "tableFrom": "homepage_layouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_access": { + "name": "host_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "override_credential_id": { + "name": "override_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_override_credential_id_ssh_credentials_id_fk": { + "name": "host_access_override_credential_id_ssh_credentials_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "override_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_health_checks": { + "name": "host_health_checks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checks": { + "name": "checks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 300 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_health_checks_user_host": { + "name": "idx_host_health_checks_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "host_health_checks_user_id_users_id_fk": { + "name": "host_health_checks_user_id_users_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_checks_host_id_ssh_data_id_fk": { + "name": "host_health_checks_host_id_ssh_data_id_fk", + "tableFrom": "host_health_checks", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_health_history": { + "name": "host_health_history", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "check_id": { + "name": "check_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "ok": { + "name": "ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_health_history_user_id_users_id_fk": { + "name": "host_health_history_user_id_users_id_fk", + "tableFrom": "host_health_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_health_history_host_id_ssh_data_id_fk": { + "name": "host_health_history_host_id_ssh_data_id_fk", + "tableFrom": "host_health_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_metrics_history": { + "name": "host_metrics_history", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ts": { + "name": "ts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_percent": { + "name": "mem_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disk_percent": { + "name": "disk_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_rx_bytes": { + "name": "net_rx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "net_tx_bytes": { + "name": "net_tx_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "host_metrics_history_host_id_ssh_data_id_fk": { + "name": "host_metrics_history_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_history", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_metrics_preferences": { + "name": "host_metrics_preferences", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layout": { + "name": "layout", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_metrics_prefs_user_host": { + "name": "idx_host_metrics_prefs_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "host_metrics_preferences_user_id_users_id_fk": { + "name": "host_metrics_preferences_user_id_users_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_metrics_preferences_host_id_ssh_data_id_fk": { + "name": "host_metrics_preferences_host_id_ssh_data_id_fk", + "tableFrom": "host_metrics_preferences", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_data": { + "name": "ssh_data", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "use_warpgate": { + "name": "use_warpgate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_password": { + "name": "autostart_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key": { + "name": "autostart_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autostart_key_password": { + "name": "autostart_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vault_profile_id": { + "name": "vault_profile_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_terminal": { + "name": "enable_terminal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_session_logging": { + "name": "enable_session_logging", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "allow_session_sharing": { + "name": "allow_session_sharing", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_command_history": { + "name": "enable_command_history", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_tunnel": { + "name": "enable_tunnel", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "tunnel_connections": { + "name": "tunnel_connections", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_file_manager": { + "name": "enable_file_manager", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "scp_legacy": { + "name": "scp_legacy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_docker": { + "name": "enable_docker", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_tmux_monitor": { + "name": "enable_tmux_monitor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_terminal_in_sidebar": { + "name": "show_terminal_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_file_manager_in_sidebar": { + "name": "show_file_manager_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_tunnel_in_sidebar": { + "name": "show_tunnel_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_docker_in_sidebar": { + "name": "show_docker_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_server_stats_in_sidebar": { + "name": "show_server_stats_in_sidebar", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_path": { + "name": "default_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats_config": { + "name": "stats_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "docker_config": { + "name": "docker_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_proxmox": { + "name": "enable_proxmox", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "proxmox_config": { + "name": "proxmox_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_config": { + "name": "terminal_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quick_actions": { + "name": "quick_actions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_rdp": { + "name": "enable_rdp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_vnc": { + "name": "enable_vnc", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_telnet": { + "name": "enable_telnet", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ssh_port": { + "name": "ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "rdp_port": { + "name": "rdp_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 3389 + }, + "vnc_port": { + "name": "vnc_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 5900 + }, + "telnet_port": { + "name": "telnet_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 23 + }, + "rdp_credential_id": { + "name": "rdp_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_user": { + "name": "rdp_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_password": { + "name": "rdp_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_domain": { + "name": "rdp_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_security": { + "name": "rdp_security", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_ignore_cert": { + "name": "rdp_ignore_cert", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "vnc_credential_id": { + "name": "vnc_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_password": { + "name": "vnc_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_user": { + "name": "vnc_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_user": { + "name": "telnet_user", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_password": { + "name": "telnet_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_credential_id": { + "name": "telnet_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rdp_auth_type": { + "name": "rdp_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vnc_auth_type": { + "name": "vnc_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telnet_auth_type": { + "name": "telnet_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "security": { + "name": "security", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ignore_cert": { + "name": "ignore_cert", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "guacamole_config": { + "name": "guacamole_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_socks5": { + "name": "use_socks5", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wol_broadcast_address": { + "name": "wol_broadcast_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'sha256'" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vault_profile_id_vault_profiles_id_fk": { + "name": "ssh_data_vault_profile_id_vault_profiles_id_fk", + "tableFrom": "ssh_data", + "tableTo": "vault_profiles", + "columnsFrom": [ + "vault_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_rdp_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_rdp_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "rdp_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_vnc_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_vnc_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "vnc_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_telnet_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_telnet_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "telnet_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "network_topology": { + "name": "network_topology", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "topology": { + "name": "topology", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "network_topology_user_id_users_id_fk": { + "name": "network_topology_user_id_users_id_fk", + "tableFrom": "network_topology", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_channels": { + "name": "notification_channels", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channels_user_id_users_id_fk": { + "name": "notification_channels_user_id_users_id_fk", + "tableFrom": "notification_channels", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "opkssh_tokens": { + "name": "opkssh_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_opkssh_tokens_user_host": { + "name": "idx_opkssh_tokens_user_host", + "columns": [ + "user_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "opkssh_tokens_user_id_users_id_fk": { + "name": "opkssh_tokens_user_id_users_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "opkssh_tokens_host_id_ssh_data_id_fk": { + "name": "opkssh_tokens_host_id_ssh_data_id_fk", + "tableFrom": "opkssh_tokens", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "recent_activity": { + "name": "recent_activity", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "recent_activity_user_id_users_id_fk": { + "name": "recent_activity_user_id_users_id_fk", + "tableFrom": "recent_activity", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recent_activity_host_id_ssh_data_id_fk": { + "name": "recent_activity_host_id_ssh_data_id_fk", + "tableFrom": "recent_activity", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_recordings": { + "name": "session_recordings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_id": { + "name": "access_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "ended_at": { + "name": "ended_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commands": { + "name": "commands", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dangerous_actions": { + "name": "dangerous_actions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recording_path": { + "name": "recording_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'text'" + }, + "terminated_by_owner": { + "name": "terminated_by_owner", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "termination_reason": { + "name": "termination_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_recordings_host_id_ssh_data_id_fk": { + "name": "session_recordings_host_id_ssh_data_id_fk", + "tableFrom": "session_recordings", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_recordings_user_id_users_id_fk": { + "name": "session_recordings_user_id_users_id_fk", + "tableFrom": "session_recordings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "session_recordings_access_id_host_access_id_fk": { + "name": "session_recordings_access_id_host_access_id_fk", + "tableFrom": "session_recordings", + "tableTo": "host_access", + "columnsFrom": [ + "access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_share_participants": { + "name": "session_share_participants", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "share_id": { + "name": "share_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "guest_label": { + "name": "guest_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "left_at": { + "name": "left_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_share_participants_share_id_session_shares_id_fk": { + "name": "session_share_participants_share_id_session_shares_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "session_shares", + "columnsFrom": [ + "share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_share_participants_user_id_users_id_fk": { + "name": "session_share_participants_user_id_users_id_fk", + "tableFrom": "session_share_participants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_shares": { + "name": "session_shares", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_instance_id": { + "name": "tab_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_type": { + "name": "share_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "link_token": { + "name": "link_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'read-only'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_joined_at": { + "name": "last_joined_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "join_count": { + "name": "join_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "session_shares_link_token_unique": { + "name": "session_shares_link_token_unique", + "columns": [ + "link_token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_shares_host_id_ssh_data_id_fk": { + "name": "session_shares_host_id_ssh_data_id_fk", + "tableFrom": "session_shares", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_owner_user_id_users_id_fk": { + "name": "session_shares_owner_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_shares_target_user_id_users_id_fk": { + "name": "session_shares_target_user_id_users_id_fk", + "tableFrom": "session_shares", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_sid": { + "name": "oidc_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_host_secrets": { + "name": "shared_host_secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credential'" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + "host_access_id", + "target_user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "snippet_access": { + "name": "snippet_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "snippet_id": { + "name": "snippet_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'view'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "snippet_access_snippet_id_snippets_id_fk": { + "name": "snippet_access_snippet_id_snippets_id_fk", + "tableFrom": "snippet_access", + "tableTo": "snippets", + "columnsFrom": [ + "snippet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_user_id_users_id_fk": { + "name": "snippet_access_user_id_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_role_id_roles_id_fk": { + "name": "snippet_access_role_id_roles_id_fk", + "tableFrom": "snippet_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "snippet_access_granted_by_users_id_fk": { + "name": "snippet_access_granted_by_users_id_fk", + "tableFrom": "snippet_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "snippet_folders": { + "name": "snippet_folders", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "snippet_folders_sync_id_unique": { + "name": "snippet_folders_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "snippet_folders_user_id_users_id_fk": { + "name": "snippet_folders_user_id_users_id_fk", + "tableFrom": "snippet_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "snippets": { + "name": "snippets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "host_filter": { + "name": "host_filter", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "snippets_sync_id_unique": { + "name": "snippets_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "snippets_user_id_users_id_fk": { + "name": "snippets_user_id_users_id_fk", + "tableFrom": "snippets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_credential_usage": { + "name": "ssh_credential_usage", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_credentials": { + "name": "ssh_credentials", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(4096)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_folders": { + "name": "ssh_folders", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_folders_credential_id_ssh_credentials_id_fk": { + "name": "ssh_folders_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sso_providers": { + "name": "sso_providers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_tombstones": { + "name": "sync_tombstones", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "sync_tombstones_user_id_users_id_fk": { + "name": "sync_tombstones_user_id_users_id_fk", + "tableFrom": "sync_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "termix_identities": { + "name": "termix_identities", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "termix_identities_user_id_unique": { + "name": "termix_identities_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + }, + "termix_identities_handle_unique": { + "name": "termix_identities_handle_unique", + "columns": [ + "handle" + ], + "isUnique": true + } + }, + "foreignKeys": { + "termix_identities_user_id_users_id_fk": { + "name": "termix_identities_user_id_users_id_fk", + "tableFrom": "termix_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "termix_identity_ca": { + "name": "termix_identity_ca", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(4096)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validity_days": { + "name": "validity_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "termix_identity_ca_identity_id_unique": { + "name": "termix_identity_ca_identity_id_unique", + "columns": [ + "identity_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "termix_identity_ca_identity_id_termix_identities_id_fk": { + "name": "termix_identity_ca_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_ca_user_id_users_id_fk": { + "name": "termix_identity_ca_user_id_users_id_fk", + "tableFrom": "termix_identity_ca", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "termix_identity_keys": { + "name": "termix_identity_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "identity_id": { + "name": "identity_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "termix_identity_keys_identity_id_termix_identities_id_fk": { + "name": "termix_identity_keys_identity_id_termix_identities_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "termix_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_user_id_users_id_fk": { + "name": "termix_identity_keys_user_id_users_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "termix_identity_keys_credential_id_ssh_credentials_id_fk": { + "name": "termix_identity_keys_credential_id_ssh_credentials_id_fk", + "tableFrom": "termix_identity_keys", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tmux_session_tags": { + "name": "tmux_session_tags", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_name": { + "name": "session_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "tmux_session_tags_user_id_users_id_fk": { + "name": "tmux_session_tags_user_id_users_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tmux_session_tags_host_id_ssh_data_id_fk": { + "name": "tmux_session_tags_host_id_ssh_data_id_fk", + "tableFrom": "tmux_session_tags", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "transfer_recent": { + "name": "transfer_recent", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_host_id": { + "name": "source_host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_host_id": { + "name": "dest_host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_path": { + "name": "dest_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_path_label": { + "name": "dest_path_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "transfer_recent_user_id_users_id_fk": { + "name": "transfer_recent_user_id_users_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_source_host_id_ssh_data_id_fk": { + "name": "transfer_recent_source_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "source_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transfer_recent_dest_host_id_ssh_data_id_fk": { + "name": "transfer_recent_dest_host_id_ssh_data_id_fk", + "tableFrom": "transfer_recent", + "tableTo": "ssh_data", + "columnsFrom": [ + "dest_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "trusted_devices": { + "name": "trusted_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_open_tabs": { + "name": "user_open_tabs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_autocomplete": { + "name": "command_autocomplete", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_palette_enabled": { + "name": "command_palette_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_host_tags": { + "name": "show_host_tags", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_tray_on_click": { + "name": "host_tray_on_click", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_app_rail": { + "name": "pin_app_rail", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expand_app_rail_on_hover": { + "name": "expand_app_rail_on_hover", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_snippet_execution": { + "name": "confirm_snippet_execution", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_themes": { + "name": "custom_themes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + "user_id", + "role_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_oidc": { + "name": "is_oidc", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "oidc_identifier": { + "name": "oidc_identifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_provider_id": { + "name": "sso_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identifier_path": { + "name": "identifier_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_path": { + "name": "name_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'openid email profile'" + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "totp_backup_codes": { + "name": "totp_backup_codes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "donation_modal_dismissed": { + "name": "donation_modal_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_profiles": { + "name": "vault_profiles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vault_addr": { + "name": "vault_addr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vault_namespace": { + "name": "vault_namespace", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_mount": { + "name": "oidc_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_role": { + "name": "oidc_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_mount": { + "name": "ssh_mount", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_role": { + "name": "ssh_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "valid_principals": { + "name": "valid_principals", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared": { + "name": "shared", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "vault_profiles_sync_id_unique": { + "name": "vault_profiles_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_profiles_user_id_users_id_fk": { + "name": "vault_profiles_user_id_users_id_fk", + "tableFrom": "vault_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_tokens": { + "name": "vault_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "profile_id": { + "name": "profile_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ssh_cert": { + "name": "ssh_cert", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_vault_tokens_user_profile": { + "name": "idx_vault_tokens_user_profile", + "columns": [ + "user_id", + "profile_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_tokens_user_id_users_id_fk": { + "name": "vault_tokens_user_id_users_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_tokens_profile_id_vault_profiles_id_fk": { + "name": "vault_tokens_profile_id_vault_profiles_id_fk", + "tableFrom": "vault_tokens", + "tableTo": "vault_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webauthn_credentials": { + "name": "webauthn_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_verification": { + "name": "user_verification", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'preferred'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "webauthn_credentials_user_id_users_id_fk": { + "name": "webauthn_credentials_user_id_users_id_fk", + "tableFrom": "webauthn_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json new file mode 100644 index 00000000..3ac97ac9 --- /dev/null +++ b/drizzle/sqlite/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1785276655786, + "tag": "0000_clever_hercules", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index ee87445c..78798841 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -46,4 +46,57 @@ export default tseslint.config([ "react-refresh/only-export-components": "warn", }, }, + { + // MySQL has no RETURNING clause, and drizzle's mysql-core does not expose + // the method at all — a bare .returning() is a TypeError there, not a bad + // query, and it only fails on the engine no test in this repo runs against. + // + // 175 call sites were migrated off it. This is what stops number 176. + // Writes that need rows back go through repositories/returning.ts, which + // picks one statement or a read-then-write transaction per dialect. + files: ["src/backend/database/repositories/**/*.ts"], + ignores: [ + // The two files whose job is to absorb these differences. + "src/backend/database/repositories/returning.ts", + "src/backend/database/repositories/mutation-result.ts", + ], + rules: { + "no-restricted-syntax": [ + "error", + { + selector: "CallExpression[callee.property.name='returning']", + message: + "MySQL has no RETURNING. Use insertReturning/updateReturning/deleteReturning from ./returning.js, or rowsAffected() if you only need a count. Inside a proven sqlite-only branch, disable this rule with a comment saying so.", + }, + { + // `||` concatenates on SQLite and Postgres. On MySQL it is logical OR + // unless the server runs with PIPES_AS_CONCAT, so a folder path built + // this way silently became 0. Use CONCAT, which all three agree on. + selector: + "TaggedTemplateExpression[tag.name='sql'] TemplateElement[value.raw=/\\|\\|/]", + message: + "`||` is logical OR on MySQL, not concatenation. Use CONCAT(...).", + }, + { + // Postgres and SQLite spell it ON CONFLICT; MySQL spells it ON + // DUPLICATE KEY and names no columns, so drizzle's mysql-core has no + // onConflictDoUpdate at all — another TypeError, not a bad query. + selector: "CallExpression[callee.property.name='onConflictDoUpdate']", + message: + "MySQL has no ON CONFLICT. Use upsert() from ./returning.js.", + }, + { + // better-sqlite3 puts these on a write result; node-postgres and + // mysql2 do not, so reading them directly yields undefined — and + // Number(undefined) is NaN, which reaches the database as the string + // "NaN" and fails an integer column. Three call sites did exactly + // this and only broke on Postgres. + selector: + "MemberExpression[property.name=/^(lastInsertRowid|changes)$/]", + message: + "lastInsertRowid and changes are better-sqlite3 only. Use insertedId() or rowsAffected() from ./mutation-result.js.", + }, + ], + }, + }, ]); diff --git a/package-lock.json b/package-lock.json index fc364e21..a30ee8ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,9 @@ "ldapjs": "^3.0.7", "motion": "^12.42.2", "multer": "^2.2.0", + "mysql2": "^3.23.2", "nanoid": "^6.0.0", + "pg": "^8.22.0", "qrcode": "^1.5.4", "serialport": "^13.0.0", "socks": "^2.8.7", @@ -88,6 +90,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.1.0", "@types/node": "^26.0.0", + "@types/pg": "^8.20.0", "@types/qrcode": "^1.5.6", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", @@ -110,6 +113,7 @@ "cmdk": "^1.1.1", "concurrently": "^10.0.4", "cytoscape": "^3.34.0", + "drizzle-kit": "^0.31.10", "electron": "^43.0.0", "electron-builder": "^26.15.3", "eslint": "^10.5.0", @@ -1688,6 +1692,13 @@ "node": ">=20.0.0" } }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@electron-internal/extract-zip": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.3.tgz", @@ -2021,6 +2032,884 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -7017,6 +7906,18 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/prismjs": { "version": "1.26.6", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", @@ -8225,6 +9126,15 @@ "node": ">= 4.0.0" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/aws4": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", @@ -9554,6 +10464,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -9760,6 +10679,22 @@ "url": "https://dotenvx.com" } }, + "node_modules/drizzle-kit": { + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, "node_modules/drizzle-orm": { "version": "0.45.2", "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", @@ -10232,6 +11167,48 @@ "benchmarks" ] }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -10961,6 +11938,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -11040,6 +12026,19 @@ "node": ">= 0.4" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/git-raw-commits": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-5.0.1.tgz", @@ -11739,6 +12738,12 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -12733,6 +13738,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -12792,6 +13803,21 @@ "yallist": "^3.0.2" } }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/lucide-react": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.20.0.tgz", @@ -14067,6 +15093,40 @@ "node": ">= 0.6" } }, + "node_modules/mysql2": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.2.tgz", + "integrity": "sha512-fxh3HpQ8vJtu/Mmnd4Xsur19jGjHGzRLMxptiDtOkbX7EVBgnafGSGDx1WGGVmJLClVh2LeeBMMo24IFv8wCyQ==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nan": { "version": "2.27.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", @@ -14592,6 +15652,95 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -14715,6 +15864,45 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/precond": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", @@ -15707,6 +16895,16 @@ "node": ">=8" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -16293,6 +17491,30 @@ "node": ">= 0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, "node_modules/ssh2": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", @@ -16816,6 +18038,509 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/tsyringe": { "version": "4.10.0", "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", @@ -17707,6 +19432,15 @@ "dev": true, "license": "MIT" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 8296f502..78b690c3 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,11 @@ "biome:fix": "biome check --write biome.json package.json", "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-guacamole-common-js.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-xterm-android-ime.cjs", "prebuild": "node scripts/write-electron-build-info.cjs", - "lint": "eslint .", + "lint": "node scripts/generate-dialect-schema.cjs --check && eslint .", "lint:fix": "eslint --fix .", "type-check": "tsc --noEmit", "test": "vitest run", + "verify:dialect": "tsx scripts/verify-dialects.mjs", "test:watch": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", @@ -40,7 +41,10 @@ "build:linux-appimage": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --linux AppImage", "build:linux-targz": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --linux tar.gz", "build:mac": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --mac --universal", - "build:mac-dev": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --mac dir --publish=never" + "build:mac-dev": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --mac dir --publish=never", + "schema:generate": "node scripts/generate-dialect-schema.cjs", + "schema:check": "node scripts/generate-dialect-schema.cjs --check", + "schema:migrations": "drizzle-kit generate --config=drizzle.config.sqlite.ts && drizzle-kit generate --config=drizzle.config.pg.ts && drizzle-kit generate --config=drizzle.config.mysql.ts" }, "dependencies": { "@simplewebauthn/browser": "^13.3.0", @@ -65,7 +69,9 @@ "ldapjs": "^3.0.7", "motion": "^12.42.2", "multer": "^2.2.0", + "mysql2": "^3.23.2", "nanoid": "^6.0.0", + "pg": "^8.22.0", "qrcode": "^1.5.4", "serialport": "^13.0.0", "socks": "^2.8.7", @@ -122,6 +128,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.1.0", "@types/node": "^26.0.0", + "@types/pg": "^8.20.0", "@types/qrcode": "^1.5.6", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", @@ -144,6 +151,7 @@ "cmdk": "^1.1.1", "concurrently": "^10.0.4", "cytoscape": "^3.34.0", + "drizzle-kit": "^0.31.10", "electron": "^43.0.0", "electron-builder": "^26.15.3", "eslint": "^10.5.0", diff --git a/scripts/generate-dialect-schema.cjs b/scripts/generate-dialect-schema.cjs new file mode 100644 index 00000000..06c5b0c4 --- /dev/null +++ b/scripts/generate-dialect-schema.cjs @@ -0,0 +1,228 @@ +/** + * Generates the Postgres and MySQL schema modules from the SQLite one. + * + * ## These files produce DDL. They are not used at runtime. + * + * drizzle-kit reads them to emit the migrations in drizzle/postgres and + * drizzle/mysql. Nothing imports them to run a query. + * + * That is not an oversight. The query builder needs two things from a table + * object — the identifiers to interpolate, and the encoders that turn JS values + * into driver values — and the sqlite definitions supply both correctly for + * every engine, which is why all 44 repositories import schema.ts directly: + * + * - text and integer encode as themselves everywhere + * - integer({ mode: "boolean" }) writes 1/0, which Postgres and MySQL both + * accept for a boolean column, and reads back through `Number(v) === 1`, + * which is true for JS `true` as well as for 1 + * - real is a plain number on all three + * + * What genuinely differs between the dialects is DDL — column types, key + * lengths, autoincrement syntax — and DDL is exactly what these files exist to + * generate. See scripts/verify-dialects.mjs, which asserts the round-trips + * above against real servers rather than trusting this comment. + * + * The schema is declared once, in sqlite-core, and the other two dialects are + * derived. Hand-maintaining three copies of 52 tables would mean a renamed + * table has to land in three places consistently or a foreign key silently + * points at the wrong one — and the schema is regular enough that the mapping + * is mechanical. + * + * What varies between dialects is small and closed: + * - booleans are integers on sqlite, native elsewhere + * - autoincrement keys are `integer primary key autoincrement`, `serial`, + * and `int auto_increment` + * - MySQL cannot index unbounded TEXT, so any column that is a primary key, + * is unique, or participates in a foreign key must be varchar + * - MySQL rejects a bare DEFAULT CURRENT_TIMESTAMP on a text column, so it is + * written as a parenthesised expression default + * + * Usage: node scripts/generate-dialect-schema.cjs [--check] + * --check verifies the committed files match what would be generated, + * for CI to catch a schema edit that forgot to regenerate. + */ + +const fs = require("fs"); +const path = require("path"); + +const ROOT = path.join(__dirname, ".."); +const SOURCE = path.join(ROOT, "src/backend/database/db/schema.ts"); +const TARGETS = { + postgres: path.join(ROOT, "src/backend/database/db/schema.pg.ts"), + mysql: path.join(ROOT, "src/backend/database/db/schema.mysql.ts"), +}; + +const KEY_LENGTH = 255; + +/** + * Columns that must be varchar rather than text on MySQL. A column qualifies if + * it is a primary key, is unique, or is either end of a foreign key. + */ +function collectKeyColumns(source) { + const keyed = new Set(); + + // `name: text("col")....primaryKey()` / `.unique()` / `.references(...)` + const declaration = + /(\w+):\s*text\("([a-z0-9_]+)"\)((?:\s*\.\w+\([^)]*\))*)/g; + let match; + while ((match = declaration.exec(source)) !== null) { + const [, prop, column, modifiers] = match; + if (/\.(primaryKey|unique|references)\(/.test(modifiers)) { + keyed.add(column); + } + void prop; + } + + // Multi-line form: the modifiers land on following lines. + const multiline = + /(\w+):\s*text\("([a-z0-9_]+)"\)\s*\n(\s*\.\w+\([\s\S]*?\),)/g; + while ((match = multiline.exec(source)) !== null) { + if (/\.(primaryKey|unique|references)\(/.test(match[3])) { + keyed.add(match[2]); + } + } + + // A referenced column implies the referencing side too; both must match. + const reference = /\.references\(\(\)\s*=>\s*\w+\.(\w+)/g; + while ((match = reference.exec(source)) !== null) { + keyed.add(camelToSnake(match[1])); + } + + // Table-level indexes: `(table) => [uniqueIndex("x").on(table.a, table.b)]`. + // These were invisible here at first, and MySQL rejected the migration with + // "BLOB/TEXT column used in key specification without a key length" — but + // only on MySQL 8; MariaDB took it. + const tableIndex = /uniqueIndex\("[a-z0-9_]+"\)\.on\(([^)]*)\)/g; + while ((match = tableIndex.exec(source)) !== null) { + for (const column of match[1].split(",")) { + const name = column.trim().replace(/^\w+\./, ""); + if (name) keyed.add(camelToSnake(name)); + } + } + + return keyed; +} + +function camelToSnake(value) { + return value.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); +} + +function transform(source, dialect) { + const keyed = collectKeyColumns(source); + const isPg = dialect === "postgres"; + let out = source; + + // Autoincrement primary keys, before the plain integer rule below. + out = out.replace( + /integer\("([a-z0-9_]+)"\)\.primaryKey\(\{\s*autoIncrement:\s*true\s*\}\)/g, + (_, col) => + isPg + ? `serial("${col}").primaryKey()` + : `int("${col}").autoincrement().primaryKey()`, + ); + + // Integer-backed booleans become native ones. Prettier wraps the longer + // declarations across lines, so this has to span newlines too. + out = out.replace( + /integer\(\s*"([a-z0-9_]+)",\s*\{\s*mode:\s*"boolean",?\s*\},?\s*\)/g, + (_, col) => `boolean("${col}")`, + ); + + // Remaining integers. + if (!isPg) { + out = out.replace( + /\binteger\("([a-z0-9_]+)"\)/g, + (_, col) => `int("${col}")`, + ); + + // Timestamps are stored as text (see sql-timestamp.ts). MySQL only accepts + // DEFAULT CURRENT_TIMESTAMP on a DATETIME or TIMESTAMP column — on a TEXT + // one it is ER_INVALID_DEFAULT, "Invalid default value". Since 8.0.13 an + // expression default works on any type, and an expression is written + // parenthesised. MariaDB accepts the bare form, which is why this only + // surfaces against real MySQL. + out = out.replace(/sql`CURRENT_TIMESTAMP`/g, "sql`(CURRENT_TIMESTAMP)`"); + } + + // Floating point. + out = out.replace(/\breal\("([a-z0-9_]+)"\)/g, (_, col) => + isPg ? `doublePrecision("${col}")` : `double("${col}")`, + ); + + // Key-bearing strings must be indexable. + out = out.replace(/\btext\("([a-z0-9_]+)"\)/g, (whole, col) => + keyed.has(col) ? `varchar("${col}", { length: ${KEY_LENGTH} })` : whole, + ); + + // text("x", { length: n }) is sqlite-only sugar; drop the length. + out = out.replace( + /\btext\("([a-z0-9_]+)",\s*\{\s*length:\s*\d+\s*\}\)/g, + (_, col) => `text("${col}")`, + ); + + out = out.replace(/\bsqliteTable\(/g, isPg ? "pgTable(" : "mysqlTable("); + + const imports = isPg + ? `import {\n pgTable,\n text,\n varchar,\n integer,\n serial,\n boolean,\n doublePrecision,\n uniqueIndex,\n} from "drizzle-orm/pg-core";` + : `import {\n mysqlTable,\n text,\n varchar,\n int,\n boolean,\n double,\n uniqueIndex,\n} from "drizzle-orm/mysql-core";`; + + out = out.replace( + /import\s*\{[^}]*\}\s*from\s*"drizzle-orm\/sqlite-core";/, + imports, + ); + + return `${header(dialect)}\n${out}`; +} + +function header(dialect) { + return `// GENERATED FILE — do not edit. +// +// Produced from schema.ts by scripts/generate-dialect-schema.cjs. +// Edit the sqlite schema and re-run \`node scripts/generate-dialect-schema.cjs\`. +// Target dialect: ${dialect}. +// +// DDL source for drizzle-kit. NOT imported to run queries — repositories use +// schema.ts on every dialect. See the generator header for why that is correct. +`; +} + +function main() { + const check = process.argv.includes("--check"); + const source = fs.readFileSync(SOURCE, "utf8"); + let drift = false; + + for (const [dialect, target] of Object.entries(TARGETS)) { + const generated = transform(source, dialect); + + if (check) { + const current = fs.existsSync(target) + ? fs.readFileSync(target, "utf8") + : ""; + if (current !== generated) { + console.error( + `[generate-dialect-schema] ${path.relative(ROOT, target)} is out of date`, + ); + drift = true; + } + continue; + } + + fs.writeFileSync(target, generated); + console.log( + `[generate-dialect-schema] wrote ${path.relative(ROOT, target)}`, + ); + } + + if (drift) { + console.error( + "[generate-dialect-schema] run `node scripts/generate-dialect-schema.cjs` and commit the result", + ); + process.exit(1); + } +} + +module.exports = { transform, collectKeyColumns }; + +if (require.main === module) { + main(); +} diff --git a/scripts/generate-dialect-schema.test.ts b/scripts/generate-dialect-schema.test.ts new file mode 100644 index 00000000..dad931f3 --- /dev/null +++ b/scripts/generate-dialect-schema.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { transform, collectKeyColumns } = + require("./generate-dialect-schema.cjs") as { + transform: (source: string, dialect: "postgres" | "mysql") => string; + collectKeyColumns: (source: string) => Set; + }; + +const SOURCE = `import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core"; +import { sql } from "drizzle-orm"; + +export const users = sqliteTable("users", { + id: text("id").primaryKey(), + username: text("username").notNull(), + isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false), + wrapped: integer("wrapped", { + mode: "boolean", + }) + .notNull() + .default(true), + score: real("score"), + ssoProviderId: integer("sso_provider_id"), +}); + +export const folders = sqliteTable("folders", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + syncId: text("sync_id").unique(), + cert: text("cert", { length: 8192 }), +}); +`; + +describe("collectKeyColumns", () => { + it("finds columns that must be indexable", () => { + const keyed = collectKeyColumns(SOURCE); + + // primary key, unique, and both ends of the foreign key + expect(keyed.has("id")).toBe(true); + expect(keyed.has("sync_id")).toBe(true); + expect(keyed.has("user_id")).toBe(true); + }); + + it("leaves ordinary strings alone", () => { + const keyed = collectKeyColumns(SOURCE); + + expect(keyed.has("username")).toBe(false); + expect(keyed.has("name")).toBe(false); + expect(keyed.has("cert")).toBe(false); + }); +}); + +describe("postgres output", () => { + const out = transform(SOURCE, "postgres"); + + it("is marked generated", () => { + expect(out.startsWith("// GENERATED FILE")).toBe(true); + }); + + it("uses pg-core", () => { + expect(out).toContain('from "drizzle-orm/pg-core"'); + expect(out).not.toContain("sqlite-core"); + expect(out).toContain("pgTable("); + expect(out).not.toContain("sqliteTable("); + }); + + it("maps autoincrement keys to serial", () => { + expect(out).toContain('serial("id").primaryKey()'); + expect(out).not.toContain("autoIncrement"); + }); + + it("maps integer-backed booleans, including the wrapped form", () => { + expect(out).toContain('boolean("is_admin")'); + // Prettier splits longer declarations across lines; both must convert. + expect(out).toContain('boolean("wrapped")'); + expect(out).not.toMatch(/mode:\s*"boolean"/); + }); + + it("keeps plain integers and maps real", () => { + expect(out).toContain('integer("sso_provider_id")'); + expect(out).toContain('doublePrecision("score")'); + }); + + it("makes key columns varchar and leaves the rest text", () => { + expect(out).toContain('varchar("id", { length: 255 })'); + expect(out).toContain('varchar("user_id", { length: 255 })'); + expect(out).toContain('varchar("sync_id", { length: 255 })'); + expect(out).toContain('text("username")'); + expect(out).toContain('text("name")'); + }); + + it("drops the sqlite-only text length", () => { + expect(out).toContain('text("cert")'); + expect(out).not.toContain("length: 8192"); + }); +}); + +describe("mysql output", () => { + const out = transform(SOURCE, "mysql"); + + it("uses mysql-core", () => { + expect(out).toContain('from "drizzle-orm/mysql-core"'); + expect(out).toContain("mysqlTable("); + }); + + it("maps autoincrement keys to int auto_increment", () => { + expect(out).toContain('int("id").autoincrement().primaryKey()'); + }); + + it("renames integer to int", () => { + expect(out).toContain('int("sso_provider_id")'); + expect(out).not.toMatch(/\binteger\(/); + }); + + it("maps real to double", () => { + expect(out).toContain('double("score")'); + }); + + it("makes key columns varchar — MySQL cannot index unbounded TEXT", () => { + expect(out).toContain('varchar("user_id", { length: 255 })'); + expect(out).toContain('text("name")'); + }); +}); + +describe("determinism", () => { + it("produces identical output for identical input", () => { + expect(transform(SOURCE, "postgres")).toBe(transform(SOURCE, "postgres")); + expect(transform(SOURCE, "mysql")).toBe(transform(SOURCE, "mysql")); + }); + + it("keeps foreign key behaviour verbatim", () => { + for (const dialect of ["postgres", "mysql"] as const) { + expect(transform(SOURCE, dialect)).toContain('onDelete: "cascade"'); + } + }); +}); diff --git a/scripts/verify-dialects.mjs b/scripts/verify-dialects.mjs new file mode 100644 index 00000000..308a530b --- /dev/null +++ b/scripts/verify-dialects.mjs @@ -0,0 +1,140 @@ +/** + * Runs the repository layer against a real Postgres or MySQL server. + * + * The unit tests only ever see SQLite, so the parts of this codebase that + * differ per engine — the RETURNING replacements, the read-then-write + * transactions, the value encoders — have no coverage there at all. This is + * what covers them, and it needs a live server, which is why it is a script + * rather than a test. + * + * Usage: + * npm run verify:dialect -- postgres://user:pass@host:5432/db + * npm run verify:dialect -- mysql://user:pass@host:3306/db + * + * Applies the migrations first, through the same runRemoteMigrations() the + * application uses at startup — so a broken migration fails here rather than in + * production. Writes real rows: point it at a scratch database. + */ + +import { randomUUID } from "crypto"; + +const url = process.argv[2]; +if (!url) { + console.error("usage: node scripts/verify-dialects.mjs "); + process.exit(2); +} + +const scheme = url.split("://", 1)[0].toLowerCase(); +const dialect = scheme.startsWith("postgres") + ? "postgres" + : scheme === "mysql" || scheme === "mariadb" + ? "mysql" + : null; + +if (!dialect) { + console.error(`unsupported URL scheme "${scheme}://"`); + process.exit(2); +} + +const { drizzle } = await import( + dialect === "postgres" ? "drizzle-orm/node-postgres" : "drizzle-orm/mysql2" +); + +// No schema option on purpose — see connect.ts. +const db = drizzle(url); +const context = { dialect, drizzle: db }; + +const { runRemoteMigrations } = + await import("../src/backend/database/db/migrate.js"); +await runRemoteMigrations(dialect, db); + +const { UserRepository } = + await import("../src/backend/database/repositories/user-repository.js"); +const { HostRepository } = + await import("../src/backend/database/repositories/host-repository.js"); +const { SettingsRepository } = + await import("../src/backend/database/repositories/settings-repository.js"); + +let failures = 0; +const check = (label, got, want) => { + const ok = JSON.stringify(got) === JSON.stringify(want); + if (!ok) failures++; + console.log( + ` ${ok ? "ok " : "FAIL"} ${label}` + + (ok + ? "" + : `\n got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`), + ); +}; + +console.log(`\nverifying ${dialect} at ${url.replace(/:[^:@]*@/, ":***@")}\n`); + +const users = new UserRepository(context); +const userId = `verify-${randomUUID()}`; + +// insertReturning: on MySQL this is an insert plus a read inside a transaction. +const created = await users.create({ + id: userId, + username: "before", + passwordHash: "x", + isAdmin: true, +}); +check("insert returns the stored row", created?.username, "before"); + +// The one non-identity value encoder in the schema. Booleans are integers in +// the sqlite definitions the repositories import, so this asserts that 1/0 +// survives a round trip through a native boolean column. +check("boolean true survives the round trip", created?.isAdmin, true); + +// updateReturning must report the state AFTER the write. Reading first would +// return the value the update replaced — silently, with no error. +const updated = await users.update(userId, { username: "after" }); +check("update returns the new value", updated?.username, "after"); + +const hosts = new HostRepository(context); +const host = await hosts.create({ + userId, + name: "verify", + ip: "127.0.0.1", + port: 22, + username: "root", + authType: "password", + enableTerminal: true, +}); +check( + "autoincrement id came back", + typeof host?.id === "number" && host.id > 0, + true, +); +check( + "database-assigned createdAt came back", + typeof host?.createdAt === "string" && host.createdAt.length > 0, + true, +); + +// deleteReturning must report the state BEFORE the write. Reading afterwards +// would find nothing at all. +const settings = new SettingsRepository(context); +const prefix = `verify-${randomUUID()}`; +await settings.set(`${prefix}-a`, "1"); +await settings.set(`${prefix}-b`, "2"); +check( + "delete reports the rows it removed", + await settings.deleteLike(`${prefix}-%`), + 2, +); +check( + "and they are actually gone", + (await settings.listAll()).filter((row) => row.key.startsWith(prefix)).length, + 0, +); + +await hosts.deleteForUser(userId, host.id); +check("host really deleted", await hosts.findById(host.id), null); + +console.log( + failures === 0 + ? `\n${dialect}: all checks passed\n` + : `\n${dialect}: ${failures} FAILED\n`, +); +process.exit(failures === 0 ? 0 : 1); diff --git a/src/backend/database/db/column-kit.ts b/src/backend/database/db/column-kit.ts new file mode 100644 index 00000000..bcda1b8d --- /dev/null +++ b/src/backend/database/db/column-kit.ts @@ -0,0 +1,71 @@ +import * as sqlite from "drizzle-orm/sqlite-core"; +import * as pg from "drizzle-orm/pg-core"; +import * as mysql from "drizzle-orm/mysql-core"; + +/** + * Per-dialect column constructors, so a table can be declared once instead of + * three times. + * + * The existing schema only uses three column types (text, integer, real) plus + * an integer-backed boolean, which is what makes this tractable — the surface + * to abstract is small and closed. Anything a dialect cannot express the same + * way is spelled out here rather than at 52 call sites. + * + * Notable differences this papers over: + * - booleans are integers in SQLite, native in Postgres and tinyint in MySQL + * - autoincrement keys are `integer primary key autoincrement`, `serial`, and + * `int auto_increment` respectively + * - MySQL cannot index an unbounded TEXT, so keyed/indexed strings must be + * varchar; `shortText` exists for columns used as keys or in unique indexes + */ +export interface ColumnKit { + table: typeof sqlite.sqliteTable | typeof pg.pgTable | typeof mysql.mysqlTable; + /** Free-form string; unbounded where the engine allows it. */ + text: (name: string) => AnyColumnBuilder; + /** String used as a key, unique or indexed — bounded so MySQL can index it. */ + shortText: (name: string, length?: number) => AnyColumnBuilder; + int: (name: string) => AnyColumnBuilder; + /** Auto-incrementing surrogate primary key. */ + serial: (name: string) => AnyColumnBuilder; + bool: (name: string) => AnyColumnBuilder; + real: (name: string) => AnyColumnBuilder; +} + +// drizzle's builders are heavily generic; the schema modules keep their own +// precise types, so this alias only exists to describe the kit's shape. +type AnyColumnBuilder = ReturnType; + +const DEFAULT_KEY_LENGTH = 255; + +export const sqliteKit = { + table: sqlite.sqliteTable, + text: (name: string) => sqlite.text(name), + shortText: (name: string) => sqlite.text(name), + int: (name: string) => sqlite.integer(name), + serial: (name: string) => + sqlite.integer(name).primaryKey({ autoIncrement: true }), + bool: (name: string) => sqlite.integer(name, { mode: "boolean" }), + real: (name: string) => sqlite.real(name), +} as const; + +export const pgKit = { + table: pg.pgTable, + text: (name: string) => pg.text(name), + shortText: (name: string, length = DEFAULT_KEY_LENGTH) => + pg.varchar(name, { length }), + int: (name: string) => pg.integer(name), + serial: (name: string) => pg.serial(name).primaryKey(), + bool: (name: string) => pg.boolean(name), + real: (name: string) => pg.doublePrecision(name), +} as const; + +export const mysqlKit = { + table: mysql.mysqlTable, + text: (name: string) => mysql.text(name), + shortText: (name: string, length = DEFAULT_KEY_LENGTH) => + mysql.varchar(name, { length }), + int: (name: string) => mysql.int(name), + serial: (name: string) => mysql.int(name).autoincrement().primaryKey(), + bool: (name: string) => mysql.boolean(name), + real: (name: string) => mysql.double(name), +} as const; diff --git a/src/backend/database/db/connect.ts b/src/backend/database/db/connect.ts new file mode 100644 index 00000000..db35b207 --- /dev/null +++ b/src/backend/database/db/connect.ts @@ -0,0 +1,74 @@ +import type { DatabaseDialect } from "./dialect.js"; +import type { PortableDatabase } from "../repositories/database-context.js"; + +export const DATABASE_URL_ENV = "DATABASE_URL"; + +/** + * Opens a connection to a client-server engine. + * + * SQLite is not handled here — it has its own lifecycle in db/index.ts, where + * the database is decrypted into memory and serialised back to a file. This + * covers the engines that connect to something already running. + * + * The returned handle is typed as PortableDatabase; see the note there on why + * that is an approximation and what guarantees it. + */ +export function databaseUrl(env: NodeJS.ProcessEnv = process.env): string | null { + const url = env[DATABASE_URL_ENV]?.trim(); + return url ? url : null; +} + +/** + * Checks the connection string suits the configured engine before trying to + * open it, so a mismatch fails with something readable rather than a driver + * error thirty frames down. + */ +export function assertUrlMatchesDialect( + url: string, + dialect: DatabaseDialect, +): void { + const scheme = url.split("://", 1)[0].toLowerCase(); + + const expected: Record = { + postgres: ["postgres", "postgresql"], + mysql: ["mysql", "mariadb"], + }; + + const allowed = expected[dialect]; + if (!allowed) { + throw new Error(`${dialect} does not use ${DATABASE_URL_ENV}`); + } + + if (!allowed.includes(scheme)) { + throw new Error( + `${DATABASE_URL_ENV} is a "${scheme}://" URL but DATABASE_DIALECT is "${dialect}". ` + + `Expected one of ${allowed.map((s) => `${s}://`).join(", ")}.`, + ); + } +} + +export async function connectRemoteDatabase( + dialect: DatabaseDialect, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const url = databaseUrl(env); + if (!url) { + throw new Error( + `${DATABASE_URL_ENV} must be set when DATABASE_DIALECT is "${dialect}".`, + ); + } + + assertUrlMatchesDialect(url, dialect); + + // No `schema` option: it only feeds drizzle's relational query API + // (`db.query.*`), which nothing here uses. The query builder takes its table + // names and value encoders from the table objects the repositories import — + // see the note in schema.pg.ts on why the generated schemas are DDL-only. + if (dialect === "postgres") { + const { drizzle } = await import("drizzle-orm/node-postgres"); + return drizzle(url) as unknown as PortableDatabase; + } + + const { drizzle } = await import("drizzle-orm/mysql2"); + return drizzle(url) as unknown as PortableDatabase; +} diff --git a/src/backend/database/db/dialect.ts b/src/backend/database/db/dialect.ts new file mode 100644 index 00000000..171017b5 --- /dev/null +++ b/src/backend/database/db/dialect.ts @@ -0,0 +1,50 @@ +/** + * Which engine the schema and repositories are built against. + * + * SQLite is not going away: the desktop app embeds its backend and cannot ship + * a database server, so it will always run on SQLite. Postgres and MySQL are + * for self-hosted deployments that need more than one process to reach the + * data. This is a multi-backend story, not a migration off SQLite. + */ +export type DatabaseDialect = "sqlite" | "postgres" | "mysql"; + +export const DATABASE_DIALECT_ENV = "DATABASE_DIALECT"; + +const SUPPORTED: readonly DatabaseDialect[] = ["sqlite", "postgres", "mysql"]; + +export function isDatabaseDialect(value: unknown): value is DatabaseDialect { + return ( + typeof value === "string" && + (SUPPORTED as readonly string[]).includes(value) + ); +} + +/** + * Resolves the configured dialect, defaulting to SQLite so existing + * deployments and the desktop build are unaffected by this being added. + */ +export function resolveDatabaseDialect( + env: NodeJS.ProcessEnv = process.env, +): DatabaseDialect { + const raw = env[DATABASE_DIALECT_ENV]?.trim().toLowerCase(); + if (!raw) return "sqlite"; + + if (!isDatabaseDialect(raw)) { + throw new Error( + `Unsupported ${DATABASE_DIALECT_ENV}: "${raw}". Expected one of ${SUPPORTED.join(", ")}.`, + ); + } + return raw; +} + +/** + * Whether a write has to be explicitly persisted after it commits. + * + * SQLite here is an in-memory database serialised back to an encrypted file, so + * every write needs a trigger to flush it. Client-server engines have already + * durably committed by the time the query returns — there is no file to write + * and nothing to schedule. + */ +export function needsExplicitPersist(dialect: DatabaseDialect): boolean { + return dialect === "sqlite"; +} diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 4315533c..af989997 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -14,6 +14,10 @@ import { DataDirMisconfiguredError, } from "../../utils/data-dir-guard.js"; import { getDefaultGuacdUrl } from "../../utils/guacd-config.js"; +import { resolveDatabaseDialect, type DatabaseDialect } from "./dialect.js"; +import { connectRemoteDatabase } from "./connect.js"; +import { runRemoteMigrations } from "./migrate.js"; +import type { PortableDatabase } from "../repositories/database-context.js"; const dataDir = process.env.DATA_DIR || "./db/data"; const dbDir = path.resolve(dataDir); @@ -2622,10 +2626,54 @@ async function handlePostInitFileEncryption() { } async function initializeDatabase(): Promise { + const dialect = resolveDatabaseDialect(); + + if (dialect !== "sqlite") { + await initializeRemoteDatabase(dialect); + return; + } + await initializeCompleteDatabase(); await handlePostInitFileEncryption(); } +/** + * Startup against Postgres or MySQL. + * + * Shorter than the SQLite path because most of what that one does has no + * counterpart here: there is no file to decrypt, no in-memory copy to keep in + * step with disk, and the schema comes from drizzle-kit migrations instead of + * the inline DDL below. + * + * What does carry over is the settings cache. 27 call sites read settings + * synchronously, which better-sqlite3 allows and no remote driver does, so the + * table is loaded once here before anything asks for it. + */ +async function initializeRemoteDatabase( + dialect: Exclude, +): Promise { + databaseLogger.info(`Connecting to ${dialect} database`, { + operation: "db_init", + dialect, + }); + + db = await connectRemoteDatabase(dialect); + await runRemoteMigrations(dialect, db); + + // Imported here rather than at the top: factory.ts imports getDb from this + // module, and a static import would close the cycle at module-load time. + const { primeCurrentSettingsCache, startSettingsCacheRefresh } = await import( + "../repositories/factory.js" + ); + await primeCurrentSettingsCache(); + startSettingsCacheRefresh(); + + databaseLogger.info(`${dialect} database ready`, { + operation: "db_init_complete", + dialect, + }); +} + export { initializeDatabase }; async function cleanupDatabase() { @@ -2703,9 +2751,9 @@ process.on("SIGTERM", async () => { process.exit(0); }); -let db: ReturnType>; +let db: PortableDatabase; -export function getDb(): ReturnType> { +export function getDb(): PortableDatabase { if (!db) { throw new Error( "Database not initialized. Ensure initializeDatabase() is called before accessing db.", @@ -2716,6 +2764,13 @@ export function getDb(): ReturnType> { export function getSqlite(): Database.Database { if (!sqlite) { + const dialect = resolveDatabaseDialect(); + if (dialect !== "sqlite") { + throw new Error( + `No SQLite handle: DATABASE_DIALECT is "${dialect}". This caller needs a ` + + `synchronous query, which only SQLite offers — give it an async path instead.`, + ); + } throw new Error( "SQLite not initialized. Ensure initializeDatabase() is called before accessing sqlite.", ); diff --git a/src/backend/database/db/migrate.ts b/src/backend/database/db/migrate.ts new file mode 100644 index 00000000..57d99912 --- /dev/null +++ b/src/backend/database/db/migrate.ts @@ -0,0 +1,51 @@ +import path from "path"; +import type { DatabaseDialect } from "./dialect.js"; +import type { PortableDatabase } from "../repositories/database-context.js"; + +export const MIGRATIONS_DIR_ENV = "DRIZZLE_MIGRATIONS_DIR"; + +/** + * Where the generated migrations live. + * + * SQLite does not appear here: it builds its schema from the DDL in index.ts + * and patches it forward with migrateSchema(). Only the client-server engines + * use drizzle-kit migrations, and each has its own folder because the + * generated SQL differs per dialect. + */ +export function migrationsFolder( + dialect: DatabaseDialect, + env: NodeJS.ProcessEnv = process.env, +): string { + const override = env[MIGRATIONS_DIR_ENV]?.trim(); + const root = override || path.resolve(process.cwd(), "drizzle"); + return path.join(root, dialect); +} + +/** + * Brings a remote database up to the current schema. + * + * drizzle's migrator records what it has applied in its own table, so this is + * safe to run on every start — including against a database another instance + * already migrated. + */ +export async function runRemoteMigrations( + dialect: DatabaseDialect, + db: PortableDatabase, + env: NodeJS.ProcessEnv = process.env, +): Promise { + if (dialect === "sqlite") { + throw new Error("SQLite builds its schema in index.ts, not from drizzle/"); + } + + const folder = migrationsFolder(dialect, env); + + const { migrate } = + dialect === "postgres" + ? await import("drizzle-orm/node-postgres/migrator") + : await import("drizzle-orm/mysql2/migrator"); + + await (migrate as (db: unknown, config: { migrationsFolder: string }) => Promise)( + db, + { migrationsFolder: folder }, + ); +} diff --git a/src/backend/database/db/schema.mysql.ts b/src/backend/database/db/schema.mysql.ts new file mode 100644 index 00000000..77b057ca --- /dev/null +++ b/src/backend/database/db/schema.mysql.ts @@ -0,0 +1,1248 @@ +// GENERATED FILE — do not edit. +// +// Produced from schema.ts by scripts/generate-dialect-schema.cjs. +// Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`. +// Target dialect: mysql. +// +// DDL source for drizzle-kit. NOT imported to run queries — repositories use +// schema.ts on every dialect. See the generator header for why that is correct. + +import { + mysqlTable, + text, + varchar, + int, + boolean, + double, + uniqueIndex, +} from "drizzle-orm/mysql-core"; +import { sql } from "drizzle-orm"; + +export const users = mysqlTable("users", { + id: varchar("id", { length: 255 }).primaryKey(), + username: text("username").notNull(), + passwordHash: text("password_hash").notNull(), + isAdmin: boolean("is_admin").notNull().default(false), + + isOidc: boolean("is_oidc").notNull().default(false), + oidcIdentifier: text("oidc_identifier"), + ssoProviderId: int("sso_provider_id"), + clientId: text("client_id"), + clientSecret: text("client_secret"), + issuerUrl: text("issuer_url"), + authorizationUrl: text("authorization_url"), + tokenUrl: text("token_url"), + identifierPath: text("identifier_path"), + namePath: text("name_path"), + scopes: text().default("openid email profile"), + + totpSecret: text("totp_secret"), + totpEnabled: boolean("totp_enabled") + .notNull() + .default(false), + totpBackupCodes: text("totp_backup_codes"), + + registeredAt: text("registered_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), + donationModalDismissed: boolean("donation_modal_dismissed") + .notNull() + .default(false), +}); + +export const settings = mysqlTable("settings", { + key: varchar("key", { length: 255 }).primaryKey(), + value: text("value").notNull(), +}); + +export const ssoProviders = mysqlTable("sso_providers", { + id: int("id").autoincrement().primaryKey(), + name: varchar("name", { length: 255 }).notNull(), + type: text("type").notNull(), + enabled: boolean("enabled").notNull().default(true), + displayOrder: int("display_order").notNull().default(0), + config: text("config").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const sessions = mysqlTable("sessions", { + id: varchar("id", { length: 255 }).primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + jwtToken: text("jwt_token").notNull(), + deviceType: text("device_type").notNull(), + deviceInfo: text("device_info").notNull(), + oidcSub: text("oidc_sub"), + oidcSid: text("oidc_sid"), + ssoProviderId: int("sso_provider_id"), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + expiresAt: text("expires_at").notNull(), + lastActiveAt: text("last_active_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const trustedDevices = mysqlTable("trusted_devices", { + id: varchar("id", { length: 255 }).primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + deviceFingerprint: text("device_fingerprint").notNull(), + deviceType: text("device_type").notNull(), + deviceInfo: text("device_info").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + expiresAt: text("expires_at").notNull(), + lastUsedAt: text("last_used_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const webauthnCredentials = mysqlTable("webauthn_credentials", { + id: varchar("id", { length: 255 }).primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + credentialId: text("credential_id").notNull(), + publicKey: text("public_key").notNull(), + counter: int("counter").notNull().default(0), + deviceType: text("device_type"), + backedUp: boolean("backed_up").notNull().default(false), + transports: text("transports"), + userVerification: text("user_verification").notNull().default("preferred"), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + lastUsedAt: text("last_used_at"), +}); + +export const hosts = mysqlTable("ssh_data", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + connectionType: text("connection_type").notNull().default("ssh"), + name: varchar("name", { length: 255 }), + ip: text("ip").notNull(), + port: int("port").notNull(), + username: text("username").notNull(), + folder: text("folder"), + tags: text("tags"), + pin: boolean("pin").notNull().default(false), + authType: text("auth_type").notNull(), + useWarpgate: boolean("use_warpgate").notNull().default(false), + forceKeyboardInteractive: text("force_keyboard_interactive"), + + password: text("password"), + key: text("key"), + keyPassword: text("key_password"), + keyType: text("key_type"), + sudoPassword: text("sudo_password"), + + autostartPassword: text("autostart_password"), + autostartKey: text("autostart_key"), + autostartKeyPassword: text("autostart_key_password"), + + credentialId: int("credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), + overrideCredentialUsername: boolean("override_credential_username"), + // When authType is "vault", the host authenticates via a Vault SSH signer + // profile (shared settings, no secrets). The signing certificate is obtained + // per-user at connect time via an interactive Vault OIDC flow. + vaultProfileId: int("vault_profile_id").references( + () => vaultProfiles.id, + { onDelete: "set null" }, + ), + enableTerminal: boolean("enable_terminal") + .notNull() + .default(true), + enableSessionLogging: boolean("enable_session_logging") + .notNull() + .default(true), + allowSessionSharing: boolean("allow_session_sharing") + .notNull() + .default(true), + enableCommandHistory: boolean("enable_command_history") + .notNull() + .default(true), + enableTunnel: boolean("enable_tunnel") + .notNull() + .default(true), + tunnelConnections: text("tunnel_connections"), + jumpHosts: text("jump_hosts"), + enableFileManager: boolean("enable_file_manager") + .notNull() + .default(true), + scpLegacy: boolean("scp_legacy").notNull().default(false), + enableDocker: boolean("enable_docker") + .notNull() + .default(false), + enableTmuxMonitor: boolean("enable_tmux_monitor") + .notNull() + .default(false), + showTerminalInSidebar: boolean("show_terminal_in_sidebar") + .notNull() + .default(true), + showFileManagerInSidebar: boolean("show_file_manager_in_sidebar") + .notNull() + .default(false), + showTunnelInSidebar: boolean("show_tunnel_in_sidebar") + .notNull() + .default(false), + showDockerInSidebar: boolean("show_docker_in_sidebar") + .notNull() + .default(false), + showServerStatsInSidebar: boolean("show_server_stats_in_sidebar") + .notNull() + .default(false), + defaultPath: text("default_path"), + statsConfig: text("stats_config"), + dockerConfig: text("docker_config"), + enableProxmox: boolean("enable_proxmox") + .notNull() + .default(false), + proxmoxConfig: text("proxmox_config"), + terminalConfig: text("terminal_config"), + quickActions: text("quick_actions"), + notes: text("notes"), + enableSsh: boolean("enable_ssh").notNull().default(true), + enableRdp: boolean("enable_rdp").notNull().default(false), + enableVnc: boolean("enable_vnc").notNull().default(false), + enableTelnet: boolean("enable_telnet").notNull().default(false), + + sshPort: int("ssh_port").default(22), + rdpPort: int("rdp_port").default(3389), + vncPort: int("vnc_port").default(5900), + telnetPort: int("telnet_port").default(23), + + rdpCredentialId: int("rdp_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), + rdpUser: text("rdp_user"), + rdpPassword: text("rdp_password"), + rdpDomain: text("rdp_domain"), + rdpSecurity: text("rdp_security"), + rdpIgnoreCert: boolean("rdp_ignore_cert").default(false), + + vncCredentialId: int("vnc_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), + vncPassword: text("vnc_password"), + vncUser: text("vnc_user"), + + telnetUser: text("telnet_user"), + telnetPassword: text("telnet_password"), + telnetCredentialId: int("telnet_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), + + rdpAuthType: text("rdp_auth_type"), + vncAuthType: text("vnc_auth_type"), + telnetAuthType: text("telnet_auth_type"), + + domain: text("domain"), + security: text("security"), + ignoreCert: boolean("ignore_cert").default(false), + guacamoleConfig: text("guacamole_config"), + + useSocks5: boolean("use_socks5"), + socks5Host: text("socks5_host"), + socks5Port: int("socks5_port"), + socks5Username: text("socks5_username"), + socks5Password: text("socks5_password"), + socks5ProxyChain: text("socks5_proxy_chain"), + + // null = use the desktop app's global default; "local" | "remote" pins + // this specific host's SSH/Docker-console/Serial connections to originate + // from the embedded local backend or a connected remote sync server. + // Ignored for rdp/vnc/telnet, which always require the remote server. + connectionOrigin: text("connection_origin"), + + macAddress: text("mac_address"), + wolBroadcastAddress: text("wol_broadcast_address"), + portKnockSequence: text("port_knock_sequence"), + + hostKeyFingerprint: text("host_key_fingerprint"), + hostKeyType: text("host_key_type"), + hostKeyAlgorithm: text("host_key_algorithm").default("sha256"), + hostKeyFirstSeen: text("host_key_first_seen"), + hostKeyLastVerified: text("host_key_last_verified"), + hostKeyChangedCount: int("host_key_changed_count").default(0), + + // Stable identity used to match this row across two independently-seeded + // databases (the embedded backend and a connected remote server) during + // sync -- local autoincrement ids collide across instances. + syncId: varchar("sync_id", { length: 255 }).unique(), + + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const fileManagerRecent = mysqlTable("file_manager_recent", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + path: text("path").notNull(), + lastOpened: text("last_opened") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const fileManagerPinned = mysqlTable("file_manager_pinned", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + path: text("path").notNull(), + pinnedAt: text("pinned_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const fileManagerShortcuts = mysqlTable("file_manager_shortcuts", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + path: text("path").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const transferRecent = mysqlTable("transfer_recent", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + sourceHostId: int("source_host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + destHostId: int("dest_host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + destPath: text("dest_path").notNull(), + destPathLabel: text("dest_path_label").notNull(), + lastUsed: text("last_used") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const dismissedAlerts = mysqlTable("dismissed_alerts", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + alertId: text("alert_id").notNull(), + dismissedAt: text("dismissed_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const sshCredentials = mysqlTable("ssh_credentials", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + folder: text("folder"), + tags: text("tags"), + authType: text("auth_type").notNull(), + username: text("username"), + password: text("password"), + key: text("key"), + privateKey: text("private_key"), + publicKey: text("public_key"), + keyPassword: text("key_password"), + keyType: text("key_type"), + detectedKeyType: text("detected_key_type"), + + certPublicKey: text("cert_public_key"), + + + usageCount: int("usage_count").notNull().default(0), + lastUsed: text("last_used"), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const sshCredentialUsage = mysqlTable("ssh_credential_usage", { + id: int("id").autoincrement().primaryKey(), + credentialId: int("credential_id") + .notNull() + .references(() => sshCredentials.id, { onDelete: "cascade" }), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + usedAt: text("used_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const snippets = mysqlTable("snippets", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + content: text("content").notNull(), + description: text("description"), + folder: text("folder"), + order: int("order").notNull().default(0), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + hostFilter: text("host_filter"), +}); + +export const snippetFolders = mysqlTable("snippet_folders", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + color: text("color"), + icon: text("icon"), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const c2sTunnelPresets = mysqlTable("c2s_tunnel_presets", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + config: text("config").notNull(), + platform: text("platform"), + computerName: text("computer_name"), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const snippetAccess = mysqlTable("snippet_access", { + id: int("id").autoincrement().primaryKey(), + snippetId: int("snippet_id") + .notNull() + .references(() => snippets.id, { onDelete: "cascade" }), + + userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }), + roleId: int("role_id").references(() => roles.id, { + onDelete: "cascade", + }), + + grantedBy: varchar("granted_by", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + permissionLevel: text("permission_level").notNull().default("view"), + + expiresAt: text("expires_at"), + + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const sshFolders = mysqlTable("ssh_folders", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + color: text("color"), + icon: text("icon"), + credentialId: int("credential_id").references(() => sshCredentials.id, { + onDelete: "set null", + }), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const recentActivity = mysqlTable("recent_activity", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + type: text("type").notNull(), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + hostName: text("host_name"), + timestamp: text("timestamp") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const commandHistory = mysqlTable("command_history", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + command: text("command").notNull(), + executedAt: text("executed_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const networkTopology = mysqlTable("network_topology", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + topology: text("topology"), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const hostAccess = mysqlTable("host_access", { + id: int("id").autoincrement().primaryKey(), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + + userId: varchar("user_id", { length: 255 }) + .references(() => users.id, { onDelete: "cascade" }), + roleId: int("role_id") + .references(() => roles.id, { onDelete: "cascade" }), + + grantedBy: varchar("granted_by", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + permissionLevel: text("permission_level") + .notNull() + .default("connect"), + + expiresAt: text("expires_at"), + + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + lastAccessedAt: text("last_accessed_at"), + accessCount: int("access_count").notNull().default(0), + overrideCredentialId: int("override_credential_id").references( + () => sshCredentials.id, + { onDelete: "set null" }, + ), +}); + +export const sharedHostSecrets = mysqlTable( + "shared_host_secrets", + { + id: int("id").autoincrement().primaryKey(), + + hostAccessId: int("host_access_id") + .notNull() + .references(() => hostAccess.id, { onDelete: "cascade" }), + + targetUserId: varchar("target_user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"), + sourceType: text("source_type").notNull().default("credential"), + + originalCredentialId: int("original_credential_id").references( + () => sshCredentials.id, + { onDelete: "cascade" }, + ), + + encryptedUsername: text("encrypted_username"), + encryptedAuthType: text("encrypted_auth_type"), + encryptedPassword: text("encrypted_password"), + encryptedKey: text("encrypted_key"), + encryptedKeyPassword: text("encrypted_key_password"), + encryptedKeyType: text("encrypted_key_type"), + encryptedDomain: text("encrypted_domain"), + + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_shared_host_secrets_scope").on(table.hostAccessId, table.targetUserId, table.protocol)], +); + +export const roles = mysqlTable("roles", { + id: int("id").autoincrement().primaryKey(), + name: varchar("name", { length: 255 }).notNull().unique(), + displayName: text("display_name").notNull(), + description: text("description"), + + isSystem: boolean("is_system") + .notNull() + .default(false), + + permissions: text("permissions"), + + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const userRoles = mysqlTable( + "user_roles", + { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + roleId: int("role_id") + .notNull() + .references(() => roles.id, { onDelete: "cascade" }), + + grantedBy: varchar("granted_by", { length: 255 }).references(() => users.id, { + onDelete: "set null", + }), + grantedAt: text("granted_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_user_roles_user_role").on(table.userId, table.roleId)], +); + +export const auditLogs = mysqlTable("audit_logs", { + id: int("id").autoincrement().primaryKey(), + + // Nullable on purpose: the trail outlives the account, and username keeps the + // entry attributable once the reference is gone. + userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }), + username: text("username").notNull(), + + action: text("action").notNull(), + resourceType: text("resource_type").notNull(), + resourceId: text("resource_id"), + resourceName: text("resource_name"), + + details: text("details"), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + + success: boolean("success").notNull(), + errorMessage: text("error_message"), + + timestamp: text("timestamp") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const sessionRecordings = mysqlTable("session_recordings", { + id: int("id").autoincrement().primaryKey(), + + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + // Nullable on purpose: a recording is evidence about the host as much as the + // person, so it outlives the account. username keeps it attributable. + userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }), + username: text("username"), + accessId: int("access_id").references(() => hostAccess.id, { + onDelete: "set null", + }), + + startedAt: text("started_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + endedAt: text("ended_at"), + duration: int("duration"), + + commands: text("commands"), + dangerousActions: text("dangerous_actions"), + + recordingPath: text("recording_path"), + protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"), + format: text("format").notNull().default("text"), + + terminatedByOwner: boolean("terminated_by_owner") + .default(false), + terminationReason: text("termination_reason"), +}); + +export const sessionShares = mysqlTable("session_shares", { + id: varchar("id", { length: 255 }).primaryKey(), + + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + ownerUserId: varchar("owner_user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + protocol: varchar("protocol", { length: 255 }).notNull(), + + // Live-session binding: TerminalSessionManager's session.id for SSH, or + // guacd's own guacamoleConnectionId for rdp/vnc/telnet. Neither is a DB + // row (process-local, in-memory) so this intentionally has no FK. + sessionId: text("session_id").notNull(), + tabInstanceId: text("tab_instance_id"), + + shareType: text("share_type").notNull(), // "link" | "user" + targetUserId: varchar("target_user_id", { length: 255 }).references(() => users.id, { + onDelete: "cascade", + }), + linkToken: varchar("link_token", { length: 255 }).unique(), + + permissionLevel: text("permission_level").notNull().default("read-only"), + + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + expiresAt: text("expires_at").notNull(), + revokedAt: text("revoked_at"), + + lastJoinedAt: text("last_joined_at"), + joinCount: int("join_count").notNull().default(0), +}); + +export const sessionShareParticipants = mysqlTable( + "session_share_participants", + { + id: int("id").autoincrement().primaryKey(), + shareId: varchar("share_id", { length: 255 }) + .notNull() + .references(() => sessionShares.id, { onDelete: "cascade" }), + + userId: varchar("user_id", { length: 255 }).references(() => users.id, { + onDelete: "cascade", + }), + guestLabel: text("guest_label"), + + joinedAt: text("joined_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + leftAt: text("left_at"), + }, +); + +export const opksshTokens = mysqlTable( + "opkssh_tokens", + { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + + sshCert: text("ssh_cert").notNull(), + privateKey: text("private_key").notNull(), + + email: text("email"), + sub: text("sub"), + issuer: text("issuer"), + audience: text("audience"), + + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + expiresAt: text("expires_at").notNull(), + lastUsed: text("last_used"), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_opkssh_tokens_user_host").on(table.userId, table.hostId)], +); + +// Vault SSH signer profiles. These hold ONLY non-secret connection settings and +// are intended to be shared across users (shared === true makes a profile +// visible to every user on the server). Each user authenticates to Vault via an +// interactive OIDC flow at connect time; no tokens or keys are stored here. +export const vaultProfiles = mysqlTable("vault_profiles", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + folder: text("folder"), + tags: text("tags"), + // Vault server connection (non-secret) + vaultAddr: text("vault_addr").notNull(), + vaultNamespace: text("vault_namespace"), + // OIDC auth method mount + role used to obtain a Vault token interactively + oidcMount: text("oidc_mount"), + oidcRole: text("oidc_role"), + // SSH secrets engine mount + signer role used to sign the ephemeral key + sshMount: text("ssh_mount"), + sshRole: text("ssh_role").notNull(), + validPrincipals: text("valid_principals"), + // Ephemeral keypair algorithm to generate per connection + keyType: text("key_type"), + // When true the profile is visible/usable by all users on the server + shared: boolean("shared").notNull().default(false), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +// Per-user cache of the ephemeral SSH private key + Vault-signed certificate. +// Transient: rows live only until the certificate expires. Secret fields are +// encrypted under the user's data-encryption key (see field-crypto.ts). +export const vaultTokens = mysqlTable( + "vault_tokens", + { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + profileId: int("profile_id") + .notNull() + .references(() => vaultProfiles.id, { onDelete: "cascade" }), + + sshCert: text("ssh_cert").notNull(), + privateKey: text("private_key").notNull(), + + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + expiresAt: text("expires_at").notNull(), + lastUsed: text("last_used"), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_vault_tokens_user_profile").on(table.userId, table.profileId)], +); + +export const apiKeys = mysqlTable("api_keys", { + id: varchar("id", { length: 255 }).primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + tokenHash: text("token_hash").notNull(), + tokenPrefix: text("token_prefix").notNull(), + createdAt: text("created_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), + expiresAt: text("expires_at"), + lastUsedAt: text("last_used_at"), + isActive: boolean("is_active").notNull().default(true), +}); + +export const userOpenTabs = mysqlTable("user_open_tabs", { + id: varchar("id", { length: 255 }).primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tabType: text("tab_type").notNull(), + hostId: int("host_id").references(() => hosts.id, { onDelete: "cascade" }), + label: text("label").notNull(), + tabOrder: int("tab_order").notNull().default(0), + backendSessionId: text("backend_session_id"), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const userPreferences = mysqlTable("user_preferences", { + userId: varchar("user_id", { length: 255 }) + .primaryKey() + .references(() => users.id, { onDelete: "cascade" }), + reopenTabsOnLogin: boolean("reopen_tabs_on_login") + .notNull() + .default(false), + theme: text("theme"), + fontSize: text("font_size"), + accentColor: text("accent_color"), + language: text("language"), + storageMode: text("storage_mode"), + commandAutocomplete: boolean("command_autocomplete"), + commandPaletteEnabled: boolean("command_palette_enabled"), + showHostTags: boolean("show_host_tags"), + hostTrayOnClick: boolean("host_tray_on_click"), + pinAppRail: boolean("pin_app_rail"), + expandAppRailOnHover: boolean("expand_app_rail_on_hover"), + foldersCollapsed: boolean("folders_collapsed"), + confirmSnippetExecution: boolean("confirm_snippet_execution"), + disableUpdateCheck: boolean("disable_update_check"), + confirmTabClose: boolean("confirm_tab_close"), + hiddenRailTabs: text("hidden_rail_tabs"), + compactHostView: boolean("compact_host_view"), + statusColorScheme: text("status_color_scheme"), + customThemes: text("custom_themes"), + customKeybindings: text("custom_keybindings"), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const hostMetricsPreferences = mysqlTable( + "host_metrics_preferences", + { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + // JSON-encoded HostMetricsLayout. Layout has no secrets, so it is stored as + // plain JSON (no field-level encryption). + layout: text("layout").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + }, + // One layout per user per host. Enforced in production since the inline DDL + // creates it, but it was never declared here, so the generated Postgres and + // MySQL schemas lacked it — and the upsert has nothing to conflict on. + (table) => [ + uniqueIndex("idx_host_metrics_prefs_user_host").on(table.userId, table.hostId), + ], +); + +export const hostHealthChecks = mysqlTable( + "host_health_checks", + { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + // JSON array of { id, name, type: "tcp"|"http", target, port, path } + checks: text("checks").notNull(), + intervalSeconds: int("interval_seconds").notNull().default(300), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + }, + // Same as above: one set of checks per user per host. + (table) => [ + uniqueIndex("idx_host_health_checks_user_host").on(table.userId, table.hostId), + ], +); + +export const hostHealthHistory = mysqlTable("host_health_history", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + checkId: text("check_id").notNull(), + ts: text("ts").notNull().default(sql`(CURRENT_TIMESTAMP)`), + ok: boolean("ok").notNull(), + latencyMs: int("latency_ms"), + detail: text("detail"), +}); + +export const dashboardServiceLinks = mysqlTable("dashboard_service_links", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + label: text("label").notNull(), + url: text("url").notNull(), + order: int("order").notNull().default(0), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +// --- termix-id begin --- +// A user claims a unique public handle. Their published SSH public keys are +// served at an unauthenticated resolver endpoint in authorized_keys format, +// so any server can be provisioned with `curl /termix-id/u/ >> ~/.ssh/authorized_keys`. +export const termixIdentities = mysqlTable("termix_identities", { + id: int("id").autoincrement().primaryKey(), + // One Termix ID per user — enforced in schema, not just in code. + userId: varchar("user_id", { length: 255 }) + .notNull() + .unique() + .references(() => users.id, { onDelete: "cascade" }), + handle: varchar("handle", { length: 255 }).notNull().unique(), + description: text("description"), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const termixIdentityKeys = mysqlTable("termix_identity_keys", { + id: int("id").autoincrement().primaryKey(), + identityId: int("identity_id") + .notNull() + .references(() => termixIdentities.id, { onDelete: "cascade" }), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + // Public keys are non-secret, so they are stored in plaintext (no field-level + // encryption). This is what lets the unauthenticated resolver serve them. + publicKey: text("public_key").notNull(), + // Raw algorithm token (e.g. "ssh-ed25519"), and a normalized group used for + // the / resolver filter (RSA / ED25519 / ECDSA / ...). + keyType: text("key_type").notNull(), + algorithm: text("algorithm").notNull(), + label: text("label"), + comment: text("comment"), + // "manual" (pasted) or "credential" (imported from an ssh_credentials entry). + source: text("source").notNull().default("manual"), + credentialId: int("credential_id").references(() => sshCredentials.id, { + onDelete: "set null", + }), + enabled: boolean("enabled").notNull().default(true), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); +// Per-identity certificate authority. Servers that trust this CA (via +// TrustedUserCAKeys / @cert-authority) accept any user certificate it signs, +// giving central revocation (rotate the CA) and expiry (cert validity). +export const termixIdentityCa = mysqlTable("termix_identity_ca", { + id: int("id").autoincrement().primaryKey(), + identityId: int("identity_id") + .notNull() + .unique() + .references(() => termixIdentities.id, { onDelete: "cascade" }), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + // CA public key (plaintext — it is published); CA private key is field-encrypted. + publicKey: text("public_key").notNull(), + privateKey: text("private_key").notNull(), + validityDays: int("validity_days").notNull().default(90), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); +// --- termix-id end --- + +// --- tmux-monitor begin --- +export const tmuxSessionTags = mysqlTable("tmux_session_tags", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + sessionName: text("session_name").notNull(), + tag: text("tag").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); +// --- tmux-monitor end --- + +// --- metrics-history begin --- +export const hostMetricsHistory = mysqlTable("host_metrics_history", { + id: int("id").autoincrement().primaryKey(), + hostId: int("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + ts: text("ts") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + cpuPercent: double("cpu_percent"), + memPercent: double("mem_percent"), + diskPercent: double("disk_percent"), + netRxBytes: int("net_rx_bytes"), + netTxBytes: int("net_tx_bytes"), +}); +// --- metrics-history end --- + +// --- alerts begin --- +export const alertRules = mysqlTable("alert_rules", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: int("host_id").references(() => hosts.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + enabled: boolean("enabled").notNull().default(true), + triggerType: text("trigger_type").notNull(), + thresholdValue: double("threshold_value"), + thresholdDurationSeconds: int("threshold_duration_seconds"), + cooldownMinutes: int("cooldown_minutes").notNull().default(15), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const notificationChannels = mysqlTable("notification_channels", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + type: text("type").notNull(), + config: text("config").notNull(), + enabled: boolean("enabled").notNull().default(true), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const alertRuleChannels = mysqlTable("alert_rule_channels", { + id: int("id").autoincrement().primaryKey(), + ruleId: int("rule_id") + .notNull() + .references(() => alertRules.id, { onDelete: "cascade" }), + channelId: int("channel_id") + .notNull() + .references(() => notificationChannels.id, { onDelete: "cascade" }), +}); + +export const alertFirings = mysqlTable("alert_firings", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + ruleId: int("rule_id") + .notNull() + .references(() => alertRules.id, { onDelete: "cascade" }), + hostId: int("host_id").notNull(), + hostName: text("host_name").notNull(), + firedAt: text("fired_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + resolvedAt: text("resolved_at"), + value: double("value"), + message: text("message").notNull(), + severity: text("severity").notNull().default("warning"), + acknowledged: boolean("acknowledged").notNull().default(false), +}); +// --- alerts end --- + +// --- homepage begin --- +export const homepageItems = mysqlTable("homepage_items", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + typeId: text("type_id").notNull(), + title: text("title"), + config: text("config").notNull().default("{}"), + folderId: int("folder_id"), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); + +export const homepageLayouts = mysqlTable("homepage_layouts", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .unique() + .references(() => users.id, { onDelete: "cascade" }), + // JSON: { entries: HomepageLayoutEntry[], pan: {x,y}, zoom: number } + layout: text("layout").notNull().default("{}"), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); +// --- homepage end --- + +// --- sync begin --- +// Records a delete for a synced entity type so the other side of a sync +// pair (embedded desktop backend <-> connected remote server) learns about +// the deletion instead of re-creating the row on its next pull. +export const syncTombstones = mysqlTable("sync_tombstones", { + id: int("id").autoincrement().primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + entityType: text("entity_type").notNull(), + syncId: varchar("sync_id", { length: 255 }).notNull(), + deletedAt: text("deleted_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`), +}); +// --- sync end --- diff --git a/src/backend/database/db/schema.pg.ts b/src/backend/database/db/schema.pg.ts new file mode 100644 index 00000000..d75c50b3 --- /dev/null +++ b/src/backend/database/db/schema.pg.ts @@ -0,0 +1,1249 @@ +// GENERATED FILE — do not edit. +// +// Produced from schema.ts by scripts/generate-dialect-schema.cjs. +// Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`. +// Target dialect: postgres. +// +// DDL source for drizzle-kit. NOT imported to run queries — repositories use +// schema.ts on every dialect. See the generator header for why that is correct. + +import { + pgTable, + text, + varchar, + integer, + serial, + boolean, + doublePrecision, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; + +export const users = pgTable("users", { + id: varchar("id", { length: 255 }).primaryKey(), + username: text("username").notNull(), + passwordHash: text("password_hash").notNull(), + isAdmin: boolean("is_admin").notNull().default(false), + + isOidc: boolean("is_oidc").notNull().default(false), + oidcIdentifier: text("oidc_identifier"), + ssoProviderId: integer("sso_provider_id"), + clientId: text("client_id"), + clientSecret: text("client_secret"), + issuerUrl: text("issuer_url"), + authorizationUrl: text("authorization_url"), + tokenUrl: text("token_url"), + identifierPath: text("identifier_path"), + namePath: text("name_path"), + scopes: text().default("openid email profile"), + + totpSecret: text("totp_secret"), + totpEnabled: boolean("totp_enabled") + .notNull() + .default(false), + totpBackupCodes: text("totp_backup_codes"), + + registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`), + donationModalDismissed: boolean("donation_modal_dismissed") + .notNull() + .default(false), +}); + +export const settings = pgTable("settings", { + key: varchar("key", { length: 255 }).primaryKey(), + value: text("value").notNull(), +}); + +export const ssoProviders = pgTable("sso_providers", { + id: serial("id").primaryKey(), + name: varchar("name", { length: 255 }).notNull(), + type: text("type").notNull(), + enabled: boolean("enabled").notNull().default(true), + displayOrder: integer("display_order").notNull().default(0), + config: text("config").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const sessions = pgTable("sessions", { + id: varchar("id", { length: 255 }).primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + jwtToken: text("jwt_token").notNull(), + deviceType: text("device_type").notNull(), + deviceInfo: text("device_info").notNull(), + oidcSub: text("oidc_sub"), + oidcSid: text("oidc_sid"), + ssoProviderId: integer("sso_provider_id"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at").notNull(), + lastActiveAt: text("last_active_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const trustedDevices = pgTable("trusted_devices", { + id: varchar("id", { length: 255 }).primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + deviceFingerprint: text("device_fingerprint").notNull(), + deviceType: text("device_type").notNull(), + deviceInfo: text("device_info").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at").notNull(), + lastUsedAt: text("last_used_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const webauthnCredentials = pgTable("webauthn_credentials", { + id: varchar("id", { length: 255 }).primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + credentialId: text("credential_id").notNull(), + publicKey: text("public_key").notNull(), + counter: integer("counter").notNull().default(0), + deviceType: text("device_type"), + backedUp: boolean("backed_up").notNull().default(false), + transports: text("transports"), + userVerification: text("user_verification").notNull().default("preferred"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + lastUsedAt: text("last_used_at"), +}); + +export const hosts = pgTable("ssh_data", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + connectionType: text("connection_type").notNull().default("ssh"), + name: varchar("name", { length: 255 }), + ip: text("ip").notNull(), + port: integer("port").notNull(), + username: text("username").notNull(), + folder: text("folder"), + tags: text("tags"), + pin: boolean("pin").notNull().default(false), + authType: text("auth_type").notNull(), + useWarpgate: boolean("use_warpgate").notNull().default(false), + forceKeyboardInteractive: text("force_keyboard_interactive"), + + password: text("password"), + key: text("key"), + keyPassword: text("key_password"), + keyType: text("key_type"), + sudoPassword: text("sudo_password"), + + autostartPassword: text("autostart_password"), + autostartKey: text("autostart_key"), + autostartKeyPassword: text("autostart_key_password"), + + credentialId: integer("credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), + overrideCredentialUsername: boolean("override_credential_username"), + // When authType is "vault", the host authenticates via a Vault SSH signer + // profile (shared settings, no secrets). The signing certificate is obtained + // per-user at connect time via an interactive Vault OIDC flow. + vaultProfileId: integer("vault_profile_id").references( + () => vaultProfiles.id, + { onDelete: "set null" }, + ), + enableTerminal: boolean("enable_terminal") + .notNull() + .default(true), + enableSessionLogging: boolean("enable_session_logging") + .notNull() + .default(true), + allowSessionSharing: boolean("allow_session_sharing") + .notNull() + .default(true), + enableCommandHistory: boolean("enable_command_history") + .notNull() + .default(true), + enableTunnel: boolean("enable_tunnel") + .notNull() + .default(true), + tunnelConnections: text("tunnel_connections"), + jumpHosts: text("jump_hosts"), + enableFileManager: boolean("enable_file_manager") + .notNull() + .default(true), + scpLegacy: boolean("scp_legacy").notNull().default(false), + enableDocker: boolean("enable_docker") + .notNull() + .default(false), + enableTmuxMonitor: boolean("enable_tmux_monitor") + .notNull() + .default(false), + showTerminalInSidebar: boolean("show_terminal_in_sidebar") + .notNull() + .default(true), + showFileManagerInSidebar: boolean("show_file_manager_in_sidebar") + .notNull() + .default(false), + showTunnelInSidebar: boolean("show_tunnel_in_sidebar") + .notNull() + .default(false), + showDockerInSidebar: boolean("show_docker_in_sidebar") + .notNull() + .default(false), + showServerStatsInSidebar: boolean("show_server_stats_in_sidebar") + .notNull() + .default(false), + defaultPath: text("default_path"), + statsConfig: text("stats_config"), + dockerConfig: text("docker_config"), + enableProxmox: boolean("enable_proxmox") + .notNull() + .default(false), + proxmoxConfig: text("proxmox_config"), + terminalConfig: text("terminal_config"), + quickActions: text("quick_actions"), + notes: text("notes"), + enableSsh: boolean("enable_ssh").notNull().default(true), + enableRdp: boolean("enable_rdp").notNull().default(false), + enableVnc: boolean("enable_vnc").notNull().default(false), + enableTelnet: boolean("enable_telnet").notNull().default(false), + + sshPort: integer("ssh_port").default(22), + rdpPort: integer("rdp_port").default(3389), + vncPort: integer("vnc_port").default(5900), + telnetPort: integer("telnet_port").default(23), + + rdpCredentialId: integer("rdp_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), + rdpUser: text("rdp_user"), + rdpPassword: text("rdp_password"), + rdpDomain: text("rdp_domain"), + rdpSecurity: text("rdp_security"), + rdpIgnoreCert: boolean("rdp_ignore_cert").default(false), + + vncCredentialId: integer("vnc_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), + vncPassword: text("vnc_password"), + vncUser: text("vnc_user"), + + telnetUser: text("telnet_user"), + telnetPassword: text("telnet_password"), + telnetCredentialId: integer("telnet_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), + + rdpAuthType: text("rdp_auth_type"), + vncAuthType: text("vnc_auth_type"), + telnetAuthType: text("telnet_auth_type"), + + domain: text("domain"), + security: text("security"), + ignoreCert: boolean("ignore_cert").default(false), + guacamoleConfig: text("guacamole_config"), + + useSocks5: boolean("use_socks5"), + socks5Host: text("socks5_host"), + socks5Port: integer("socks5_port"), + socks5Username: text("socks5_username"), + socks5Password: text("socks5_password"), + socks5ProxyChain: text("socks5_proxy_chain"), + + // null = use the desktop app's global default; "local" | "remote" pins + // this specific host's SSH/Docker-console/Serial connections to originate + // from the embedded local backend or a connected remote sync server. + // Ignored for rdp/vnc/telnet, which always require the remote server. + connectionOrigin: text("connection_origin"), + + macAddress: text("mac_address"), + wolBroadcastAddress: text("wol_broadcast_address"), + portKnockSequence: text("port_knock_sequence"), + + hostKeyFingerprint: text("host_key_fingerprint"), + hostKeyType: text("host_key_type"), + hostKeyAlgorithm: text("host_key_algorithm").default("sha256"), + hostKeyFirstSeen: text("host_key_first_seen"), + hostKeyLastVerified: text("host_key_last_verified"), + hostKeyChangedCount: integer("host_key_changed_count").default(0), + + // Stable identity used to match this row across two independently-seeded + // databases (the embedded backend and a connected remote server) during + // sync -- local autoincrement ids collide across instances. + syncId: varchar("sync_id", { length: 255 }).unique(), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const fileManagerRecent = pgTable("file_manager_recent", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + path: text("path").notNull(), + lastOpened: text("last_opened") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const fileManagerPinned = pgTable("file_manager_pinned", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + path: text("path").notNull(), + pinnedAt: text("pinned_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const fileManagerShortcuts = pgTable("file_manager_shortcuts", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + path: text("path").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const transferRecent = pgTable("transfer_recent", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + sourceHostId: integer("source_host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + destHostId: integer("dest_host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + destPath: text("dest_path").notNull(), + destPathLabel: text("dest_path_label").notNull(), + lastUsed: text("last_used") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const dismissedAlerts = pgTable("dismissed_alerts", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + alertId: text("alert_id").notNull(), + dismissedAt: text("dismissed_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const sshCredentials = pgTable("ssh_credentials", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + folder: text("folder"), + tags: text("tags"), + authType: text("auth_type").notNull(), + username: text("username"), + password: text("password"), + key: text("key"), + privateKey: text("private_key"), + publicKey: text("public_key"), + keyPassword: text("key_password"), + keyType: text("key_type"), + detectedKeyType: text("detected_key_type"), + + certPublicKey: text("cert_public_key"), + + + usageCount: integer("usage_count").notNull().default(0), + lastUsed: text("last_used"), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const sshCredentialUsage = pgTable("ssh_credential_usage", { + id: serial("id").primaryKey(), + credentialId: integer("credential_id") + .notNull() + .references(() => sshCredentials.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + usedAt: text("used_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const snippets = pgTable("snippets", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + content: text("content").notNull(), + description: text("description"), + folder: text("folder"), + order: integer("order").notNull().default(0), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + hostFilter: text("host_filter"), +}); + +export const snippetFolders = pgTable("snippet_folders", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + color: text("color"), + icon: text("icon"), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const c2sTunnelPresets = pgTable("c2s_tunnel_presets", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + config: text("config").notNull(), + platform: text("platform"), + computerName: text("computer_name"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const snippetAccess = pgTable("snippet_access", { + id: serial("id").primaryKey(), + snippetId: integer("snippet_id") + .notNull() + .references(() => snippets.id, { onDelete: "cascade" }), + + userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }), + roleId: integer("role_id").references(() => roles.id, { + onDelete: "cascade", + }), + + grantedBy: varchar("granted_by", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + permissionLevel: text("permission_level").notNull().default("view"), + + expiresAt: text("expires_at"), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const sshFolders = pgTable("ssh_folders", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + color: text("color"), + icon: text("icon"), + credentialId: integer("credential_id").references(() => sshCredentials.id, { + onDelete: "set null", + }), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const recentActivity = pgTable("recent_activity", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + type: text("type").notNull(), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + hostName: text("host_name"), + timestamp: text("timestamp") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const commandHistory = pgTable("command_history", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + command: text("command").notNull(), + executedAt: text("executed_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const networkTopology = pgTable("network_topology", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + topology: text("topology"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const hostAccess = pgTable("host_access", { + id: serial("id").primaryKey(), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + + userId: varchar("user_id", { length: 255 }) + .references(() => users.id, { onDelete: "cascade" }), + roleId: integer("role_id") + .references(() => roles.id, { onDelete: "cascade" }), + + grantedBy: varchar("granted_by", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + permissionLevel: text("permission_level") + .notNull() + .default("connect"), + + expiresAt: text("expires_at"), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + lastAccessedAt: text("last_accessed_at"), + accessCount: integer("access_count").notNull().default(0), + overrideCredentialId: integer("override_credential_id").references( + () => sshCredentials.id, + { onDelete: "set null" }, + ), +}); + +export const sharedHostSecrets = pgTable( + "shared_host_secrets", + { + id: serial("id").primaryKey(), + + hostAccessId: integer("host_access_id") + .notNull() + .references(() => hostAccess.id, { onDelete: "cascade" }), + + targetUserId: varchar("target_user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"), + sourceType: text("source_type").notNull().default("credential"), + + originalCredentialId: integer("original_credential_id").references( + () => sshCredentials.id, + { onDelete: "cascade" }, + ), + + encryptedUsername: text("encrypted_username"), + encryptedAuthType: text("encrypted_auth_type"), + encryptedPassword: text("encrypted_password"), + encryptedKey: text("encrypted_key"), + encryptedKeyPassword: text("encrypted_key_password"), + encryptedKeyType: text("encrypted_key_type"), + encryptedDomain: text("encrypted_domain"), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_shared_host_secrets_scope").on(table.hostAccessId, table.targetUserId, table.protocol)], +); + +export const roles = pgTable("roles", { + id: serial("id").primaryKey(), + name: varchar("name", { length: 255 }).notNull().unique(), + displayName: text("display_name").notNull(), + description: text("description"), + + isSystem: boolean("is_system") + .notNull() + .default(false), + + permissions: text("permissions"), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const userRoles = pgTable( + "user_roles", + { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + roleId: integer("role_id") + .notNull() + .references(() => roles.id, { onDelete: "cascade" }), + + grantedBy: varchar("granted_by", { length: 255 }).references(() => users.id, { + onDelete: "set null", + }), + grantedAt: text("granted_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_user_roles_user_role").on(table.userId, table.roleId)], +); + +export const auditLogs = pgTable("audit_logs", { + id: serial("id").primaryKey(), + + // Nullable on purpose: the trail outlives the account, and username keeps the + // entry attributable once the reference is gone. + userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }), + username: text("username").notNull(), + + action: text("action").notNull(), + resourceType: text("resource_type").notNull(), + resourceId: text("resource_id"), + resourceName: text("resource_name"), + + details: text("details"), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + + success: boolean("success").notNull(), + errorMessage: text("error_message"), + + timestamp: text("timestamp") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const sessionRecordings = pgTable("session_recordings", { + id: serial("id").primaryKey(), + + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + // Nullable on purpose: a recording is evidence about the host as much as the + // person, so it outlives the account. username keeps it attributable. + userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }), + username: text("username"), + accessId: integer("access_id").references(() => hostAccess.id, { + onDelete: "set null", + }), + + startedAt: text("started_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + endedAt: text("ended_at"), + duration: integer("duration"), + + commands: text("commands"), + dangerousActions: text("dangerous_actions"), + + recordingPath: text("recording_path"), + protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"), + format: text("format").notNull().default("text"), + + terminatedByOwner: boolean("terminated_by_owner") + .default(false), + terminationReason: text("termination_reason"), +}); + +export const sessionShares = pgTable("session_shares", { + id: varchar("id", { length: 255 }).primaryKey(), + + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + ownerUserId: varchar("owner_user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + protocol: varchar("protocol", { length: 255 }).notNull(), + + // Live-session binding: TerminalSessionManager's session.id for SSH, or + // guacd's own guacamoleConnectionId for rdp/vnc/telnet. Neither is a DB + // row (process-local, in-memory) so this intentionally has no FK. + sessionId: text("session_id").notNull(), + tabInstanceId: text("tab_instance_id"), + + shareType: text("share_type").notNull(), // "link" | "user" + targetUserId: varchar("target_user_id", { length: 255 }).references(() => users.id, { + onDelete: "cascade", + }), + linkToken: varchar("link_token", { length: 255 }).unique(), + + permissionLevel: text("permission_level").notNull().default("read-only"), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at").notNull(), + revokedAt: text("revoked_at"), + + lastJoinedAt: text("last_joined_at"), + joinCount: integer("join_count").notNull().default(0), +}); + +export const sessionShareParticipants = pgTable( + "session_share_participants", + { + id: serial("id").primaryKey(), + shareId: varchar("share_id", { length: 255 }) + .notNull() + .references(() => sessionShares.id, { onDelete: "cascade" }), + + userId: varchar("user_id", { length: 255 }).references(() => users.id, { + onDelete: "cascade", + }), + guestLabel: text("guest_label"), + + joinedAt: text("joined_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + leftAt: text("left_at"), + }, +); + +export const opksshTokens = pgTable( + "opkssh_tokens", + { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + + sshCert: text("ssh_cert").notNull(), + privateKey: text("private_key").notNull(), + + email: text("email"), + sub: text("sub"), + issuer: text("issuer"), + audience: text("audience"), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at").notNull(), + lastUsed: text("last_used"), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_opkssh_tokens_user_host").on(table.userId, table.hostId)], +); + +// Vault SSH signer profiles. These hold ONLY non-secret connection settings and +// are intended to be shared across users (shared === true makes a profile +// visible to every user on the server). Each user authenticates to Vault via an +// interactive OIDC flow at connect time; no tokens or keys are stored here. +export const vaultProfiles = pgTable("vault_profiles", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + folder: text("folder"), + tags: text("tags"), + // Vault server connection (non-secret) + vaultAddr: text("vault_addr").notNull(), + vaultNamespace: text("vault_namespace"), + // OIDC auth method mount + role used to obtain a Vault token interactively + oidcMount: text("oidc_mount"), + oidcRole: text("oidc_role"), + // SSH secrets engine mount + signer role used to sign the ephemeral key + sshMount: text("ssh_mount"), + sshRole: text("ssh_role").notNull(), + validPrincipals: text("valid_principals"), + // Ephemeral keypair algorithm to generate per connection + keyType: text("key_type"), + // When true the profile is visible/usable by all users on the server + shared: boolean("shared").notNull().default(false), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +// Per-user cache of the ephemeral SSH private key + Vault-signed certificate. +// Transient: rows live only until the certificate expires. Secret fields are +// encrypted under the user's data-encryption key (see field-crypto.ts). +export const vaultTokens = pgTable( + "vault_tokens", + { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + profileId: integer("profile_id") + .notNull() + .references(() => vaultProfiles.id, { onDelete: "cascade" }), + + sshCert: text("ssh_cert").notNull(), + privateKey: text("private_key").notNull(), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at").notNull(), + lastUsed: text("last_used"), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_vault_tokens_user_profile").on(table.userId, table.profileId)], +); + +export const apiKeys = pgTable("api_keys", { + id: varchar("id", { length: 255 }).primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + tokenHash: text("token_hash").notNull(), + tokenPrefix: text("token_prefix").notNull(), + createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at"), + lastUsedAt: text("last_used_at"), + isActive: boolean("is_active").notNull().default(true), +}); + +export const userOpenTabs = pgTable("user_open_tabs", { + id: varchar("id", { length: 255 }).primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tabType: text("tab_type").notNull(), + hostId: integer("host_id").references(() => hosts.id, { onDelete: "cascade" }), + label: text("label").notNull(), + tabOrder: integer("tab_order").notNull().default(0), + backendSessionId: text("backend_session_id"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const userPreferences = pgTable("user_preferences", { + userId: varchar("user_id", { length: 255 }) + .primaryKey() + .references(() => users.id, { onDelete: "cascade" }), + reopenTabsOnLogin: boolean("reopen_tabs_on_login") + .notNull() + .default(false), + theme: text("theme"), + fontSize: text("font_size"), + accentColor: text("accent_color"), + language: text("language"), + storageMode: text("storage_mode"), + commandAutocomplete: boolean("command_autocomplete"), + commandPaletteEnabled: boolean("command_palette_enabled"), + showHostTags: boolean("show_host_tags"), + hostTrayOnClick: boolean("host_tray_on_click"), + pinAppRail: boolean("pin_app_rail"), + expandAppRailOnHover: boolean("expand_app_rail_on_hover"), + foldersCollapsed: boolean("folders_collapsed"), + confirmSnippetExecution: boolean("confirm_snippet_execution"), + disableUpdateCheck: boolean("disable_update_check"), + confirmTabClose: boolean("confirm_tab_close"), + hiddenRailTabs: text("hidden_rail_tabs"), + compactHostView: boolean("compact_host_view"), + statusColorScheme: text("status_color_scheme"), + customThemes: text("custom_themes"), + customKeybindings: text("custom_keybindings"), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const hostMetricsPreferences = pgTable( + "host_metrics_preferences", + { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + // JSON-encoded HostMetricsLayout. Layout has no secrets, so it is stored as + // plain JSON (no field-level encryption). + layout: text("layout").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + // One layout per user per host. Enforced in production since the inline DDL + // creates it, but it was never declared here, so the generated Postgres and + // MySQL schemas lacked it — and the upsert has nothing to conflict on. + (table) => [ + uniqueIndex("idx_host_metrics_prefs_user_host").on(table.userId, table.hostId), + ], +); + +export const hostHealthChecks = pgTable( + "host_health_checks", + { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + // JSON array of { id, name, type: "tcp"|"http", target, port, path } + checks: text("checks").notNull(), + intervalSeconds: integer("interval_seconds").notNull().default(300), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + // Same as above: one set of checks per user per host. + (table) => [ + uniqueIndex("idx_host_health_checks_user_host").on(table.userId, table.hostId), + ], +); + +export const hostHealthHistory = pgTable("host_health_history", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + checkId: text("check_id").notNull(), + ts: text("ts").notNull().default(sql`CURRENT_TIMESTAMP`), + ok: boolean("ok").notNull(), + latencyMs: integer("latency_ms"), + detail: text("detail"), +}); + +export const dashboardServiceLinks = pgTable("dashboard_service_links", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + label: text("label").notNull(), + url: text("url").notNull(), + order: integer("order").notNull().default(0), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +// --- termix-id begin --- +// A user claims a unique public handle. Their published SSH public keys are +// served at an unauthenticated resolver endpoint in authorized_keys format, +// so any server can be provisioned with `curl /termix-id/u/ >> ~/.ssh/authorized_keys`. +export const termixIdentities = pgTable("termix_identities", { + id: serial("id").primaryKey(), + // One Termix ID per user — enforced in schema, not just in code. + userId: varchar("user_id", { length: 255 }) + .notNull() + .unique() + .references(() => users.id, { onDelete: "cascade" }), + handle: varchar("handle", { length: 255 }).notNull().unique(), + description: text("description"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const termixIdentityKeys = pgTable("termix_identity_keys", { + id: serial("id").primaryKey(), + identityId: integer("identity_id") + .notNull() + .references(() => termixIdentities.id, { onDelete: "cascade" }), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + // Public keys are non-secret, so they are stored in plaintext (no field-level + // encryption). This is what lets the unauthenticated resolver serve them. + publicKey: text("public_key").notNull(), + // Raw algorithm token (e.g. "ssh-ed25519"), and a normalized group used for + // the / resolver filter (RSA / ED25519 / ECDSA / ...). + keyType: text("key_type").notNull(), + algorithm: text("algorithm").notNull(), + label: text("label"), + comment: text("comment"), + // "manual" (pasted) or "credential" (imported from an ssh_credentials entry). + source: text("source").notNull().default("manual"), + credentialId: integer("credential_id").references(() => sshCredentials.id, { + onDelete: "set null", + }), + enabled: boolean("enabled").notNull().default(true), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); +// Per-identity certificate authority. Servers that trust this CA (via +// TrustedUserCAKeys / @cert-authority) accept any user certificate it signs, +// giving central revocation (rotate the CA) and expiry (cert validity). +export const termixIdentityCa = pgTable("termix_identity_ca", { + id: serial("id").primaryKey(), + identityId: integer("identity_id") + .notNull() + .unique() + .references(() => termixIdentities.id, { onDelete: "cascade" }), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + // CA public key (plaintext — it is published); CA private key is field-encrypted. + publicKey: text("public_key").notNull(), + privateKey: text("private_key").notNull(), + validityDays: integer("validity_days").notNull().default(90), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); +// --- termix-id end --- + +// --- tmux-monitor begin --- +export const tmuxSessionTags = pgTable("tmux_session_tags", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + sessionName: text("session_name").notNull(), + tag: text("tag").notNull(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); +// --- tmux-monitor end --- + +// --- metrics-history begin --- +export const hostMetricsHistory = pgTable("host_metrics_history", { + id: serial("id").primaryKey(), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + ts: text("ts") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + cpuPercent: doublePrecision("cpu_percent"), + memPercent: doublePrecision("mem_percent"), + diskPercent: doublePrecision("disk_percent"), + netRxBytes: integer("net_rx_bytes"), + netTxBytes: integer("net_tx_bytes"), +}); +// --- metrics-history end --- + +// --- alerts begin --- +export const alertRules = pgTable("alert_rules", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id").references(() => hosts.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + enabled: boolean("enabled").notNull().default(true), + triggerType: text("trigger_type").notNull(), + thresholdValue: doublePrecision("threshold_value"), + thresholdDurationSeconds: integer("threshold_duration_seconds"), + cooldownMinutes: integer("cooldown_minutes").notNull().default(15), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const notificationChannels = pgTable("notification_channels", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + type: text("type").notNull(), + config: text("config").notNull(), + enabled: boolean("enabled").notNull().default(true), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const alertRuleChannels = pgTable("alert_rule_channels", { + id: serial("id").primaryKey(), + ruleId: integer("rule_id") + .notNull() + .references(() => alertRules.id, { onDelete: "cascade" }), + channelId: integer("channel_id") + .notNull() + .references(() => notificationChannels.id, { onDelete: "cascade" }), +}); + +export const alertFirings = pgTable("alert_firings", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + ruleId: integer("rule_id") + .notNull() + .references(() => alertRules.id, { onDelete: "cascade" }), + hostId: integer("host_id").notNull(), + hostName: text("host_name").notNull(), + firedAt: text("fired_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + resolvedAt: text("resolved_at"), + value: doublePrecision("value"), + message: text("message").notNull(), + severity: text("severity").notNull().default("warning"), + acknowledged: boolean("acknowledged").notNull().default(false), +}); +// --- alerts end --- + +// --- homepage begin --- +export const homepageItems = pgTable("homepage_items", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + typeId: text("type_id").notNull(), + title: text("title"), + config: text("config").notNull().default("{}"), + folderId: integer("folder_id"), + syncId: varchar("sync_id", { length: 255 }).unique(), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const homepageLayouts = pgTable("homepage_layouts", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .unique() + .references(() => users.id, { onDelete: "cascade" }), + // JSON: { entries: HomepageLayoutEntry[], pan: {x,y}, zoom: number } + layout: text("layout").notNull().default("{}"), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); +// --- homepage end --- + +// --- sync begin --- +// Records a delete for a synced entity type so the other side of a sync +// pair (embedded desktop backend <-> connected remote server) learns about +// the deletion instead of re-creating the row on its next pull. +export const syncTombstones = pgTable("sync_tombstones", { + id: serial("id").primaryKey(), + userId: varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + entityType: text("entity_type").notNull(), + syncId: varchar("sync_id", { length: 255 }).notNull(), + deletedAt: text("deleted_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); +// --- sync end --- diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 8f6942df..503d1099 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -1,4 +1,10 @@ -import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core"; +import { + sqliteTable, + text, + integer, + real, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; import { sql } from "drizzle-orm"; export const users = sqliteTable("users", { @@ -566,40 +572,47 @@ export const hostAccess = sqliteTable("host_access", { ), }); -export const sharedHostSecrets = sqliteTable("shared_host_secrets", { - id: integer("id").primaryKey({ autoIncrement: true }), - - hostAccessId: integer("host_access_id") - .notNull() - .references(() => hostAccess.id, { onDelete: "cascade" }), - - targetUserId: text("target_user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - - protocol: text("protocol").notNull().default("ssh"), - sourceType: text("source_type").notNull().default("credential"), - - originalCredentialId: integer("original_credential_id").references( - () => sshCredentials.id, - { onDelete: "cascade" }, - ), - - encryptedUsername: text("encrypted_username"), - encryptedAuthType: text("encrypted_auth_type"), - encryptedPassword: text("encrypted_password"), - encryptedKey: text("encrypted_key", { length: 16384 }), - encryptedKeyPassword: text("encrypted_key_password"), - encryptedKeyType: text("encrypted_key_type"), - encryptedDomain: text("encrypted_domain"), - - createdAt: text("created_at") - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - updatedAt: text("updated_at") - .notNull() - .default(sql`CURRENT_TIMESTAMP`), -}); +export const sharedHostSecrets = sqliteTable( + "shared_host_secrets", + { + id: integer("id").primaryKey({ autoIncrement: true }), + + hostAccessId: integer("host_access_id") + .notNull() + .references(() => hostAccess.id, { onDelete: "cascade" }), + + targetUserId: text("target_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + protocol: text("protocol").notNull().default("ssh"), + sourceType: text("source_type").notNull().default("credential"), + + originalCredentialId: integer("original_credential_id").references( + () => sshCredentials.id, + { onDelete: "cascade" }, + ), + + encryptedUsername: text("encrypted_username"), + encryptedAuthType: text("encrypted_auth_type"), + encryptedPassword: text("encrypted_password"), + encryptedKey: text("encrypted_key", { length: 16384 }), + encryptedKeyPassword: text("encrypted_key_password"), + encryptedKeyType: text("encrypted_key_type"), + encryptedDomain: text("encrypted_domain"), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_shared_host_secrets_scope").on(table.hostAccessId, table.targetUserId, table.protocol)], +); export const roles = sqliteTable("roles", { id: integer("id").primaryKey({ autoIncrement: true }), @@ -621,22 +634,29 @@ export const roles = sqliteTable("roles", { .default(sql`CURRENT_TIMESTAMP`), }); -export const userRoles = sqliteTable("user_roles", { - id: integer("id").primaryKey({ autoIncrement: true }), - userId: text("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - roleId: integer("role_id") - .notNull() - .references(() => roles.id, { onDelete: "cascade" }), - - grantedBy: text("granted_by").references(() => users.id, { - onDelete: "set null", - }), - grantedAt: text("granted_at") - .notNull() - .default(sql`CURRENT_TIMESTAMP`), -}); +export const userRoles = sqliteTable( + "user_roles", + { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + roleId: integer("role_id") + .notNull() + .references(() => roles.id, { onDelete: "cascade" }), + + grantedBy: text("granted_by").references(() => users.id, { + onDelete: "set null", + }), + grantedAt: text("granted_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_user_roles_user_role").on(table.userId, table.roleId)], +); export const auditLogs = sqliteTable("audit_logs", { id: integer("id").primaryKey({ autoIncrement: true }), @@ -751,29 +771,36 @@ export const sessionShareParticipants = sqliteTable( }, ); -export const opksshTokens = sqliteTable("opkssh_tokens", { - id: integer("id").primaryKey({ autoIncrement: true }), - userId: text("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - hostId: integer("host_id") - .notNull() - .references(() => hosts.id, { onDelete: "cascade" }), - - sshCert: text("ssh_cert", { length: 8192 }).notNull(), - privateKey: text("private_key", { length: 8192 }).notNull(), - - email: text("email"), - sub: text("sub"), - issuer: text("issuer"), - audience: text("audience"), - - createdAt: text("created_at") - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - expiresAt: text("expires_at").notNull(), - lastUsed: text("last_used"), -}); +export const opksshTokens = sqliteTable( + "opkssh_tokens", + { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + + sshCert: text("ssh_cert", { length: 8192 }).notNull(), + privateKey: text("private_key", { length: 8192 }).notNull(), + + email: text("email"), + sub: text("sub"), + issuer: text("issuer"), + audience: text("audience"), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at").notNull(), + lastUsed: text("last_used"), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_opkssh_tokens_user_host").on(table.userId, table.hostId)], +); // Vault SSH signer profiles. These hold ONLY non-secret connection settings and // are intended to be shared across users (shared === true makes a profile @@ -814,24 +841,31 @@ export const vaultProfiles = sqliteTable("vault_profiles", { // Per-user cache of the ephemeral SSH private key + Vault-signed certificate. // Transient: rows live only until the certificate expires. Secret fields are // encrypted under the user's data-encryption key (see field-crypto.ts). -export const vaultTokens = sqliteTable("vault_tokens", { - id: integer("id").primaryKey({ autoIncrement: true }), - userId: text("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - profileId: integer("profile_id") - .notNull() - .references(() => vaultProfiles.id, { onDelete: "cascade" }), - - sshCert: text("ssh_cert", { length: 8192 }).notNull(), - privateKey: text("private_key", { length: 8192 }).notNull(), - - createdAt: text("created_at") - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - expiresAt: text("expires_at").notNull(), - lastUsed: text("last_used"), -}); +export const vaultTokens = sqliteTable( + "vault_tokens", + { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + profileId: integer("profile_id") + .notNull() + .references(() => vaultProfiles.id, { onDelete: "cascade" }), + + sshCert: text("ssh_cert", { length: 8192 }).notNull(), + privateKey: text("private_key", { length: 8192 }).notNull(), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at").notNull(), + lastUsed: text("last_used"), + }, + // Declared inline in the production DDL as UNIQUE(...), but never here, + // so the generated Postgres and MySQL schemas allowed duplicates the + // SQLite deployment forbids — and the upsert had nothing to conflict on. + (table) => [uniqueIndex("idx_vault_tokens_user_profile").on(table.userId, table.profileId)], +); export const apiKeys = sqliteTable("api_keys", { id: text("id").primaryKey(), @@ -899,7 +933,9 @@ export const userPreferences = sqliteTable("user_preferences", { .default(sql`CURRENT_TIMESTAMP`), }); -export const hostMetricsPreferences = sqliteTable("host_metrics_preferences", { +export const hostMetricsPreferences = sqliteTable( + "host_metrics_preferences", + { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() @@ -916,9 +952,18 @@ export const hostMetricsPreferences = sqliteTable("host_metrics_preferences", { updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), -}); + }, + // One layout per user per host. Enforced in production since the inline DDL + // creates it, but it was never declared here, so the generated Postgres and + // MySQL schemas lacked it — and the upsert has nothing to conflict on. + (table) => [ + uniqueIndex("idx_host_metrics_prefs_user_host").on(table.userId, table.hostId), + ], +); -export const hostHealthChecks = sqliteTable("host_health_checks", { +export const hostHealthChecks = sqliteTable( + "host_health_checks", + { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() @@ -935,7 +980,12 @@ export const hostHealthChecks = sqliteTable("host_health_checks", { updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), -}); + }, + // Same as above: one set of checks per user per host. + (table) => [ + uniqueIndex("idx_host_health_checks_user_host").on(table.userId, table.hostId), + ], +); export const hostHealthHistory = sqliteTable("host_health_history", { id: integer("id").primaryKey({ autoIncrement: true }), diff --git a/src/backend/database/repositories/alert-repository.ts b/src/backend/database/repositories/alert-repository.ts index b8740104..1c3edd31 100644 --- a/src/backend/database/repositories/alert-repository.ts +++ b/src/backend/database/repositories/alert-repository.ts @@ -8,6 +8,8 @@ import { } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; import { sqlTimestampDaysAgo } from "./sql-timestamp.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning, updateReturning } from "./returning.js"; type AlertRuleRecord = typeof alertRules.$inferSelect; type NotificationChannelRecord = typeof notificationChannels.$inferSelect; @@ -118,16 +120,17 @@ export class AlertRepository { config: string; enabled: boolean; }): Promise { - const [created] = await this.context.drizzle - .insert(notificationChannels) - .values({ + const [created] = await insertReturning( + this.context, + notificationChannels, + { userId: input.userId, name: input.name, type: input.type, config: input.config, enabled: input.enabled, - }) - .returning(); + }, + ); await this.afterWrite(); return mapChannelRow(created); @@ -147,16 +150,15 @@ export class AlertRepository { return this.findNotificationChannelForUser(id, userId); } - const [updated] = await this.context.drizzle - .update(notificationChannels) - .set(input) - .where( - and( - eq(notificationChannels.id, id), - eq(notificationChannels.userId, userId), - ), - ) - .returning(); + const [updated] = await updateReturning( + this.context, + notificationChannels, + input, + and( + eq(notificationChannels.id, id), + eq(notificationChannels.userId, userId), + ), + ); if (!updated) return null; await this.afterWrite(); @@ -167,17 +169,16 @@ export class AlertRepository { id: number, userId: string, ): Promise { - const deleted = await this.context.drizzle + const result = await this.context.drizzle .delete(notificationChannels) .where( and( eq(notificationChannels.id, id), eq(notificationChannels.userId, userId), ), - ) - .returning({ id: notificationChannels.id }); + ); - if (deleted.length === 0) return false; + if (rowsAffected(result) === 0) return false; await this.afterWrite(); return true; } @@ -211,21 +212,18 @@ export class AlertRepository { channels: number[]; now: string; }): Promise { - const [created] = await this.context.drizzle - .insert(alertRules) - .values({ - userId: input.userId, - hostId: input.hostId, - name: input.name, - enabled: input.enabled, - triggerType: input.triggerType, - thresholdValue: input.thresholdValue, - thresholdDurationSeconds: input.thresholdDurationSeconds, - cooldownMinutes: input.cooldownMinutes, - createdAt: input.now, - updatedAt: input.now, - }) - .returning(); + const [created] = await insertReturning(this.context, alertRules, { + userId: input.userId, + hostId: input.hostId, + name: input.name, + enabled: input.enabled, + triggerType: input.triggerType, + thresholdValue: input.thresholdValue, + thresholdDurationSeconds: input.thresholdDurationSeconds, + cooldownMinutes: input.cooldownMinutes, + createdAt: input.now, + updatedAt: input.now, + }); const channels = await this.replaceRuleChannels( created.id, @@ -264,9 +262,10 @@ export class AlertRepository { now: string; }, ): Promise { - const [updated] = await this.context.drizzle - .update(alertRules) - .set({ + const [updated] = await updateReturning( + this.context, + alertRules, + { ...(input.name !== undefined ? { name: input.name } : {}), ...(input.hostId !== undefined ? { hostId: input.hostId } : {}), ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), @@ -283,9 +282,9 @@ export class AlertRepository { ? { cooldownMinutes: input.cooldownMinutes } : {}), updatedAt: input.now, - }) - .where(and(eq(alertRules.id, id), eq(alertRules.userId, userId))) - .returning(); + }, + and(eq(alertRules.id, id), eq(alertRules.userId, userId)), + ); if (!updated) return null; @@ -299,12 +298,11 @@ export class AlertRepository { } async deleteAlertRule(id: number, userId: string): Promise { - const deleted = await this.context.drizzle + const result = await this.context.drizzle .delete(alertRules) - .where(and(eq(alertRules.id, id), eq(alertRules.userId, userId))) - .returning({ id: alertRules.id }); + .where(and(eq(alertRules.id, id), eq(alertRules.userId, userId))); - if (deleted.length === 0) return false; + if (rowsAffected(result) === 0) return false; await this.afterWrite(); return true; } @@ -442,10 +440,9 @@ export class AlertRepository { .where(eq(notificationChannels.userId, userId)) ).map((row) => row.id); - const firingRows = await this.context.drizzle + const firingResult = await this.context.drizzle .delete(alertFirings) - .where(eq(alertFirings.userId, userId)) - .returning({ id: alertFirings.id }); + .where(eq(alertFirings.userId, userId)); const linkFilters = [ ...(ruleIds.length > 0 @@ -455,37 +452,34 @@ export class AlertRepository { ? [inArray(alertRuleChannels.channelId, channelIds)] : []), ]; - const linkRows = + const linkResult = linkFilters.length === 0 - ? [] + ? null : await this.context.drizzle .delete(alertRuleChannels) - .where(or(...linkFilters)) - .returning({ id: alertRuleChannels.id }); + .where(or(...linkFilters)); - const ruleRows = await this.context.drizzle + const ruleResult = await this.context.drizzle .delete(alertRules) - .where(eq(alertRules.userId, userId)) - .returning({ id: alertRules.id }); - const channelRows = await this.context.drizzle + .where(eq(alertRules.userId, userId)); + const result = await this.context.drizzle .delete(notificationChannels) - .where(eq(notificationChannels.userId, userId)) - .returning({ id: notificationChannels.id }); + .where(eq(notificationChannels.userId, userId)); if ( - firingRows.length > 0 || - linkRows.length > 0 || - ruleRows.length > 0 || - channelRows.length > 0 + rowsAffected(firingResult) > 0 || + rowsAffected(linkResult) > 0 || + rowsAffected(ruleResult) > 0 || + rowsAffected(result) > 0 ) { await this.afterWrite(); } return { - firingsDeleted: firingRows.length, - ruleLinksDeleted: linkRows.length, - rulesDeleted: ruleRows.length, - channelsDeleted: channelRows.length, + firingsDeleted: rowsAffected(firingResult), + ruleLinksDeleted: rowsAffected(linkResult), + rulesDeleted: rowsAffected(ruleResult), + channelsDeleted: rowsAffected(result), }; } diff --git a/src/backend/database/repositories/api-key-repository.ts b/src/backend/database/repositories/api-key-repository.ts index a24b880a..c037f780 100644 --- a/src/backend/database/repositories/api-key-repository.ts +++ b/src/backend/database/repositories/api-key-repository.ts @@ -1,6 +1,8 @@ import { eq, and } from "drizzle-orm"; import { apiKeys, users } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { deleteReturning, insertReturning } from "./returning.js"; export type ApiKeyRecord = typeof apiKeys.$inferSelect; export type NewApiKeyRecord = typeof apiKeys.$inferInsert; @@ -24,10 +26,7 @@ export class ApiKeyRepository { ) {} async create(apiKey: NewApiKeyRecord): Promise { - const rows = await this.context.drizzle - .insert(apiKeys) - .values(apiKey) - .returning(); + const rows = await insertReturning(this.context, apiKeys, apiKey); await this.afterWrite(); return rows[0]; } @@ -78,23 +77,23 @@ export class ApiKeyRepository { } async delete(id: string): Promise { - const rows = await this.context.drizzle - .delete(apiKeys) - .where(eq(apiKeys.id, id)) - .returning(); + const rows = await deleteReturning( + this.context, + apiKeys, + eq(apiKeys.id, id), + ); await this.afterWrite(); return rows[0] ?? null; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(apiKeys) - .where(eq(apiKeys.userId, userId)) - .returning({ id: apiKeys.id }); + .where(eq(apiKeys.userId, userId)); await this.afterWrite(); - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/audit-log-repository.ts b/src/backend/database/repositories/audit-log-repository.ts index 3f966455..6cae51e4 100644 --- a/src/backend/database/repositories/audit-log-repository.ts +++ b/src/backend/database/repositories/audit-log-repository.ts @@ -3,6 +3,7 @@ import { auditLogs } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; import { sqlTimestampDaysAgo } from "./sql-timestamp.js"; import { databaseLogger } from "../../utils/logger.js"; +import { countValue, rowsAffected } from "./mutation-result.js"; export type AuditLogRecord = typeof auditLogs.$inferSelect; export type NewAuditLogRecord = typeof auditLogs.$inferInsert; @@ -82,7 +83,7 @@ export class AuditLogRepository { return { logs, - total: totalResult[0]?.count ?? 0, + total: countValue(totalResult[0]?.count), }; } @@ -126,30 +127,28 @@ export class AuditLogRepository { * asked. `username` is denormalised, so the entry stays attributable. */ async anonymizeByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(auditLogs) .set({ userId: null }) - .where(eq(auditLogs.userId, userId)) - .returning({ id: auditLogs.id }); + .where(eq(auditLogs.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(auditLogs) - .where(eq(auditLogs.userId, userId)) - .returning({ id: auditLogs.id }); + .where(eq(auditLogs.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private buildWhere(filters: AuditLogFilters) { @@ -184,17 +183,16 @@ export class AuditLogRepository { if (days === null) return; const cutoff = sqlTimestampDaysAgo(days); - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(auditLogs) - .where(lt(auditLogs.timestamp, cutoff)) - .returning({ id: auditLogs.id }); + .where(lt(auditLogs.timestamp, cutoff)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { databaseLogger.info( - `Pruned ${rows.length} audit entries past retention`, + `Pruned ${rowsAffected(result)} audit entries past retention`, { operation: "audit_retention_prune", - removed: rows.length, + removed: rowsAffected(result), retentionDays: days, cutoff, }, @@ -212,7 +210,7 @@ export class AuditLogRepository { const countResult = await this.context.drizzle .select({ count: sql`COUNT(*)` }) .from(auditLogs); - const count = countResult[0]?.count ?? 0; + const count = countValue(countResult[0]?.count); if (count < max) return; diff --git a/src/backend/database/repositories/c2s-tunnel-preset-repository.ts b/src/backend/database/repositories/c2s-tunnel-preset-repository.ts index b0417132..0c14689a 100644 --- a/src/backend/database/repositories/c2s-tunnel-preset-repository.ts +++ b/src/backend/database/repositories/c2s-tunnel-preset-repository.ts @@ -1,6 +1,8 @@ import { and, asc, eq, sql } from "drizzle-orm"; import { c2sTunnelPresets } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning, updateReturning } from "./returning.js"; export type C2sTunnelPresetRecord = typeof c2sTunnelPresets.$inferSelect; @@ -64,16 +66,13 @@ export class C2sTunnelPresetRepository { userId: string, input: C2sTunnelPresetCreateInput, ): Promise { - const [created] = await this.context.drizzle - .insert(c2sTunnelPresets) - .values({ - userId, - name: input.name, - config: input.config, - platform: input.platform ?? null, - computerName: input.computerName ?? null, - }) - .returning(); + const [created] = await insertReturning(this.context, c2sTunnelPresets, { + userId, + name: input.name, + config: input.config, + platform: input.platform ?? null, + computerName: input.computerName ?? null, + }); await this.afterWrite(); return created; @@ -84,16 +83,15 @@ export class C2sTunnelPresetRepository { id: number, updates: C2sTunnelPresetUpdateInput, ): Promise { - const [updated] = await this.context.drizzle - .update(c2sTunnelPresets) - .set({ + const [updated] = await updateReturning( + this.context, + c2sTunnelPresets, + { ...updates, updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where( - and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)), - ) - .returning(); + }, + and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)), + ); if (updated) { await this.afterWrite(); @@ -103,31 +101,29 @@ export class C2sTunnelPresetRepository { } async deleteForUser(userId: string, id: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(c2sTunnelPresets) .where( and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)), - ) - .returning({ id: c2sTunnelPresets.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(c2sTunnelPresets) - .where(eq(c2sTunnelPresets.userId, userId)) - .returning({ id: c2sTunnelPresets.id }); + .where(eq(c2sTunnelPresets.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/command-history-repository.ts b/src/backend/database/repositories/command-history-repository.ts index 5bf72b3a..da7b6571 100644 --- a/src/backend/database/repositories/command-history-repository.ts +++ b/src/backend/database/repositories/command-history-repository.ts @@ -1,6 +1,8 @@ import { and, desc, eq, inArray, sql } from "drizzle-orm"; import { commandHistory } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning } from "./returning.js"; export type CommandHistoryRecord = typeof commandHistory.$inferSelect; @@ -16,10 +18,12 @@ export class CommandHistoryRepository { command: string, executedAt = new Date().toISOString(), ): Promise { - const [created] = await this.context.drizzle - .insert(commandHistory) - .values({ userId, hostId, command, executedAt }) - .returning(); + const [created] = await insertReturning(this.context, commandHistory, { + userId, + hostId, + command, + executedAt, + }); await this.afterWrite(); return created; } @@ -76,7 +80,7 @@ export class CommandHistoryRepository { hostId: number, command: string, ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(commandHistory) .where( and( @@ -84,45 +88,42 @@ export class CommandHistoryRepository { eq(commandHistory.hostId, hostId), eq(commandHistory.command, command), ), - ) - .returning({ id: commandHistory.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByUserAndHost(userId: string, hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(commandHistory) .where( and( eq(commandHistory.userId, userId), eq(commandHistory.hostId, hostId), ), - ) - .returning({ id: commandHistory.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByHostId(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(commandHistory) - .where(eq(commandHistory.hostId, hostId)) - .returning({ id: commandHistory.id }); + .where(eq(commandHistory.hostId, hostId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByHostIds(hostIds: number[]): Promise { @@ -130,29 +131,27 @@ export class CommandHistoryRepository { return 0; } - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(commandHistory) - .where(inArray(commandHistory.hostId, hostIds)) - .returning({ id: commandHistory.id }); + .where(inArray(commandHistory.hostId, hostIds)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(commandHistory) - .where(eq(commandHistory.userId, userId)) - .returning({ id: commandHistory.id }); + .where(eq(commandHistory.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/credential-repository.ts b/src/backend/database/repositories/credential-repository.ts index 5ec291be..dd6b12d1 100644 --- a/src/backend/database/repositories/credential-repository.ts +++ b/src/backend/database/repositories/credential-repository.ts @@ -3,6 +3,12 @@ import { randomUUID } from "crypto"; import { sshCredentials, sshCredentialUsage } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; import { DataCrypto } from "../../utils/data-crypto.js"; +import { rowsAffected } from "./mutation-result.js"; +import { + deleteReturning, + insertReturning, + updateReturning, +} from "./returning.js"; export type CredentialRecord = typeof sshCredentials.$inferSelect; export type NewCredentialRecord = typeof sshCredentials.$inferInsert; @@ -17,10 +23,10 @@ export class CredentialRepository { ) {} async create(credential: NewCredentialRecord): Promise { - const rows = await this.context.drizzle - .insert(sshCredentials) - .values({ syncId: randomUUID(), ...credential }) - .returning(); + const rows = await insertReturning(this.context, sshCredentials, { + syncId: randomUUID(), + ...credential, + }); await this.afterWrite(); return rows[0]; } @@ -46,10 +52,11 @@ export class CredentialRepository { delete (encryptedCredential as Partial).id; } - const rows = await this.context.drizzle - .insert(sshCredentials) - .values(encryptedCredential as NewCredentialRecord) - .returning(); + const rows = await insertReturning( + this.context, + sshCredentials, + encryptedCredential as NewCredentialRecord, + ); await this.afterWrite(); return DataCrypto.decryptRecord( @@ -143,7 +150,7 @@ export class CredentialRepository { oldName: string, newName: string, ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(sshCredentials) .set({ folder: newName, updatedAt: sql`CURRENT_TIMESTAMP` }) .where( @@ -151,14 +158,13 @@ export class CredentialRepository { eq(sshCredentials.userId, userId), eq(sshCredentials.folder, oldName), ), - ) - .returning({ id: sshCredentials.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async updateForUser( @@ -166,16 +172,15 @@ export class CredentialRepository { credentialId: number, update: CredentialUpdate, ): Promise { - const rows = await this.context.drizzle - .update(sshCredentials) - .set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` }) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId), - ), - ) - .returning(); + const rows = await updateReturning( + this.context, + sshCredentials, + { ...update, updatedAt: sql`CURRENT_TIMESTAMP` }, + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId), + ), + ); await this.afterWrite(); return rows[0] ?? null; @@ -193,16 +198,15 @@ export class CredentialRepository { userDataKey, ); - const rows = await this.context.drizzle - .update(sshCredentials) - .set({ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` }) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId), - ), - ) - .returning(); + const rows = await updateReturning( + this.context, + sshCredentials, + { ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` }, + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId), + ), + ); await this.afterWrite(); return this.decryptOne(rows[0] ?? null, userId); @@ -212,31 +216,29 @@ export class CredentialRepository { userId: string, credentialId: number, ): Promise<{ syncId: string | null } | null> { - const rows = await this.context.drizzle - .delete(sshCredentials) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId), - ), - ) - .returning({ syncId: sshCredentials.syncId }); + const rows = await deleteReturning( + this.context, + sshCredentials, + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId), + ), + ); await this.afterWrite(); - return rows[0] ?? null; + return rows[0] ? { syncId: rows[0].syncId } : null; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sshCredentials) - .where(eq(sshCredentials.userId, userId)) - .returning({ id: sshCredentials.id }); + .where(eq(sshCredentials.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async recordUsage( diff --git a/src/backend/database/repositories/dashboard-service-link-repository.ts b/src/backend/database/repositories/dashboard-service-link-repository.ts index b06c2100..3aa6d253 100644 --- a/src/backend/database/repositories/dashboard-service-link-repository.ts +++ b/src/backend/database/repositories/dashboard-service-link-repository.ts @@ -2,6 +2,12 @@ import { and, asc, eq } from "drizzle-orm"; import { randomUUID } from "crypto"; import { dashboardServiceLinks } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { + deleteReturning, + insertReturning, + updateReturning, +} from "./returning.js"; export type DashboardServiceLinkRecord = typeof dashboardServiceLinks.$inferSelect; @@ -38,9 +44,10 @@ export class DashboardServiceLinkRepository { const nextOrder = existing.length > 0 ? existing[existing.length - 1].order + 1 : 0; - const [created] = await this.context.drizzle - .insert(dashboardServiceLinks) - .values({ + const [created] = await insertReturning( + this.context, + dashboardServiceLinks, + { syncId: randomUUID(), userId, label: input.label, @@ -48,8 +55,8 @@ export class DashboardServiceLinkRepository { order: nextOrder, createdAt, updatedAt: createdAt, - }) - .returning(); + }, + ); await this.afterWrite(); return created; } @@ -77,16 +84,15 @@ export class DashboardServiceLinkRepository { id: number, updates: DashboardServiceLinkUpdate, ): Promise { - const [updated] = await this.context.drizzle - .update(dashboardServiceLinks) - .set({ ...updates, updatedAt: new Date().toISOString() }) - .where( - and( - eq(dashboardServiceLinks.id, id), - eq(dashboardServiceLinks.userId, userId), - ), - ) - .returning(); + const [updated] = await updateReturning( + this.context, + dashboardServiceLinks, + { ...updates, updatedAt: new Date().toISOString() }, + and( + eq(dashboardServiceLinks.id, id), + eq(dashboardServiceLinks.userId, userId), + ), + ); if (updated) { await this.afterWrite(); @@ -99,32 +105,30 @@ export class DashboardServiceLinkRepository { userId: string, id: number, ): Promise<{ syncId: string | null } | null> { - const rows = await this.context.drizzle - .delete(dashboardServiceLinks) - .where( - and( - eq(dashboardServiceLinks.id, id), - eq(dashboardServiceLinks.userId, userId), - ), - ) - .returning({ syncId: dashboardServiceLinks.syncId }); + const rows = await deleteReturning( + this.context, + dashboardServiceLinks, + and( + eq(dashboardServiceLinks.id, id), + eq(dashboardServiceLinks.userId, userId), + ), + ); if (rows.length === 0) return null; await this.afterWrite(); - return rows[0]; + return { syncId: rows[0].syncId }; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(dashboardServiceLinks) - .where(eq(dashboardServiceLinks.userId, userId)) - .returning({ id: dashboardServiceLinks.id }); + .where(eq(dashboardServiceLinks.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/database-context.ts b/src/backend/database/repositories/database-context.ts index 4570897b..4f0bb476 100644 --- a/src/backend/database/repositories/database-context.ts +++ b/src/backend/database/repositories/database-context.ts @@ -1,12 +1,31 @@ import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; import type * as schema from "../db/schema.js"; +// Re-exported so repositories can keep importing it from here, but defined in +// db/dialect.ts — a local copy that said "sqlite" survived here for a while and +// typed every context as SQLite-only while the runtime already carried all +// three, which silently made the dialect branches unreachable to the checker. +export type { DatabaseDialect } from "../db/dialect.js"; +import type { DatabaseDialect } from "../db/dialect.js"; + /** - * Engines the repository layer can run against. SQLite is the only one wired up - * today; the alias exists so that adding another is a change in one place - * rather than a hunt for string literals. + * The database handle repositories work against. + * + * Typed as the SQLite instance on purpose. drizzle's three Database classes + * share no base class and their signatures are incompatible: a union is not + * callable, and a generic would have to be threaded through all 43 + * repositories and every method on them. + * + * This is a deliberate approximation, not an accident. The query-builder + * surface the repositories actually use is the same on all three engines, and + * that equivalence is asserted in multi-dialect.test.ts rather than assumed — + * identifier quoting, placeholder style and value coercion are all covered + * there. At runtime this may hold a Postgres or MySQL instance. + * + * The one place the surfaces genuinely differ is RETURNING, which MySQL lacks; + * see mutation-result.ts for how that is absorbed. */ -export type DatabaseDialect = "sqlite"; +export type PortableDatabase = BetterSQLite3Database; /** * What a repository is allowed to touch. @@ -18,5 +37,5 @@ export type DatabaseDialect = "sqlite"; */ export interface DatabaseContext { dialect: DatabaseDialect; - drizzle: BetterSQLite3Database; + drizzle: PortableDatabase; } diff --git a/src/backend/database/repositories/dismissed-alert-repository.ts b/src/backend/database/repositories/dismissed-alert-repository.ts index e44d20b1..87cf40d8 100644 --- a/src/backend/database/repositories/dismissed-alert-repository.ts +++ b/src/backend/database/repositories/dismissed-alert-repository.ts @@ -1,6 +1,7 @@ import { and, eq } from "drizzle-orm"; import { dismissedAlerts } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; export type DismissedAlertRecord = typeof dismissedAlerts.$inferSelect; @@ -72,34 +73,32 @@ export class DismissedAlertRepository { } async deleteForUser(userId: string, alertId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(dismissedAlerts) .where( and( eq(dismissedAlerts.userId, userId), eq(dismissedAlerts.alertId, alertId), ), - ) - .returning({ id: dismissedAlerts.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(dismissedAlerts) - .where(eq(dismissedAlerts.userId, userId)) - .returning({ id: dismissedAlerts.id }); + .where(eq(dismissedAlerts.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/factory.ts b/src/backend/database/repositories/factory.ts index 4d25b665..12eaee51 100644 --- a/src/backend/database/repositories/factory.ts +++ b/src/backend/database/repositories/factory.ts @@ -1,5 +1,7 @@ import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { getDb, getSqlite } from "../db/index.js"; +import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js"; +import { primeSettingsCache, readCachedSetting } from "./settings-cache.js"; import type { DatabaseContext } from "./database-context.js"; import { WebauthnCredentialRepository } from "./webauthn-credential-repository.js"; import { AlertRepository } from "./alert-repository.js"; @@ -52,9 +54,18 @@ export function createCurrentRepositoryContext(): DatabaseContext { }; } +/** + * Post-write hook handed to every repository. + * + * Only meaningful for SQLite, where the database lives in memory and has to be + * serialised back to its encrypted file. On Postgres and MySQL the write is + * already durable, so no hook is installed at all rather than one that does + * nothing — repositories call it as `this.onWrite?.()`. + */ export function createCurrentRepositoryWriteHook( reason: string, -): () => Promise { +): (() => Promise) | undefined { + if (!needsExplicitPersist(resolveDatabaseDialect())) return undefined; return () => DatabaseSaveTrigger.forceSave(reason); } @@ -68,7 +79,18 @@ export function getCurrentRepositorySqlite() { return getSqlite(); } +/** + * Synchronous settings read. + * + * SQLite can be queried synchronously, so it is read directly and stays + * authoritative. Other engines have no synchronous query, so the value comes + * from the cache primed at startup and kept current by SettingsRepository. + */ export function getCurrentSettingValue(key: string): string | null { + if (!needsExplicitPersist(resolveDatabaseDialect())) { + return readCachedSetting(key); + } + const row = getCurrentRepositorySqlite() .prepare("SELECT value FROM settings WHERE key = ?") .get(key) as { value?: string } | undefined; @@ -375,3 +397,69 @@ export function createCurrentVaultTokenRepository(): VaultTokenRepository { createCurrentRepositoryWriteHook("vault_token_repository_write"), ); } + +/** + * Loads the settings cache. Must run during startup on engines without a + * synchronous read, before anything calls getCurrentSettingValue. + */ +export async function primeCurrentSettingsCache(): Promise { + const rows = await createCurrentSettingsRepository().listAll(); + primeSettingsCache(rows); +} + +/** + * How often a replica re-reads the settings table. + * + * Override with SETTINGS_CACHE_REFRESH_SECONDS; 0 disables the refresh. + */ +const REFRESH_SECONDS_ENV = "SETTINGS_CACHE_REFRESH_SECONDS"; +const DEFAULT_REFRESH_SECONDS = 30; + +let refreshTimer: NodeJS.Timeout | null = null; + +/** + * Keeps the settings cache from drifting on a multi-replica deployment. + * + * The cache is per-process and updated in the process that writes. That is + * enough for SQLite, where there is only ever one process. On Postgres and + * MySQL — which exist here precisely so more than one instance can share the + * data — a setting changed on one replica would otherwise never reach the + * others, because the synchronous read has no way to go back to the database. + * + * Periodic re-priming does not make the value immediately consistent. It bounds + * how long it can be wrong, which is the difference between a setting that + * takes effect on the next tick and one that takes effect at the next restart. + */ +export function startSettingsCacheRefresh( + env = process.env, + refresh: () => Promise = primeCurrentSettingsCache, +): void { + if (refreshTimer) return; + + const seconds = refreshIntervalSeconds(env); + if (seconds === null) return; + + refreshTimer = setInterval(() => { + void refresh().catch(() => { + // A failed refresh leaves the previous values in place, which is the + // right outcome: a transient database blip should not blank the cache. + // Every caller reads a missing setting as "use the default", so an empty + // cache would silently revert configuration across the deployment. + }); + }, seconds * 1000); + + refreshTimer.unref(); +} + +/** The configured interval, or null when refreshing is switched off. */ +export function refreshIntervalSeconds(env = process.env): number | null { + const seconds = Number(env[REFRESH_SECONDS_ENV] ?? DEFAULT_REFRESH_SECONDS); + return Number.isFinite(seconds) && seconds > 0 ? seconds : null; +} + +/** Test seam. */ +export function stopSettingsCacheRefresh(): void { + if (!refreshTimer) return; + clearInterval(refreshTimer); + refreshTimer = null; +} diff --git a/src/backend/database/repositories/file-manager-bookmark-repository.ts b/src/backend/database/repositories/file-manager-bookmark-repository.ts index dfd68b3f..351b2af7 100644 --- a/src/backend/database/repositories/file-manager-bookmark-repository.ts +++ b/src/backend/database/repositories/file-manager-bookmark-repository.ts @@ -5,6 +5,7 @@ import { fileManagerShortcuts, } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; export type FileManagerRecentRecord = typeof fileManagerRecent.$inferSelect; export type FileManagerPinnedRecord = typeof fileManagerPinned.$inferSelect; @@ -112,7 +113,7 @@ export class FileManagerBookmarkRepository { userId: string, input: Pick, ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerRecent) .where( and( @@ -120,14 +121,13 @@ export class FileManagerBookmarkRepository { eq(fileManagerRecent.hostId, input.hostId), eq(fileManagerRecent.path, input.path), ), - ) - .returning({ id: fileManagerRecent.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async listPinnedForHost( @@ -199,7 +199,7 @@ export class FileManagerBookmarkRepository { userId: string, input: Pick, ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerPinned) .where( and( @@ -207,14 +207,13 @@ export class FileManagerBookmarkRepository { eq(fileManagerPinned.hostId, input.hostId), eq(fileManagerPinned.path, input.path), ), - ) - .returning({ id: fileManagerPinned.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async listShortcutsForHost( @@ -288,7 +287,7 @@ export class FileManagerBookmarkRepository { userId: string, input: Pick, ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerShortcuts) .where( and( @@ -296,14 +295,13 @@ export class FileManagerBookmarkRepository { eq(fileManagerShortcuts.hostId, input.hostId), eq(fileManagerShortcuts.path, input.path), ), - ) - .returning({ id: fileManagerShortcuts.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByUserId(userId: string): Promise { @@ -456,75 +454,66 @@ export class FileManagerBookmarkRepository { } private async deleteRecentByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerRecent) - .where(eq(fileManagerRecent.userId, userId)) - .returning({ id: fileManagerRecent.id }); - return rows.length; + .where(eq(fileManagerRecent.userId, userId)); + return rowsAffected(result); } private async deletePinnedByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerPinned) - .where(eq(fileManagerPinned.userId, userId)) - .returning({ id: fileManagerPinned.id }); - return rows.length; + .where(eq(fileManagerPinned.userId, userId)); + return rowsAffected(result); } private async deleteShortcutsByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerShortcuts) - .where(eq(fileManagerShortcuts.userId, userId)) - .returning({ id: fileManagerShortcuts.id }); - return rows.length; + .where(eq(fileManagerShortcuts.userId, userId)); + return rowsAffected(result); } private async deleteRecentByHostId(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerRecent) - .where(eq(fileManagerRecent.hostId, hostId)) - .returning({ id: fileManagerRecent.id }); - return rows.length; + .where(eq(fileManagerRecent.hostId, hostId)); + return rowsAffected(result); } private async deletePinnedByHostId(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerPinned) - .where(eq(fileManagerPinned.hostId, hostId)) - .returning({ id: fileManagerPinned.id }); - return rows.length; + .where(eq(fileManagerPinned.hostId, hostId)); + return rowsAffected(result); } private async deleteShortcutsByHostId(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerShortcuts) - .where(eq(fileManagerShortcuts.hostId, hostId)) - .returning({ id: fileManagerShortcuts.id }); - return rows.length; + .where(eq(fileManagerShortcuts.hostId, hostId)); + return rowsAffected(result); } private async deleteRecentByHostIds(hostIds: number[]): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerRecent) - .where(inArray(fileManagerRecent.hostId, hostIds)) - .returning({ id: fileManagerRecent.id }); - return rows.length; + .where(inArray(fileManagerRecent.hostId, hostIds)); + return rowsAffected(result); } private async deletePinnedByHostIds(hostIds: number[]): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerPinned) - .where(inArray(fileManagerPinned.hostId, hostIds)) - .returning({ id: fileManagerPinned.id }); - return rows.length; + .where(inArray(fileManagerPinned.hostId, hostIds)); + return rowsAffected(result); } private async deleteShortcutsByHostIds(hostIds: number[]): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(fileManagerShortcuts) - .where(inArray(fileManagerShortcuts.hostId, hostIds)) - .returning({ id: fileManagerShortcuts.id }); - return rows.length; + .where(inArray(fileManagerShortcuts.hostId, hostIds)); + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/homepage-item-repository.ts b/src/backend/database/repositories/homepage-item-repository.ts index 2be7ebed..7f4a88ce 100644 --- a/src/backend/database/repositories/homepage-item-repository.ts +++ b/src/backend/database/repositories/homepage-item-repository.ts @@ -2,6 +2,12 @@ import { and, asc, eq } from "drizzle-orm"; import { randomUUID } from "crypto"; import { homepageItems } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { + deleteReturning, + insertReturning, + updateReturning, +} from "./returning.js"; export type HomepageItemRecord = typeof homepageItems.$inferSelect; @@ -35,18 +41,15 @@ export class HomepageItemRepository { input: HomepageItemCreateInput, now = new Date().toISOString(), ): Promise { - const [created] = await this.context.drizzle - .insert(homepageItems) - .values({ - syncId: randomUUID(), - userId, - typeId: input.typeId, - title: input.title, - config: input.config, - createdAt: now, - updatedAt: now, - }) - .returning(); + const [created] = await insertReturning(this.context, homepageItems, { + syncId: randomUUID(), + userId, + typeId: input.typeId, + title: input.title, + config: input.config, + createdAt: now, + updatedAt: now, + }); await this.afterWrite(); return created; @@ -71,11 +74,12 @@ export class HomepageItemRepository { updates: HomepageItemUpdateInput, updatedAt = new Date().toISOString(), ): Promise { - const [updated] = await this.context.drizzle - .update(homepageItems) - .set({ ...updates, updatedAt }) - .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))) - .returning(); + const [updated] = await updateReturning( + this.context, + homepageItems, + { ...updates, updatedAt }, + and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)), + ); if (updated) { await this.afterWrite(); @@ -88,27 +92,27 @@ export class HomepageItemRepository { userId: string, id: number, ): Promise<{ syncId: string | null } | null> { - const rows = await this.context.drizzle - .delete(homepageItems) - .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))) - .returning({ syncId: homepageItems.syncId }); + const rows = await deleteReturning( + this.context, + homepageItems, + and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)), + ); if (rows.length === 0) return null; await this.afterWrite(); - return rows[0]; + return { syncId: rows[0].syncId }; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(homepageItems) - .where(eq(homepageItems.userId, userId)) - .returning({ id: homepageItems.id }); + .where(eq(homepageItems.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/homepage-layout-repository.ts b/src/backend/database/repositories/homepage-layout-repository.ts index bb10c453..d1e2d8c8 100644 --- a/src/backend/database/repositories/homepage-layout-repository.ts +++ b/src/backend/database/repositories/homepage-layout-repository.ts @@ -1,6 +1,8 @@ import { eq } from "drizzle-orm"; import { homepageLayouts } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning, updateReturning } from "./returning.js"; export type HomepageLayoutRecord = typeof homepageLayouts.$inferSelect; @@ -28,34 +30,35 @@ export class HomepageLayoutRepository { const existing = await this.findByUserId(userId); if (!existing) { - const [created] = await this.context.drizzle - .insert(homepageLayouts) - .values({ userId, layout, updatedAt }) - .returning(); + const [created] = await insertReturning(this.context, homepageLayouts, { + userId, + layout, + updatedAt, + }); await this.afterWrite(); return created; } - const [updated] = await this.context.drizzle - .update(homepageLayouts) - .set({ layout, updatedAt }) - .where(eq(homepageLayouts.userId, userId)) - .returning(); + const [updated] = await updateReturning( + this.context, + homepageLayouts, + { layout, updatedAt }, + eq(homepageLayouts.userId, userId), + ); await this.afterWrite(); return updated; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(homepageLayouts) - .where(eq(homepageLayouts.userId, userId)) - .returning({ id: homepageLayouts.id }); + .where(eq(homepageLayouts.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/host-folder-repository.ts b/src/backend/database/repositories/host-folder-repository.ts index d83df5a3..f7c739e4 100644 --- a/src/backend/database/repositories/host-folder-repository.ts +++ b/src/backend/database/repositories/host-folder-repository.ts @@ -3,6 +3,12 @@ import { randomUUID } from "crypto"; import type { SQLiteColumn } from "drizzle-orm/sqlite-core"; import { hosts, sshCredentials, sshFolders } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { + deleteReturning, + insertReturning, + updateReturning, +} from "./returning.js"; export type HostFolderRecord = typeof sshFolders.$inferSelect; export type HostFolderHostRecord = typeof hosts.$inferSelect; @@ -24,19 +30,31 @@ export class HostFolderRepository { newName: string, now = new Date().toISOString(), ): Promise { + // CAST target: every engine spells the text type differently enough to + // matter here — MySQL has no `text` cast and wants `char`. + const textType = this.context.dialect === "mysql" ? "char" : "text"; const oldPrefix = `${oldName} / `; const newPrefix = `${newName} / `; const childLike = `${oldPrefix}%`; + // CONCAT, not `||`: MySQL reads `||` as logical OR unless the server runs + // with PIPES_AS_CONCAT, so the child paths would have been rewritten to 0. + // No error, just wrong folder names. CONCAT and SUBSTR mean the same thing + // on all three engines. + // + // The prefix is inlined rather than bound: CONCAT is variadic, so Postgres + // cannot infer a parameter's type from its position and rejects the + // statement with 42P18 before it runs. The value is a folder name the + // caller supplied, so it goes through a bound placeholder in a plain + // concatenation instead of sql.raw. const renameExpr = (col: SQLiteColumn) => - sql`CASE WHEN ${col} = ${oldName} THEN ${newName} ELSE ${newPrefix} || substr(${col}, ${oldPrefix.length + 1}) END`; + sql`CASE WHEN ${col} = ${oldName} THEN ${newName} ELSE CONCAT(CAST(${newPrefix} AS ${sql.raw(textType)}), SUBSTR(${col}, ${sql.raw(String(oldPrefix.length + 1))})) END`; const folderMatch = (col: SQLiteColumn) => or(eq(col, oldName), like(col, childLike)); const updatedHosts = await this.context.drizzle .update(hosts) .set({ folder: renameExpr(hosts.folder), updatedAt: now }) - .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))) - .returning({ id: hosts.id }); + .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))); const updatedCredentials = await this.context.drizzle .update(sshCredentials) @@ -46,8 +64,7 @@ export class HostFolderRepository { eq(sshCredentials.userId, userId), folderMatch(sshCredentials.folder), ), - ) - .returning({ id: sshCredentials.id }); + ); await this.context.drizzle .update(sshFolders) @@ -56,8 +73,8 @@ export class HostFolderRepository { await this.afterWrite(); return { - updatedHosts: updatedHosts.length, - updatedCredentials: updatedCredentials.length, + updatedHosts: rowsAffected(updatedHosts), + updatedCredentials: rowsAffected(updatedCredentials), }; } @@ -78,35 +95,33 @@ export class HostFolderRepository { ): Promise<{ folder: HostFolderRecord; created: boolean }> { const existing = await this.findFolder(userId, name); if (existing) { - const [updated] = await this.context.drizzle - .update(sshFolders) - .set({ + const [updated] = await updateReturning( + this.context, + sshFolders, + { color, icon, credentialId: credentialId === undefined ? existing.credentialId : credentialId, updatedAt: now, - }) - .where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name))) - .returning(); + }, + and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)), + ); await this.afterWrite(); return { folder: updated, created: false }; } - const [created] = await this.context.drizzle - .insert(sshFolders) - .values({ - syncId: randomUUID(), - userId, - name, - color, - icon, - credentialId: credentialId ?? null, - createdAt: now, - updatedAt: now, - }) - .returning(); + const [created] = await insertReturning(this.context, sshFolders, { + syncId: randomUUID(), + userId, + name, + color, + icon, + credentialId: credentialId ?? null, + createdAt: now, + updatedAt: now, + }); await this.afterWrite(); return { folder: created, created: true }; @@ -139,10 +154,11 @@ export class HostFolderRepository { .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))); } - const deletedFolders = await this.context.drizzle - .delete(sshFolders) - .where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name))) - .returning({ syncId: sshFolders.syncId }); + const deletedFolders = await deleteReturning( + this.context, + sshFolders, + and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)), + ); await this.afterWrite(); @@ -157,16 +173,15 @@ export class HostFolderRepository { } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sshFolders) - .where(eq(sshFolders.userId, userId)) - .returning({ id: sshFolders.id }); + .where(eq(sshFolders.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async findFolder( diff --git a/src/backend/database/repositories/host-health-repository.ts b/src/backend/database/repositories/host-health-repository.ts index 31ff776e..875e1d74 100644 --- a/src/backend/database/repositories/host-health-repository.ts +++ b/src/backend/database/repositories/host-health-repository.ts @@ -1,6 +1,8 @@ import { and, desc, eq, notInArray } from "drizzle-orm"; import { hostHealthChecks, hostHealthHistory } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning, updateReturning } from "./returning.js"; export type HostHealthCheckRecord = typeof hostHealthChecks.$inferSelect; export type HostHealthHistoryRecord = typeof hostHealthHistory.$inferSelect; @@ -45,27 +47,25 @@ export class HostHealthRepository { ): Promise { const existing = await this.findChecksByUserAndHost(userId, hostId); if (existing) { - const [updated] = await this.context.drizzle - .update(hostHealthChecks) - .set({ checks, intervalSeconds, updatedAt: now }) - .where(eq(hostHealthChecks.id, existing.id)) - .returning(); + const [updated] = await updateReturning( + this.context, + hostHealthChecks, + { checks, intervalSeconds, updatedAt: now }, + eq(hostHealthChecks.id, existing.id), + ); await this.afterWrite(); return updated; } - const [created] = await this.context.drizzle - .insert(hostHealthChecks) - .values({ - userId, - hostId, - checks, - intervalSeconds, - createdAt: now, - updatedAt: now, - }) - .returning(); + const [created] = await insertReturning(this.context, hostHealthChecks, { + userId, + hostId, + checks, + intervalSeconds, + createdAt: now, + updatedAt: now, + }); await this.afterWrite(); return created; @@ -121,23 +121,21 @@ export class HostHealthRepository { checksDeleted: number; historyDeleted: number; }> { - const historyRows = await this.context.drizzle + const historyResult = await this.context.drizzle .delete(hostHealthHistory) - .where(eq(hostHealthHistory.userId, userId)) - .returning({ id: hostHealthHistory.id }); + .where(eq(hostHealthHistory.userId, userId)); - const checkRows = await this.context.drizzle + const result = await this.context.drizzle .delete(hostHealthChecks) - .where(eq(hostHealthChecks.userId, userId)) - .returning({ id: hostHealthChecks.id }); + .where(eq(hostHealthChecks.userId, userId)); - if (historyRows.length > 0 || checkRows.length > 0) { + if (rowsAffected(historyResult) > 0 || rowsAffected(result) > 0) { await this.afterWrite(); } return { - checksDeleted: checkRows.length, - historyDeleted: historyRows.length, + checksDeleted: rowsAffected(result), + historyDeleted: rowsAffected(historyResult), }; } diff --git a/src/backend/database/repositories/host-metrics-preference-repository.ts b/src/backend/database/repositories/host-metrics-preference-repository.ts index 070063d7..108acf8a 100644 --- a/src/backend/database/repositories/host-metrics-preference-repository.ts +++ b/src/backend/database/repositories/host-metrics-preference-repository.ts @@ -1,6 +1,8 @@ import { and, eq } from "drizzle-orm"; import { hostMetricsPreferences, hosts } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning, updateReturning } from "./returning.js"; export type HostMetricsPreferenceRecord = typeof hostMetricsPreferences.$inferSelect; @@ -37,26 +39,28 @@ export class HostMetricsPreferenceRepository { ): Promise { const existing = await this.findByUserAndHost(userId, hostId); if (existing) { - const [updated] = await this.context.drizzle - .update(hostMetricsPreferences) - .set({ layout, updatedAt: now }) - .where(eq(hostMetricsPreferences.id, existing.id)) - .returning(); + const [updated] = await updateReturning( + this.context, + hostMetricsPreferences, + { layout, updatedAt: now }, + eq(hostMetricsPreferences.id, existing.id), + ); await this.afterWrite(); return updated; } - const [created] = await this.context.drizzle - .insert(hostMetricsPreferences) - .values({ + const [created] = await insertReturning( + this.context, + hostMetricsPreferences, + { userId, hostId, layout, createdAt: now, updatedAt: now, - }) - .returning(); + }, + ); await this.afterWrite(); return created; @@ -67,28 +71,26 @@ export class HostMetricsPreferenceRepository { hostId: number, statsConfig: string, ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(hosts) .set({ statsConfig }) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) - .returning({ id: hosts.id }); + .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))); - if (rows.length === 0) return false; + if (rowsAffected(result) === 0) return false; await this.afterWrite(); return true; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(hostMetricsPreferences) - .where(eq(hostMetricsPreferences.userId, userId)) - .returning({ id: hostMetricsPreferences.id }); + .where(eq(hostMetricsPreferences.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/host-repository.ts b/src/backend/database/repositories/host-repository.ts index c7d2cf72..e3319444 100644 --- a/src/backend/database/repositories/host-repository.ts +++ b/src/backend/database/repositories/host-repository.ts @@ -3,6 +3,12 @@ import { randomUUID } from "crypto"; import { hostAccess, hosts } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; import { DataCrypto } from "../../utils/data-crypto.js"; +import { rowsAffected } from "./mutation-result.js"; +import { + deleteReturning, + insertReturning, + updateReturning, +} from "./returning.js"; export type HostRecord = typeof hosts.$inferSelect; export type NewHostRecord = typeof hosts.$inferInsert; @@ -21,10 +27,10 @@ export class HostRepository { ) {} async create(host: NewHostRecord): Promise { - const rows = await this.context.drizzle - .insert(hosts) - .values({ syncId: randomUUID(), ...host }) - .returning(); + const rows = await insertReturning(this.context, hosts, { + syncId: randomUUID(), + ...host, + }); await this.afterWrite(); return rows[0]; } @@ -51,10 +57,11 @@ export class HostRepository { delete (encryptedHost as Partial).id; } - const rows = await this.context.drizzle - .insert(hosts) - .values(encryptedHost as NewHostRecord) - .returning(); + const rows = await insertReturning( + this.context, + hosts, + encryptedHost as NewHostRecord, + ); await this.afterWrite(); return DataCrypto.decryptRecord("ssh_data", rows[0], userId, userDataKey); @@ -150,11 +157,12 @@ export class HostRepository { hostId: number, update: HostUpdate, ): Promise { - const rows = await this.context.drizzle - .update(hosts) - .set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` }) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) - .returning(); + const rows = await updateReturning( + this.context, + hosts, + { ...update, updatedAt: sql`CURRENT_TIMESTAMP` }, + and(eq(hosts.id, hostId), eq(hosts.userId, userId)), + ); await this.afterWrite(); return rows[0] ?? null; @@ -173,11 +181,12 @@ export class HostRepository { userDataKey, ); - const rows = await this.context.drizzle - .update(hosts) - .set({ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` }) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) - .returning(); + const rows = await updateReturning( + this.context, + hosts, + { ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` }, + and(eq(hosts.id, hostId), eq(hosts.userId, userId)), + ); await this.afterWrite(); return rows[0] @@ -213,17 +222,16 @@ export class HostRepository { return 0; } - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(hosts) .set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` }) - .where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId))) - .returning({ id: hosts.id }); + .where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId))); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteForUser( @@ -232,39 +240,38 @@ export class HostRepository { ): Promise<{ syncId: string | null } | null> { await this.deleteAccessForHost(hostId); - const rows = await this.context.drizzle - .delete(hosts) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) - .returning({ syncId: hosts.syncId }); + const rows = await deleteReturning( + this.context, + hosts, + and(eq(hosts.id, hostId), eq(hosts.userId, userId)), + ); await this.afterWrite(); - return rows[0] ?? null; + return rows[0] ? { syncId: rows[0].syncId } : null; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(hosts) - .where(eq(hosts.userId, userId)) - .returning({ id: hosts.id }); + .where(eq(hosts.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteAccessForHost(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(hostAccess) - .where(eq(hostAccess.hostId, hostId)) - .returning({ id: hostAccess.id }); + .where(eq(hostAccess.hostId, hostId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/mutation-result.ts b/src/backend/database/repositories/mutation-result.ts new file mode 100644 index 00000000..d849c774 --- /dev/null +++ b/src/backend/database/repositories/mutation-result.ts @@ -0,0 +1,157 @@ +import type { DatabaseDialect } from "../db/dialect.js"; + +/** + * Reading the outcome of a write without depending on RETURNING. + * + * SQLite and Postgres can attach `.returning()` to a delete or update and get + * the affected rows back. **MySQL cannot** — it has no RETURNING clause, and + * drizzle's mysql-core does not expose the method at all, so the call is a + * TypeError rather than a bad query. 175 call sites here read a write's result, + * so the difference has to be absorbed somewhere. + * + * The split that matters is what the caller actually needs: + * + * - **How many rows changed** — the majority, and none of them need the rows. + * They used to ask for them anyway, via `.returning().length`. Dropping the + * `.returning()` and reading the driver's own count is both portable and one + * less thing for the database to send back. + * - **The rows themselves** — cannot be emulated on MySQL without reading + * first, which needs a transaction to stay correct under concurrency. Those + * call sites are handled individually rather than behind a helper that hides + * an extra round trip. + */ + +/** + * The count each driver reports for a write, under its own name. + * + * Every engine says how many rows a write touched. None of them agree on what + * to call it: + * + * | driver | shape | + * |----------------|----------------------------------------| + * | better-sqlite3 | `{ changes, lastInsertRowid }` | + * | node-postgres | `{ rowCount, rows, command }` | + * | mysql2 | `[{ affectedRows, insertId }, fields]` | + * + * These are the shapes returned when NO `.returning()` is attached — which is + * the portable way to write, since MySQL has no RETURNING clause at all. + */ +interface WriteHeader { + changes?: number; + rowCount?: number; + affectedRows?: number; + lastInsertRowid?: number | bigint; + insertId?: number; +} + +const COUNT_FIELDS = ["changes", "rowCount", "affectedRows"] as const; + +/** + * mysql2 hands back `[ResultSetHeader, fields]`, which is itself an array — so + * "is it an array" cannot distinguish a write header from a returning() result. + * The header is identified by carrying one of the fields above instead. + */ +function asWriteHeader(result: unknown): WriteHeader | null { + const candidate = + Array.isArray(result) && result.length > 0 ? result[0] : result; + + if (!candidate || typeof candidate !== "object") return null; + const header = candidate as WriteHeader; + + const known = + COUNT_FIELDS.some((field) => typeof header[field] === "number") || + typeof header.insertId === "number" || + typeof header.lastInsertRowid === "number" || + typeof header.lastInsertRowid === "bigint"; + + return known ? header : null; +} + +/** + * Number of rows a write touched. + * + * Pass the result of the write itself — every driver's header is understood, so + * the caller neither branches on the dialect nor attaches `.returning()` just to + * count what came back. + * + * A `.returning()` array is still accepted, for the call sites that need the + * rows for their own reasons and would rather not count them twice. + */ +export function rowsAffected(result: unknown): number { + const header = asWriteHeader(result); + if (header) { + for (const field of COUNT_FIELDS) { + const count = header[field]; + if (typeof count === "number") return count; + } + // A header with only insertId: one row went in. + return 0; + } + + if (Array.isArray(result)) return result.length; + return 0; +} + +/** + * Id assigned by an insert. + * + * **Only meaningful on the result of an insert.** SQLite's `lastInsertRowid` and + * MySQL's `insertId` are connection-level values that survive the statement that + * set them — after a delete, SQLite still reports whatever the last insert + * produced. Passing an update or delete result here gets a stale id, not null. + * + * Returns null when the table has no autoincrement key. + */ +export function insertedId(result: unknown): number | null { + const header = asWriteHeader(result); + if (header) { + // MySQL and SQLite both use 0 for "no autoincrement column". + if (typeof header.insertId === "number") { + return header.insertId > 0 ? header.insertId : null; + } + if (typeof header.lastInsertRowid === "bigint") { + return header.lastInsertRowid > 0n + ? Number(header.lastInsertRowid) + : null; + } + if (typeof header.lastInsertRowid === "number") { + return header.lastInsertRowid > 0 ? header.lastInsertRowid : null; + } + return null; + } + + if (Array.isArray(result)) { + const first = result[0] as { id?: unknown } | undefined; + return typeof first?.id === "number" ? first.id : null; + } + + return null; +} + +/** + * Whether `.returning()` can be attached to a write on this engine. + * + * Call sites that genuinely need the affected rows use this to choose between + * one statement and a read-then-write inside a transaction. + */ +export function supportsReturning(dialect: DatabaseDialect): boolean { + return dialect !== "mysql"; +} + +/** + * Reads an aggregate count as a number. + * + * `sql` is a type assertion, not a conversion. Postgres returns COUNT() + * as bigint, which node-postgres hands back as a **string** so that values past + * 2^53 survive — so the annotation is a lie there and comparisons like + * `count < max` compare a string to a number. + */ +export function countValue(value: unknown): number { + if (typeof value === "number") return value; + if (typeof value === "bigint") return Number(value); + if (typeof value === "string") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} diff --git a/src/backend/database/repositories/network-topology-repository.ts b/src/backend/database/repositories/network-topology-repository.ts index fa8024fb..074c442a 100644 --- a/src/backend/database/repositories/network-topology-repository.ts +++ b/src/backend/database/repositories/network-topology-repository.ts @@ -1,6 +1,7 @@ import { eq } from "drizzle-orm"; import { networkTopology } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; export type NetworkTopologyRecord = typeof networkTopology.$inferSelect; @@ -45,16 +46,15 @@ export class NetworkTopologyRepository { } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(networkTopology) - .where(eq(networkTopology.userId, userId)) - .returning({ id: networkTopology.id }); + .where(eq(networkTopology.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/open-tab-repository.ts b/src/backend/database/repositories/open-tab-repository.ts index 5cad21de..832386c0 100644 --- a/src/backend/database/repositories/open-tab-repository.ts +++ b/src/backend/database/repositories/open-tab-repository.ts @@ -1,6 +1,7 @@ import { and, eq, gt } from "drizzle-orm"; import { userOpenTabs } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; export type OpenTabRecord = typeof userOpenTabs.$inferSelect; export type NewOpenTabRecord = typeof userOpenTabs.$inferInsert; @@ -111,43 +112,40 @@ export class OpenTabRepository { update: OpenTabUpdate, updatedAt = new Date().toISOString(), ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(userOpenTabs) .set({ ...update, updatedAt }) - .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))) - .returning({ id: userOpenTabs.id }); + .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteForUser(userId: string, id: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(userOpenTabs) - .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))) - .returning({ id: userOpenTabs.id }); + .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(userOpenTabs) - .where(eq(userOpenTabs.userId, userId)) - .returning({ id: userOpenTabs.id }); + .where(eq(userOpenTabs.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async findByIdForUser( diff --git a/src/backend/database/repositories/opkssh-token-repository.ts b/src/backend/database/repositories/opkssh-token-repository.ts index 0c15bddd..e802ef88 100644 --- a/src/backend/database/repositories/opkssh-token-repository.ts +++ b/src/backend/database/repositories/opkssh-token-repository.ts @@ -1,6 +1,8 @@ import { and, eq } from "drizzle-orm"; import { opksshTokens } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { upsert } from "./returning.js"; export type OpksshTokenRecord = typeof opksshTokens.$inferSelect; @@ -26,9 +28,10 @@ export class OpksshTokenRepository { async upsert(input: OpksshTokenUpsertInput): Promise { const createdAt = input.createdAt ?? new Date().toISOString(); - await this.context.drizzle - .insert(opksshTokens) - .values({ + await upsert( + this.context, + opksshTokens, + { userId: input.userId, hostId: input.hostId, sshCert: input.sshCert, @@ -38,8 +41,8 @@ export class OpksshTokenRepository { issuer: input.issuer, audience: input.audience, expiresAt: input.expiresAt, - }) - .onConflictDoUpdate({ + }, + { target: [opksshTokens.userId, opksshTokens.hostId], set: { sshCert: input.sshCert, @@ -51,7 +54,8 @@ export class OpksshTokenRepository { expiresAt: input.expiresAt, createdAt, }, - }); + }, + ); await this.afterWrite(); } @@ -76,47 +80,44 @@ export class OpksshTokenRepository { hostId: number, lastUsed = new Date().toISOString(), ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(opksshTokens) .set({ lastUsed }) .where( and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), - ) - .returning({ id: opksshTokens.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteByUserAndHost(userId: string, hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(opksshTokens) .where( and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), - ) - .returning({ id: opksshTokens.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(opksshTokens) - .where(eq(opksshTokens.userId, userId)) - .returning({ id: opksshTokens.id }); + .where(eq(opksshTokens.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/rbac-access-repository.ts b/src/backend/database/repositories/rbac-access-repository.ts index 92a2463e..f9b09f05 100644 --- a/src/backend/database/repositories/rbac-access-repository.ts +++ b/src/backend/database/repositories/rbac-access-repository.ts @@ -9,6 +9,8 @@ import { users, } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning } from "./returning.js"; export type RbacAccessTargetType = "user" | "role"; @@ -156,7 +158,7 @@ export class RbacAccessRepository { return { id: existing.id, created: false }; } - const result = await this.context.drizzle.insert(hostAccess).values({ + const [created] = await insertReturning(this.context, hostAccess, { hostId: input.hostId, userId: input.targetType === "user" ? input.targetUserId : null, roleId: input.targetType === "role" ? input.targetRoleId : null, @@ -166,7 +168,7 @@ export class RbacAccessRepository { }); await this.afterWrite(); - return { id: Number(result.lastInsertRowid), created: true }; + return { id: created.id, created: true }; } async revokeHostAccess(accessId: number, hostId: number): Promise { @@ -177,16 +179,15 @@ export class RbacAccessRepository { } async deleteHostAccessForHost(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(hostAccess) - .where(eq(hostAccess.hostId, hostId)) - .returning({ id: hostAccess.id }); + .where(eq(hostAccess.hostId, hostId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteHostAccessForHosts(hostIds: number[]): Promise { @@ -194,30 +195,27 @@ export class RbacAccessRepository { return 0; } - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(hostAccess) - .where(inArray(hostAccess.hostId, hostIds)) - .returning({ id: hostAccess.id }); + .where(inArray(hostAccess.hostId, hostIds)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteHostAccessForUserReferences(userId: string): Promise { - const directRows = await this.context.drizzle + const directResult = await this.context.drizzle .delete(hostAccess) - .where(eq(hostAccess.userId, userId)) - .returning({ id: hostAccess.id }); + .where(eq(hostAccess.userId, userId)); - const grantedRows = await this.context.drizzle + const result = await this.context.drizzle .delete(hostAccess) - .where(eq(hostAccess.grantedBy, userId)) - .returning({ id: hostAccess.id }); + .where(eq(hostAccess.grantedBy, userId)); - const deletedCount = directRows.length + grantedRows.length; + const deletedCount = rowsAffected(directResult) + rowsAffected(result); if (deletedCount > 0) { await this.afterWrite(); } @@ -291,7 +289,7 @@ export class RbacAccessRepository { return { id: existing.id, created: false }; } - const result = await this.context.drizzle.insert(snippetAccess).values({ + const [created] = await insertReturning(this.context, snippetAccess, { snippetId: input.snippetId, userId: input.targetType === "user" ? input.targetUserId : null, roleId: input.targetType === "role" ? input.targetRoleId : null, @@ -301,7 +299,7 @@ export class RbacAccessRepository { }); await this.afterWrite(); - return { id: Number(result.lastInsertRowid), created: true }; + return { id: created.id, created: true }; } async revokeSnippetAccess( @@ -512,21 +510,20 @@ export class RbacAccessRepository { async deleteExpiredHostAccess( now = new Date().toISOString(), ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(hostAccess) .where( and( sql`${hostAccess.expiresAt} IS NOT NULL`, sql`${hostAccess.expiresAt} <= ${now}`, ), - ) - .returning({ id: hostAccess.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async findActiveHostAccess( @@ -635,17 +632,16 @@ export class RbacAccessRepository { hostId: number, update: { permissionLevel?: string; expiresAt?: string | null }, ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(hostAccess) .set(update) - .where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId))) - .returning({ id: hostAccess.id }); + .where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId))); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async findHostAccessOwnerId(hostAccessId: number): Promise { diff --git a/src/backend/database/repositories/recent-activity-repository.ts b/src/backend/database/repositories/recent-activity-repository.ts index bd7979fc..30b5fd09 100644 --- a/src/backend/database/repositories/recent-activity-repository.ts +++ b/src/backend/database/repositories/recent-activity-repository.ts @@ -1,6 +1,8 @@ import { desc, eq, inArray } from "drizzle-orm"; import { recentActivity } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning } from "./returning.js"; export type RecentActivityRecord = typeof recentActivity.$inferSelect; export type NewRecentActivityRecord = typeof recentActivity.$inferInsert; @@ -26,10 +28,7 @@ export class RecentActivityRepository { async create( activity: NewRecentActivityRecord, ): Promise { - const rows = await this.context.drizzle - .insert(recentActivity) - .values(activity) - .returning(); + const rows = await insertReturning(this.context, recentActivity, activity); await this.afterWrite(); return rows[0]; @@ -51,42 +50,39 @@ export class RecentActivityRepository { return 0; } - const deletedRows = await this.context.drizzle + const result = await this.context.drizzle .delete(recentActivity) - .where(inArray(recentActivity.id, idsToDelete)) - .returning({ id: recentActivity.id }); + .where(inArray(recentActivity.id, idsToDelete)); - if (deletedRows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return deletedRows.length; + return rowsAffected(result); } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(recentActivity) - .where(eq(recentActivity.userId, userId)) - .returning({ id: recentActivity.id }); + .where(eq(recentActivity.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByHostId(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(recentActivity) - .where(eq(recentActivity.hostId, hostId)) - .returning({ id: recentActivity.id }); + .where(eq(recentActivity.hostId, hostId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByHostIds(hostIds: number[]): Promise { @@ -94,16 +90,15 @@ export class RecentActivityRepository { return 0; } - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(recentActivity) - .where(inArray(recentActivity.hostId, hostIds)) - .returning({ id: recentActivity.id }); + .where(inArray(recentActivity.hostId, hostIds)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/returning.ts b/src/backend/database/repositories/returning.ts new file mode 100644 index 00000000..fe2e05b1 --- /dev/null +++ b/src/backend/database/repositories/returning.ts @@ -0,0 +1,227 @@ +import { eq, type SQL } from "drizzle-orm"; +import type { SQLiteColumn, SQLiteTable } from "drizzle-orm/sqlite-core"; +import type { DatabaseContext } from "./database-context.js"; +import { + insertedId, + rowsAffected, + supportsReturning, +} from "./mutation-result.js"; + +/** + * Writes that need the affected rows back. + * + * `mutation-result.ts` covers the call sites that only wanted a count. These are + * the ones that genuinely read the rows — an updated record to return to the + * caller, a deleted row's fields to clean up alongside it. + * + * SQLite and Postgres do this in one statement with RETURNING. MySQL has no + * such clause, so the read is a second statement, and the pair has to be atomic: + * + * - **insert** — write, then read the row back by its key. + * - **update** — write, then read. Reading first would return the old values. + * - **delete** — read, then write. Reading after would return nothing. + * + * Both run in a transaction. Without one, a concurrent write between the two + * statements makes the returned rows describe a state that never existed, and + * with a connection pool the second statement might not even reach the same + * connection. + * + * ## The trap, and why it cannot bite silently + * + * On MySQL the update path re-reads using the same `where`. If the update + * changes a column that `where` tests, the read finds nothing — SQLite would + * have returned the row. Every current caller filters on an id it does not + * modify, but that is a convention, not a guarantee, so the mismatch is + * detected and thrown rather than returned as an empty array. Same for an + * insert whose row cannot be read back. + * + * Row types come from the table, so call sites keep the typing they had with + * `.returning()` and nothing has to be annotated by hand. + */ + +/** + * What `.set()` accepts: a column's own type, or a SQL expression in its place — + * `updatedAt: sql`CURRENT_TIMESTAMP`` is the common one here. + */ +type UpdateValues = { + [K in keyof T["$inferInsert"]]?: T["$inferInsert"][K] | SQL; +}; + +export async function updateReturning( + context: DatabaseContext, + table: T, + values: UpdateValues, + where: SQL, +): Promise { + const db = context.drizzle; + + if (supportsReturning(context.dialect)) { + // The cast resolves a conditional in drizzle's return type that TypeScript + // cannot narrow while T is still generic. The runtime shape is the rows. + return db.update(table).set(values).where(where).returning() as Promise< + T["$inferSelect"][] + >; + } + + return db.transaction(async (tx) => { + const written = await tx.update(table).set(values).where(where); + const rows = await tx.select().from(table).where(where); + + // The trap this catches: if the update changed a column that `where` tests, + // the read finds nothing and the caller gets [] — on MySQL only, with no + // error, where SQLite would have returned the row. Rows changed but none + // readable back is exactly that case, so make it loud instead. + if (rows.length === 0 && rowsAffected(written) > 0) { + throw new Error( + `updateReturning wrote ${rowsAffected(written)} row(s) but could not read ` + + `them back: the update changed a column the where clause filters on. ` + + `Read the rows first, or filter on a column the update leaves alone.`, + ); + } + + return rows; + }); +} + +export async function deleteReturning( + context: DatabaseContext, + table: T, + where: SQL, +): Promise { + const db = context.drizzle; + + if (supportsReturning(context.dialect)) { + return db.delete(table).where(where).returning() as Promise< + T["$inferSelect"][] + >; + } + + return db.transaction(async (tx) => { + const rows = await tx.select().from(table).where(where); + await tx.delete(table).where(where); + return rows; + }); +} + +/** A table this can read a single row back from. */ +type Keyed = SQLiteTable & { id: SQLiteColumn }; + +/** + * Inserts one row and returns it as stored, including whatever the database + * filled in — defaults, an autoincrement id, a CURRENT_TIMESTAMP. + * + * This is the one case Postgres cannot shortcut either: without RETURNING there + * is no id to read back by. Hence the split is genuinely three-way — except + * that sqlite and pg both have RETURNING, so it collapses to two again. + * + * On MySQL the key comes from one of two places: + * + * - the caller supplied it (tables keyed by a text id, like `users`) + * - the engine assigned it, reported as `insertId` + * + * Restricted to tables with an `id` column, so a table keyed some other way is + * a compile error here rather than a row that silently fails to come back. + */ +export async function insertReturning( + context: DatabaseContext, + table: T, + values: T["$inferInsert"], +): Promise { + const db = context.drizzle; + + if (supportsReturning(context.dialect)) { + return db.insert(table).values(values).returning() as Promise< + T["$inferSelect"][] + >; + } + + return db.transaction(async (tx) => { + const result = await tx.insert(table).values(values); + + const supplied = (values as { id?: string | number }).id; + const key = supplied ?? insertedId(result); + if (key === null || key === undefined) { + throw new Error( + `Insert into ${String(table)} returned no id to read the row back by.`, + ); + } + + const rows = await tx.select().from(table).where(eq(table.id, key)); + if (rows.length === 0) { + throw new Error( + `Inserted into ${String(table)} but could not read the row back by id ${key}.`, + ); + } + return rows; + }); +} + +/** + * Inserts one row into a table keyed by something other than `id`, reading it + * back by an explicit condition. + * + * `user_preferences` is keyed by `userId` and has no `id` column at all, so + * there is no insertId to read back by — the caller has to say what identifies + * the row it just wrote. + */ +export async function insertReturningWhere( + context: DatabaseContext, + table: T, + values: T["$inferInsert"], + where: SQL, +): Promise { + const db = context.drizzle; + + if (supportsReturning(context.dialect)) { + return db.insert(table).values(values).returning() as Promise< + T["$inferSelect"][] + >; + } + + return db.transaction(async (tx) => { + await tx.insert(table).values(values); + const rows = await tx.select().from(table).where(where); + if (rows.length === 0) { + throw new Error( + `Inserted into ${String(table)} but the read-back condition matched nothing.`, + ); + } + return rows; + }); +} + +/** + * Insert, or update the row that collides with it. + * + * The clause has three spellings. SQLite and Postgres take + * `ON CONFLICT (cols) DO UPDATE`; **MySQL takes `ON DUPLICATE KEY UPDATE` and + * names no columns** — it uses whichever unique key was violated. drizzle + * follows suit, so `onConflictDoUpdate` does not exist on mysql-core at all and + * calling it is a TypeError rather than a rejected query. + * + * The conflict target still has to be passed: it is what SQLite and Postgres + * need, and stating it keeps the caller honest about which unique constraint it + * is relying on — four of those were missing from the schema entirely until the + * cross-dialect tests went looking. + */ +export async function upsert( + context: DatabaseContext, + table: T, + values: T["$inferInsert"], + conflict: { target: SQLiteColumn[]; set: UpdateValues }, +): Promise { + const db = context.drizzle; + + if (context.dialect === "mysql") { + const insert = db.insert(table).values(values) as unknown as { + onDuplicateKeyUpdate: (config: { set: UpdateValues }) => Promise; + }; + await insert.onDuplicateKeyUpdate({ set: conflict.set }); + return; + } + + await db + .insert(table) + .values(values) + .onConflictDoUpdate({ target: conflict.target, set: conflict.set }); +} diff --git a/src/backend/database/repositories/role-repository.ts b/src/backend/database/repositories/role-repository.ts index 47f53d76..3790f238 100644 --- a/src/backend/database/repositories/role-repository.ts +++ b/src/backend/database/repositories/role-repository.ts @@ -1,6 +1,8 @@ import { and, eq, inArray } from "drizzle-orm"; import { hostAccess, roles, userRoles } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { deleteReturning, insertReturning } from "./returning.js"; export type RoleRecord = typeof roles.$inferSelect; export type NewRoleRecord = typeof roles.$inferInsert; @@ -62,27 +64,27 @@ export class RoleRepository { } async createRole(role: NewRoleRecord): Promise { - const result = await this.context.drizzle.insert(roles).values(role); + const [created] = await insertReturning(this.context, roles, role); await this.afterWrite(); - return Number(result.lastInsertRowid); + return created.id; } async updateRole(id: number, update: RoleUpdate): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(roles) .set(update) - .where(eq(roles.id, id)) - .returning({ id: roles.id }); + .where(eq(roles.id, id)); await this.afterWrite(); - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteRole(id: number): Promise<{ deletedUserIds: string[] }> { - const deletedUserRoles = await this.context.drizzle - .delete(userRoles) - .where(eq(userRoles.roleId, id)) - .returning({ userId: userRoles.userId }); + const deletedUserRoles = await deleteReturning( + this.context, + userRoles, + eq(userRoles.roleId, id), + ); await this.context.drizzle .delete(hostAccess) @@ -169,16 +171,15 @@ export class RoleRepository { } if (removeRole) { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(userRoles) .where( and( eq(userRoles.userId, input.userId), eq(userRoles.roleId, removeRole.id), ), - ) - .returning({ id: userRoles.id }); - removed = rows.length > 0; + ); + removed = rowsAffected(result) > 0; } if (added || removed) { @@ -196,16 +197,15 @@ export class RoleRepository { } async removeAllRolesFromUser(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(userRoles) - .where(eq(userRoles.userId, userId)) - .returning({ id: userRoles.id }); + .where(eq(userRoles.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async listUserRoleIds(userId: string): Promise { diff --git a/src/backend/database/repositories/session-recording-repository.ts b/src/backend/database/repositories/session-recording-repository.ts index 9259d108..7961e473 100644 --- a/src/backend/database/repositories/session-recording-repository.ts +++ b/src/backend/database/repositories/session-recording-repository.ts @@ -1,6 +1,8 @@ import { and, desc, eq, inArray, lt } from "drizzle-orm"; import { hosts, sessionRecordings } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning } from "./returning.js"; export type SessionRecordingRecord = typeof sessionRecordings.$inferSelect; @@ -47,10 +49,11 @@ export class SessionRecordingRepository { async create( input: SessionRecordingCreateInput, ): Promise { - const [created] = await this.context.drizzle - .insert(sessionRecordings) - .values(input) - .returning(); + const [created] = await insertReturning( + this.context, + sessionRecordings, + input, + ); await this.afterWrite(); return created; @@ -170,31 +173,29 @@ export class SessionRecordingRepository { } async deleteById(id: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sessionRecordings) - .where(eq(sessionRecordings.id, id)) - .returning({ id: sessionRecordings.id }); + .where(eq(sessionRecordings.id, id)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteForUser(userId: string, id: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sessionRecordings) .where( and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)), - ) - .returning({ id: sessionRecordings.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } /** @@ -203,43 +204,40 @@ export class SessionRecordingRepository { * file stays on disk regardless — deleting only the row would orphan it. */ async anonymizeByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(sessionRecordings) .set({ userId: null }) - .where(eq(sessionRecordings.userId, userId)) - .returning({ id: sessionRecordings.id }); + .where(eq(sessionRecordings.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sessionRecordings) - .where(eq(sessionRecordings.userId, userId)) - .returning({ id: sessionRecordings.id }); + .where(eq(sessionRecordings.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByHostId(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sessionRecordings) - .where(eq(sessionRecordings.hostId, hostId)) - .returning({ id: sessionRecordings.id }); + .where(eq(sessionRecordings.hostId, hostId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByHostIds(hostIds: number[]): Promise { @@ -247,16 +245,15 @@ export class SessionRecordingRepository { return 0; } - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sessionRecordings) - .where(inArray(sessionRecordings.hostId, hostIds)) - .returning({ id: sessionRecordings.id }); + .where(inArray(sessionRecordings.hostId, hostIds)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/session-repository.ts b/src/backend/database/repositories/session-repository.ts index ff83c46e..6379a214 100644 --- a/src/backend/database/repositories/session-repository.ts +++ b/src/backend/database/repositories/session-repository.ts @@ -1,6 +1,8 @@ import { and, eq, lte, ne } from "drizzle-orm"; import { sessions } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning } from "./returning.js"; export type SessionRecord = typeof sessions.$inferSelect; export type NewSessionRecord = typeof sessions.$inferInsert; @@ -12,10 +14,7 @@ export class SessionRepository { ) {} async create(session: NewSessionRecord): Promise { - const rows = await this.context.drizzle - .insert(sessions) - .values(session) - .returning(); + const rows = await insertReturning(this.context, sessions, session); await this.afterWrite(); return rows[0]; } @@ -72,13 +71,12 @@ export class SessionRepository { } async revoke(id: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sessions) - .where(eq(sessions.id, id)) - .returning({ id: sessions.id }); + .where(eq(sessions.id, id)); await this.afterWrite(); - return rows.length > 0; + return rowsAffected(result) > 0; } async revokeAllForUser( @@ -89,23 +87,19 @@ export class SessionRepository { ? and(eq(sessions.userId, userId), ne(sessions.id, exceptSessionId)) : eq(sessions.userId, userId); - const rows = await this.context.drizzle - .delete(sessions) - .where(where) - .returning({ id: sessions.id }); + const result = await this.context.drizzle.delete(sessions).where(where); await this.afterWrite(); - return rows.length; + return rowsAffected(result); } async deleteExpired(now = new Date()): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sessions) - .where(lte(sessions.expiresAt, now.toISOString())) - .returning({ id: sessions.id }); + .where(lte(sessions.expiresAt, now.toISOString())); await this.afterWrite(); - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/session-share-repository.ts b/src/backend/database/repositories/session-share-repository.ts index 016c44f1..39a8495b 100644 --- a/src/backend/database/repositories/session-share-repository.ts +++ b/src/backend/database/repositories/session-share-repository.ts @@ -6,6 +6,8 @@ import { users, } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning } from "./returning.js"; export type SessionShareRecord = typeof sessionShares.$inferSelect; export type SessionShareParticipantRecord = @@ -49,22 +51,19 @@ export class SessionShareRepository { ) {} async create(input: SessionShareCreateInput): Promise { - const [created] = await this.context.drizzle - .insert(sessionShares) - .values({ - id: input.id, - hostId: input.hostId, - ownerUserId: input.ownerUserId, - protocol: input.protocol, - sessionId: input.sessionId, - tabInstanceId: input.tabInstanceId ?? null, - shareType: input.shareType, - targetUserId: input.targetUserId ?? null, - linkToken: input.linkToken ?? null, - permissionLevel: input.permissionLevel, - expiresAt: input.expiresAt, - }) - .returning(); + const [created] = await insertReturning(this.context, sessionShares, { + id: input.id, + hostId: input.hostId, + ownerUserId: input.ownerUserId, + protocol: input.protocol, + sessionId: input.sessionId, + tabInstanceId: input.tabInstanceId ?? null, + shareType: input.shareType, + targetUserId: input.targetUserId ?? null, + linkToken: input.linkToken ?? null, + permissionLevel: input.permissionLevel, + expiresAt: input.expiresAt, + }); await this.afterWrite(); return created; @@ -151,7 +150,7 @@ export class SessionShareRepository { } async revoke(shareId: string, requestingUserId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(sessionShares) .set({ revokedAt: new Date().toISOString() }) .where( @@ -159,38 +158,35 @@ export class SessionShareRepository { eq(sessionShares.id, shareId), eq(sessionShares.ownerUserId, requestingUserId), ), - ) - .returning({ id: sessionShares.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async revokeAsAdmin(shareId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(sessionShares) .set({ revokedAt: new Date().toISOString() }) - .where(eq(sessionShares.id, shareId)) - .returning({ id: sessionShares.id }); + .where(eq(sessionShares.id, shareId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteExpiredShares(now = new Date().toISOString()): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sessionShares) - .where(lt(sessionShares.expiresAt, now)) - .returning({ id: sessionShares.id }); + .where(lt(sessionShares.expiresAt, now)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async touchShareUsage( @@ -213,10 +209,11 @@ export class SessionShareRepository { userId: string | null, guestLabel: string | null, ): Promise { - const [created] = await this.context.drizzle - .insert(sessionShareParticipants) - .values({ shareId, userId, guestLabel }) - .returning(); + const [created] = await insertReturning( + this.context, + sessionShareParticipants, + { shareId, userId, guestLabel }, + ); await this.afterWrite(); return created; } @@ -230,15 +227,14 @@ export class SessionShareRepository { } async deleteSharesForHost(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sessionShares) - .where(eq(sessionShares.hostId, hostId)) - .returning({ id: sessionShares.id }); + .where(eq(sessionShares.hostId, hostId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/settings-cache.ts b/src/backend/database/repositories/settings-cache.ts new file mode 100644 index 00000000..a8b43f81 --- /dev/null +++ b/src/backend/database/repositories/settings-cache.ts @@ -0,0 +1,52 @@ +/** + * Synchronous read-through cache for the settings table. + * + * 27 call sites read settings synchronously — during startup, inside request + * handlers, and from the guacd server bootstrap. On SQLite that works because + * better-sqlite3 is synchronous; on Postgres or MySQL there is no synchronous + * query at all, and making all 27 async would push `await` through code paths + * that have no business being asynchronous. + * + * Settings are a handful of low-cardinality configuration rows that change + * rarely and are read constantly, so they are cached in full. Writes go through + * SettingsRepository, which updates the cache in the same call, and the cache is + * primed once at startup. + */ + +let cache: Map | null = null; + +export function isSettingsCachePrimed(): boolean { + return cache !== null; +} + +/** Loads the full settings table. Called once during startup. */ +export function primeSettingsCache( + rows: { key: string; value: string }[], +): void { + cache = new Map(rows.map((row) => [row.key, row.value])); +} + +/** + * Reads a cached setting. + * + * Returns null both for "not set" and "cache not primed yet" — every caller + * already treats a missing setting as "use the default", and startup ordering + * means a read before priming should behave the same way rather than throw. + */ +export function readCachedSetting(key: string): string | null { + return cache?.get(key) ?? null; +} + +/** Keeps the cache in step with a write. */ +export function updateCachedSetting(key: string, value: string): void { + cache?.set(key, value); +} + +export function forgetCachedSetting(key: string): void { + cache?.delete(key); +} + +/** Test seam. */ +export function resetSettingsCache(): void { + cache = null; +} diff --git a/src/backend/database/repositories/settings-repository.ts b/src/backend/database/repositories/settings-repository.ts index 3b69fbbc..ac043aab 100644 --- a/src/backend/database/repositories/settings-repository.ts +++ b/src/backend/database/repositories/settings-repository.ts @@ -1,6 +1,8 @@ import { eq, like } from "drizzle-orm"; import { settings } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { forgetCachedSetting, updateCachedSetting } from "./settings-cache.js"; +import { deleteReturning } from "./returning.js"; export class SettingsRepository { constructor( @@ -34,6 +36,9 @@ export class SettingsRepository { const existing = await this.get(key); if (existing === null) { await this.context.drizzle.insert(settings).values({ key, value }); + // Kept in step here so the synchronous readers cannot observe a stale + // value after a write in the same process. + updateCachedSetting(key, value); await this.afterWrite(); return; } @@ -42,6 +47,7 @@ export class SettingsRepository { .update(settings) .set({ value }) .where(eq(settings.key, key)); + updateCachedSetting(key, value); await this.afterWrite(); } @@ -51,14 +57,17 @@ export class SettingsRepository { async delete(key: string): Promise { await this.context.drizzle.delete(settings).where(eq(settings.key, key)); + forgetCachedSetting(key); await this.afterWrite(); } async deleteLike(pattern: string): Promise { - const rows = await this.context.drizzle - .delete(settings) - .where(like(settings.key, pattern)) - .returning({ key: settings.key }); + const rows = await deleteReturning( + this.context, + settings, + like(settings.key, pattern), + ); + for (const row of rows) forgetCachedSetting(row.key); await this.afterWrite(); return rows.length; } diff --git a/src/backend/database/repositories/shared-host-secrets-repository.ts b/src/backend/database/repositories/shared-host-secrets-repository.ts index 23e2a86d..56cd2249 100644 --- a/src/backend/database/repositories/shared-host-secrets-repository.ts +++ b/src/backend/database/repositories/shared-host-secrets-repository.ts @@ -1,6 +1,7 @@ import { and, eq, inArray, or } from "drizzle-orm"; import { hostAccess, hosts, sharedHostSecrets } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; export type SharedHostSecretRecord = typeof sharedHostSecrets.$inferSelect; export type NewSharedHostSecretRecord = typeof sharedHostSecrets.$inferInsert; @@ -108,16 +109,15 @@ export class SharedHostSecretsRepository { } async deleteByHostAccessId(hostAccessId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sharedHostSecrets) - .where(eq(sharedHostSecrets.hostAccessId, hostAccessId)) - .returning({ id: sharedHostSecrets.id }); + .where(eq(sharedHostSecrets.hostAccessId, hostAccessId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteForRoleMember( @@ -148,29 +148,27 @@ export class SharedHostSecretsRepository { } async deleteByOriginalCredentialId(credentialId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sharedHostSecrets) - .where(eq(sharedHostSecrets.originalCredentialId, credentialId)) - .returning({ id: sharedHostSecrets.id }); + .where(eq(sharedHostSecrets.originalCredentialId, credentialId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByTargetUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sharedHostSecrets) - .where(eq(sharedHostSecrets.targetUserId, userId)) - .returning({ id: sharedHostSecrets.id }); + .where(eq(sharedHostSecrets.targetUserId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async findHostIdsReferencingCredential( diff --git a/src/backend/database/repositories/snippet-repository.ts b/src/backend/database/repositories/snippet-repository.ts index 60e6bf17..8b511c3c 100644 --- a/src/backend/database/repositories/snippet-repository.ts +++ b/src/backend/database/repositories/snippet-repository.ts @@ -2,6 +2,12 @@ import { and, asc, eq, sql } from "drizzle-orm"; import { randomUUID } from "crypto"; import { snippetFolders, snippets } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { + deleteReturning, + insertReturning, + updateReturning, +} from "./returning.js"; export type SnippetRecord = typeof snippets.$inferSelect; export type SnippetFolderRecord = typeof snippetFolders.$inferSelect; @@ -84,11 +90,16 @@ export class SnippetRepository { } async listSnippetsForExport(userId: string): Promise { - return this.context.drizzle - .select() - .from(snippets) - .where(eq(snippets.userId, userId)) - .orderBy(asc(snippets.folder), asc(snippets.order)); + return ( + this.context.drizzle + .select() + .from(snippets) + .where(eq(snippets.userId, userId)) + // coalesce, not asc(folder): folder is nullable, and NULLs sort first on + // SQLite and MySQL but last on Postgres. An export whose row order depends + // on the engine is not much of an export. + .orderBy(sql`coalesce(${snippets.folder}, '')`, asc(snippets.order)) + ); } async listFoldersForExport(userId: string): Promise { @@ -149,19 +160,16 @@ export class SnippetRepository { ? await this.nextOrderForFolder(userId, folderValue) : input.order; - const rows = await this.context.drizzle - .insert(snippets) - .values({ - syncId: randomUUID(), - userId, - name: input.name.trim(), - content: input.content.trim(), - description: input.description?.trim() || null, - folder: input.folder?.trim() || null, - order, - hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null, - }) - .returning(); + const rows = await insertReturning(this.context, snippets, { + syncId: randomUUID(), + userId, + name: input.name.trim(), + content: input.content.trim(), + description: input.description?.trim() || null, + folder: input.folder?.trim() || null, + order, + hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null, + }); await this.afterWrite(); return rows[0]; @@ -200,11 +208,12 @@ export class SnippetRepository { ? JSON.stringify(input.hostFilter) : null; - const rows = await this.context.drizzle - .update(snippets) - .set(updateFields) - .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))) - .returning(); + const rows = await updateReturning( + this.context, + snippets, + updateFields, + and(eq(snippets.id, snippetId), eq(snippets.userId, userId)), + ); await this.afterWrite(); return { existing, updated: rows[0] }; @@ -229,23 +238,21 @@ export class SnippetRepository { snippetsDeleted: number; foldersDeleted: number; }> { - const deletedSnippets = await this.context.drizzle + const snippetResult = await this.context.drizzle .delete(snippets) - .where(eq(snippets.userId, userId)) - .returning({ id: snippets.id }); + .where(eq(snippets.userId, userId)); - const deletedFolders = await this.context.drizzle + const result = await this.context.drizzle .delete(snippetFolders) - .where(eq(snippetFolders.userId, userId)) - .returning({ id: snippetFolders.id }); + .where(eq(snippetFolders.userId, userId)); - if (deletedSnippets.length > 0 || deletedFolders.length > 0) { + if (rowsAffected(snippetResult) > 0 || rowsAffected(result) > 0) { await this.afterWrite(); } return { - snippetsDeleted: deletedSnippets.length, - foldersDeleted: deletedFolders.length, + snippetsDeleted: rowsAffected(snippetResult), + foldersDeleted: rowsAffected(result), }; } @@ -377,16 +384,13 @@ export class SnippetRepository { const existing = await this.findFolderByName(userId, name); if (existing) return null; - const rows = await this.context.drizzle - .insert(snippetFolders) - .values({ - syncId: randomUUID(), - userId, - name: name.trim(), - color: color?.trim() || null, - icon: icon?.trim() || null, - }) - .returning(); + const rows = await insertReturning(this.context, snippetFolders, { + syncId: randomUUID(), + userId, + name: name.trim(), + color: color?.trim() || null, + icon: icon?.trim() || null, + }); if (triggerSave) { await this.afterWrite(); @@ -414,13 +418,12 @@ export class SnippetRepository { if (color !== undefined) updateFields.color = color?.trim() || null; if (icon !== undefined) updateFields.icon = icon?.trim() || null; - const rows = await this.context.drizzle - .update(snippetFolders) - .set(updateFields) - .where( - and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), - ) - .returning(); + const rows = await updateReturning( + this.context, + snippetFolders, + updateFields, + and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), + ); await this.afterWrite(); return rows[0] ?? null; @@ -465,15 +468,14 @@ export class SnippetRepository { .set({ folder: null }) .where(and(eq(snippets.userId, userId), eq(snippets.folder, name))); - const rows = await this.context.drizzle - .delete(snippetFolders) - .where( - and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), - ) - .returning({ syncId: snippetFolders.syncId }); + const rows = await deleteReturning( + this.context, + snippetFolders, + and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), + ); await this.afterWrite(); - return rows[0] ?? null; + return rows[0] ? { syncId: rows[0].syncId } : null; } private async findFolderByName( diff --git a/src/backend/database/repositories/sqlite-foreign-keys.ts b/src/backend/database/repositories/sqlite-foreign-keys.ts index 09abf115..8b55d755 100644 --- a/src/backend/database/repositories/sqlite-foreign-keys.ts +++ b/src/backend/database/repositories/sqlite-foreign-keys.ts @@ -1,4 +1,5 @@ import { getCurrentRepositorySqlite } from "./factory.js"; +import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js"; export interface SqliteForeignKeyClient { exec(sql: string): unknown; @@ -16,8 +17,28 @@ export async function withSqliteForeignKeysDisabled( } } +/** + * Runs a bulk import with foreign keys relaxed. + * + * Backup restore writes tables in an order that is not dependency-safe, so the + * constraints have to stand down for the duration. + * + * **This has no equivalent on Postgres or MySQL here.** Postgres needs + * superuser to disable triggers, and MySQL's `SET FOREIGN_KEY_CHECKS = 0` is + * per-connection, which a pool does not guarantee. Rather than run the import + * with constraints enforced and have it fail partway through — leaving a + * half-restored database — it refuses with a message that says why. + */ export async function withCurrentSqliteForeignKeysDisabled( operation: () => Promise, ): Promise { + const dialect = resolveDatabaseDialect(); + if (!needsExplicitPersist(dialect)) { + throw new Error( + `Importing a backup is only supported on SQLite; this deployment uses ${dialect}. ` + + `Restore into the database directly with its own tooling instead.`, + ); + } + return withSqliteForeignKeysDisabled(getCurrentRepositorySqlite(), operation); } diff --git a/src/backend/database/repositories/ssh-credential-usage-repository.ts b/src/backend/database/repositories/ssh-credential-usage-repository.ts index 97d1aa0e..4558d64a 100644 --- a/src/backend/database/repositories/ssh-credential-usage-repository.ts +++ b/src/backend/database/repositories/ssh-credential-usage-repository.ts @@ -1,6 +1,8 @@ import { eq, inArray } from "drizzle-orm"; import { sshCredentialUsage } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning } from "./returning.js"; export type SshCredentialUsageRecord = typeof sshCredentialUsage.$inferSelect; @@ -22,38 +24,37 @@ export class SshCredentialUsageRepository { hostId: number, userId: string, ): Promise { - const [created] = await this.context.drizzle - .insert(sshCredentialUsage) - .values({ credentialId, hostId, userId }) - .returning(); + const [created] = await insertReturning(this.context, sshCredentialUsage, { + credentialId, + hostId, + userId, + }); await this.afterWrite(); return created; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sshCredentialUsage) - .where(eq(sshCredentialUsage.userId, userId)) - .returning({ id: sshCredentialUsage.id }); + .where(eq(sshCredentialUsage.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByHostId(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sshCredentialUsage) - .where(eq(sshCredentialUsage.hostId, hostId)) - .returning({ id: sshCredentialUsage.id }); + .where(eq(sshCredentialUsage.hostId, hostId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByHostIds(hostIds: number[]): Promise { @@ -61,16 +62,15 @@ export class SshCredentialUsageRepository { return 0; } - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(sshCredentialUsage) - .where(inArray(sshCredentialUsage.hostId, hostIds)) - .returning({ id: sshCredentialUsage.id }); + .where(inArray(sshCredentialUsage.hostId, hostIds)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/sso-provider-repository.ts b/src/backend/database/repositories/sso-provider-repository.ts index 403a81a0..275e3981 100644 --- a/src/backend/database/repositories/sso-provider-repository.ts +++ b/src/backend/database/repositories/sso-provider-repository.ts @@ -1,6 +1,8 @@ import { asc, eq } from "drizzle-orm"; import { ssoProviders, users } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning, updateReturning } from "./returning.js"; export type SsoProviderRecord = typeof ssoProviders.$inferSelect; export type NewSsoProviderRecord = typeof ssoProviders.$inferInsert; @@ -76,10 +78,7 @@ export class SsoProviderRepository { } async create(provider: NewSsoProviderRecord): Promise { - const rows = await this.context.drizzle - .insert(ssoProviders) - .values(provider) - .returning(); + const rows = await insertReturning(this.context, ssoProviders, provider); await this.afterWrite(); return rows[0]; @@ -89,27 +88,27 @@ export class SsoProviderRepository { id: number, update: SsoProviderUpdate, ): Promise { - const rows = await this.context.drizzle - .update(ssoProviders) - .set(update) - .where(eq(ssoProviders.id, id)) - .returning(); + const rows = await updateReturning( + this.context, + ssoProviders, + update, + eq(ssoProviders.id, id), + ); await this.afterWrite(); return rows[0] ?? null; } async delete(id: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(ssoProviders) - .where(eq(ssoProviders.id, id)) - .returning({ id: ssoProviders.id }); + .where(eq(ssoProviders.id, id)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async countUsersByProviderId(providerId: number): Promise { diff --git a/src/backend/database/repositories/termix-identity-ca-repository.ts b/src/backend/database/repositories/termix-identity-ca-repository.ts index ea1fcd3b..c95e9cef 100644 --- a/src/backend/database/repositories/termix-identity-ca-repository.ts +++ b/src/backend/database/repositories/termix-identity-ca-repository.ts @@ -2,6 +2,12 @@ import { eq } from "drizzle-orm"; import { termixIdentityCa } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; import { DataCrypto } from "../../utils/data-crypto.js"; +import { + insertedId, + rowsAffected, + supportsReturning, +} from "./mutation-result.js"; +import { updateReturning } from "./returning.js"; export type TermixIdentityCaRecord = typeof termixIdentityCa.$inferSelect; export type NewTermixIdentityCaRecord = typeof termixIdentityCa.$inferInsert; @@ -54,27 +60,7 @@ export class TermixIdentityCaRepository { ca: NewTermixIdentityCaRecord, ): Promise { const userDataKey = DataCrypto.validateUserAccess(userId); - const result = this.context.drizzle.transaction((tx) => { - const inserted = tx - .insert(termixIdentityCa) - .values({ ...ca, privateKey: "" }) - .returning() - .all(); - const row = inserted[0]; - const encrypted = DataCrypto.encryptRecord( - "termix_identity_ca", - { id: row.id, privateKey: ca.privateKey }, - userId, - userDataKey, - ); - - return tx - .update(termixIdentityCa) - .set({ privateKey: encrypted.privateKey }) - .where(eq(termixIdentityCa.id, row.id)) - .returning() - .all()[0]; - }); + const result = await this.insertThenEncrypt(userId, ca, userDataKey); await this.afterWrite(); return DataCrypto.decryptRecord( @@ -85,6 +71,81 @@ export class TermixIdentityCaRepository { ); } + /** + * Writes a CA in two steps, because the ciphertext depends on the id. + * + * The private key is encrypted with the row's own id as context, which does + * not exist until the row does. So: insert with an empty key, encrypt, update. + * The empty key must never be observable, hence the transaction. + * + * Two branches because better-sqlite3 rejects an async transaction callback — + * see the same note in UserRepository. + */ + private async insertThenEncrypt( + userId: string, + ca: NewTermixIdentityCaRecord, + userDataKey: Buffer, + ): Promise { + const draft = { ...ca, privateKey: "" }; + + const seal = (id: number) => + DataCrypto.encryptRecord( + "termix_identity_ca", + { id, privateKey: ca.privateKey }, + userId, + userDataKey, + ).privateKey; + + if (this.context.dialect === "sqlite") { + /* eslint-disable no-restricted-syntax -- sqlite-only branch: the dialect + is checked directly above, and better-sqlite3 needs the synchronous + .all() form, which has no async equivalent. */ + return this.context.drizzle.transaction((tx) => { + const row = tx + .insert(termixIdentityCa) + .values(draft) + .returning() + .all()[0]; + return tx + .update(termixIdentityCa) + .set({ privateKey: seal(row.id) }) + .where(eq(termixIdentityCa.id, row.id)) + .returning() + .all()[0]; + }); + /* eslint-enable no-restricted-syntax */ + } + + return this.context.drizzle.transaction(async (tx) => { + let id: number | null; + if (supportsReturning(this.context.dialect)) { + // eslint-disable-next-line no-restricted-syntax -- guarded by the check above + const rows = await tx + .insert(termixIdentityCa) + .values(draft) + .returning(); + id = rows[0]?.id ?? null; + } else { + id = insertedId(await tx.insert(termixIdentityCa).values(draft)); + } + + if (id === null) { + throw new Error("Insert into termix_identity_ca returned no id."); + } + + await tx + .update(termixIdentityCa) + .set({ privateKey: seal(id) }) + .where(eq(termixIdentityCa.id, id)); + + const [row] = await tx + .select() + .from(termixIdentityCa) + .where(eq(termixIdentityCa.id, id)); + return row; + }); + } + async updateEncryptedForIdentity( userId: string, identityId: number, @@ -103,43 +164,42 @@ export class TermixIdentityCaRepository { ).privateKey : undefined; - const rows = await this.context.drizzle - .update(termixIdentityCa) - .set({ + const rows = await updateReturning( + this.context, + termixIdentityCa, + { ...update, ...(encryptedPrivateKey ? { privateKey: encryptedPrivateKey } : {}), - }) - .where(eq(termixIdentityCa.identityId, identityId)) - .returning(); + }, + eq(termixIdentityCa.identityId, identityId), + ); await this.afterWrite(); return this.decryptOne(rows[0] ?? null, userId); } async deleteByIdentityId(identityId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(termixIdentityCa) - .where(eq(termixIdentityCa.identityId, identityId)) - .returning({ id: termixIdentityCa.id }); + .where(eq(termixIdentityCa.identityId, identityId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(termixIdentityCa) - .where(eq(termixIdentityCa.userId, userId)) - .returning({ id: termixIdentityCa.id }); + .where(eq(termixIdentityCa.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private decryptOne>( diff --git a/src/backend/database/repositories/termix-identity-repository.ts b/src/backend/database/repositories/termix-identity-repository.ts index 9badeedd..eae585f8 100644 --- a/src/backend/database/repositories/termix-identity-repository.ts +++ b/src/backend/database/repositories/termix-identity-repository.ts @@ -1,6 +1,8 @@ import { and, asc, eq } from "drizzle-orm"; import { termixIdentities, termixIdentityKeys } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning, updateReturning } from "./returning.js"; export type TermixIdentityRecord = typeof termixIdentities.$inferSelect; export type NewTermixIdentityRecord = typeof termixIdentities.$inferInsert; @@ -57,10 +59,11 @@ export class TermixIdentityRepository { async createIdentity( identity: NewTermixIdentityRecord, ): Promise { - const rows = await this.context.drizzle - .insert(termixIdentities) - .values(identity) - .returning(); + const rows = await insertReturning( + this.context, + termixIdentities, + identity, + ); await this.afterWrite(); return rows[0]; @@ -70,11 +73,12 @@ export class TermixIdentityRepository { userId: string, update: TermixIdentityUpdate, ): Promise { - const rows = await this.context.drizzle - .update(termixIdentities) - .set(update) - .where(eq(termixIdentities.userId, userId)) - .returning(); + const rows = await updateReturning( + this.context, + termixIdentities, + update, + eq(termixIdentities.userId, userId), + ); if (rows.length > 0) { await this.afterWrite(); @@ -84,39 +88,36 @@ export class TermixIdentityRepository { } async deleteIdentityForUser(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(termixIdentities) - .where(eq(termixIdentities.userId, userId)) - .returning({ id: termixIdentities.id }); + .where(eq(termixIdentities.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteByUserId(userId: string): Promise<{ identitiesDeleted: number; keysDeleted: number; }> { - const keyRows = await this.context.drizzle + const keyResult = await this.context.drizzle .delete(termixIdentityKeys) - .where(eq(termixIdentityKeys.userId, userId)) - .returning({ id: termixIdentityKeys.id }); + .where(eq(termixIdentityKeys.userId, userId)); - const identityRows = await this.context.drizzle + const result = await this.context.drizzle .delete(termixIdentities) - .where(eq(termixIdentities.userId, userId)) - .returning({ id: termixIdentities.id }); + .where(eq(termixIdentities.userId, userId)); - if (keyRows.length > 0 || identityRows.length > 0) { + if (rowsAffected(keyResult) > 0 || rowsAffected(result) > 0) { await this.afterWrite(); } return { - identitiesDeleted: identityRows.length, - keysDeleted: keyRows.length, + identitiesDeleted: rowsAffected(result), + keysDeleted: rowsAffected(keyResult), }; } @@ -170,10 +171,7 @@ export class TermixIdentityRepository { async createKey( key: NewTermixIdentityKeyRecord, ): Promise { - const rows = await this.context.drizzle - .insert(termixIdentityKeys) - .values(key) - .returning(); + const rows = await insertReturning(this.context, termixIdentityKeys, key); await this.afterWrite(); return rows[0]; @@ -184,16 +182,12 @@ export class TermixIdentityRepository { id: number, update: TermixIdentityKeyUpdate, ): Promise { - const rows = await this.context.drizzle - .update(termixIdentityKeys) - .set(update) - .where( - and( - eq(termixIdentityKeys.id, id), - eq(termixIdentityKeys.userId, userId), - ), - ) - .returning(); + const rows = await updateReturning( + this.context, + termixIdentityKeys, + update, + and(eq(termixIdentityKeys.id, id), eq(termixIdentityKeys.userId, userId)), + ); if (rows.length > 0) { await this.afterWrite(); @@ -203,21 +197,20 @@ export class TermixIdentityRepository { } async deleteKeyForUser(userId: string, id: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(termixIdentityKeys) .where( and( eq(termixIdentityKeys.id, id), eq(termixIdentityKeys.userId, userId), ), - ) - .returning({ id: termixIdentityKeys.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async findKeyForUser( diff --git a/src/backend/database/repositories/tmux-session-tag-repository.ts b/src/backend/database/repositories/tmux-session-tag-repository.ts index 3d89d856..60735f0f 100644 --- a/src/backend/database/repositories/tmux-session-tag-repository.ts +++ b/src/backend/database/repositories/tmux-session-tag-repository.ts @@ -1,6 +1,7 @@ import { and, eq } from "drizzle-orm"; import { tmuxSessionTags } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; export type TmuxSessionTagRecord = typeof tmuxSessionTags.$inferSelect; @@ -45,7 +46,7 @@ export class TmuxSessionTagRepository { sessionName: string, newSessionName: string, ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(tmuxSessionTags) .set({ sessionName: newSessionName }) .where( @@ -53,35 +54,33 @@ export class TmuxSessionTagRepository { eq(tmuxSessionTags.hostId, hostId), eq(tmuxSessionTags.sessionName, sessionName), ), - ) - .returning({ id: tmuxSessionTags.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteSessionForHost( hostId: number, sessionName: string, ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(tmuxSessionTags) .where( and( eq(tmuxSessionTags.hostId, hostId), eq(tmuxSessionTags.sessionName, sessionName), ), - ) - .returning({ id: tmuxSessionTags.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async replaceForUserHostSession( @@ -90,7 +89,7 @@ export class TmuxSessionTagRepository { sessionName: string, tags: string[], ): Promise { - const deletedRows = await this.context.drizzle + const result = await this.context.drizzle .delete(tmuxSessionTags) .where( and( @@ -98,8 +97,7 @@ export class TmuxSessionTagRepository { eq(tmuxSessionTags.hostId, hostId), eq(tmuxSessionTags.sessionName, sessionName), ), - ) - .returning({ id: tmuxSessionTags.id }); + ); if (tags.length > 0) { await this.context.drizzle.insert(tmuxSessionTags).values( @@ -112,7 +110,7 @@ export class TmuxSessionTagRepository { ); } - const changedRows = deletedRows.length + tags.length; + const changedRows = rowsAffected(result) + tags.length; if (changedRows > 0) { await this.afterWrite(); } @@ -121,16 +119,15 @@ export class TmuxSessionTagRepository { } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(tmuxSessionTags) - .where(eq(tmuxSessionTags.userId, userId)) - .returning({ id: tmuxSessionTags.id }); + .where(eq(tmuxSessionTags.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/transfer-recent-repository.ts b/src/backend/database/repositories/transfer-recent-repository.ts index 309b270e..de1266ea 100644 --- a/src/backend/database/repositories/transfer-recent-repository.ts +++ b/src/backend/database/repositories/transfer-recent-repository.ts @@ -1,6 +1,7 @@ import { and, desc, eq, inArray, or } from "drizzle-orm"; import { transferRecent } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; export type TransferRecentRecord = typeof transferRecent.$inferSelect; @@ -100,47 +101,44 @@ export class TransferRecentRepository { return 0; } - const deleted = await this.context.drizzle + const result = await this.context.drizzle .delete(transferRecent) - .where(inArray(transferRecent.id, idsToDelete)) - .returning({ id: transferRecent.id }); + .where(inArray(transferRecent.id, idsToDelete)); - if (deleted.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return deleted.length; + return rowsAffected(result); } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(transferRecent) - .where(eq(transferRecent.userId, userId)) - .returning({ id: transferRecent.id }); + .where(eq(transferRecent.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByHostId(hostId: number): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(transferRecent) .where( or( eq(transferRecent.sourceHostId, hostId), eq(transferRecent.destHostId, hostId), ), - ) - .returning({ id: transferRecent.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } async deleteByHostIds(hostIds: number[]): Promise { @@ -148,21 +146,20 @@ export class TransferRecentRepository { return 0; } - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(transferRecent) .where( or( inArray(transferRecent.sourceHostId, hostIds), inArray(transferRecent.destHostId, hostIds), ), - ) - .returning({ id: transferRecent.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/user-preference-repository.ts b/src/backend/database/repositories/user-preference-repository.ts index 7163c284..6a37a030 100644 --- a/src/backend/database/repositories/user-preference-repository.ts +++ b/src/backend/database/repositories/user-preference-repository.ts @@ -1,6 +1,8 @@ import { eq } from "drizzle-orm"; import { userPreferences } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturningWhere, updateReturning } from "./returning.js"; export type UserPreferenceRecord = typeof userPreferences.$inferSelect; export type NewUserPreferenceRecord = typeof userPreferences.$inferInsert; @@ -31,34 +33,36 @@ export class UserPreferenceRepository { const existing = await this.findByUserId(userId); if (!existing) { - const rows = await this.context.drizzle - .insert(userPreferences) - .values({ userId, ...update }) - .returning(); + const rows = await insertReturningWhere( + this.context, + userPreferences, + { userId, ...update }, + eq(userPreferences.userId, userId), + ); await this.afterWrite(); return rows[0]; } - const rows = await this.context.drizzle - .update(userPreferences) - .set(update) - .where(eq(userPreferences.userId, userId)) - .returning(); + const rows = await updateReturning( + this.context, + userPreferences, + update, + eq(userPreferences.userId, userId), + ); await this.afterWrite(); return rows[0]; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(userPreferences) - .where(eq(userPreferences.userId, userId)) - .returning({ userId: userPreferences.userId }); + .where(eq(userPreferences.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/user-repository.ts b/src/backend/database/repositories/user-repository.ts index 68442f4f..59d6b75b 100644 --- a/src/backend/database/repositories/user-repository.ts +++ b/src/backend/database/repositories/user-repository.ts @@ -1,6 +1,8 @@ import { eq, inArray } from "drizzle-orm"; import { users } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected, supportsReturning } from "./mutation-result.js"; +import { insertReturning, updateReturning } from "./returning.js"; export type UserRecord = typeof users.$inferSelect; export type NewUserRecord = typeof users.$inferInsert; @@ -62,10 +64,7 @@ export class UserRepository { } async create(user: NewUserRecord): Promise { - const rows = await this.context.drizzle - .insert(users) - .values(user) - .returning(); + const rows = await insertReturning(this.context, users, user); await this.afterWrite(); return rows[0]; } @@ -73,17 +72,10 @@ export class UserRepository { async createFirstLocalUser( user: NewFirstLocalUserRecord, ): Promise<{ user: UserRecord; isFirstUser: boolean }> { - const result = this.context.drizzle.transaction((tx) => { - const existingUsers = tx.select({ id: users.id }).from(users).all(); - const isFirstUser = existingUsers.length === 0; - const rows = tx - .insert(users) - .values({ ...user, isAdmin: isFirstUser }) - .returning() - .all(); - - return { user: rows[0], isFirstUser }; - }); + const result = await this.createCheckingIfFirst((isFirstUser) => ({ + ...user, + isAdmin: isFirstUser, + })); await this.afterWrite(); return result; @@ -92,41 +84,87 @@ export class UserRepository { async createFirstSsoUser( user: NewUserRecord, ): Promise<{ user: UserRecord; isFirstUser: boolean }> { - const result = this.context.drizzle.transaction((tx) => { - const existingUsers = tx.select({ id: users.id }).from(users).all(); - const isFirstUser = existingUsers.length === 0; - const rows = tx - .insert(users) - .values({ ...user, isAdmin: isFirstUser || Boolean(user.isAdmin) }) - .returning() - .all(); - - return { user: rows[0], isFirstUser }; - }); + const result = await this.createCheckingIfFirst((isFirstUser) => ({ + ...user, + isAdmin: isFirstUser || Boolean(user.isAdmin), + })); await this.afterWrite(); return result; } + /** + * Creates a user, making them an admin if the table was empty. + * + * The check and the insert have to be one transaction: two people signing up + * at once would otherwise both see an empty table and both become admin. + * + * The two branches are not a style choice. better-sqlite3 is synchronous and + * rejects an async transaction callback outright — "Transaction function + * cannot return a promise" — so a single body cannot serve both. It fails + * loudly rather than silently skipping the write, which is the one mercy here. + */ + private async createCheckingIfFirst( + build: (isFirstUser: boolean) => NewUserRecord, + ): Promise<{ user: UserRecord; isFirstUser: boolean }> { + if (this.context.dialect === "sqlite") { + /* eslint-disable no-restricted-syntax -- sqlite-only branch: the dialect + is checked directly above, and better-sqlite3 rejects an async + transaction callback, so this cannot use the shared helpers. */ + return this.context.drizzle.transaction((tx) => { + const isFirstUser = + tx.select({ id: users.id }).from(users).all().length === 0; + const rows = tx + .insert(users) + .values(build(isFirstUser)) + .returning() + .all(); + return { user: rows[0], isFirstUser }; + }); + /* eslint-enable no-restricted-syntax */ + } + + return this.context.drizzle.transaction(async (tx) => { + const existing = await tx.select({ id: users.id }).from(users); + const isFirstUser = existing.length === 0; + const values = build(isFirstUser); + + if (supportsReturning(this.context.dialect)) { + // eslint-disable-next-line no-restricted-syntax -- guarded by the check on this line + const rows = await tx.insert(users).values(values).returning(); + return { user: rows[0], isFirstUser }; + } + + // users is keyed by a text id the caller supplies, so there is something + // to read back by even without RETURNING. + await tx.insert(users).values(values); + const [user] = await tx + .select() + .from(users) + .where(eq(users.id, values.id)); + return { user, isFirstUser }; + }); + } + async update(id: string, update: UserUpdate): Promise { - const rows = await this.context.drizzle - .update(users) - .set(update) - .where(eq(users.id, id)) - .returning(); + const rows = await updateReturning( + this.context, + users, + update, + eq(users.id, id), + ); await this.afterWrite(); return rows[0] ?? null; } async delete(id: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(users) - .where(eq(users.id, id)) - .returning({ id: users.id }); + .where(eq(users.id, id)); await this.afterWrite(); - return rows.length > 0; + return rowsAffected(result) > 0; } async countAdmins(): Promise { diff --git a/src/backend/database/repositories/vault-profile-repository.ts b/src/backend/database/repositories/vault-profile-repository.ts index 64ff3706..bcf8cec5 100644 --- a/src/backend/database/repositories/vault-profile-repository.ts +++ b/src/backend/database/repositories/vault-profile-repository.ts @@ -2,6 +2,12 @@ import { desc, eq, or } from "drizzle-orm"; import { randomUUID } from "crypto"; import { vaultProfiles } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { + deleteReturning, + insertReturning, + updateReturning, +} from "./returning.js"; export type VaultProfileRecord = typeof vaultProfiles.$inferSelect; @@ -45,26 +51,23 @@ export class VaultProfileRepository { } async create(input: VaultProfileCreateInput): Promise { - const [created] = await this.context.drizzle - .insert(vaultProfiles) - .values({ - syncId: randomUUID(), - userId: input.userId, - name: input.name, - description: input.description, - folder: input.folder, - tags: input.tags, - vaultAddr: input.vaultAddr, - vaultNamespace: input.vaultNamespace, - oidcMount: input.oidcMount, - oidcRole: input.oidcRole, - sshMount: input.sshMount, - sshRole: input.sshRole, - validPrincipals: input.validPrincipals, - keyType: input.keyType, - shared: input.shared ?? false, - }) - .returning(); + const [created] = await insertReturning(this.context, vaultProfiles, { + syncId: randomUUID(), + userId: input.userId, + name: input.name, + description: input.description, + folder: input.folder, + tags: input.tags, + vaultAddr: input.vaultAddr, + vaultNamespace: input.vaultNamespace, + oidcMount: input.oidcMount, + oidcRole: input.oidcRole, + sshMount: input.sshMount, + sshRole: input.sshRole, + validPrincipals: input.validPrincipals, + keyType: input.keyType, + shared: input.shared ?? false, + }); await this.afterWrite(); return created; @@ -84,14 +87,15 @@ export class VaultProfileRepository { id: number, input: VaultProfileUpdateInput, ): Promise { - const [updated] = await this.context.drizzle - .update(vaultProfiles) - .set({ + const [updated] = await updateReturning( + this.context, + vaultProfiles, + { ...input, updatedAt: input.updatedAt ?? new Date().toISOString(), - }) - .where(eq(vaultProfiles.id, id)) - .returning(); + }, + eq(vaultProfiles.id, id), + ); if (updated) { await this.afterWrite(); @@ -101,27 +105,27 @@ export class VaultProfileRepository { } async deleteById(id: number): Promise<{ syncId: string | null } | null> { - const rows = await this.context.drizzle - .delete(vaultProfiles) - .where(eq(vaultProfiles.id, id)) - .returning({ syncId: vaultProfiles.syncId }); + const rows = await deleteReturning( + this.context, + vaultProfiles, + eq(vaultProfiles.id, id), + ); if (rows.length === 0) return null; await this.afterWrite(); - return rows[0]; + return { syncId: rows[0].syncId }; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(vaultProfiles) - .where(eq(vaultProfiles.userId, userId)) - .returning({ id: vaultProfiles.id }); + .where(eq(vaultProfiles.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/vault-token-repository.ts b/src/backend/database/repositories/vault-token-repository.ts index 1db262f0..a14ea70b 100644 --- a/src/backend/database/repositories/vault-token-repository.ts +++ b/src/backend/database/repositories/vault-token-repository.ts @@ -1,6 +1,8 @@ import { and, eq } from "drizzle-orm"; import { vaultTokens } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { upsert } from "./returning.js"; export type VaultTokenRecord = typeof vaultTokens.$inferSelect; @@ -22,16 +24,17 @@ export class VaultTokenRepository { async upsert(input: VaultTokenUpsertInput): Promise { const createdAt = input.createdAt ?? new Date().toISOString(); - await this.context.drizzle - .insert(vaultTokens) - .values({ + await upsert( + this.context, + vaultTokens, + { userId: input.userId, profileId: input.profileId, sshCert: input.sshCert, privateKey: input.privateKey, expiresAt: input.expiresAt, - }) - .onConflictDoUpdate({ + }, + { target: [vaultTokens.userId, vaultTokens.profileId], set: { sshCert: input.sshCert, @@ -39,7 +42,8 @@ export class VaultTokenRepository { expiresAt: input.expiresAt, createdAt, }, - }); + }, + ); await this.afterWrite(); } @@ -67,7 +71,7 @@ export class VaultTokenRepository { profileId: number, lastUsed = new Date().toISOString(), ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .update(vaultTokens) .set({ lastUsed }) .where( @@ -75,48 +79,45 @@ export class VaultTokenRepository { eq(vaultTokens.userId, userId), eq(vaultTokens.profileId, profileId), ), - ) - .returning({ id: vaultTokens.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteByUserAndProfile( userId: string, profileId: number, ): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(vaultTokens) .where( and( eq(vaultTokens.userId, userId), eq(vaultTokens.profileId, profileId), ), - ) - .returning({ id: vaultTokens.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } async deleteByUserId(userId: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(vaultTokens) - .where(eq(vaultTokens.userId, userId)) - .returning({ id: vaultTokens.id }); + .where(eq(vaultTokens.userId, userId)); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length; + return rowsAffected(result); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/webauthn-credential-repository.ts b/src/backend/database/repositories/webauthn-credential-repository.ts index f5dac86d..5810cade 100644 --- a/src/backend/database/repositories/webauthn-credential-repository.ts +++ b/src/backend/database/repositories/webauthn-credential-repository.ts @@ -1,6 +1,8 @@ import { and, eq } from "drizzle-orm"; import { webauthnCredentials } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { rowsAffected } from "./mutation-result.js"; +import { insertReturning } from "./returning.js"; export type WebauthnCredentialRecord = typeof webauthnCredentials.$inferSelect; export type NewWebauthnCredentialRecord = @@ -41,10 +43,11 @@ export class WebauthnCredentialRepository { async create( record: NewWebauthnCredentialRecord, ): Promise { - const rows = await this.context.drizzle - .insert(webauthnCredentials) - .values(record) - .returning(); + const rows = await insertReturning( + this.context, + webauthnCredentials, + record, + ); await this.afterWrite(); return rows[0]; @@ -63,21 +66,20 @@ export class WebauthnCredentialRepository { } async deleteForUser(userId: string, id: string): Promise { - const rows = await this.context.drizzle + const result = await this.context.drizzle .delete(webauthnCredentials) .where( and( eq(webauthnCredentials.id, id), eq(webauthnCredentials.userId, userId), ), - ) - .returning({ id: webauthnCredentials.id }); + ); - if (rows.length > 0) { + if (rowsAffected(result) > 0) { await this.afterWrite(); } - return rows.length > 0; + return rowsAffected(result) > 0; } private async afterWrite(): Promise { diff --git a/src/backend/tests/database/db/connect.test.ts b/src/backend/tests/database/db/connect.test.ts new file mode 100644 index 00000000..f2bfb1d8 --- /dev/null +++ b/src/backend/tests/database/db/connect.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + assertUrlMatchesDialect, + connectRemoteDatabase, + databaseUrl, + DATABASE_URL_ENV, +} from "../../../database/db/connect.js"; + +describe("databaseUrl", () => { + it("is absent unless set", () => { + expect(databaseUrl({})).toBeNull(); + expect(databaseUrl({ [DATABASE_URL_ENV]: " " })).toBeNull(); + }); + + it("trims surrounding whitespace", () => { + expect( + databaseUrl({ [DATABASE_URL_ENV]: " postgres://db/termix " }), + ).toBe("postgres://db/termix"); + }); +}); + +describe("assertUrlMatchesDialect", () => { + it("accepts the schemes each engine answers to", () => { + expect(() => + assertUrlMatchesDialect("postgres://db/termix", "postgres"), + ).not.toThrow(); + expect(() => + assertUrlMatchesDialect("postgresql://db/termix", "postgres"), + ).not.toThrow(); + expect(() => + assertUrlMatchesDialect("mysql://db/termix", "mysql"), + ).not.toThrow(); + // MariaDB speaks the MySQL protocol. + expect(() => + assertUrlMatchesDialect("mariadb://db/termix", "mysql"), + ).not.toThrow(); + }); + + it("is case-insensitive about the scheme", () => { + expect(() => + assertUrlMatchesDialect("POSTGRES://db/termix", "postgres"), + ).not.toThrow(); + }); + + it("catches a mismatch and says what is wrong", () => { + // The failure mode this exists to prevent: a driver error thirty frames + // down that never mentions the actual misconfiguration. + expect(() => + assertUrlMatchesDialect("mysql://db/termix", "postgres"), + ).toThrow(/is a "mysql:\/\/" URL but DATABASE_DIALECT is "postgres"/); + + expect(() => + assertUrlMatchesDialect("postgres://db/termix", "mysql"), + ).toThrow(/expected one of mysql:\/\/, mariadb:\/\//i); + }); + + it("rejects sqlite, which does not use a URL", () => { + expect(() => + assertUrlMatchesDialect("postgres://db/termix", "sqlite"), + ).toThrow(/does not use DATABASE_URL/); + }); +}); + +describe("connectRemoteDatabase", () => { + it("refuses to connect without a URL, naming the variable", () => { + return expect(connectRemoteDatabase("postgres", {})).rejects.toThrow( + /DATABASE_URL must be set when DATABASE_DIALECT is "postgres"/, + ); + }); + + it("rejects a mismatched URL before opening a connection", () => { + return expect( + connectRemoteDatabase("postgres", { + [DATABASE_URL_ENV]: "mysql://db/termix", + }), + ).rejects.toThrow(/DATABASE_DIALECT is "postgres"/); + }); +}); diff --git a/src/backend/tests/database/db/migrate.test.ts b/src/backend/tests/database/db/migrate.test.ts new file mode 100644 index 00000000..d61eb7be --- /dev/null +++ b/src/backend/tests/database/db/migrate.test.ts @@ -0,0 +1,39 @@ +import path from "path"; +import { describe, expect, it } from "vitest"; +import { + migrationsFolder, + runRemoteMigrations, + MIGRATIONS_DIR_ENV, +} from "../../../database/db/migrate.js"; + +describe("migrationsFolder", () => { + it("gives each engine its own folder", () => { + // The generated SQL differs per dialect, so they cannot share one. + expect(migrationsFolder("postgres", {})).toBe( + path.resolve(process.cwd(), "drizzle", "postgres"), + ); + expect(migrationsFolder("mysql", {})).toBe( + path.resolve(process.cwd(), "drizzle", "mysql"), + ); + }); + + it("honours an explicit root", () => { + expect( + migrationsFolder("postgres", { [MIGRATIONS_DIR_ENV]: "/srv/migrations" }), + ).toBe(path.join("/srv/migrations", "postgres")); + }); + + it("ignores a blank override", () => { + expect(migrationsFolder("mysql", { [MIGRATIONS_DIR_ENV]: " " })).toBe( + path.resolve(process.cwd(), "drizzle", "mysql"), + ); + }); +}); + +describe("runRemoteMigrations", () => { + it("refuses sqlite, which builds its schema elsewhere", () => { + return expect( + runRemoteMigrations("sqlite", {} as never), + ).rejects.toThrow(/SQLite builds its schema in index.ts/); + }); +}); diff --git a/src/backend/tests/database/db/multi-dialect.test.ts b/src/backend/tests/database/db/multi-dialect.test.ts new file mode 100644 index 00000000..df103c82 --- /dev/null +++ b/src/backend/tests/database/db/multi-dialect.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { drizzle as sqliteDrizzle } from "drizzle-orm/better-sqlite3"; +import { drizzle as pgDrizzle } from "drizzle-orm/node-postgres"; +import { drizzle as mysqlDrizzle } from "drizzle-orm/mysql2"; +import { getTableConfig as sqliteTableConfig } from "drizzle-orm/sqlite-core"; +import { getTableConfig as pgTableConfig } from "drizzle-orm/pg-core"; +import { getTableConfig as mysqlTableConfig } from "drizzle-orm/mysql-core"; +import Database from "better-sqlite3"; +import * as sqliteSchema from "../../../database/db/schema.js"; +import * as pgSchema from "../../../database/db/schema.pg.js"; +import * as mysqlSchema from "../../../database/db/schema.mysql.js"; +import { + DATABASE_DIALECT_ENV, + isDatabaseDialect, + needsExplicitPersist, + resolveDatabaseDialect, +} from "../../../database/db/dialect.js"; + +describe("resolveDatabaseDialect", () => { + it("defaults to sqlite so existing deployments are unaffected", () => { + expect(resolveDatabaseDialect({})).toBe("sqlite"); + expect(resolveDatabaseDialect({ [DATABASE_DIALECT_ENV]: "" })).toBe( + "sqlite", + ); + }); + + it("accepts the supported engines, case-insensitively", () => { + expect(resolveDatabaseDialect({ [DATABASE_DIALECT_ENV]: "postgres" })).toBe( + "postgres", + ); + expect(resolveDatabaseDialect({ [DATABASE_DIALECT_ENV]: "MySQL" })).toBe( + "mysql", + ); + }); + + it("refuses an unknown engine rather than silently using sqlite", () => { + expect(() => + resolveDatabaseDialect({ [DATABASE_DIALECT_ENV]: "oracle" }), + ).toThrow(/Unsupported/); + }); + + it("narrows correctly", () => { + expect(isDatabaseDialect("mysql")).toBe(true); + expect(isDatabaseDialect("mongo")).toBe(false); + }); +}); + +describe("needsExplicitPersist", () => { + it("is true only for sqlite", () => { + // SQLite runs in memory and is serialised back to an encrypted file, so + // every write needs a flush. The others have already committed durably. + expect(needsExplicitPersist("sqlite")).toBe(true); + expect(needsExplicitPersist("postgres")).toBe(false); + expect(needsExplicitPersist("mysql")).toBe(false); + }); +}); + +/** + * schema.pg.ts and schema.mysql.ts are generated from schema.ts. These check the + * generated output is usable rather than merely syntactically valid — the + * repository layer's correctness rests on all three behaving the same way. + */ +describe("generated schemas", () => { + it("declares the same tables in all three dialects", () => { + const tablesOf = (schema: Record) => + Object.keys(schema).sort(); + + expect(tablesOf(pgSchema)).toEqual(tablesOf(sqliteSchema)); + expect(tablesOf(mysqlSchema)).toEqual(tablesOf(sqliteSchema)); + // Guard against a generator that silently emits nothing. + expect(tablesOf(sqliteSchema).length).toBeGreaterThan(40); + }); + + it("maps each column to the right storage type per dialect", () => { + expect(sqliteSchema.users.isAdmin.getSQLType()).toBe("integer"); + expect(pgSchema.users.isAdmin.getSQLType()).toBe("boolean"); + expect(mysqlSchema.users.isAdmin.getSQLType()).toBe("boolean"); + + // A primary key must be indexable, which rules out unbounded TEXT on MySQL. + expect(sqliteSchema.users.id.getSQLType()).toBe("text"); + expect(pgSchema.users.id.getSQLType()).toContain("varchar"); + expect(mysqlSchema.users.id.getSQLType()).toContain("varchar"); + }); + + it("spells the autoincrement key three different ways", () => { + expect(sqliteSchema.auditLogs.id.getSQLType()).toBe("integer"); + expect(pgSchema.auditLogs.id.getSQLType()).toBe("serial"); + expect(mysqlSchema.auditLogs.id.getSQLType()).toBe("int"); + + for (const schema of [sqliteSchema, pgSchema, mysqlSchema]) { + expect(schema.auditLogs.id.primary).toBe(true); + } + }); + + it("preserves both foreign-key behaviours", () => { + // 80 cascade + 12 set null across the schema; set null is what keeps the + // audit trail after a user is deleted (#1132). + const perDialect = [ + { schema: sqliteSchema, config: sqliteTableConfig }, + { schema: pgSchema, config: pgTableConfig }, + { schema: mysqlSchema, config: mysqlTableConfig }, + ] as const; + + for (const { schema, config } of perDialect) { + const read = config as (table: unknown) => { + foreignKeys: { onDelete?: string }[]; + }; + + const auditFks = read(schema.auditLogs).foreignKeys; + expect(auditFks).toHaveLength(1); + expect(auditFks[0].onDelete).toBe("set null"); + + const folderFks = read(schema.sshFolders).foreignKeys; + expect(folderFks.map((fk) => fk.onDelete).sort()).toEqual([ + "cascade", + "set null", + ]); + } + }); + + it("keeps nullability and uniqueness", () => { + for (const schema of [sqliteSchema, pgSchema, mysqlSchema]) { + expect(schema.auditLogs.userId.notNull).toBe(false); + expect(schema.sshFolders.userId.notNull).toBe(true); + expect(schema.sshFolders.syncId.isUnique).toBe(true); + } + }); +}); + +/** + * Queries are built, never executed, so no server is required. What matters is + * that identical repository-style code produces correct SQL for each engine. + */ +describe("query generation per dialect", () => { + const sqliteDb = sqliteDrizzle(new Database(":memory:"), { + schema: sqliteSchema, + }); + const pgDb = pgDrizzle.mock({ schema: pgSchema }); + const mysqlDb = mysqlDrizzle.mock({ schema: mysqlSchema, mode: "default" }); + + it("quotes identifiers the way each engine expects", () => { + const built = [ + sqliteDb + .select() + .from(sqliteSchema.settings) + .where(eq(sqliteSchema.settings.key, "guac_url")) + .toSQL(), + pgDb + .select() + .from(pgSchema.settings) + .where(eq(pgSchema.settings.key, "guac_url")) + .toSQL(), + mysqlDb + .select() + .from(mysqlSchema.settings) + .where(eq(mysqlSchema.settings.key, "guac_url")) + .toSQL(), + ]; + + expect(built[0].sql).toContain('"settings"'); + expect(built[1].sql).toContain('"settings"'); + expect(built[2].sql).toContain("`settings`"); + + // The value is parameterised either way, never inlined. + for (const sql of built) { + expect(sql.params).toEqual(["guac_url"]); + } + }); + + it("uses each engine's placeholder style", () => { + expect( + pgDb + .select() + .from(pgSchema.users) + .where(eq(pgSchema.users.id, "u-1")) + .toSQL().sql, + ).toContain("$1"); + + expect( + mysqlDb + .select() + .from(mysqlSchema.users) + .where(eq(mysqlSchema.users.id, "u-1")) + .toSQL().sql, + ).toContain("?"); + }); + + it("stores booleans as the type each engine expects", () => { + const row = { id: "u-1", username: "alice", passwordHash: "hash" }; + + const sqliteSql = sqliteDb + .insert(sqliteSchema.users) + .values({ ...row, isAdmin: true }) + .toSQL(); + const pgSql = pgDb + .insert(pgSchema.users) + .values({ ...row, isAdmin: true }) + .toSQL(); + + // The storage difference the generator exists to absorb. + expect(sqliteSql.params).toContain(1); + expect(pgSql.params).toContain(true); + }); + + it("round-trips on the engine that is actually wired up", () => { + const sqlite = new Database(":memory:"); + sqlite.exec( + `CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);`, + ); + const db = sqliteDrizzle(sqlite, { schema: sqliteSchema }); + + db.insert(sqliteSchema.settings) + .values({ key: "guac_url", value: "guacd:4822" }) + .run(); + + expect(db.select().from(sqliteSchema.settings).all()).toEqual([ + { key: "guac_url", value: "guacd:4822" }, + ]); + + sqlite.close(); + }); +}); diff --git a/src/backend/tests/database/repositories/alert-repository.test.ts b/src/backend/tests/database/repositories/alert-repository.test.ts index 97e651ae..8cb02c81 100644 --- a/src/backend/tests/database/repositories/alert-repository.test.ts +++ b/src/backend/tests/database/repositories/alert-repository.test.ts @@ -17,68 +17,11 @@ describe("AlertRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT, - ip TEXT NOT NULL - ); - - CREATE TABLE alert_rules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER, - name TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - trigger_type TEXT NOT NULL, - threshold_value REAL, - threshold_duration_seconds INTEGER, - cooldown_minutes INTEGER NOT NULL DEFAULT 15, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE notification_channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - type TEXT NOT NULL, - config TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE alert_rule_channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - rule_id INTEGER NOT NULL, - channel_id INTEGER NOT NULL - ); - - CREATE TABLE alert_firings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - rule_id INTEGER NOT NULL, - host_id INTEGER NOT NULL, - host_name TEXT NOT NULL, - fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - resolved_at TEXT, - value REAL, - message TEXT NOT NULL, - severity TEXT NOT NULL DEFAULT 'warning', - acknowledged INTEGER NOT NULL DEFAULT 0 - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO ssh_data (id, user_id, name, ip) - VALUES (1, 'user-1', 'alpha', '127.0.0.1'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'alpha', '127.0.0.1', 22, 'root', 'password'); `); return new AlertRepository(context, onWrite); diff --git a/src/backend/tests/database/repositories/api-key-repository.test.ts b/src/backend/tests/database/repositories/api-key-repository.test.ts index f4482ae4..c43d4bdd 100644 --- a/src/backend/tests/database/repositories/api-key-repository.test.ts +++ b/src/backend/tests/database/repositories/api-key-repository.test.ts @@ -17,28 +17,7 @@ describe("ApiKeyRepository", () => { }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE api_keys ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - token_hash TEXT NOT NULL, - token_prefix TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TEXT, - last_used_at TEXT, - is_active INTEGER NOT NULL DEFAULT 1, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'admin', 'hash'), ('user-2', 'target', 'hash'); diff --git a/src/backend/tests/database/repositories/audit-log-repository.test.ts b/src/backend/tests/database/repositories/audit-log-repository.test.ts index 91d80261..b177d9fe 100644 --- a/src/backend/tests/database/repositories/audit-log-repository.test.ts +++ b/src/backend/tests/database/repositories/audit-log-repository.test.ts @@ -17,31 +17,7 @@ describe("AuditLogRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE audit_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT REFERENCES users(id) ON DELETE SET NULL, - username TEXT NOT NULL, - action TEXT NOT NULL, - resource_type TEXT NOT NULL, - resource_id TEXT, - resource_name TEXT, - details TEXT, - ip_address TEXT, - user_agent TEXT, - success INTEGER NOT NULL, - error_message TEXT, - timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); `); diff --git a/src/backend/tests/database/repositories/audit-log-retention.test.ts b/src/backend/tests/database/repositories/audit-log-retention.test.ts index a8d768f4..e0a290c5 100644 --- a/src/backend/tests/database/repositories/audit-log-retention.test.ts +++ b/src/backend/tests/database/repositories/audit-log-retention.test.ts @@ -41,23 +41,10 @@ afterEach(async () => { async function createRepository() { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE audit_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT, - username TEXT NOT NULL, - action TEXT NOT NULL, - resource_type TEXT NOT NULL, - resource_id TEXT, - resource_name TEXT, - details TEXT, - ip_address TEXT, - user_agent TEXT, - success INTEGER NOT NULL, - error_message TEXT, - timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - `); + await adapter.exec(` + INSERT INTO users (id, username, password_hash) VALUES + ('u-1', 'u-1', 'hash'); + `); return new AuditLogRepository(context); } diff --git a/src/backend/tests/database/repositories/c2s-tunnel-preset-repository.test.ts b/src/backend/tests/database/repositories/c2s-tunnel-preset-repository.test.ts index fecd283c..5922ca4f 100644 --- a/src/backend/tests/database/repositories/c2s-tunnel-preset-repository.test.ts +++ b/src/backend/tests/database/repositories/c2s-tunnel-preset-repository.test.ts @@ -17,24 +17,7 @@ describe("C2sTunnelPresetRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE c2s_tunnel_presets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - config TEXT NOT NULL, - platform TEXT, - computer_name TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); `); diff --git a/src/backend/tests/database/repositories/command-history-repository.test.ts b/src/backend/tests/database/repositories/command-history-repository.test.ts index 714ab2c7..83e821a9 100644 --- a/src/backend/tests/database/repositories/command-history-repository.test.ts +++ b/src/backend/tests/database/repositories/command-history-repository.test.ts @@ -17,33 +17,11 @@ describe("CommandHistoryRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); - - CREATE TABLE command_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER NOT NULL, - command TEXT NOT NULL, - executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO hosts (id, user_id, name) - VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password'); `); return new CommandHistoryRepository(context, onWrite); diff --git a/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts b/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts index 6fdcb9b9..4f3c47ce 100644 --- a/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts +++ b/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts @@ -17,26 +17,7 @@ describe("DashboardServiceLinkRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE dashboard_service_links ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - label TEXT NOT NULL, - url TEXT NOT NULL, - "order" INTEGER NOT NULL DEFAULT 0, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); `); diff --git a/src/backend/tests/database/repositories/dismissed-alert-repository.test.ts b/src/backend/tests/database/repositories/dismissed-alert-repository.test.ts index b519c1b6..abc488bd 100644 --- a/src/backend/tests/database/repositories/dismissed-alert-repository.test.ts +++ b/src/backend/tests/database/repositories/dismissed-alert-repository.test.ts @@ -17,22 +17,7 @@ describe("DismissedAlertRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE dismissed_alerts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - alert_id TEXT NOT NULL, - dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); `); diff --git a/src/backend/tests/database/repositories/file-manager-bookmark-repository.test.ts b/src/backend/tests/database/repositories/file-manager-bookmark-repository.test.ts index 25bafec5..71cf3df0 100644 --- a/src/backend/tests/database/repositories/file-manager-bookmark-repository.test.ts +++ b/src/backend/tests/database/repositories/file-manager-bookmark-repository.test.ts @@ -17,50 +17,11 @@ describe("FileManagerBookmarkRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); - - CREATE TABLE file_manager_recent ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER NOT NULL, - name TEXT NOT NULL, - path TEXT NOT NULL, - last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE file_manager_pinned ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER NOT NULL, - name TEXT NOT NULL, - path TEXT NOT NULL, - pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE file_manager_shortcuts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER NOT NULL, - name TEXT NOT NULL, - path TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO hosts (id, user_id, name) - VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password'); `); return new FileManagerBookmarkRepository(context, onWrite); diff --git a/src/backend/tests/database/repositories/homepage-item-repository.test.ts b/src/backend/tests/database/repositories/homepage-item-repository.test.ts index 16e1869e..a81f7eaf 100644 --- a/src/backend/tests/database/repositories/homepage-item-repository.test.ts +++ b/src/backend/tests/database/repositories/homepage-item-repository.test.ts @@ -17,25 +17,7 @@ describe("HomepageItemRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE homepage_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - type_id TEXT NOT NULL, - title TEXT, - config TEXT NOT NULL DEFAULT '{}', - folder_id INTEGER, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); `); diff --git a/src/backend/tests/database/repositories/homepage-layout-repository.test.ts b/src/backend/tests/database/repositories/homepage-layout-repository.test.ts index e9210ab3..56bd3b76 100644 --- a/src/backend/tests/database/repositories/homepage-layout-repository.test.ts +++ b/src/backend/tests/database/repositories/homepage-layout-repository.test.ts @@ -17,22 +17,7 @@ describe("HomepageLayoutRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE homepage_layouts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL UNIQUE, - layout TEXT NOT NULL DEFAULT '{}', - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); `); diff --git a/src/backend/tests/database/repositories/host-credential-repositories.test.ts b/src/backend/tests/database/repositories/host-credential-repositories.test.ts index 6c969793..866f87a3 100644 --- a/src/backend/tests/database/repositories/host-credential-repositories.test.ts +++ b/src/backend/tests/database/repositories/host-credential-repositories.test.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { afterEach, describe, expect, it, vi } from "vitest"; import { TestSqliteDatabase } from "./test-support.js"; import { CredentialRepository } from "../../../database/repositories/credential-repository.js"; @@ -21,174 +22,10 @@ describe("HostRepository and CredentialRepository", () => { ): Promise<{ credentials: CredentialRepository; hosts: HostRepository; - sqlite: NonNullable< - Awaited>["sqlite"] - >; }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE ssh_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - description TEXT, - folder TEXT, - tags TEXT, - auth_type TEXT NOT NULL, - username TEXT, - password TEXT, - key TEXT, - private_key TEXT, - public_key TEXT, - key_password TEXT, - key_type TEXT, - detected_key_type TEXT, - cert_public_key TEXT, - usage_count INTEGER NOT NULL DEFAULT 0, - last_used TEXT, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - - CREATE TABLE ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - connection_type TEXT NOT NULL DEFAULT 'ssh', - name TEXT, - ip TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT NOT NULL, - folder TEXT, - tags TEXT, - pin INTEGER NOT NULL DEFAULT 0, - auth_type TEXT NOT NULL, - use_warpgate INTEGER NOT NULL DEFAULT 0, - force_keyboard_interactive TEXT, - password TEXT, - key TEXT, - key_password TEXT, - key_type TEXT, - sudo_password TEXT, - autostart_password TEXT, - autostart_key TEXT, - autostart_key_password TEXT, - credential_id INTEGER, - override_credential_username INTEGER, - vault_profile_id INTEGER, - enable_terminal INTEGER NOT NULL DEFAULT 1, - enable_session_logging INTEGER NOT NULL DEFAULT 1, - allow_session_sharing INTEGER NOT NULL DEFAULT 1, - enable_command_history INTEGER NOT NULL DEFAULT 1, - enable_tunnel INTEGER NOT NULL DEFAULT 1, - tunnel_connections TEXT, - jump_hosts TEXT, - enable_file_manager INTEGER NOT NULL DEFAULT 1, - scp_legacy INTEGER NOT NULL DEFAULT 0, - enable_docker INTEGER NOT NULL DEFAULT 0, - enable_tmux_monitor INTEGER NOT NULL DEFAULT 0, - show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1, - show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0, - default_path TEXT, - stats_config TEXT, - docker_config TEXT, - enable_proxmox INTEGER NOT NULL DEFAULT 0, - proxmox_config TEXT, - terminal_config TEXT, - quick_actions TEXT, - notes TEXT, - enable_ssh INTEGER NOT NULL DEFAULT 1, - enable_rdp INTEGER NOT NULL DEFAULT 0, - enable_vnc INTEGER NOT NULL DEFAULT 0, - enable_telnet INTEGER NOT NULL DEFAULT 0, - ssh_port INTEGER DEFAULT 22, - rdp_port INTEGER DEFAULT 3389, - vnc_port INTEGER DEFAULT 5900, - telnet_port INTEGER DEFAULT 23, - rdp_credential_id INTEGER, - rdp_user TEXT, - rdp_password TEXT, - rdp_domain TEXT, - rdp_security TEXT, - rdp_ignore_cert INTEGER DEFAULT 0, - vnc_credential_id INTEGER, - vnc_password TEXT, - vnc_user TEXT, - telnet_user TEXT, - telnet_password TEXT, - telnet_credential_id INTEGER, - rdp_auth_type TEXT, - vnc_auth_type TEXT, - telnet_auth_type TEXT, - domain TEXT, - security TEXT, - ignore_cert INTEGER DEFAULT 0, - guacamole_config TEXT, - use_socks5 INTEGER, - socks5_host TEXT, - socks5_port INTEGER, - socks5_username TEXT, - socks5_password TEXT, - socks5_proxy_chain TEXT, - mac_address TEXT, - wol_broadcast_address TEXT, - port_knock_sequence TEXT, - host_key_fingerprint TEXT, - host_key_type TEXT, - host_key_algorithm TEXT DEFAULT 'sha256', - host_key_first_seen TEXT, - host_key_last_verified TEXT, - host_key_changed_count INTEGER DEFAULT 0, - connection_origin TEXT, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (credential_id) REFERENCES ssh_credentials(id) ON DELETE SET NULL - ); - - CREATE TABLE host_access ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_id INTEGER NOT NULL, - user_id TEXT, - role_id INTEGER, - granted_by TEXT NOT NULL, - permission_level TEXT NOT NULL DEFAULT 'view', - expires_at TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_accessed_at TEXT, - access_count INTEGER NOT NULL DEFAULT 0, - override_credential_id INTEGER, - FOREIGN KEY (host_id) REFERENCES ssh_data(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (override_credential_id) REFERENCES ssh_credentials(id) ON DELETE SET NULL - ); - - CREATE TABLE ssh_credential_usage ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - credential_id INTEGER NOT NULL, - host_id INTEGER NOT NULL, - user_id TEXT NOT NULL, - used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (credential_id) REFERENCES ssh_credentials(id) ON DELETE CASCADE, - FOREIGN KEY (host_id) REFERENCES ssh_data(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'user', 'hash'), ('user-2', 'other', 'hash'); @@ -197,7 +34,6 @@ describe("HostRepository and CredentialRepository", () => { return { credentials: new CredentialRepository(context, onCredentialWrite), hosts: new HostRepository(context, onHostWrite), - sqlite: adapter.raw, }; } @@ -224,9 +60,9 @@ describe("HostRepository and CredentialRepository", () => { // deterministically observable regardless of clock resolution -- // the sync engine's last-write-wins conflict resolution depends on // every mutating update actually advancing this column. - repo.sqlite - .prepare("UPDATE ssh_credentials SET updated_at = ? WHERE id = ?") - .run("2000-01-01 00:00:00", created.id); + await adapter!.run( + sql`UPDATE ssh_credentials SET updated_at = ${"2000-01-01 00:00:00"} WHERE id = ${created.id}`, + ); const updated = await repo.credentials.updateForUser("user-1", created.id, { folder: "ops", @@ -342,23 +178,27 @@ describe("HostRepository and CredentialRepository", () => { password: "secret", }); - const raw = repo.sqlite - .prepare("SELECT password FROM ssh_credentials WHERE id = ?") - .get(created.id) as { password: string }; + const raw = ( + await adapter!.query( + sql`SELECT password FROM ssh_credentials WHERE id = ${created.id}`, + ) + )[0] as { password: string }; expect(raw.password).toBe("user-encrypted-password"); - repo.sqlite - .prepare("UPDATE ssh_credentials SET updated_at = ? WHERE id = ?") - .run("2000-01-01 00:00:00", created.id); + await adapter!.run( + sql`UPDATE ssh_credentials SET updated_at = ${"2000-01-01 00:00:00"} WHERE id = ${created.id}`, + ); await repo.credentials.updateEncryptedForUser("user-1", created.id, { password: "updated-secret", }); - const updatedRaw = repo.sqlite - .prepare("SELECT password, updated_at FROM ssh_credentials WHERE id = ?") - .get(created.id) as { password: string; updated_at: string }; + const updatedRaw = ( + await adapter!.query( + sql`SELECT password, updated_at FROM ssh_credentials WHERE id = ${created.id}`, + ) + )[0] as { password: string; updated_at: string }; expect(updatedRaw.password).toBe("user-encrypted-password"); expect(updatedRaw.updated_at).not.toBe("2000-01-01 00:00:00"); @@ -410,9 +250,9 @@ describe("HostRepository and CredentialRepository", () => { authType: "password", folder: "prod", }); - repo.sqlite - .prepare("UPDATE ssh_credentials SET updated_at = ? WHERE id = ?") - .run("2000-01-01 00:00:00", primary.id); + await adapter!.run( + sql`UPDATE ssh_credentials SET updated_at = ${"2000-01-01 00:00:00"} WHERE id = ${primary.id}`, + ); onWrite.mockClear(); await expect( @@ -423,9 +263,11 @@ describe("HostRepository and CredentialRepository", () => { expect(await repo.credentials.listFolders("user-2")).toEqual(["prod"]); expect(onWrite).toHaveBeenCalledTimes(1); - const renamedRow = repo.sqlite - .prepare("SELECT updated_at FROM ssh_credentials WHERE id = ?") - .get(primary.id) as { updated_at: string }; + const renamedRow = ( + await adapter!.query( + sql`SELECT updated_at FROM ssh_credentials WHERE id = ${primary.id}`, + ) + )[0] as { updated_at: string }; expect(renamedRow.updated_at).not.toBe("2000-01-01 00:00:00"); }); @@ -467,9 +309,9 @@ describe("HostRepository and CredentialRepository", () => { (await repo.hosts.listByUserId("user-1")).map((item) => item.id), ).toEqual([host.id]); - repo.sqlite - .prepare("UPDATE ssh_data SET updated_at = ? WHERE id = ?") - .run("2000-01-01 00:00:00", host.id); + await adapter!.run( + sql`UPDATE ssh_data SET updated_at = ${"2000-01-01 00:00:00"} WHERE id = ${host.id}`, + ); const updated = await repo.hosts.updateForUser("user-1", host.id, { name: "web-1-renamed", @@ -511,23 +353,27 @@ describe("HostRepository and CredentialRepository", () => { password: "secret", }); - const raw = repo.sqlite - .prepare("SELECT password FROM ssh_data WHERE id = ?") - .get(created.id) as { password: string }; + const raw = ( + await adapter!.query( + sql`SELECT password FROM ssh_data WHERE id = ${created.id}`, + ) + )[0] as { password: string }; expect(raw.password).toBe("encrypted-host-password"); - repo.sqlite - .prepare("UPDATE ssh_data SET updated_at = ? WHERE id = ?") - .run("2000-01-01 00:00:00", created.id); + await adapter!.run( + sql`UPDATE ssh_data SET updated_at = ${"2000-01-01 00:00:00"} WHERE id = ${created.id}`, + ); await repo.hosts.updateEncryptedForUser("user-1", created.id, { password: "updated-secret", }); - const updatedRaw = repo.sqlite - .prepare("SELECT password, updated_at FROM ssh_data WHERE id = ?") - .get(created.id) as { password: string; updated_at: string }; + const updatedRaw = ( + await adapter!.query( + sql`SELECT password, updated_at FROM ssh_data WHERE id = ${created.id}`, + ) + )[0] as { password: string; updated_at: string }; expect(updatedRaw.password).toBe("encrypted-host-password"); expect(updatedRaw.updated_at).not.toBe("2000-01-01 00:00:00"); @@ -655,9 +501,9 @@ describe("HostRepository and CredentialRepository", () => { username: "root", authType: "password", }); - repo.sqlite - .prepare("UPDATE ssh_data SET updated_at = ? WHERE id IN (?, ?)") - .run("2000-01-01 00:00:00", first.id, second.id); + await adapter!.run( + sql`UPDATE ssh_data SET updated_at = ${"2000-01-01 00:00:00"} WHERE id IN (${first.id}, ${second.id})`, + ); onWrite.mockClear(); const states = await repo.hosts.listBulkUpdateState("user-1", [ @@ -726,11 +572,9 @@ describe("HostRepository and CredentialRepository", () => { authType: "password", }); - repo.sqlite - .prepare( - "INSERT INTO host_access (host_id, user_id, granted_by) VALUES (?, ?, ?)", - ) - .run(host.id, "user-2", "user-1"); + await adapter!.run( + sql`INSERT INTO host_access (host_id, user_id, granted_by) VALUES (${host.id}, ${"user-2"}, ${"user-1"})`, + ); expect(await repo.hosts.deleteAccessForHost(host.id)).toBe(1); expect(await repo.hosts.deleteForUser("user-1", host.id)).toEqual({ diff --git a/src/backend/tests/database/repositories/host-folder-repository.test.ts b/src/backend/tests/database/repositories/host-folder-repository.test.ts index edef9cd2..f156d20d 100644 --- a/src/backend/tests/database/repositories/host-folder-repository.test.ts +++ b/src/backend/tests/database/repositories/host-folder-repository.test.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { afterEach, describe, expect, it } from "vitest"; import { TestSqliteDatabase } from "./test-support.js"; import { HostFolderRepository } from "../../../database/repositories/host-folder-repository.js"; @@ -14,153 +15,21 @@ describe("HostFolderRepository", () => { async function createRepository( onWrite?: () => void | Promise, - ): Promise<{ - repository: HostFolderRepository; - sqlite: NonNullable< - Awaited>["sqlite"] - >; - }> { + ): Promise<{ repository: HostFolderRepository }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE ssh_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - folder TEXT, - auth_type TEXT NOT NULL, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - connection_type TEXT NOT NULL DEFAULT 'ssh', - name TEXT, - ip TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT NOT NULL, - folder TEXT, - tags TEXT, - pin INTEGER NOT NULL DEFAULT 0, - auth_type TEXT NOT NULL, - use_warpgate INTEGER NOT NULL DEFAULT 0, - force_keyboard_interactive TEXT, - password TEXT, - key TEXT, - key_password TEXT, - key_type TEXT, - sudo_password TEXT, - autostart_password TEXT, - autostart_key TEXT, - autostart_key_password TEXT, - credential_id INTEGER, - override_credential_username INTEGER, - vault_profile_id INTEGER, - enable_terminal INTEGER NOT NULL DEFAULT 1, - enable_session_logging INTEGER NOT NULL DEFAULT 1, - allow_session_sharing INTEGER NOT NULL DEFAULT 1, - enable_command_history INTEGER NOT NULL DEFAULT 1, - enable_tunnel INTEGER NOT NULL DEFAULT 1, - tunnel_connections TEXT, - jump_hosts TEXT, - enable_file_manager INTEGER NOT NULL DEFAULT 1, - scp_legacy INTEGER NOT NULL DEFAULT 0, - enable_docker INTEGER NOT NULL DEFAULT 0, - enable_tmux_monitor INTEGER NOT NULL DEFAULT 0, - show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1, - show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0, - default_path TEXT, - stats_config TEXT, - docker_config TEXT, - enable_proxmox INTEGER NOT NULL DEFAULT 0, - proxmox_config TEXT, - terminal_config TEXT, - quick_actions TEXT, - notes TEXT, - enable_ssh INTEGER NOT NULL DEFAULT 1, - enable_rdp INTEGER NOT NULL DEFAULT 0, - enable_vnc INTEGER NOT NULL DEFAULT 0, - enable_telnet INTEGER NOT NULL DEFAULT 0, - ssh_port INTEGER DEFAULT 22, - rdp_port INTEGER DEFAULT 3389, - vnc_port INTEGER DEFAULT 5900, - telnet_port INTEGER DEFAULT 23, - rdp_credential_id INTEGER, - rdp_user TEXT, - rdp_password TEXT, - rdp_domain TEXT, - rdp_security TEXT, - rdp_ignore_cert INTEGER DEFAULT 0, - vnc_credential_id INTEGER, - vnc_password TEXT, - vnc_user TEXT, - telnet_user TEXT, - telnet_password TEXT, - telnet_credential_id INTEGER, - rdp_auth_type TEXT, - vnc_auth_type TEXT, - telnet_auth_type TEXT, - domain TEXT, - security TEXT, - ignore_cert INTEGER DEFAULT 0, - guacamole_config TEXT, - use_socks5 INTEGER, - socks5_host TEXT, - socks5_port INTEGER, - socks5_username TEXT, - socks5_password TEXT, - socks5_proxy_chain TEXT, - mac_address TEXT, - wol_broadcast_address TEXT, - port_knock_sequence TEXT, - host_key_fingerprint TEXT, - host_key_type TEXT, - host_key_algorithm TEXT DEFAULT 'sha256', - host_key_first_seen TEXT, - host_key_last_verified TEXT, - host_key_changed_count INTEGER DEFAULT 0, - connection_origin TEXT, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE ssh_folders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - color TEXT, - icon TEXT, - credential_id INTEGER, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO ssh_credentials (id, user_id, name, folder, auth_type, username) + VALUES (1, 'user-1', 'cred-one', 'prod', 'password', 'root'), + (2, 'user-1', 'cred-two', 'prod / api', 'password', 'root'), + (3, 'user-2', 'cred-other', 'prod', 'password', 'root'); INSERT INTO ssh_data (id, user_id, name, ip, port, username, folder, auth_type) VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'prod', 'password'), (2, 'user-1', 'two', '10.0.0.2', 22, 'root', 'prod / api', 'password'), (3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'prod', 'password'); - INSERT INTO ssh_credentials (id, user_id, name, folder, auth_type) - VALUES - (1, 'user-1', 'cred-one', 'prod', 'password'), - (2, 'user-1', 'cred-two', 'prod / api', 'password'), - (3, 'user-2', 'cred-other', 'prod', 'password'); INSERT INTO ssh_folders (id, user_id, name, color, icon) VALUES (1, 'user-1', 'prod', '#111111', 'server'), @@ -168,15 +37,12 @@ describe("HostFolderRepository", () => { (3, 'user-2', 'prod', '#333333', 'user'); `); - return { - repository: new HostFolderRepository(context, onWrite), - sqlite: adapter.raw, - }; + return { repository: new HostFolderRepository(context, onWrite) }; } it("renames folders across hosts, credentials, and folder records", async () => { let writes = 0; - const { repository, sqlite } = await createRepository(() => { + const { repository } = await createRepository(() => { writes += 1; }); @@ -189,22 +55,23 @@ describe("HostFolderRepository", () => { ), ).resolves.toEqual({ updatedHosts: 2, updatedCredentials: 2 }); + // Portable on purpose: the rename builds the child path with string + // concatenation, which is the one place a dialect difference shows up as + // wrong data rather than an error. expect( - sqlite - .prepare("SELECT folder FROM ssh_data WHERE user_id = ? ORDER BY id") - .all("user-1"), + await adapter!.query( + sql`SELECT folder FROM ssh_data WHERE user_id = 'user-1' ORDER BY id`, + ), ).toEqual([{ folder: "ops" }, { folder: "ops / api" }]); expect( - sqlite - .prepare( - "SELECT folder FROM ssh_credentials WHERE user_id = ? ORDER BY id", - ) - .all("user-1"), + await adapter!.query( + sql`SELECT folder FROM ssh_credentials WHERE user_id = 'user-1' ORDER BY id`, + ), ).toEqual([{ folder: "ops" }, { folder: "ops / api" }]); expect( - sqlite - .prepare("SELECT name FROM ssh_folders WHERE user_id = ? ORDER BY id") - .all("user-1"), + await adapter!.query( + sql`SELECT name FROM ssh_folders WHERE user_id = 'user-1' ORDER BY id`, + ), ).toEqual([{ name: "ops" }, { name: "ops / api" }]); expect(writes).toBe(1); }); @@ -269,7 +136,7 @@ describe("HostFolderRepository", () => { it("lists and deletes hosts and folder records in a folder tree", async () => { let writes = 0; - const { repository, sqlite } = await createRepository(() => { + const { repository } = await createRepository(() => { writes += 1; }); @@ -278,28 +145,28 @@ describe("HostFolderRepository", () => { await repository.deleteHostsAndFolderRecords("user-1", "prod"); - expect(sqlite.prepare("SELECT id FROM ssh_data ORDER BY id").all()).toEqual( - [{ id: 3 }], - ); expect( - sqlite.prepare("SELECT id FROM ssh_folders ORDER BY id").all(), + await adapter!.query(sql`SELECT id FROM ssh_data ORDER BY id`), + ).toEqual([{ id: 3 }]); + expect( + await adapter!.query(sql`SELECT id FROM ssh_folders ORDER BY id`), ).toEqual([{ id: 3 }]); expect(writes).toBe(1); }); it("deletes folder records for a user", async () => { let writes = 0; - const { repository, sqlite } = await createRepository(() => { + const { repository } = await createRepository(() => { writes += 1; }); await expect(repository.deleteByUserId("user-1")).resolves.toBe(2); - expect(sqlite.prepare("SELECT id FROM ssh_data ORDER BY id").all()).toEqual( - [{ id: 1 }, { id: 2 }, { id: 3 }], - ); expect( - sqlite.prepare("SELECT id FROM ssh_folders ORDER BY id").all(), + await adapter!.query(sql`SELECT id FROM ssh_data ORDER BY id`), + ).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); + expect( + await adapter!.query(sql`SELECT id FROM ssh_folders ORDER BY id`), ).toEqual([{ id: 3 }]); expect(writes).toBe(1); }); diff --git a/src/backend/tests/database/repositories/host-health-repository.test.ts b/src/backend/tests/database/repositories/host-health-repository.test.ts index a73fc54e..99058e34 100644 --- a/src/backend/tests/database/repositories/host-health-repository.test.ts +++ b/src/backend/tests/database/repositories/host-health-repository.test.ts @@ -17,44 +17,11 @@ describe("HostHealthRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); - - CREATE TABLE host_health_checks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER NOT NULL, - checks TEXT NOT NULL, - interval_seconds INTEGER NOT NULL DEFAULT 300, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE host_health_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER NOT NULL, - check_id TEXT NOT NULL, - ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - ok INTEGER NOT NULL, - latency_ms INTEGER, - detail TEXT - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO hosts (id, user_id, name) - VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '10.0.0.1', 22, 'root', 'password'); INSERT INTO host_health_checks ( user_id, host_id, checks, interval_seconds, created_at, updated_at ) diff --git a/src/backend/tests/database/repositories/host-metrics-history-repository.test.ts b/src/backend/tests/database/repositories/host-metrics-history-repository.test.ts index 28ea0e3e..3fb64e67 100644 --- a/src/backend/tests/database/repositories/host-metrics-history-repository.test.ts +++ b/src/backend/tests/database/repositories/host-metrics-history-repository.test.ts @@ -17,26 +17,13 @@ describe("HostMetricsHistoryRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); + await adapter.exec(` + INSERT INTO users (id, username, password_hash) VALUES + ('user-1', 'user-1', 'hash'), + ('user-2', 'user-2', 'hash'); - CREATE TABLE host_metrics_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_id INTEGER NOT NULL, - ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - cpu_percent REAL, - mem_percent REAL, - disk_percent REAL, - net_rx_bytes INTEGER, - net_tx_bytes INTEGER - ); - - INSERT INTO hosts (id, user_id, name) - VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '10.0.0.1', 22, 'root', 'password'); INSERT INTO host_metrics_history ( host_id, ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes ) diff --git a/src/backend/tests/database/repositories/host-metrics-preference-repository.test.ts b/src/backend/tests/database/repositories/host-metrics-preference-repository.test.ts index e76b1c0d..e45cb6e0 100644 --- a/src/backend/tests/database/repositories/host-metrics-preference-repository.test.ts +++ b/src/backend/tests/database/repositories/host-metrics-preference-repository.test.ts @@ -17,33 +17,11 @@ describe("HostMetricsPreferenceRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - stats_config TEXT - ); - - CREATE TABLE host_metrics_preferences ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER NOT NULL, - layout TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO ssh_data (id, user_id, name, stats_config) - VALUES (1, 'user-1', 'one', '{}'), (2, 'user-2', 'two', '{}'); + INSERT INTO ssh_data (id, user_id, name, stats_config, ip, port, username, auth_type) + VALUES (1, 'user-1', 'one', '{}', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '{}', '10.0.0.1', 22, 'root', 'password'); INSERT INTO host_metrics_preferences ( user_id, host_id, layout, created_at, updated_at ) diff --git a/src/backend/tests/database/repositories/host-resolution-repository.test.ts b/src/backend/tests/database/repositories/host-resolution-repository.test.ts index 4292789a..bd275303 100644 --- a/src/backend/tests/database/repositories/host-resolution-repository.test.ts +++ b/src/backend/tests/database/repositories/host-resolution-repository.test.ts @@ -27,162 +27,15 @@ describe("HostResolutionRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - connection_type TEXT NOT NULL DEFAULT 'ssh', - name TEXT, - ip TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT NOT NULL, - folder TEXT, - tags TEXT, - pin INTEGER NOT NULL DEFAULT 0, - auth_type TEXT NOT NULL, - use_warpgate INTEGER NOT NULL DEFAULT 0, - force_keyboard_interactive TEXT, - password TEXT, - key TEXT, - key_password TEXT, - key_type TEXT, - sudo_password TEXT, - autostart_password TEXT, - autostart_key TEXT, - autostart_key_password TEXT, - credential_id INTEGER, - override_credential_username INTEGER, - vault_profile_id INTEGER, - enable_terminal INTEGER NOT NULL DEFAULT 1, - enable_session_logging INTEGER NOT NULL DEFAULT 1, - allow_session_sharing INTEGER NOT NULL DEFAULT 1, - enable_command_history INTEGER NOT NULL DEFAULT 1, - enable_tunnel INTEGER NOT NULL DEFAULT 1, - tunnel_connections TEXT, - jump_hosts TEXT, - enable_file_manager INTEGER NOT NULL DEFAULT 1, - scp_legacy INTEGER NOT NULL DEFAULT 0, - enable_docker INTEGER NOT NULL DEFAULT 0, - enable_tmux_monitor INTEGER NOT NULL DEFAULT 0, - show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1, - show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0, - default_path TEXT, - stats_config TEXT, - docker_config TEXT, - enable_proxmox INTEGER NOT NULL DEFAULT 0, - proxmox_config TEXT, - terminal_config TEXT, - quick_actions TEXT, - notes TEXT, - enable_ssh INTEGER NOT NULL DEFAULT 1, - enable_rdp INTEGER NOT NULL DEFAULT 0, - enable_vnc INTEGER NOT NULL DEFAULT 0, - enable_telnet INTEGER NOT NULL DEFAULT 0, - ssh_port INTEGER DEFAULT 22, - rdp_port INTEGER DEFAULT 3389, - vnc_port INTEGER DEFAULT 5900, - telnet_port INTEGER DEFAULT 23, - rdp_credential_id INTEGER, - rdp_user TEXT, - rdp_password TEXT, - rdp_domain TEXT, - rdp_security TEXT, - rdp_ignore_cert INTEGER DEFAULT 0, - vnc_credential_id INTEGER, - vnc_password TEXT, - vnc_user TEXT, - telnet_user TEXT, - telnet_password TEXT, - telnet_credential_id INTEGER, - rdp_auth_type TEXT, - vnc_auth_type TEXT, - telnet_auth_type TEXT, - domain TEXT, - security TEXT, - ignore_cert INTEGER DEFAULT 0, - guacamole_config TEXT, - use_socks5 INTEGER, - socks5_host TEXT, - socks5_port INTEGER, - socks5_username TEXT, - socks5_password TEXT, - socks5_proxy_chain TEXT, - mac_address TEXT, - wol_broadcast_address TEXT, - port_knock_sequence TEXT, - host_key_fingerprint TEXT, - host_key_type TEXT, - host_key_algorithm TEXT DEFAULT 'sha256', - host_key_first_seen TEXT, - host_key_last_verified TEXT, - host_key_changed_count INTEGER DEFAULT 0, - connection_origin TEXT, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE ssh_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - description TEXT, - folder TEXT, - tags TEXT, - auth_type TEXT NOT NULL, - username TEXT, - password TEXT, - key TEXT, - private_key TEXT, - public_key TEXT, - key_password TEXT, - key_type TEXT, - detected_key_type TEXT, - cert_public_key TEXT, - usage_count INTEGER NOT NULL DEFAULT 0, - last_used TEXT, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE host_access ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_id INTEGER NOT NULL, - user_id TEXT, - role_id INTEGER, - granted_by TEXT NOT NULL, - permission_level TEXT NOT NULL DEFAULT 'view', - expires_at TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_accessed_at TEXT, - access_count INTEGER NOT NULL DEFAULT 0, - override_credential_id INTEGER - ); - - CREATE TABLE ssh_folders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - color TEXT, - icon TEXT, - credential_id INTEGER, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO ssh_credentials ( + id, user_id, name, auth_type, username, password, private_key, key_password + ) + VALUES + (7, 'user-1', 'owner', 'password', 'root', 'secret', NULL, NULL), + (8, 'user-2', 'override', 'key', 'alice', NULL, 'private', 'pass'); INSERT INTO ssh_data ( id, user_id, name, ip, port, username, auth_type, credential_id, tunnel_connections @@ -191,21 +44,15 @@ describe("HostResolutionRepository", () => { (1, 'user-1', 'web', '10.0.0.1', 22, 'root', 'password', 7, '[{"autoStart":true}]'), (2, 'user-1', 'db', '10.0.0.2', 22, 'admin', 'none', NULL, NULL), (3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'none', NULL, '[{"autoStart":false}]'); - INSERT INTO ssh_credentials ( - id, user_id, name, auth_type, username, password, private_key, key_password - ) - VALUES - (7, 'user-1', 'owner', 'password', 'root', 'secret', NULL, NULL), - (8, 'user-2', 'override', 'key', 'alice', NULL, 'private', 'pass'); - INSERT INTO host_access ( - host_id, user_id, granted_by, permission_level, override_credential_id - ) - VALUES (1, 'user-2', 'user-1', 'execute', 8); INSERT INTO ssh_folders (user_id, name, credential_id) VALUES ('user-1', 'switches', 7), ('user-1', 'switches / floor1', NULL), ('user-1', 'no-cred', NULL); + INSERT INTO host_access ( + host_id, user_id, granted_by, permission_level, override_credential_id + ) + VALUES (1, 'user-2', 'user-1', 'execute', 8); `); return new HostResolutionRepository(context, onWrite); diff --git a/src/backend/tests/database/repositories/mutation-result.test.ts b/src/backend/tests/database/repositories/mutation-result.test.ts new file mode 100644 index 00000000..b905de24 --- /dev/null +++ b/src/backend/tests/database/repositories/mutation-result.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { + insertedId, + rowsAffected, + supportsReturning, +} from "../../../database/repositories/mutation-result.js"; + +describe("rowsAffected", () => { + it("counts a returning() array from sqlite or postgres", () => { + expect(rowsAffected([{ id: 1 }, { id: 2 }, { id: 3 }])).toBe(3); + expect(rowsAffected([])).toBe(0); + }); + + it("reads affectedRows from a mysql write result", () => { + expect(rowsAffected({ affectedRows: 4, insertId: 0 })).toBe(4); + expect(rowsAffected({ affectedRows: 0 })).toBe(0); + }); + + it("reads changes from a better-sqlite3 write result", () => { + // The shape of a write with no .returning() attached — verified against + // the driver, not assumed. + expect(rowsAffected({ changes: 1, lastInsertRowid: 7 })).toBe(1); + expect(rowsAffected({ changes: 0, lastInsertRowid: 7 })).toBe(0); + }); + + it("reads rowCount from a node-postgres write result", () => { + expect(rowsAffected({ rowCount: 3, rows: [], command: "DELETE" })).toBe(3); + }); + + it("unwraps the [header, fields] tuple mysql2 returns", () => { + expect(rowsAffected([{ affectedRows: 2 }, []])).toBe(2); + }); + + it("does not mistake a returning() array for a mysql header", () => { + // A single returned row is one row, not whatever affectedRows might say. + expect(rowsAffected([{ id: 7 }])).toBe(1); + }); + + it("reports zero for a shape it does not recognise", () => { + expect(rowsAffected(undefined)).toBe(0); + expect(rowsAffected(null)).toBe(0); + expect(rowsAffected({})).toBe(0); + }); +}); + +describe("insertedId", () => { + it("reads the id from a returning() array", () => { + expect(insertedId([{ id: 42 }])).toBe(42); + }); + + it("reads insertId from a mysql write result", () => { + expect(insertedId({ affectedRows: 1, insertId: 42 })).toBe(42); + expect(insertedId([{ affectedRows: 1, insertId: 42 }, []])).toBe(42); + }); + + it("treats mysql's zero insertId as absent", () => { + // MySQL reports 0 when the table has no autoincrement column. + expect(insertedId({ affectedRows: 1, insertId: 0 })).toBeNull(); + }); + + it("reads lastInsertRowid from better-sqlite3, as number or bigint", () => { + expect(insertedId({ changes: 1, lastInsertRowid: 9 })).toBe(9); + expect(insertedId({ changes: 1, lastInsertRowid: 9n })).toBe(9); + expect(insertedId({ changes: 1, lastInsertRowid: 0 })).toBeNull(); + }); + + it("returns null when nothing was inserted", () => { + expect(insertedId([])).toBeNull(); + expect(insertedId({})).toBeNull(); + expect(insertedId(undefined)).toBeNull(); + }); + + it("returns null for a non-numeric id", () => { + // Tables keyed by a text id, e.g. users. + expect(insertedId([{ id: "u-1" }])).toBeNull(); + }); +}); + +describe("supportsReturning", () => { + it("is false only for mysql", () => { + expect(supportsReturning("sqlite")).toBe(true); + expect(supportsReturning("postgres")).toBe(true); + // No RETURNING clause in MySQL, and drizzle's mysql-core does not expose + // the method — call sites that need rows back must read first. + expect(supportsReturning("mysql")).toBe(false); + }); +}); diff --git a/src/backend/tests/database/repositories/network-topology-repository.test.ts b/src/backend/tests/database/repositories/network-topology-repository.test.ts index 72820118..985336ee 100644 --- a/src/backend/tests/database/repositories/network-topology-repository.test.ts +++ b/src/backend/tests/database/repositories/network-topology-repository.test.ts @@ -17,23 +17,7 @@ describe("NetworkTopologyRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE network_topology ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - topology TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); `); diff --git a/src/backend/tests/database/repositories/open-tab-repository.test.ts b/src/backend/tests/database/repositories/open-tab-repository.test.ts index ab82f683..abae4917 100644 --- a/src/backend/tests/database/repositories/open-tab-repository.test.ts +++ b/src/backend/tests/database/repositories/open-tab-repository.test.ts @@ -17,29 +17,12 @@ describe("OpenTabRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE user_open_tabs ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - tab_type TEXT NOT NULL, - host_id INTEGER, - label TEXT NOT NULL, - tab_order INTEGER NOT NULL DEFAULT 0, - backend_session_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) VALUES + (1, 'user-1', 'host-1', '10.0.0.1', 22, 'root', 'password'), + (2, 'user-1', 'host-2', '10.0.0.2', 22, 'root', 'password'); `); return new OpenTabRepository(context, onWrite); diff --git a/src/backend/tests/database/repositories/opkssh-token-repository.test.ts b/src/backend/tests/database/repositories/opkssh-token-repository.test.ts index 861ab109..de7efde3 100644 --- a/src/backend/tests/database/repositories/opkssh-token-repository.test.ts +++ b/src/backend/tests/database/repositories/opkssh-token-repository.test.ts @@ -17,39 +17,11 @@ describe("OpksshTokenRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); - - CREATE TABLE opkssh_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER NOT NULL, - ssh_cert TEXT NOT NULL, - private_key TEXT NOT NULL, - email TEXT, - sub TEXT, - issuer TEXT, - audience TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TEXT NOT NULL, - last_used TEXT, - UNIQUE(user_id, host_id) - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO hosts (id, user_id, name) - VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password'); INSERT INTO opkssh_tokens ( user_id, host_id, ssh_cert, private_key, email, expires_at ) diff --git a/src/backend/tests/database/repositories/rbac-access-repository.test.ts b/src/backend/tests/database/repositories/rbac-access-repository.test.ts index c3e64579..814da8f0 100644 --- a/src/backend/tests/database/repositories/rbac-access-repository.test.ts +++ b/src/backend/tests/database/repositories/rbac-access-repository.test.ts @@ -18,112 +18,30 @@ describe("RbacAccessRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE roles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - display_name TEXT NOT NULL, - description TEXT, - is_system INTEGER NOT NULL DEFAULT 0, - permissions TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE host_access ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_id INTEGER NOT NULL, - user_id TEXT, - role_id INTEGER, - granted_by TEXT NOT NULL, - permission_level TEXT NOT NULL DEFAULT 'view', - expires_at TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_accessed_at TEXT, - access_count INTEGER NOT NULL DEFAULT 0, - override_credential_id INTEGER - ); - - CREATE TABLE shared_host_secrets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_access_id INTEGER NOT NULL, - target_user_id TEXT NOT NULL, - protocol TEXT NOT NULL DEFAULT 'ssh', - source_type TEXT NOT NULL DEFAULT 'credential', - original_credential_id INTEGER, - encrypted_username TEXT, - encrypted_auth_type TEXT, - encrypted_password TEXT, - encrypted_key TEXT, - encrypted_key_password TEXT, - encrypted_key_type TEXT, - encrypted_domain TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE(host_access_id, target_user_id, protocol) - ); - - CREATE TABLE ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT, - ip TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT NOT NULL, - credential_id INTEGER, - rdp_credential_id INTEGER, - vnc_credential_id INTEGER, - telnet_credential_id INTEGER, - folder TEXT, - tags TEXT - ); - - CREATE TABLE snippets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - content TEXT NOT NULL, - description TEXT, - folder TEXT, - "order" INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - host_filter TEXT - ); - - CREATE TABLE snippet_access ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - snippet_id INTEGER NOT NULL, - user_id TEXT, - role_id INTEGER, - granted_by TEXT NOT NULL, - permission_level TEXT NOT NULL DEFAULT 'view', - expires_at TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash, is_admin, is_oidc) VALUES ('admin', 'admin', 'hash', 1, 0), ('user-1', 'alice', 'hash', 0, 0), ('owner-1', 'owner', 'hash', 0, 0); - INSERT INTO roles (id, name, display_name, is_system) VALUES (7, 'ops', 'Operations', 0); - + INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES + (123, 'admin', 'cred-123', 'root', 'password'), + (124, 'admin', 'cred-124', 'root', 'password'), + (125, 'admin', 'cred-125', 'root', 'password'), + (126, 'admin', 'cred-126', 'root', 'password'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) VALUES + (43, 'admin', 'host-43', '10.0.0.43', 22, 'root', 'password'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) VALUES + (44, 'admin', 'host-44', '10.0.0.45', 22, 'root', 'password'); INSERT INTO ssh_data ( - id, user_id, name, ip, port, username, credential_id, rdp_credential_id, vnc_credential_id, telnet_credential_id, folder, tags - ) - VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 125, 126, 'servers', 'linux'); - + id, user_id, name, ip, port, username, credential_id, rdp_credential_id, vnc_credential_id, telnet_credential_id, folder, tags, auth_type) + VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 125, 126, 'servers', 'linux', 'password'); + INSERT INTO snippets (id, user_id, name, content) + VALUES + (99, 'owner-1', 'deploy', 'echo deploy'), + (100, 'owner-1', 'rollback', 'echo rollback'); INSERT INTO host_access ( id, host_id, user_id, role_id, granted_by, permission_level, expires_at, created_at ) @@ -131,23 +49,18 @@ describe("RbacAccessRepository", () => { (1, 42, 'user-1', NULL, 'admin', 'view', NULL, '2026-06-26T00:00:00.000Z'), (2, 42, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z'), (5, 44, 'user-1', NULL, 'admin', 'view', '2026-06-25T00:00:00.000Z', '2026-06-24T00:00:00.000Z'); - - INSERT INTO shared_host_secrets ( - id, host_access_id, target_user_id, protocol, source_type, original_credential_id, encrypted_username, encrypted_auth_type - ) - VALUES - (8, 2, 'user-1', 'ssh', 'credential', 123, 'enc-user', 'enc-auth'), - (9, 2, 'user-1', 'rdp', 'inline', NULL, 'enc-rdp-user', 'direct'); - - INSERT INTO snippets (id, user_id, name, content) - VALUES (99, 'owner-1', 'deploy', 'echo deploy'); - INSERT INTO snippet_access ( id, snippet_id, user_id, role_id, granted_by, permission_level, expires_at, created_at ) VALUES (3, 99, 'user-1', NULL, 'admin', 'view', NULL, '2026-06-26T00:00:00.000Z'), (4, 99, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z'); + INSERT INTO shared_host_secrets ( + id, host_access_id, target_user_id, protocol, source_type, original_credential_id, encrypted_username, encrypted_auth_type + ) + VALUES + (8, 2, 'user-1', 'ssh', 'credential', 123, 'enc-user', 'enc-auth'), + (9, 2, 'user-1', 'rdp', 'inline', NULL, 'enc-rdp-user', 'direct'); `); return new RbacAccessRepository(context, onWrite); diff --git a/src/backend/tests/database/repositories/recent-activity-repository.test.ts b/src/backend/tests/database/repositories/recent-activity-repository.test.ts index 245e5d8d..08087947 100644 --- a/src/backend/tests/database/repositories/recent-activity-repository.test.ts +++ b/src/backend/tests/database/repositories/recent-activity-repository.test.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { afterEach, describe, expect, it } from "vitest"; import { TestSqliteDatabase } from "./test-support.js"; import { RecentActivityRepository } from "../../../database/repositories/recent-activity-repository.js"; @@ -16,40 +17,14 @@ describe("RecentActivityRepository", () => { onWrite?: () => void | Promise, ): Promise<{ repository: RecentActivityRepository; - sqlite: NonNullable< - Awaited>["sqlite"] - >; }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); - - CREATE TABLE recent_activity ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - type TEXT NOT NULL, - host_id INTEGER NOT NULL, - host_name TEXT, - timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO hosts (id, user_id, name) - VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password'); INSERT INTO recent_activity (id, user_id, type, host_id, host_name, timestamp) VALUES (1, 'user-1', 'connect', 1, 'one', '2026-06-26T00:00:00.000Z'), @@ -59,13 +34,12 @@ describe("RecentActivityRepository", () => { return { repository: new RecentActivityRepository(context, onWrite), - sqlite: adapter.raw, }; } it("lists, creates, and trims recent activity", async () => { let writeCount = 0; - const { repository, sqlite } = await createRepository(() => { + const { repository } = await createRepository(() => { writeCount += 1; }); @@ -88,11 +62,9 @@ describe("RecentActivityRepository", () => { expect(await repository.trimUserActivity("user-1", 2)).toBe(1); expect( - sqlite - .prepare( - "SELECT id FROM recent_activity WHERE user_id = ? ORDER BY timestamp DESC", - ) - .all("user-1"), + await adapter!.query( + sql`SELECT id FROM recent_activity WHERE user_id = 'user-1' ORDER BY timestamp DESC`, + ), ).toEqual([{ id: created.id }, { id: 2 }]); expect(writeCount).toBe(2); }); diff --git a/src/backend/tests/database/repositories/returning.test.ts b/src/backend/tests/database/repositories/returning.test.ts new file mode 100644 index 00000000..42c1111c --- /dev/null +++ b/src/backend/tests/database/repositories/returning.test.ts @@ -0,0 +1,177 @@ +import { sql } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { + deleteReturning, + updateReturning, +} from "../../../database/repositories/returning.js"; +import type { DatabaseContext } from "../../../database/repositories/database-context.js"; +import type { DatabaseDialect } from "../../../database/db/dialect.js"; + +/** + * The MySQL path cannot be exercised against a real engine here, and its whole + * correctness is an ordering property: an update must be read AFTER the write, + * a delete BEFORE it. Get either backwards and the rows describe the wrong + * state — silently, with no error anywhere. + * + * So the drizzle handle is stubbed and the order of calls is recorded. + */ +function recordingContext(dialect: DatabaseDialect) { + const calls: string[] = []; + const rows = [{ id: 1, name: "before" }]; + + const chain = (label: string, result: unknown) => { + calls.push(label); + const thenable = { + set: () => thenable, + from: () => thenable, + where: () => thenable, + returning: () => Promise.resolve(result), + then: (resolve: (v: unknown) => void) => + Promise.resolve(result).then(resolve), + }; + return thenable; + }; + + const db = { + update: () => chain("update", rows), + delete: () => chain("delete", rows), + select: () => chain("select", rows), + transaction: (fn: (tx: unknown) => Promise) => { + calls.push("begin"); + return fn(db).then((value) => { + calls.push("commit"); + return value; + }); + }, + }; + + return { + context: { dialect, drizzle: db } as unknown as DatabaseContext, + calls, + }; +} + +const where = sql`id = 1`; + +describe("updateReturning", () => { + it.each(["sqlite", "postgres"] as const)( + "uses a single statement on %s, where RETURNING exists", + async (dialect) => { + const { context, calls } = recordingContext(dialect); + await updateReturning(context, {} as never, {}, where); + expect(calls).toEqual(["update"]); + }, + ); + + it("on mysql, writes first and reads the new state after", async () => { + const { context, calls } = recordingContext("mysql"); + await updateReturning(context, {} as never, {}, where); + + // Reading first would return the values the update replaced. + expect(calls).toEqual(["begin", "update", "select", "commit"]); + }); +}); + +describe("updateReturning, when the read-back cannot find the rows", () => { + /** + * The failure mode: an update that changes a column its own `where` filters + * on. MySQL writes the rows, then the re-read matches nothing. Returning [] + * would be indistinguishable from "matched nothing" and silently wrong. + */ + function contextThatWritesButCannotReadBack() { + const chain = (result: unknown) => { + const thenable: Record = { + set: () => thenable, + from: () => thenable, + where: () => thenable, + then: (resolve: (v: unknown) => void) => + Promise.resolve(result).then(resolve), + }; + return thenable; + }; + const db = { + update: () => chain({ affectedRows: 3 }), + select: () => chain([]), + transaction: (fn: (tx: unknown) => Promise) => fn(db), + }; + return { dialect: "mysql", drizzle: db } as unknown as DatabaseContext; + } + + it("throws instead of returning an empty array", async () => { + await expect( + updateReturning( + contextThatWritesButCannotReadBack(), + {} as never, + {}, + where, + ), + ).rejects.toThrow(/wrote 3 row\(s\) but could not read them back/); + }); + + it("says how to fix it", async () => { + await expect( + updateReturning( + contextThatWritesButCannotReadBack(), + {} as never, + {}, + where, + ), + ).rejects.toThrow(/filter on a column the update leaves alone/); + }); + + it("still returns [] when the update genuinely matched nothing", async () => { + const { context } = recordingContext("mysql"); + // recordingContext reports rows for select, so use a zero-write stub. + const chain = (result: unknown) => { + const t: Record = { + set: () => t, + from: () => t, + where: () => t, + then: (r: (v: unknown) => void) => Promise.resolve(result).then(r), + }; + return t; + }; + const db = { + update: () => chain({ affectedRows: 0 }), + select: () => chain([]), + transaction: (fn: (tx: unknown) => Promise) => fn(db), + }; + void context; + await expect( + updateReturning( + { dialect: "mysql", drizzle: db } as unknown as DatabaseContext, + {} as never, + {}, + where, + ), + ).resolves.toEqual([]); + }); +}); + +describe("deleteReturning", () => { + it("uses a single statement where RETURNING exists", async () => { + const { context, calls } = recordingContext("postgres"); + await deleteReturning(context, {} as never, where); + expect(calls).toEqual(["delete"]); + }); + + it("on mysql, reads first and deletes after", async () => { + const { context, calls } = recordingContext("mysql"); + const rows = await deleteReturning(context, {} as never, where); + + // Reading after the delete would find nothing at all. + expect(calls).toEqual(["begin", "select", "delete", "commit"]); + expect(rows).toHaveLength(1); + }); + + it("keeps both statements in one transaction", async () => { + const { context, calls } = recordingContext("mysql"); + await deleteReturning(context, {} as never, where); + + // Without this, a concurrent write between them makes the returned rows + // describe a state that never existed — and with a pool the second + // statement need not even reach the same connection. + expect(calls[0]).toBe("begin"); + expect(calls[calls.length - 1]).toBe("commit"); + }); +}); diff --git a/src/backend/tests/database/repositories/role-repository.test.ts b/src/backend/tests/database/repositories/role-repository.test.ts index 0852ca0b..963c5953 100644 --- a/src/backend/tests/database/repositories/role-repository.test.ts +++ b/src/backend/tests/database/repositories/role-repository.test.ts @@ -17,45 +17,7 @@ describe("RoleRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE roles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - display_name TEXT NOT NULL, - description TEXT, - is_system INTEGER NOT NULL DEFAULT 0, - permissions TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE user_roles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - role_id INTEGER NOT NULL, - granted_by TEXT, - granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE host_access ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_id INTEGER NOT NULL, - user_id TEXT, - role_id INTEGER, - granted_by TEXT NOT NULL, - permission_level TEXT NOT NULL DEFAULT 'view', - expires_at TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash, is_admin, is_oidc) VALUES ('admin', 'admin', 'hash', 1, 0), ('user-1', 'user', 'hash', 0, 0); `); diff --git a/src/backend/tests/database/repositories/session-recording-repository.test.ts b/src/backend/tests/database/repositories/session-recording-repository.test.ts index 64f3f3ba..9da43e44 100644 --- a/src/backend/tests/database/repositories/session-recording-repository.test.ts +++ b/src/backend/tests/database/repositories/session-recording-repository.test.ts @@ -17,42 +17,11 @@ describe("SessionRecordingRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - ip TEXT - ); - - CREATE TABLE session_recordings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_id INTEGER NOT NULL, - user_id TEXT, - username TEXT, - access_id INTEGER, - started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - ended_at TEXT, - duration INTEGER, - commands TEXT, - dangerous_actions TEXT, - recording_path TEXT, - protocol TEXT NOT NULL DEFAULT 'ssh', - format TEXT NOT NULL DEFAULT 'text', - terminated_by_owner INTEGER DEFAULT 0, - termination_reason TEXT - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO ssh_data (id, user_id, name, ip) - VALUES (1, 'user-1', 'one', '10.0.0.1'), (2, 'user-1', 'two', '10.0.0.2'), (3, 'user-2', 'other', '10.0.0.3'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.2', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'password'); `); return new SessionRecordingRepository(context, onWrite); diff --git a/src/backend/tests/database/repositories/session-share-repository.test.ts b/src/backend/tests/database/repositories/session-share-repository.test.ts index f0a1254f..9f8e16cc 100644 --- a/src/backend/tests/database/repositories/session-share-repository.test.ts +++ b/src/backend/tests/database/repositories/session-share-repository.test.ts @@ -17,51 +17,11 @@ describe("SessionShareRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - ip TEXT - ); - - CREATE TABLE session_shares ( - id TEXT PRIMARY KEY, - host_id INTEGER NOT NULL, - owner_user_id TEXT NOT NULL, - protocol TEXT NOT NULL, - session_id TEXT NOT NULL, - tab_instance_id TEXT, - share_type TEXT NOT NULL, - target_user_id TEXT, - link_token TEXT UNIQUE, - permission_level TEXT NOT NULL DEFAULT 'read-only', - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TEXT NOT NULL, - revoked_at TEXT, - last_joined_at TEXT, - join_count INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE session_share_participants ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - share_id TEXT NOT NULL, - user_id TEXT, - guest_label TEXT, - joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - left_at TEXT - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('owner-1', 'alice', 'hash'), ('guest-1', 'bob', 'hash'); - INSERT INTO ssh_data (id, user_id, name, ip) - VALUES (1, 'owner-1', 'host-one', '10.0.0.1'), (2, 'owner-1', 'host-two', '10.0.0.2'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'owner-1', 'host-one', '10.0.0.1', 22, 'root', 'password'), (2, 'owner-1', 'host-two', '10.0.0.2', 22, 'root', 'password'); `); return new SessionShareRepository(context, onWrite); diff --git a/src/backend/tests/database/repositories/settings-cache-refresh.test.ts b/src/backend/tests/database/repositories/settings-cache-refresh.test.ts new file mode 100644 index 00000000..1f644d4c --- /dev/null +++ b/src/backend/tests/database/repositories/settings-cache-refresh.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + refreshIntervalSeconds, + startSettingsCacheRefresh, + stopSettingsCacheRefresh, +} from "../../../database/repositories/factory.js"; + +/** + * The settings cache lives in one process and is updated by whichever process + * wrote the setting. On SQLite that is the only process there is. On Postgres + * and MySQL — the reason those exist here is to let several instances share one + * database — a setting changed on one replica would otherwise never reach the + * others, because the synchronous read cannot go back to the database. + * + * Re-priming on a timer does not make settings immediately consistent. It + * bounds how long they can disagree. + */ +describe("settings cache refresh", () => { + afterEach(() => stopSettingsCacheRefresh()); + + describe("interval", () => { + it("defaults to something short enough to matter", () => { + expect(refreshIntervalSeconds({})).toBe(30); + }); + + it("is configurable", () => { + expect( + refreshIntervalSeconds({ SETTINGS_CACHE_REFRESH_SECONDS: "5" }), + ).toBe(5); + }); + + it("treats zero and nonsense as off", () => { + expect( + refreshIntervalSeconds({ SETTINGS_CACHE_REFRESH_SECONDS: "0" }), + ).toBeNull(); + expect( + refreshIntervalSeconds({ SETTINGS_CACHE_REFRESH_SECONDS: "-1" }), + ).toBeNull(); + expect( + refreshIntervalSeconds({ SETTINGS_CACHE_REFRESH_SECONDS: "soon" }), + ).toBeNull(); + }); + }); + + it("re-reads on the interval", async () => { + const refresh = vi.fn().mockResolvedValue(undefined); + + startSettingsCacheRefresh( + { SETTINGS_CACHE_REFRESH_SECONDS: "0.01" }, + refresh, + ); + + await vi.waitFor(() => + expect(refresh.mock.calls.length).toBeGreaterThan(1), + ); + }); + + it("keeps running after a refresh throws", async () => { + // A transient database blip must not stop the loop, or the replica is stuck + // on stale settings until it restarts — the exact failure this prevents. + const refresh = vi + .fn() + .mockRejectedValueOnce(new Error("connection reset")) + .mockResolvedValue(undefined); + + startSettingsCacheRefresh( + { SETTINGS_CACHE_REFRESH_SECONDS: "0.01" }, + refresh, + ); + + await vi.waitFor(() => + expect(refresh.mock.calls.length).toBeGreaterThan(1), + ); + }); + + it("does nothing when switched off", async () => { + const refresh = vi.fn().mockResolvedValue(undefined); + + startSettingsCacheRefresh({ SETTINGS_CACHE_REFRESH_SECONDS: "0" }, refresh); + await new Promise((resolve) => setTimeout(resolve, 40)); + + expect(refresh).not.toHaveBeenCalled(); + }); + + it("stops when told to, and does not stack timers", async () => { + const refresh = vi.fn().mockResolvedValue(undefined); + + startSettingsCacheRefresh( + { SETTINGS_CACHE_REFRESH_SECONDS: "0.02" }, + refresh, + ); + startSettingsCacheRefresh( + { SETTINGS_CACHE_REFRESH_SECONDS: "0.02" }, + refresh, + ); + + await new Promise((resolve) => setTimeout(resolve, 70)); + stopSettingsCacheRefresh(); + + const afterStop = refresh.mock.calls.length; + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(refresh.mock.calls.length).toBe(afterStop); + }); +}); diff --git a/src/backend/tests/database/repositories/settings-cache.test.ts b/src/backend/tests/database/repositories/settings-cache.test.ts new file mode 100644 index 00000000..81099b3e --- /dev/null +++ b/src/backend/tests/database/repositories/settings-cache.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + forgetCachedSetting, + isSettingsCachePrimed, + primeSettingsCache, + readCachedSetting, + resetSettingsCache, + updateCachedSetting, +} from "../../../database/repositories/settings-cache.js"; + +afterEach(() => resetSettingsCache()); + +describe("settings cache", () => { + it("starts unprimed", () => { + expect(isSettingsCachePrimed()).toBe(false); + }); + + it("reads back what was primed", () => { + primeSettingsCache([ + { key: "guac_url", value: "guacd:4822" }, + { key: "allow_registration", value: "false" }, + ]); + + expect(isSettingsCachePrimed()).toBe(true); + expect(readCachedSetting("guac_url")).toBe("guacd:4822"); + expect(readCachedSetting("allow_registration")).toBe("false"); + }); + + it("returns null for a key that is not set", () => { + primeSettingsCache([{ key: "guac_url", value: "guacd:4822" }]); + + expect(readCachedSetting("missing")).toBeNull(); + }); + + it("returns null rather than throwing before priming", () => { + // Startup ordering means a read can land first. Every caller already + // treats null as "use the default", so this must not throw. + expect(readCachedSetting("guac_url")).toBeNull(); + }); + + it("reflects a write immediately", () => { + primeSettingsCache([{ key: "log_level", value: "info" }]); + + updateCachedSetting("log_level", "debug"); + + // A synchronous reader must not see the pre-write value. + expect(readCachedSetting("log_level")).toBe("debug"); + }); + + it("accepts a key that did not exist at prime time", () => { + primeSettingsCache([]); + + updateCachedSetting("new_key", "value"); + + expect(readCachedSetting("new_key")).toBe("value"); + }); + + it("forgets a deleted key", () => { + primeSettingsCache([{ key: "guac_url", value: "guacd:4822" }]); + + forgetCachedSetting("guac_url"); + + expect(readCachedSetting("guac_url")).toBeNull(); + }); + + it("ignores writes while unprimed instead of half-populating", () => { + // A partially filled cache would be worse than an empty one: readers + // could not tell a real value from a missing prime. + updateCachedSetting("guac_url", "guacd:4822"); + + expect(isSettingsCachePrimed()).toBe(false); + expect(readCachedSetting("guac_url")).toBeNull(); + }); + + it("replaces the previous contents when primed again", () => { + primeSettingsCache([{ key: "old", value: "1" }]); + primeSettingsCache([{ key: "new", value: "2" }]); + + expect(readCachedSetting("old")).toBeNull(); + expect(readCachedSetting("new")).toBe("2"); + }); +}); diff --git a/src/backend/tests/database/repositories/settings-repository.test.ts b/src/backend/tests/database/repositories/settings-repository.test.ts index f4520311..1db75791 100644 --- a/src/backend/tests/database/repositories/settings-repository.test.ts +++ b/src/backend/tests/database/repositories/settings-repository.test.ts @@ -15,12 +15,6 @@ describe("SettingsRepository", () => { async function createRepository(): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ) - `); return new SettingsRepository(context); } diff --git a/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts b/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts index 8288ca90..9eeb5680 100644 --- a/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts +++ b/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts @@ -16,65 +16,25 @@ describe("SharedHostSecretsRepository", () => { onWrite?: () => void | Promise, ): Promise<{ repository: SharedHostSecretsRepository; - sqlite: NonNullable< - Awaited>["sqlite"] - >; }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE host_access ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_id INTEGER NOT NULL, - user_id TEXT, - role_id INTEGER, - granted_by TEXT NOT NULL, - permission_level TEXT NOT NULL DEFAULT 'connect', - expires_at TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_accessed_at TEXT, - access_count INTEGER NOT NULL DEFAULT 0, - override_credential_id INTEGER - ); - - CREATE TABLE ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT, - ip TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT NOT NULL, - credential_id INTEGER, - rdp_credential_id INTEGER, - vnc_credential_id INTEGER, - telnet_credential_id INTEGER - ); - - CREATE TABLE shared_host_secrets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_access_id INTEGER NOT NULL, - target_user_id TEXT NOT NULL, - protocol TEXT NOT NULL DEFAULT 'ssh', - source_type TEXT NOT NULL DEFAULT 'credential', - original_credential_id INTEGER, - encrypted_username TEXT, - encrypted_auth_type TEXT, - encrypted_password TEXT, - encrypted_key TEXT, - encrypted_key_password TEXT, - encrypted_key_type TEXT, - encrypted_domain TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE(host_access_id, target_user_id, protocol) - ); - - INSERT INTO ssh_data (id, user_id, name, ip, port, username, credential_id, rdp_credential_id) - VALUES - (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124), - (43, 'owner-1', 'staging', '10.0.0.43', 22, 'root', NULL, NULL), - (44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 123, NULL); - + await adapter.exec(` + INSERT INTO users (id, username, password_hash) VALUES + ('owner-1', 'owner-1', 'hash'), + ('owner-2', 'owner-2', 'hash'); + INSERT INTO users (id, username, password_hash) VALUES + ('user-1', 'user-1', 'hash'), + ('user-2', 'user-2', 'hash'); + INSERT INTO roles (id, name, display_name, is_system) VALUES + (7, 'role-7', 'Role 7', 0); + INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES + (123, 'user-1', 'cred-123', 'root', 'password'), + (124, 'user-1', 'cred-124', 'root', 'password'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, credential_id, rdp_credential_id, auth_type) + VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 'password'), + (43, 'owner-1', 'staging', '10.0.0.43', 22, 'root', NULL, NULL, 'password'), + (44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 123, NULL, 'password'); INSERT INTO host_access (id, host_id, user_id, role_id, granted_by) VALUES (1, 42, 'user-1', NULL, 'owner-1'), @@ -84,7 +44,6 @@ describe("SharedHostSecretsRepository", () => { return { repository: new SharedHostSecretsRepository(context, onWrite), - sqlite: adapter.raw, }; } diff --git a/src/backend/tests/database/repositories/snippet-repository.test.ts b/src/backend/tests/database/repositories/snippet-repository.test.ts index 25235600..b7909c57 100644 --- a/src/backend/tests/database/repositories/snippet-repository.test.ts +++ b/src/backend/tests/database/repositories/snippet-repository.test.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { afterEach, describe, expect, it, vi } from "vitest"; import { TestSqliteDatabase } from "./test-support.js"; import { SnippetRepository } from "../../../database/repositories/snippet-repository.js"; @@ -14,37 +15,13 @@ describe("SnippetRepository", () => { async function createRepository(onWrite?: () => void): Promise<{ repository: SnippetRepository; - sqlite: NonNullable< - Awaited>["sqlite"] - >; }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE snippets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - content TEXT NOT NULL, - description TEXT, - folder TEXT, - "order" INTEGER NOT NULL DEFAULT 0, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - host_filter TEXT - ); - - CREATE TABLE snippet_folders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - color TEXT, - icon TEXT, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); + await adapter.exec(` + INSERT INTO users (id, username, password_hash) VALUES + ('user-1', 'user-1', 'hash'), + ('user-2', 'user-2', 'hash'); INSERT INTO snippets ( id, user_id, name, content, description, folder, "order", host_filter @@ -53,7 +30,6 @@ describe("SnippetRepository", () => { (1, 'user-1', 'root', 'uptime', NULL, NULL, 2, NULL), (2, 'user-1', 'deploy', 'make deploy', 'Deploy app', 'ops', 1, 'linux'), (3, 'user-2', 'other', 'whoami', NULL, NULL, 1, NULL); - INSERT INTO snippet_folders (id, user_id, name, color, icon) VALUES (1, 'user-1', 'ops', '#123456', 'terminal'), @@ -63,7 +39,6 @@ describe("SnippetRepository", () => { return { repository: new SnippetRepository(context, onWrite), - sqlite: adapter.raw, }; } @@ -189,18 +164,18 @@ describe("SnippetRepository", () => { it("deletes all snippets and folders for a user", async () => { const onWrite = vi.fn(); - const { repository, sqlite } = await createRepository(onWrite); + const { repository } = await createRepository(onWrite); await expect(repository.deleteByUserId("user-1")).resolves.toEqual({ snippetsDeleted: 2, foldersDeleted: 2, }); - expect(sqlite.prepare("SELECT id FROM snippets ORDER BY id").all()).toEqual( - [{ id: 3 }], - ); expect( - sqlite.prepare("SELECT id FROM snippet_folders ORDER BY id").all(), + await adapter!.query(sql`SELECT id FROM snippets ORDER BY id`), + ).toEqual([{ id: 3 }]); + expect( + await adapter!.query(sql`SELECT id FROM snippet_folders ORDER BY id`), ).toEqual([{ id: 3 }]); expect(onWrite).toHaveBeenCalledTimes(1); }); diff --git a/src/backend/tests/database/repositories/ssh-credential-usage-repository.test.ts b/src/backend/tests/database/repositories/ssh-credential-usage-repository.test.ts index d8308ce7..9847b538 100644 --- a/src/backend/tests/database/repositories/ssh-credential-usage-repository.test.ts +++ b/src/backend/tests/database/repositories/ssh-credential-usage-repository.test.ts @@ -17,41 +17,13 @@ describe("SshCredentialUsageRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); - - CREATE TABLE ssh_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); - - CREATE TABLE ssh_credential_usage ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - credential_id INTEGER NOT NULL, - host_id INTEGER NOT NULL, - user_id TEXT NOT NULL, - used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO hosts (id, user_id, name) - VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other'); - INSERT INTO ssh_credentials (id, user_id, name) - VALUES (1, 'user-1', 'cred-one'), (2, 'user-2', 'cred-two'); + INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) + VALUES (1, 'user-1', 'cred-one', 'root', 'password'), (2, 'user-2', 'cred-two', 'root', 'password'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password'); `); return new SshCredentialUsageRepository(context, onWrite); diff --git a/src/backend/tests/database/repositories/sso-provider-repository.test.ts b/src/backend/tests/database/repositories/sso-provider-repository.test.ts index 0ed50a19..7c0b78d5 100644 --- a/src/backend/tests/database/repositories/sso-provider-repository.test.ts +++ b/src/backend/tests/database/repositories/sso-provider-repository.test.ts @@ -4,13 +4,11 @@ import { SsoProviderRepository } from "../../../database/repositories/sso-provid describe("SsoProviderRepository", () => { let adapter: TestSqliteDatabase | null = null; - let sqlite: Awaited>["sqlite"]; afterEach(async () => { if (adapter) { await adapter.close(); adapter = null; - sqlite = undefined; } }); @@ -19,28 +17,6 @@ describe("SsoProviderRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - sqlite = adapter.raw; - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0, - sso_provider_id INTEGER - ); - - CREATE TABLE sso_providers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - type TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - display_order INTEGER NOT NULL DEFAULT 0, - config TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - `); return new SsoProviderRepository(context, onWrite); } @@ -96,7 +72,7 @@ describe("SsoProviderRepository", () => { config: "{}", }); - sqlite?.exec(` + await adapter!.exec(` INSERT INTO users (id, username, password_hash, sso_provider_id) VALUES ('user-1', 'u1', 'hash', ${provider.id}), ('user-2', 'u2', 'hash', ${provider.id}), diff --git a/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts b/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts index 152de5c8..d03deb22 100644 --- a/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts +++ b/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts @@ -17,21 +17,7 @@ describe("SyncTombstoneRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE sync_tombstones ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - entity_type TEXT NOT NULL, - sync_id TEXT NOT NULL, - deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); `); @@ -102,24 +88,9 @@ describe("SyncTombstoneRepository", () => { const adapterLocal = new TestSqliteDatabase(); adapter = adapterLocal; const context = await adapterLocal.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE sync_tombstones ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - entity_type TEXT NOT NULL, - sync_id TEXT NOT NULL, - deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'); - INSERT INTO sync_tombstones (user_id, entity_type, sync_id, deleted_at) VALUES ('user-1', 'hosts', 'old', '2026-01-01T00:00:00.000Z'), diff --git a/src/backend/tests/database/repositories/termix-identity-ca-repository.test.ts b/src/backend/tests/database/repositories/termix-identity-ca-repository.test.ts index ef157f36..ce099d53 100644 --- a/src/backend/tests/database/repositories/termix-identity-ca-repository.test.ts +++ b/src/backend/tests/database/repositories/termix-identity-ca-repository.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { sql } from "drizzle-orm"; +import { afterEach, describe, expect, vi } from "vitest"; import { TestSqliteDatabase } from "./test-support.js"; import { DataCrypto } from "../../../utils/data-crypto.js"; import { TermixIdentityCaRepository } from "../../../database/repositories/termix-identity-ca-repository.js"; @@ -16,45 +17,11 @@ describe("TermixIdentityCaRepository", () => { async function createRepository(onWrite = vi.fn()): Promise<{ repo: TermixIdentityCaRepository; - sqlite: NonNullable< - Awaited>["sqlite"] - >; onWrite: ReturnType; }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE termix_identities ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL UNIQUE, - handle TEXT NOT NULL UNIQUE, - description TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - - CREATE TABLE termix_identity_ca ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - identity_id INTEGER NOT NULL UNIQUE, - user_id TEXT NOT NULL, - public_key TEXT NOT NULL, - private_key TEXT NOT NULL, - validity_days INTEGER NOT NULL DEFAULT 90, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (identity_id) REFERENCES termix_identities(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'); INSERT INTO termix_identities (id, user_id, handle) @@ -63,7 +30,6 @@ describe("TermixIdentityCaRepository", () => { return { repo: new TermixIdentityCaRepository(context, onWrite), - sqlite: adapter.raw, onWrite, }; } @@ -95,7 +61,7 @@ describe("TermixIdentityCaRepository", () => { } it("creates CA private keys with the real row id before encryption", async () => { - const { repo, sqlite, onWrite } = await createRepository(); + const { repo, onWrite } = await createRepository(); mockCrypto(); const created = await repo.createEncryptedForUser("user-1", { @@ -106,16 +72,14 @@ describe("TermixIdentityCaRepository", () => { validityDays: 120, }); - const raw = sqlite - .prepare( - "SELECT id, public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = ?", - ) - .get(7) as { + const [raw] = (await adapter!.query( + sql`SELECT id, public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = 7`, + )) as { id: number; public_key: string; private_key: string; validity_days: number; - }; + }[]; expect(created.privateKey).toBe("decrypted-ca-private"); expect(raw.private_key).toBe("encrypted-ca-private"); @@ -131,13 +95,12 @@ describe("TermixIdentityCaRepository", () => { }); it("reads public CA metadata without decrypting private key material", async () => { - const { repo, sqlite } = await createRepository(); + const { repo } = await createRepository(); const decryptSpy = vi.spyOn(DataCrypto, "decryptRecord"); - sqlite - .prepare( - "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", - ) - .run(7, "user-1", "ssh-ed25519 public", "encrypted-ca-private", 45); + await adapter!.run( + sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) + VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`, + ); await expect(repo.findPublicByIdentityId(7)).resolves.toEqual({ publicKey: "ssh-ed25519 public", @@ -147,13 +110,12 @@ describe("TermixIdentityCaRepository", () => { }); it("decrypts CA private keys through the user data boundary", async () => { - const { repo, sqlite } = await createRepository(); + const { repo } = await createRepository(); mockCrypto(); - sqlite - .prepare( - "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", - ) - .run(7, "user-1", "ssh-ed25519 public", "encrypted-ca-private", 45); + await adapter!.run( + sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) + VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`, + ); const ca = await repo.findDecryptedByIdentityId("user-1", 7); @@ -172,13 +134,11 @@ describe("TermixIdentityCaRepository", () => { }); it("updates CA private keys through encrypted writes", async () => { - const { repo, sqlite, onWrite } = await createRepository(); + const { repo, onWrite } = await createRepository(); mockCrypto(); - sqlite - .prepare( - "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", - ) - .run(7, "user-1", "ssh-ed25519 old", "encrypted-ca-private", 45); + await adapter!.run( + sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (7, 'user-1', 'ssh-ed25519 old', 'encrypted-ca-private', 45)`, + ); onWrite.mockClear(); const updated = await repo.updateEncryptedForIdentity("user-1", 7, { @@ -187,15 +147,13 @@ describe("TermixIdentityCaRepository", () => { validityDays: 90, }); - const raw = sqlite - .prepare( - "SELECT public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = ?", - ) - .get(7) as { + const [raw] = (await adapter!.query( + sql`SELECT public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = 7`, + )) as { public_key: string; private_key: string; validity_days: number; - }; + }[]; expect(updated).toMatchObject({ publicKey: "ssh-ed25519 new", @@ -219,55 +177,47 @@ describe("TermixIdentityCaRepository", () => { }); it("deletes CA rows through the write boundary", async () => { - const { repo, sqlite, onWrite } = await createRepository(); - sqlite - .prepare( - "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", - ) - .run(7, "user-1", "ssh-ed25519 public", "encrypted-ca-private", 45); + const { repo, onWrite } = await createRepository(); + await adapter!.run( + sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`, + ); onWrite.mockClear(); await expect(repo.deleteByIdentityId(7)).resolves.toBe(true); await expect(repo.deleteByIdentityId(7)).resolves.toBe(false); expect( - sqlite.prepare("SELECT COUNT(*) AS count FROM termix_identity_ca").get(), - ).toEqual({ count: 0 }); + ( + await adapter!.query( + sql`SELECT COUNT(*) AS count FROM termix_identity_ca`, + ) + ).map((row) => Number((row as { count: unknown }).count)), + ).toEqual([0]); expect(onWrite).toHaveBeenCalledTimes(1); }); it("deletes CA rows for a user", async () => { - const { repo, sqlite, onWrite } = await createRepository(); - sqlite - .prepare( - "INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)", - ) - .run("user-2", "bob", "hash"); - sqlite - .prepare( - "INSERT INTO termix_identities (id, user_id, handle) VALUES (?, ?, ?)", - ) - .run(8, "user-2", "bob"); - sqlite - .prepare( - "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", - ) - .run(7, "user-1", "ssh-ed25519 public", "encrypted-ca-private", 45); - sqlite - .prepare( - "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", - ) - .run(8, "user-2", "ssh-ed25519 other", "encrypted-other", 90); + const { repo, onWrite } = await createRepository(); + await adapter!.run( + sql`INSERT INTO users (id, username, password_hash) VALUES ('user-2', 'bob', 'hash')`, + ); + await adapter!.run( + sql`INSERT INTO termix_identities (id, user_id, handle) VALUES (8, 'user-2', 'bob')`, + ); + await adapter!.run( + sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (7, 'user-1', 'ssh-ed25519 public', 'encrypted-ca-private', 45)`, + ); + await adapter!.run( + sql`INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (8, 'user-2', 'ssh-ed25519 other', 'encrypted-other', 90)`, + ); onWrite.mockClear(); await expect(repo.deleteByUserId("user-1")).resolves.toBe(1); await expect(repo.deleteByUserId("missing")).resolves.toBe(0); expect( - sqlite - .prepare( - "SELECT user_id, public_key FROM termix_identity_ca ORDER BY user_id", - ) - .all(), + await adapter!.query( + sql`SELECT user_id, public_key FROM termix_identity_ca ORDER BY user_id`, + ), ).toEqual([{ user_id: "user-2", public_key: "ssh-ed25519 other" }]); expect(onWrite).toHaveBeenCalledTimes(1); }); diff --git a/src/backend/tests/database/repositories/termix-identity-repository.test.ts b/src/backend/tests/database/repositories/termix-identity-repository.test.ts index 12d8012d..c801faf8 100644 --- a/src/backend/tests/database/repositories/termix-identity-repository.test.ts +++ b/src/backend/tests/database/repositories/termix-identity-repository.test.ts @@ -18,44 +18,12 @@ describe("TermixIdentityRepository", () => { }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE termix_identities ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL UNIQUE, - handle TEXT NOT NULL UNIQUE, - description TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - - CREATE TABLE termix_identity_keys ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - identity_id INTEGER NOT NULL, - user_id TEXT NOT NULL, - public_key TEXT NOT NULL, - key_type TEXT NOT NULL, - algorithm TEXT NOT NULL, - label TEXT, - comment TEXT, - source TEXT NOT NULL DEFAULT 'manual', - credential_id INTEGER, - enabled INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (identity_id) REFERENCES termix_identities(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES + (10, 'user-1', 'cred-10', 'root', 'password'), + (20, 'user-1', 'cred-20', 'root', 'password'); `); return { diff --git a/src/backend/tests/database/repositories/test-support.ts b/src/backend/tests/database/repositories/test-support.ts index 3dab8a03..849255ff 100644 --- a/src/backend/tests/database/repositories/test-support.ts +++ b/src/backend/tests/database/repositories/test-support.ts @@ -1,17 +1,59 @@ import Database from "better-sqlite3"; import { drizzle } from "drizzle-orm/better-sqlite3"; +import { + getTableColumns, + getTableName, + is, + sql, + Table, + type SQL, +} from "drizzle-orm"; +import fs from "fs"; +import path from "path"; import * as schema from "../../../database/db/schema.js"; import type { DatabaseContext } from "../../../database/repositories/database-context.js"; +import type { DatabaseDialect } from "../../../database/db/dialect.js"; + +/** + * Which engine the repository tests run against. + * + * Defaults to SQLite, so `npm test` behaves as it always has and needs no + * server. Set TEST_DIALECT=postgres or mysql, plus TEST_DATABASE_URL, to run + * the same tests against a real one — see the database-dialects CI job. + */ +export function testDialect(env = process.env): DatabaseDialect { + const value = env.TEST_DIALECT?.trim().toLowerCase(); + if (value === "postgres" || value === "mysql") return value; + return "sqlite"; +} + +/** Every table drizzle knows about, for wiping between tests. */ +function allTableNames(): string[] { + return Object.values(schema) + .filter((value) => is(value, Table)) + .map((table) => getTableName(table as Table)); +} export class TestSqliteDatabase { private sqlite: Database.Database | null = null; private context: DatabaseContext | null = null; + private readonly dialect: DatabaseDialect; + + constructor(dialect: DatabaseDialect = testDialect()) { + this.dialect = dialect; + } async connect(): Promise { if (this.context) return this.context; + if (this.dialect !== "sqlite") { + this.context = await this.connectRemote(); + return this.context; + } + this.sqlite = new Database(":memory:"); this.sqlite.exec("PRAGMA foreign_keys = ON"); + this.sqlite.exec(sqliteSchemaSql()); this.context = { dialect: "sqlite", drizzle: drizzle(this.sqlite, { schema }), @@ -20,31 +62,393 @@ export class TestSqliteDatabase { return this.context; } - /** - * Schema setup for tests. Lives on the fixture rather than on - * DatabaseContext, which is drizzle-only so that no repository can reach for - * engine-specific SQL. - */ - /** Raw handle for assertions that read the database directly. Tests only. */ - get raw(): Database.Database { - if (!this.sqlite) { - throw new Error("connect() must be called before raw access"); + private async connectRemote(): Promise { + const url = process.env.TEST_DATABASE_URL; + if (!url) { + throw new Error( + `TEST_DIALECT=${this.dialect} requires TEST_DATABASE_URL to be set.`, + ); } - return this.sqlite; + + const { drizzle: connect } = await import( + this.dialect === "postgres" + ? "drizzle-orm/node-postgres" + : "drizzle-orm/mysql2" + ); + const db = connect(url) as unknown as DatabaseContext["drizzle"]; + const context: DatabaseContext = { dialect: this.dialect, drizzle: db }; + + await migrateOnce(this.dialect, db); + await truncateAll(context); + + return context; } - exec(sql: string): void { - if (!this.sqlite) { - throw new Error("connect() must be called before exec()"); + /** + * Runs seed SQL. Synchronous on SQLite, which is what the tests were written + * against; on the other engines it returns a promise the caller must await. + * + * The seeds are plain INSERTs, portable apart from identifier quoting, which + * `portableSql` fixes up. + */ + exec(statements: string): void | Promise { + if (this.sqlite) { + this.sqlite.exec(statements); + return; } - this.sqlite.exec(sql); + const context = this.context; + if (!context) throw new Error("connect() must be called before exec()"); + + return (async () => { + const touched = new Set(); + for (const statement of splitStatements(statements)) { + await runSql(context, sql.raw(portableSql(statement, context.dialect))); + const table = /INSERT INTO\s+([a-z_]+)/i.exec(statement)?.[1]; + if (table) touched.add(table); + } + await resyncAutoIncrement(context, touched); + })(); + } + + /** + * Portable read for assertions. Build the statement with drizzle's `sql` + * template so placeholders and quoting come out right on each engine. + */ + async query>(statement: SQL): Promise { + if (!this.context) + throw new Error("connect() must be called before query()"); + return runSql(this.context, statement); + } + + /** + * Portable write for test setup. + * + * Separate from query() because better-sqlite3 refuses `.all()` on a + * statement that returns no rows — "This statement does not return data". + */ + async run(statement: SQL): Promise { + const context = this.context; + if (!context) throw new Error("connect() must be called before run()"); + + if (this.sqlite) { + (context.drizzle as unknown as { run: (s: SQL) => unknown }).run( + statement, + ); + return; + } + await runSql(context, statement); } async close(): Promise { if (this.sqlite) { this.sqlite.close(); this.sqlite = null; - this.context = null; + } + this.context = null; + } +} + +async function runSql( + context: DatabaseContext, + statement: SQL, +): Promise { + const db = context.drizzle as unknown as { + all?: (s: SQL) => Promise | T[]; + execute?: (s: SQL) => Promise; + }; + + if (context.dialect === "sqlite" && db.all) { + return (await db.all(statement)) as T[]; + } + + const result = (await db.execute!(statement)) as + | { rows?: T[] } + | T[] + | undefined; + + // mysql2 answers [rows, fields]; node-postgres answers { rows }. + if (Array.isArray(result)) { + return (Array.isArray(result[0]) ? result[0] : result) as T[]; + } + return (result?.rows ?? []) as T[]; +} + +/** + * Empties every table between tests on the client-server engines, where the + * database outlives the process and cannot be thrown away like an in-memory + * SQLite one. + */ +async function truncateAll(context: DatabaseContext): Promise { + const tables = allTableNames(); + + if (context.dialect === "postgres") { + const list = tables.map((t) => `"${t}"`).join(", "); + await runSql( + context, + sql.raw(`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE`), + ); + return; + } + + // Truncating all 53 tables takes ~2s on MySQL, which every test would pay. + // Ask which ones actually hold rows first: after the first test only a + // handful do, and the check is a single query. + // Each branch is parenthesised: LIMIT binds to the whole UNION otherwise. + const counts = tables + .map((t) => `(SELECT '${t}' AS name FROM \`${t}\` LIMIT 1)`) + .join(" UNION ALL "); + const occupied = await runSql<{ name: string }>(context, sql.raw(counts)); + if (occupied.length === 0) return; + + await runSql(context, sql.raw("SET FOREIGN_KEY_CHECKS = 0")); + for (const { name } of occupied) { + await runSql(context, sql.raw(`TRUNCATE TABLE \`${name}\``)); + } + await runSql(context, sql.raw("SET FOREIGN_KEY_CHECKS = 1")); +} + +/** + * Splits seed SQL into statements, ignoring semicolons inside string literals — + * JSON payloads in the fixtures contain them. + */ +function splitStatements(sql: string): string[] { + const out: string[] = []; + let current = ""; + let inString = false; + + for (let i = 0; i < sql.length; i++) { + const ch = sql[i]; + if (ch === "'") { + // '' is an escaped quote inside a string, not a delimiter. + if (inString && sql[i + 1] === "'") { + current += "''"; + i++; + continue; + } + inString = !inString; + } + if (ch === ";" && !inString) { + if (current.trim()) out.push(current.trim()); + current = ""; + continue; + } + current += ch; + } + if (current.trim()) out.push(current.trim()); + return out; +} + +let cachedBooleanColumns: Set | null = null; + +/** + * Columns the schema declares as booleans, by table.column. + * + * Read from drizzle rather than listed here, so a new boolean column needs no + * change in this file. + */ +function booleanColumns(): Set { + if (cachedBooleanColumns) return cachedBooleanColumns; + + const found = new Set(); + for (const value of Object.values(schema)) { + if (!is(value, Table)) continue; + const table = getTableName(value as Table); + for (const column of Object.values(getTableColumns(value as Table))) { + if (column.dataType === "boolean") found.add(`${table}.${column.name}`); + } + } + cachedBooleanColumns = found; + return found; +} + +/** + * Seeds are written in SQLite's dialect. Two things do not carry: + * + * - a reserved word used as a column name is `"order"` on SQLite and Postgres, + * `` `order` `` on MySQL + * - SQLite stores booleans as 0/1, and writing an integer into a native boolean + * column is an error on Postgres. Every engine understands the TRUE/FALSE + * keywords, so boolean columns are rewritten to those. + */ +function portableSql(statement: string, dialect: DatabaseDialect): string { + const out = rewriteBooleanLiterals(statement); + if (dialect !== "mysql") return out; + + // Only the column list, before VALUES. A blanket replace also mangles the + // double quotes inside JSON payloads in the values — '{"slots":[]}' became + // '{`slots`:[]}', which is valid SQL and silently wrong data. + const split = /^(.*?\bVALUES\b)(.*)$/is.exec(out); + if (!split) return out.replace(/"([a-z_]+)"/g, "`$1`"); + return split[1].replace(/"([a-z_]+)"/g, "`$1`") + split[2]; +} + +/** Rewrites 0/1 to FALSE/TRUE in the value positions of boolean columns. */ +function rewriteBooleanLiterals(statement: string): string { + const booleans = booleanColumns(); + + return statement.replace( + /INSERT INTO\s+([a-z_]+)\s*\(([^)]*)\)\s*VALUES\s*((?:\([^()]*\)\s*,?\s*)+)/gis, + (whole, table: string, cols: string, values: string) => { + const names = cols.split(",").map((c) => c.trim().replace(/["`]/g, "")); + const flags = names.map((n) => booleans.has(`${table}.${n}`)); + if (!flags.some(Boolean)) return whole; + + const rewritten = values.replace( + /\(([^()]*)\)/g, + (row, inner: string) => { + const parts = splitValues(inner); + return `(${parts + .map((v, i) => + flags[i] && /^[01]$/.test(v.trim()) + ? v.trim() === "1" + ? "TRUE" + : "FALSE" + : v, + ) + .join(",")})`; + }, + ); + return `INSERT INTO ${table} (${cols}) VALUES ${rewritten}`; + }, + ); +} + +/** Splits a VALUES row on commas that are not inside a string literal. */ +function splitValues(row: string): string[] { + const parts: string[] = []; + let current = ""; + let inString = false; + for (let i = 0; i < row.length; i++) { + const ch = row[i]; + if (ch === "'") { + if (inString && row[i + 1] === "'") { + current += "''"; + i++; + continue; + } + inString = !inString; + } + if (ch === "," && !inString) { + parts.push(current); + current = ""; + continue; + } + current += ch; + } + parts.push(current); + return parts; +} + +/** + * Moves each table's id generator past the ids the seed inserted by hand. + * + * SQLite picks `max(id) + 1` when a row omits the key, so a fixture that writes + * `id = 1, 2, 3` and then lets the repository insert one more just works. A + * Postgres sequence or a MySQL auto_increment counter does not know about rows + * inserted with an explicit id, so it hands out 1 again and the insert collides + * with the fixture's own data. + */ +async function resyncAutoIncrement( + context: DatabaseContext, + tables: Set, +): Promise { + for (const table of tables) { + // Only tables whose id is generated. A text primary key, like users.id, + // has no sequence and no counter to move. + if (context.dialect === "postgres") { + const [seq] = await runSql<{ name: string | null }>( + context, + sql.raw(`SELECT pg_get_serial_sequence('${table}', 'id') AS name`), + ); + if (!seq?.name) continue; + + await runSql( + context, + sql.raw( + `SELECT setval('${seq.name}', ` + + `COALESCE((SELECT MAX(id) FROM "${table}"), 0) + 1, false)`, + ), + ); + continue; + } + + const [column] = await runSql<{ extra: string }>( + context, + sql.raw( + `SELECT EXTRA AS extra FROM information_schema.columns ` + + `WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '${table}' ` + + `AND COLUMN_NAME = 'id'`, + ), + ); + if (!column?.extra?.includes("auto_increment")) continue; + + const [row] = await runSql<{ next: number | null }>( + context, + sql.raw(`SELECT MAX(id) + 1 AS next FROM \`${table}\``), + ); + if (row?.next) { + await runSql( + context, + sql.raw(`ALTER TABLE \`${table}\` AUTO_INCREMENT = ${row.next}`), + ); } } } + +/** + * Migrations run once per worker, not once per fixture. + * + * Every test builds a fixture, and each would otherwise re-run the migrator + * against the same shared database. drizzle's journal makes that a no-op only + * when the first run finished — several fixtures racing inside one file hit + * "table already exists" instead. + */ +const migrations = new Map>(); + +function migrateOnce( + dialect: DatabaseDialect, + db: DatabaseContext["drizzle"], +): Promise { + const key = `${dialect}:${process.env.TEST_DATABASE_URL}`; + let running = migrations.get(key); + if (!running) { + running = (async () => { + const { runRemoteMigrations } = + await import("../../../database/db/migrate.js"); + await runRemoteMigrations(dialect, db); + })(); + migrations.set(key, running); + } + return running; +} + +let cachedSqliteSchema: string | null = null; + +/** + * The full schema, from the generated SQLite migration rather than hand-written + * DDL in each test file. + * + * Tests used to declare a cut-down version of every table they touched — a + * `users` with five columns where the real one has thirty. That drifts from the + * schema silently, and it is the reason the same tests could not be pointed at + * another engine. + */ +function sqliteSchemaSql(): string { + if (cachedSqliteSchema) return cachedSqliteSchema; + + const dir = path.resolve(process.cwd(), "drizzle", "sqlite"); + const file = fs + .readdirSync(dir) + .filter((name) => name.endsWith(".sql")) + .sort() + .at(-1); + + if (!file) throw new Error(`No SQLite migration found in ${dir}`); + + cachedSqliteSchema = fs + .readFileSync(path.join(dir, file), "utf8") + .split("--> statement-breakpoint") + .join("\n"); + + return cachedSqliteSchema; +} diff --git a/src/backend/tests/database/repositories/tmux-session-tag-repository.test.ts b/src/backend/tests/database/repositories/tmux-session-tag-repository.test.ts index 6efa9cdc..f55be5b3 100644 --- a/src/backend/tests/database/repositories/tmux-session-tag-repository.test.ts +++ b/src/backend/tests/database/repositories/tmux-session-tag-repository.test.ts @@ -17,32 +17,11 @@ describe("TmuxSessionTagRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); - - CREATE TABLE tmux_session_tags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER NOT NULL, - session_name TEXT NOT NULL, - tag TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO hosts (id, user_id, name) - VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '10.0.0.1', 22, 'root', 'password'); INSERT INTO tmux_session_tags (user_id, host_id, session_name, tag) VALUES ('user-1', 1, 'api', 'prod'), diff --git a/src/backend/tests/database/repositories/transfer-recent-repository.test.ts b/src/backend/tests/database/repositories/transfer-recent-repository.test.ts index 0e887af1..65d2eccb 100644 --- a/src/backend/tests/database/repositories/transfer-recent-repository.test.ts +++ b/src/backend/tests/database/repositories/transfer-recent-repository.test.ts @@ -17,33 +17,11 @@ describe("TransferRecentRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); - - CREATE TABLE transfer_recent ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - source_host_id INTEGER NOT NULL, - dest_host_id INTEGER NOT NULL, - dest_path TEXT NOT NULL, - dest_path_label TEXT NOT NULL, - last_used TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO hosts (id, user_id, name) - VALUES (1, 'user-1', 'source'), (2, 'user-1', 'dest-a'), (3, 'user-1', 'dest-b'), (4, 'user-2', 'other'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES (1, 'user-1', 'source', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'dest-a', '10.0.0.1', 22, 'root', 'password'), (3, 'user-1', 'dest-b', '10.0.0.1', 22, 'root', 'password'), (4, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password'); `); return new TransferRecentRepository(context, onWrite); diff --git a/src/backend/tests/database/repositories/trusted-device-repository.test.ts b/src/backend/tests/database/repositories/trusted-device-repository.test.ts index d7a22198..80e7c068 100644 --- a/src/backend/tests/database/repositories/trusted-device-repository.test.ts +++ b/src/backend/tests/database/repositories/trusted-device-repository.test.ts @@ -17,27 +17,7 @@ describe("TrustedDeviceRepository", () => { }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE trusted_devices ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - device_fingerprint TEXT NOT NULL, - device_type TEXT NOT NULL, - device_info TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TEXT NOT NULL, - last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'admin', 'hash'), ('user-2', 'user', 'hash'); diff --git a/src/backend/tests/database/repositories/user-data-export-repository.test.ts b/src/backend/tests/database/repositories/user-data-export-repository.test.ts index 2db650fe..e2185227 100644 --- a/src/backend/tests/database/repositories/user-data-export-repository.test.ts +++ b/src/backend/tests/database/repositories/user-data-export-repository.test.ts @@ -15,146 +15,17 @@ describe("UserDataExportRepository", () => { async function createRepository(): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE ssh_data ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - connection_type TEXT NOT NULL DEFAULT 'ssh', - name TEXT, - ip TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT NOT NULL, - folder TEXT, - tags TEXT, - pin INTEGER NOT NULL DEFAULT 0, - auth_type TEXT NOT NULL, - use_warpgate INTEGER NOT NULL DEFAULT 0, - force_keyboard_interactive TEXT, - password TEXT, - key TEXT, - key_password TEXT, - key_type TEXT, - sudo_password TEXT, - autostart_password TEXT, - autostart_key TEXT, - autostart_key_password TEXT, - credential_id INTEGER, - override_credential_username INTEGER, - vault_profile_id INTEGER, - enable_terminal INTEGER NOT NULL DEFAULT 1, - enable_session_logging INTEGER NOT NULL DEFAULT 1, - allow_session_sharing INTEGER NOT NULL DEFAULT 1, - enable_command_history INTEGER NOT NULL DEFAULT 1, - enable_tunnel INTEGER NOT NULL DEFAULT 1, - tunnel_connections TEXT, - jump_hosts TEXT, - enable_file_manager INTEGER NOT NULL DEFAULT 1, - scp_legacy INTEGER NOT NULL DEFAULT 0, - enable_docker INTEGER NOT NULL DEFAULT 0, - enable_tmux_monitor INTEGER NOT NULL DEFAULT 0, - show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1, - show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0, - show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0, - default_path TEXT, - stats_config TEXT, - docker_config TEXT, - enable_proxmox INTEGER NOT NULL DEFAULT 0, - proxmox_config TEXT, - terminal_config TEXT, - quick_actions TEXT, - notes TEXT, - enable_ssh INTEGER NOT NULL DEFAULT 1, - enable_rdp INTEGER NOT NULL DEFAULT 0, - enable_vnc INTEGER NOT NULL DEFAULT 0, - enable_telnet INTEGER NOT NULL DEFAULT 0, - ssh_port INTEGER DEFAULT 22, - rdp_port INTEGER DEFAULT 3389, - vnc_port INTEGER DEFAULT 5900, - telnet_port INTEGER DEFAULT 23, - rdp_credential_id INTEGER, - rdp_user TEXT, - rdp_password TEXT, - rdp_domain TEXT, - rdp_security TEXT, - rdp_ignore_cert INTEGER DEFAULT 0, - vnc_credential_id INTEGER, - vnc_password TEXT, - vnc_user TEXT, - telnet_user TEXT, - telnet_password TEXT, - telnet_credential_id INTEGER, - rdp_auth_type TEXT, - vnc_auth_type TEXT, - telnet_auth_type TEXT, - domain TEXT, - security TEXT, - ignore_cert INTEGER DEFAULT 0, - guacamole_config TEXT, - use_socks5 INTEGER, - socks5_host TEXT, - socks5_port INTEGER, - socks5_username TEXT, - socks5_password TEXT, - socks5_proxy_chain TEXT, - mac_address TEXT, - wol_broadcast_address TEXT, - port_knock_sequence TEXT, - host_key_fingerprint TEXT, - host_key_type TEXT, - host_key_algorithm TEXT DEFAULT 'sha256', - host_key_first_seen TEXT, - host_key_last_verified TEXT, - host_key_changed_count INTEGER DEFAULT 0, - connection_origin TEXT, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE ssh_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - description TEXT, - folder TEXT, - tags TEXT, - auth_type TEXT NOT NULL, - username TEXT, - password TEXT, - key TEXT, - private_key TEXT, - public_key TEXT, - key_password TEXT, - key_type TEXT, - detected_key_type TEXT, - cert_public_key TEXT, - usage_count INTEGER NOT NULL DEFAULT 0, - last_used TEXT, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - - INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) - VALUES - (1, 'user-1', 'web', '10.0.0.1', 22, 'root', 'password'), - (2, 'user-2', 'db', '10.0.0.2', 22, 'root', 'password'); - INSERT INTO ssh_credentials (id, user_id, name, auth_type, username, password) VALUES (1, 'user-1', 'prod', 'password', 'root', 'secret'), (2, 'user-2', 'other', 'password', 'root', 'secret'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES + (1, 'user-1', 'web', '10.0.0.1', 22, 'root', 'password'), + (2, 'user-2', 'db', '10.0.0.2', 22, 'root', 'password'); `); return new UserDataExportRepository(context); diff --git a/src/backend/tests/database/repositories/user-preference-repository.test.ts b/src/backend/tests/database/repositories/user-preference-repository.test.ts index 627ffae8..febf757c 100644 --- a/src/backend/tests/database/repositories/user-preference-repository.test.ts +++ b/src/backend/tests/database/repositories/user-preference-repository.test.ts @@ -17,41 +17,7 @@ describe("UserPreferenceRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE user_preferences ( - user_id TEXT PRIMARY KEY, - reopen_tabs_on_login INTEGER NOT NULL DEFAULT 0, - theme TEXT, - font_size TEXT, - accent_color TEXT, - language TEXT, - storage_mode TEXT, - command_autocomplete INTEGER, - command_palette_enabled INTEGER, - show_host_tags INTEGER, - host_tray_on_click INTEGER, - pin_app_rail INTEGER, - expand_app_rail_on_hover INTEGER, - folders_collapsed INTEGER, - confirm_snippet_execution INTEGER, - disable_update_check INTEGER, - confirm_tab_close INTEGER, - hidden_rail_tabs TEXT, - compact_host_view INTEGER, - status_color_scheme TEXT, - custom_themes TEXT, - custom_keybindings TEXT, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'); `); diff --git a/src/backend/tests/database/repositories/user-session-repositories.test.ts b/src/backend/tests/database/repositories/user-session-repositories.test.ts index 40daa580..f53391dc 100644 --- a/src/backend/tests/database/repositories/user-session-repositories.test.ts +++ b/src/backend/tests/database/repositories/user-session-repositories.test.ts @@ -35,45 +35,6 @@ describe("UserRepository and SessionRepository", () => { }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL, - is_admin INTEGER NOT NULL DEFAULT 0, - is_oidc INTEGER NOT NULL DEFAULT 0, - oidc_identifier TEXT, - sso_provider_id INTEGER, - client_id TEXT, - client_secret TEXT, - issuer_url TEXT, - authorization_url TEXT, - token_url TEXT, - identifier_path TEXT, - name_path TEXT, - scopes TEXT DEFAULT 'openid email profile', - totp_secret TEXT, - totp_enabled INTEGER NOT NULL DEFAULT 0, - totp_backup_codes TEXT, - registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - donation_modal_dismissed INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE sessions ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - jwt_token TEXT NOT NULL, - device_type TEXT NOT NULL, - device_info TEXT NOT NULL, - oidc_sub TEXT, - oidc_sid TEXT, - sso_provider_id INTEGER, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TEXT NOT NULL, - last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - `); return { users: new UserRepository(context, options.onUserWrite), diff --git a/src/backend/tests/database/repositories/vault-profile-repository.test.ts b/src/backend/tests/database/repositories/vault-profile-repository.test.ts index e0cbdfbf..b5e9c4e2 100644 --- a/src/backend/tests/database/repositories/vault-profile-repository.test.ts +++ b/src/backend/tests/database/repositories/vault-profile-repository.test.ts @@ -17,41 +17,13 @@ describe("VaultProfileRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE vault_profiles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - description TEXT, - folder TEXT, - tags TEXT, - vault_addr TEXT NOT NULL, - vault_namespace TEXT, - oidc_mount TEXT, - oidc_role TEXT, - ssh_mount TEXT, - ssh_role TEXT NOT NULL, - valid_principals TEXT, - key_type TEXT, - shared INTEGER NOT NULL DEFAULT 0, - sync_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); INSERT INTO vault_profiles ( id, user_id, name, vault_addr, ssh_role, shared, updated_at ) - VALUES - (1, 'user-1', 'owned', 'https://vault.one', 'role-one', 0, '2026-01-01T00:00:00.000Z'), + VALUES (1, 'user-1', 'owned', 'https://vault.one', 'role-one', 0, '2026-01-01T00:00:00.000Z'), (2, 'user-2', 'shared', 'https://vault.two', 'role-two', 1, '2026-01-02T00:00:00.000Z'), (3, 'user-2', 'hidden', 'https://vault.three', 'role-three', 0, '2026-01-03T00:00:00.000Z'); `); diff --git a/src/backend/tests/database/repositories/vault-token-repository.test.ts b/src/backend/tests/database/repositories/vault-token-repository.test.ts index 35b13020..01484f5b 100644 --- a/src/backend/tests/database/repositories/vault-token-repository.test.ts +++ b/src/backend/tests/database/repositories/vault-token-repository.test.ts @@ -17,35 +17,11 @@ describe("VaultTokenRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - adapter.exec(` - CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL, - password_hash TEXT NOT NULL - ); - - CREATE TABLE vault_profiles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - name TEXT NOT NULL - ); - - CREATE TABLE vault_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - profile_id INTEGER NOT NULL, - ssh_cert TEXT NOT NULL, - private_key TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TEXT NOT NULL, - last_used TEXT, - UNIQUE(user_id, profile_id) - ); - + await adapter.exec(` INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); - INSERT INTO vault_profiles (id, user_id, name) - VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two'); + INSERT INTO vault_profiles (id, user_id, name, vault_addr, ssh_role) + VALUES (1, 'user-1', 'one', 'http://vault', 'r'), (2, 'user-2', 'two', 'http://vault', 'r'); INSERT INTO vault_tokens ( user_id, profile_id, ssh_cert, private_key, expires_at ) diff --git a/src/backend/utils/crypto-migration/legacy-share-cleanup.ts b/src/backend/utils/crypto-migration/legacy-share-cleanup.ts index 64cbb058..4fa020ba 100644 --- a/src/backend/utils/crypto-migration/legacy-share-cleanup.ts +++ b/src/backend/utils/crypto-migration/legacy-share-cleanup.ts @@ -1,6 +1,10 @@ import { databaseLogger } from "../logger.js"; import { DatabaseSaveTrigger } from "../database-save-trigger.js"; import { getCurrentRepositorySqlite } from "../../database/repositories/factory.js"; +import { + needsExplicitPersist, + resolveDatabaseDialect, +} from "../../database/db/dialect.js"; interface SqliteLike { prepare(sql: string): { @@ -39,9 +43,16 @@ function dropColumnIfExists( export async function runLegacySharedCredentialCleanup(): Promise<{ columnsDropped: number; }> { - const sqlite = getCurrentRepositorySqlite() as unknown as SqliteLike; const result = { columnsDropped: 0 }; + // Nothing to clean up on an engine this application has never run on. A + // Postgres or MySQL database is created by the drizzle migrations, which have + // never emitted these legacy columns, so there is nothing to drop — and the + // check itself needs a synchronous PRAGMA that only SQLite offers. + if (!needsExplicitPersist(resolveDatabaseDialect())) return result; + + const sqlite = getCurrentRepositorySqlite() as unknown as SqliteLike; + for (const [table, column] of [ ["ssh_credentials", "system_password"], ["ssh_credentials", "system_key"], diff --git a/src/backend/utils/crypto-migration/shared-host-secrets-migration.ts b/src/backend/utils/crypto-migration/shared-host-secrets-migration.ts index 963bdbeb..b6e6a463 100644 --- a/src/backend/utils/crypto-migration/shared-host-secrets-migration.ts +++ b/src/backend/utils/crypto-migration/shared-host-secrets-migration.ts @@ -6,6 +6,10 @@ import { getCurrentRepositorySqlite, } from "../../database/repositories/factory.js"; import { SharedHostSecretsManager } from "../shared-host-secrets-manager.js"; +import { + needsExplicitPersist, + resolveDatabaseDialect, +} from "../../database/db/dialect.js"; const MIGRATION_FLAG = "shared_host_secrets_migrated_v1"; @@ -35,9 +39,15 @@ export async function runSharedHostSecretsMigration(): Promise<{ return null; } - const sqlite = getCurrentRepositorySqlite(); const result = { snapshotted: 0, skipped: 0 }; + // Same reasoning as legacy-share-cleanup: this rebuilds shares that only a + // pre-existing SQLite deployment can have, using a synchronous query no other + // driver provides. + if (!needsExplicitPersist(resolveDatabaseDialect())) return result; + + const sqlite = getCurrentRepositorySqlite(); + try { const grants = sqlite .prepare( diff --git a/src/backend/utils/data-crypto.ts b/src/backend/utils/data-crypto.ts index a92de14b..77eb3169 100644 --- a/src/backend/utils/data-crypto.ts +++ b/src/backend/utils/data-crypto.ts @@ -1,5 +1,9 @@ import { FieldCrypto } from "./field-crypto.js"; import { LazyFieldEncryption } from "./lazy-field-encryption.js"; +import { + needsExplicitPersist, + resolveDatabaseDialect, +} from "../database/db/dialect.js"; import { UserKeyManager } from "./user-keys.js"; import { DatabaseSaveTrigger } from "./database-save-trigger.js"; import { databaseLogger } from "./logger.js"; @@ -218,6 +222,14 @@ class DataCrypto { migratedTables: string[]; migratedFieldsCount: number; }> { + // Only a database that predates field encryption has plaintext to migrate, + // and only SQLite deployments can predate it — Postgres and MySQL support + // arrived after. The store also needs synchronous queries no other driver + // has, so this would throw rather than find nothing to do. + if (!needsExplicitPersist(resolveDatabaseDialect())) { + return { migrated: false, migratedTables: [], migratedFieldsCount: 0 }; + } + const result = await this.migrateUserSensitiveFieldsInStore( userId, userDataKey, diff --git a/vitest.config.ts b/vitest.config.ts index 49456d74..7fa55c2c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -34,6 +34,12 @@ export default defineConfig({ name: "backend", environment: "node", include: ["src/backend/**/*.test.ts"], + // The repository tests can be pointed at a real Postgres or MySQL + // (TEST_DIALECT). Connecting, migrating and clearing tables between + // tests costs seconds there, against microseconds for in-memory + // SQLite, so the default timeout only fits the SQLite run. + testTimeout: process.env.TEST_DIALECT ? 60_000 : 5_000, + hookTimeout: process.env.TEST_DIALECT ? 60_000 : 10_000, }, }, {