mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
Groundwork for Postgres and MySQL backends (#1134)
* groundwork for postgres and mysql backends #1127 made the repository layer dialect-agnostic. This adds the pieces needed to actually target a second engine, as a foundation only — nothing is wired up and sqlite remains the sole runtime path. - DatabaseDialect covers sqlite, postgres and mysql, resolved from DATABASE_DIALECT and defaulting to sqlite so nothing changes for existing deployments or the desktop build - a column kit holding the per-dialect type choices in one file: booleans are integers on sqlite and native elsewhere, autoincrement differs three ways, and MySQL cannot index unbounded TEXT so key columns need varchar - settings and users declared for all three dialects as a proof slice, chosen because between them they use every construct the real schema does - pg and mysql2 added as dependencies The tests build real queries for all three engines without a server, asserting identifier quoting, placeholder style and boolean storage, so the property the repositories depend on is verified rather than assumed. * verify foreign keys and unique constraints port across dialects The first slice only covered plain columns. The real schema also has 92 foreign keys (80 cascade, 12 set null) and 14 unique columns, so the approach is only viable if those survive the port. Adds audit_logs and ssh_folders to the proof slice: one nullable reference with ON DELETE SET NULL, one required reference with ON DELETE CASCADE, a unique column, and an autoincrement surrogate key — which is spelled three different ways underneath (integer primary key autoincrement, serial, int auto_increment). All of it holds. Worth noting for whoever picks this up: getTableConfig is dialect-specific and silently fails on a table from another dialect, so the test uses each engine's own. * generate the postgres and mysql schemas instead of hand-writing them The proof slice showed the constructs port, but left the maintenance question open. Three hand-written copies of 52 tables is the wrong answer: with foreign keys the copies cross-reference each other, so a renamed table has to land in three places consistently or a key silently points at the wrong one. The mapping is mechanical, so a script does it. schema.ts stays the single source of truth and schema.pg.ts / schema.mysql.ts are derived, covering all 52 tables — the column kit and the two-table portable slice are gone, since the generator now holds those decisions. The transforms are the ones the kit enumerated: integer-backed booleans become native, autoincrement keys become serial or int auto_increment, real becomes double precision or double, and any column that is a primary key, is unique, or sits on either end of a foreign key becomes varchar because MySQL cannot index unbounded TEXT. > termix@2.6.0 lint > node scripts/generate-dialect-schema.cjs --check && eslint . /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/database/routes/homepage-favicon-routes.ts 99:12 warning 'err' is defined but never used unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/database/routes/homepage-ping-routes.ts 123:12 warning 'err' is defined but never used unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/database/routes/homepage-rss-routes.ts 144:12 warning 'err' is defined but never used unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/database/routes/session-log-routes.ts 46:16 warning 'canAccessRecording' is defined but never used. Allowed unused vars must match /^_/u unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/hosts/vault-signer-core.ts 55:12 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any 75:13 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/tests/hosts/auth-manager.test.ts 18:73 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/tests/utils/shared-host-secrets-manager.test.ts 7:6 warning 'SecretRow' is defined but never used. Allowed unused vars must match /^_/u unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/utils/auth-manager.ts 510:13 warning 'affectedUsers' is assigned a value but never used. Allowed unused vars must match /^_/u unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/utils/notification-sender.ts 48:12 warning 'firstErr' is defined but never used unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/api/ssh-file-operations-api.ts 35:10 warning 'buildFileManagerUrl' is defined but never used. Allowed unused vars must match /^_/u unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/components/folder-style.tsx 61:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components 116:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components 121:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components 149:17 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx 109:19 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any 190:19 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/HomepageCanvas.tsx 345:15 warning Empty block statement no-empty 388:15 warning Empty block statement no-empty 415:15 warning Empty block statement no-empty /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/dialogs/SingleHostEditForm.tsx 24:6 warning React Hook useEffect has a missing dependency: 'filter'. Either include it or remove the dependency array. If 'setHosts' needs the current value of 'filter', you can also switch to useReducer instead of useState and read 'filter' in the reducer react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/AlertFeedWidget.tsx 93:6 warning React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/CustomApiWidget.tsx 77:6 warning React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/DockerActivityWidget.tsx 50:6 warning React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/DockerWidget.tsx 16:10 warning Fast refresh only works when a file has exports. Move your component(s) to a separate file react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/FileManagerWidget.tsx 16:10 warning Fast refresh only works when a file has exports. Move your component(s) to a separate file react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/HostGridWidget.tsx 61:6 warning React Hook useCallback has a missing dependency: 'hostIds'. Either include it or remove the dependency array react-hooks/exhaustive-deps 61:7 warning React Hook useCallback has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/MetricsChartWidget.tsx 168:6 warning React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/PingStatusWidget.tsx 79:6 warning React Hook useEffect has a missing dependency: 'fetchAll'. Either include it or remove the dependency array react-hooks/exhaustive-deps 79:7 warning React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/QuickConnectWidget.tsx 64:10 warning Fast refresh only works when a file has exports. Move your component(s) to a separate file react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/RecentActivityWidget.tsx 82:6 warning React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array react-hooks/exhaustive-deps 82:17 warning React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/SshQuickConnectWidget.tsx 67:6 warning React Hook useCallback has a missing dependency: 'hostIds'. Either include it or remove the dependency array react-hooks/exhaustive-deps 67:7 warning React Hook useCallback has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked react-hooks/exhaustive-deps 99:17 warning 'online' is assigned a value but never used. Allowed unused vars must match /^_/u unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/SshTerminalWidget.tsx 17:10 warning Fast refresh only works when a file has exports. Move your component(s) to a separate file react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/SystemOverviewWidget.tsx 72:6 warning React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/TunnelWidget.tsx 15:10 warning Fast refresh only works when a file has exports. Move your component(s) to a separate file react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/host-metrics/cards/CpuCard.tsx 14:10 warning 'computeChartData' is defined but never used. Allowed unused vars must match /^_/u unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/sidebar/FolderPathPicker.tsx 15:17 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components 22:17 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/sidebar/HostsPanel.tsx 601:52 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any ✖ 44 problems (0 errors, 44 warnings) now fails if the generated files are out of date, so editing the schema without regenerating cannot reach main. * select durability behaviour per dialect, and document the backends The onWrite hook every repository receives exists to serialise the in-memory SQLite database back to its encrypted file. On a client-server engine a committed write is already durable and there is nothing to flush, so the factory now installs no hook at all rather than one that does nothing. Repositories call it as this.onWrite?.(), so none of the 43 of them change. Also adds docs/database-backends.md, mostly to be explicit about encryption, which is the part most likely to be misread. Field-level encryption is identical on all three engines and covers every credential. Whole-file encryption has no equivalent on Postgres or MySQL, so host names, snippet contents, audit entries and backups are only as protected as the storage underneath them — that is the operator's responsibility and the docs should not imply otherwise. * generate DDL with drizzle-kit, and give settings a synchronous path Two of the three remaining blockers. DDL: db/index.ts hand-writes 67 CREATE TABLE statements and 122 ADD COLUMN migrations, all in SQLite dialect. Rather than port them, drizzle-kit now generates migrations from the schema modules — 817 lines for Postgres, 869 for MySQL, with the type mapping already correct because the schemas it reads are themselves generated. > termix@2.6.0 schema:migrations > drizzle-kit generate --config=drizzle.config.pg.ts && drizzle-kit generate --config=drizzle.config.mysql.ts Reading config file '/mnt/c/Users/29037/WebstormProjects/Termix/drizzle.config.pg.ts' 52 tables alert_firings 11 columns 0 indexes 2 fks alert_rule_channels 3 columns 0 indexes 2 fks alert_rules 11 columns 0 indexes 2 fks api_keys 9 columns 0 indexes 1 fks audit_logs 13 columns 0 indexes 1 fks c2s_tunnel_presets 8 columns 0 indexes 1 fks command_history 5 columns 0 indexes 2 fks dashboard_service_links 8 columns 0 indexes 1 fks dismissed_alerts 4 columns 0 indexes 1 fks file_manager_pinned 6 columns 0 indexes 2 fks file_manager_recent 6 columns 0 indexes 2 fks file_manager_shortcuts 6 columns 0 indexes 2 fks homepage_items 9 columns 0 indexes 1 fks homepage_layouts 4 columns 0 indexes 1 fks host_access 11 columns 0 indexes 5 fks host_health_checks 7 columns 0 indexes 2 fks host_health_history 8 columns 0 indexes 2 fks host_metrics_history 8 columns 0 indexes 1 fks host_metrics_preferences 6 columns 0 indexes 2 fks ssh_data 94 columns 0 indexes 6 fks network_topology 5 columns 0 indexes 1 fks notification_channels 7 columns 0 indexes 1 fks opkssh_tokens 12 columns 0 indexes 2 fks recent_activity 6 columns 0 indexes 2 fks roles 8 columns 0 indexes 0 fks session_recordings 15 columns 0 indexes 3 fks session_share_participants 6 columns 0 indexes 2 fks session_shares 15 columns 0 indexes 3 fks sessions 11 columns 0 indexes 1 fks settings 2 columns 0 indexes 0 fks shared_host_secrets 15 columns 0 indexes 3 fks snippet_access 8 columns 0 indexes 4 fks snippet_folders 8 columns 0 indexes 1 fks snippets 11 columns 0 indexes 1 fks ssh_credential_usage 5 columns 0 indexes 3 fks ssh_credentials 21 columns 0 indexes 1 fks ssh_folders 9 columns 0 indexes 2 fks sso_providers 8 columns 0 indexes 0 fks sync_tombstones 5 columns 0 indexes 1 fks termix_identities 6 columns 0 indexes 1 fks termix_identity_ca 8 columns 0 indexes 2 fks termix_identity_keys 12 columns 0 indexes 3 fks tmux_session_tags 6 columns 0 indexes 2 fks transfer_recent 7 columns 0 indexes 3 fks trusted_devices 8 columns 0 indexes 1 fks user_open_tabs 9 columns 0 indexes 2 fks user_preferences 23 columns 0 indexes 1 fks user_roles 5 columns 0 indexes 3 fks users 20 columns 0 indexes 0 fks vault_profiles 18 columns 0 indexes 1 fks vault_tokens 8 columns 0 indexes 2 fks webauthn_credentials 12 columns 0 indexes 1 fks No schema changes, nothing to migrate 😴 Reading config file '/mnt/c/Users/29037/WebstormProjects/Termix/drizzle.config.mysql.ts' Reading schema files: /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/database/db/schema.mysql.ts 52 tables alert_firings 11 columns 0 indexes 2 fks alert_rule_channels 3 columns 0 indexes 2 fks alert_rules 11 columns 0 indexes 2 fks api_keys 9 columns 0 indexes 1 fks audit_logs 13 columns 0 indexes 1 fks c2s_tunnel_presets 8 columns 0 indexes 1 fks command_history 5 columns 0 indexes 2 fks dashboard_service_links 8 columns 0 indexes 1 fks dismissed_alerts 4 columns 0 indexes 1 fks file_manager_pinned 6 columns 0 indexes 2 fks file_manager_recent 6 columns 0 indexes 2 fks file_manager_shortcuts 6 columns 0 indexes 2 fks homepage_items 9 columns 0 indexes 1 fks homepage_layouts 4 columns 0 indexes 1 fks host_access 11 columns 0 indexes 5 fks host_health_checks 7 columns 0 indexes 2 fks host_health_history 8 columns 0 indexes 2 fks host_metrics_history 8 columns 0 indexes 1 fks host_metrics_preferences 6 columns 0 indexes 2 fks ssh_data 94 columns 0 indexes 6 fks network_topology 5 columns 0 indexes 1 fks notification_channels 7 columns 0 indexes 1 fks opkssh_tokens 12 columns 0 indexes 2 fks recent_activity 6 columns 0 indexes 2 fks roles 8 columns 0 indexes 0 fks session_recordings 15 columns 0 indexes 3 fks session_share_participants 6 columns 0 indexes 2 fks session_shares 15 columns 0 indexes 3 fks sessions 11 columns 0 indexes 1 fks settings 2 columns 0 indexes 0 fks shared_host_secrets 15 columns 0 indexes 3 fks snippet_access 8 columns 0 indexes 4 fks snippet_folders 8 columns 0 indexes 1 fks snippets 11 columns 0 indexes 1 fks ssh_credential_usage 5 columns 0 indexes 3 fks ssh_credentials 21 columns 0 indexes 1 fks ssh_folders 9 columns 0 indexes 2 fks sso_providers 8 columns 0 indexes 0 fks sync_tombstones 5 columns 0 indexes 1 fks termix_identities 6 columns 0 indexes 1 fks termix_identity_ca 8 columns 0 indexes 2 fks termix_identity_keys 12 columns 0 indexes 3 fks tmux_session_tags 6 columns 0 indexes 2 fks transfer_recent 7 columns 0 indexes 3 fks trusted_devices 8 columns 0 indexes 1 fks user_open_tabs 9 columns 0 indexes 2 fks user_preferences 23 columns 0 indexes 1 fks user_roles 5 columns 0 indexes 3 fks users 20 columns 0 indexes 0 fks vault_profiles 18 columns 0 indexes 1 fks vault_tokens 8 columns 0 indexes 2 fks webauthn_credentials 12 columns 0 indexes 1 fks No schema changes, nothing to migrate 😴 regenerates both. Settings: 27 call sites read settings synchronously, during startup and inside request handlers. better-sqlite3 can do that; Postgres and MySQL cannot, and making all 27 async would push await through code that has no reason to be asynchronous. Settings are a handful of rarely-changing rows read constantly, so they are cached in full — primed at startup, kept in step by SettingsRepository on every set/delete/deleteLike. SQLite keeps reading the database directly and stays authoritative; only the other engines use the cache. Opening a connection is still not done. DatabaseContext.drizzle is typed as BetterSQLite3Database and 43 repositories depend on that inference; the three drizzle instance types are not interchangeable, so widening it is a design decision rather than a mechanical change. * exclude drizzle-kit output from prettier The generated migrations and snapshots are tool output; their formatting is drizzle-kit's to decide, and prettier cannot parse the .sql files at all. * absorb the RETURNING gap so mysql stays reachable MySQL has no RETURNING clause and drizzle's mysql-core does not expose the method, while 156 call sites here read the result of a write. That is the real blocker for MySQL, not the connection layer. Classifying those call sites showed the split is favourable: 92 of them only read .length, which every engine reports — as a returned array on sqlite and postgres, as affectedRows on MySQL. rowsAffected() reads both shapes, so those sites need no change in query shape. insertedId() does the same for the autoincrement key, which MySQL reports as insertId. What is left is the ~34 sites that genuinely consume the returned rows. Those cannot be emulated without reading first, which needs a transaction to stay correct under concurrency, so they will be handled individually rather than behind a helper that quietly adds a round trip. supportsReturning() is the seam for that. Identifying the mysql2 result by its own fields rather than by array shape matters: it hands back [ResultSetHeader, fields], which is an array, so shape alone cannot tell it apart from a returning() result. * name the portable database type, and open remote connections Two pieces of the connection layer. drizzle's three Database classes share no base class and their signatures are incompatible, so there is no honest type that covers all three: a union is not callable and a generic would have to be threaded through 43 repositories and every method on them. DatabaseContext.drizzle is now PortableDatabase, still the SQLite type underneath, but named and documented as the deliberate approximation it is. What makes it safe is that the equivalence is asserted in multi-dialect.test.ts rather than assumed, and the one place the surfaces truly differ — RETURNING — is handled explicitly in mutation-result.ts. connect.ts opens Postgres and MySQL from DATABASE_URL, with the schema module and driver imported lazily so neither is loaded on a SQLite deployment. The URL scheme is checked against the configured dialect first: a postgres:// URL with DATABASE_DIALECT=mysql otherwise surfaces as a driver error deep in a stack that never mentions the actual misconfiguration. * open postgres and mysql at startup * count writes without RETURNING * read affected rows without RETURNING on mysql * insert without RETURNING, and split the sync transactions * stop pretending the generated schemas are used at runtime * run the dialect checks in CI * mysql rejects a bare CURRENT_TIMESTAMP default on text * make the read-back mismatch loud, and stop the next bare returning() * run the repository tests on the real schema * skip the byte-level assertions off sqlite * move generated ids past the seeded ones * keep the export order the same on every engine * stop reading better-sqlite3 fields off every write * read counts as numbers, not whatever the driver returns * make the fixture usable against a live server * upsert on the engine that has no ON CONFLICT * run the repository suite on all three engines in CI * mysql cannot index a text column without a length * document how to actually run on postgres or mysql * keep the sqlite-era migrations off the other engines * concat strings in a way mysql agrees with * run every repository test on every engine * bound how long replicas can disagree about settings * generate the sqlite migrations alongside the others
This commit is contained in:
@@ -24,8 +24,10 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Run ESLint
|
- name: Lint
|
||||||
run: npx eslint .
|
# 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
|
- name: Run Prettier check
|
||||||
run: npx prettier --check .
|
run: npx prettier --check .
|
||||||
@@ -35,3 +37,76 @@ jobs:
|
|||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run 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
|
||||||
|
|||||||
@@ -17,3 +17,6 @@ db
|
|||||||
*.min.js
|
*.min.js
|
||||||
*.min.css
|
*.min.css
|
||||||
openapi.json
|
openapi.json
|
||||||
|
|
||||||
|
# Generated by drizzle-kit; formatting is the tool's own
|
||||||
|
drizzle/
|
||||||
|
|||||||
@@ -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=production-deps /app/node_modules /app/node_modules
|
||||||
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
|
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
|
||||||
COPY --chown=node:node package.json ./
|
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"]
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
|
|||||||
@@ -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 -- <url>` 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=<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.
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: "sqlite",
|
||||||
|
schema: "./src/backend/database/db/schema.ts",
|
||||||
|
out: "./drizzle/sqlite",
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "mysql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1785276659909,
|
||||||
|
"tag": "0000_clean_pretty_boy",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785276657789,
|
||||||
|
"tag": "0000_jazzy_infant_terrible",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1785276655786,
|
||||||
|
"tag": "0000_clever_hercules",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -46,4 +46,57 @@ export default tseslint.config([
|
|||||||
"react-refresh/only-export-components": "warn",
|
"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.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
Generated
+1734
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -16,10 +16,11 @@
|
|||||||
"biome:fix": "biome check --write biome.json package.json",
|
"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",
|
"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",
|
"prebuild": "node scripts/write-electron-build-info.cjs",
|
||||||
"lint": "eslint .",
|
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint .",
|
||||||
"lint:fix": "eslint --fix .",
|
"lint:fix": "eslint --fix .",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"verify:dialect": "tsx scripts/verify-dialects.mjs",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
"test:coverage": "vitest run --coverage",
|
"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-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: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": "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": {
|
"dependencies": {
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
@@ -65,7 +69,9 @@
|
|||||||
"ldapjs": "^3.0.7",
|
"ldapjs": "^3.0.7",
|
||||||
"motion": "^12.42.2",
|
"motion": "^12.42.2",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
|
"mysql2": "^3.23.2",
|
||||||
"nanoid": "^6.0.0",
|
"nanoid": "^6.0.0",
|
||||||
|
"pg": "^8.22.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"serialport": "^13.0.0",
|
"serialport": "^13.0.0",
|
||||||
"socks": "^2.8.7",
|
"socks": "^2.8.7",
|
||||||
@@ -122,6 +128,7 @@
|
|||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^26.0.0",
|
"@types/node": "^26.0.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -144,6 +151,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"concurrently": "^10.0.4",
|
"concurrently": "^10.0.4",
|
||||||
"cytoscape": "^3.34.0",
|
"cytoscape": "^3.34.0",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
"electron": "^43.0.0",
|
"electron": "^43.0.0",
|
||||||
"electron-builder": "^26.15.3",
|
"electron-builder": "^26.15.3",
|
||||||
"eslint": "^10.5.0",
|
"eslint": "^10.5.0",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 <DATABASE_URL>");
|
||||||
|
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);
|
||||||
@@ -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<typeof sqlite.text>;
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -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<string, readonly string[]> = {
|
||||||
|
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<PortableDatabase> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ import {
|
|||||||
DataDirMisconfiguredError,
|
DataDirMisconfiguredError,
|
||||||
} from "../../utils/data-dir-guard.js";
|
} from "../../utils/data-dir-guard.js";
|
||||||
import { getDefaultGuacdUrl } from "../../utils/guacd-config.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 dataDir = process.env.DATA_DIR || "./db/data";
|
||||||
const dbDir = path.resolve(dataDir);
|
const dbDir = path.resolve(dataDir);
|
||||||
@@ -2622,10 +2626,54 @@ async function handlePostInitFileEncryption() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function initializeDatabase(): Promise<void> {
|
async function initializeDatabase(): Promise<void> {
|
||||||
|
const dialect = resolveDatabaseDialect();
|
||||||
|
|
||||||
|
if (dialect !== "sqlite") {
|
||||||
|
await initializeRemoteDatabase(dialect);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await initializeCompleteDatabase();
|
await initializeCompleteDatabase();
|
||||||
await handlePostInitFileEncryption();
|
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<DatabaseDialect, "sqlite">,
|
||||||
|
): Promise<void> {
|
||||||
|
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 };
|
export { initializeDatabase };
|
||||||
|
|
||||||
async function cleanupDatabase() {
|
async function cleanupDatabase() {
|
||||||
@@ -2703,9 +2751,9 @@ process.on("SIGTERM", async () => {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
let db: ReturnType<typeof drizzle<typeof schema>>;
|
let db: PortableDatabase;
|
||||||
|
|
||||||
export function getDb(): ReturnType<typeof drizzle<typeof schema>> {
|
export function getDb(): PortableDatabase {
|
||||||
if (!db) {
|
if (!db) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Database not initialized. Ensure initializeDatabase() is called before accessing db.",
|
"Database not initialized. Ensure initializeDatabase() is called before accessing db.",
|
||||||
@@ -2716,6 +2764,13 @@ export function getDb(): ReturnType<typeof drizzle<typeof schema>> {
|
|||||||
|
|
||||||
export function getSqlite(): Database.Database {
|
export function getSqlite(): Database.Database {
|
||||||
if (!sqlite) {
|
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(
|
throw new Error(
|
||||||
"SQLite not initialized. Ensure initializeDatabase() is called before accessing sqlite.",
|
"SQLite not initialized. Ensure initializeDatabase() is called before accessing sqlite.",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<void>)(
|
||||||
|
db,
|
||||||
|
{ migrationsFolder: folder },
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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";
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
export const users = sqliteTable("users", {
|
export const users = sqliteTable("users", {
|
||||||
@@ -566,40 +572,47 @@ export const hostAccess = sqliteTable("host_access", {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sharedHostSecrets = sqliteTable("shared_host_secrets", {
|
export const sharedHostSecrets = sqliteTable(
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
"shared_host_secrets",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
|
||||||
hostAccessId: integer("host_access_id")
|
hostAccessId: integer("host_access_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => hostAccess.id, { onDelete: "cascade" }),
|
.references(() => hostAccess.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
targetUserId: text("target_user_id")
|
targetUserId: text("target_user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
protocol: text("protocol").notNull().default("ssh"),
|
protocol: text("protocol").notNull().default("ssh"),
|
||||||
sourceType: text("source_type").notNull().default("credential"),
|
sourceType: text("source_type").notNull().default("credential"),
|
||||||
|
|
||||||
originalCredentialId: integer("original_credential_id").references(
|
originalCredentialId: integer("original_credential_id").references(
|
||||||
() => sshCredentials.id,
|
() => sshCredentials.id,
|
||||||
{ onDelete: "cascade" },
|
{ onDelete: "cascade" },
|
||||||
),
|
),
|
||||||
|
|
||||||
encryptedUsername: text("encrypted_username"),
|
encryptedUsername: text("encrypted_username"),
|
||||||
encryptedAuthType: text("encrypted_auth_type"),
|
encryptedAuthType: text("encrypted_auth_type"),
|
||||||
encryptedPassword: text("encrypted_password"),
|
encryptedPassword: text("encrypted_password"),
|
||||||
encryptedKey: text("encrypted_key", { length: 16384 }),
|
encryptedKey: text("encrypted_key", { length: 16384 }),
|
||||||
encryptedKeyPassword: text("encrypted_key_password"),
|
encryptedKeyPassword: text("encrypted_key_password"),
|
||||||
encryptedKeyType: text("encrypted_key_type"),
|
encryptedKeyType: text("encrypted_key_type"),
|
||||||
encryptedDomain: text("encrypted_domain"),
|
encryptedDomain: text("encrypted_domain"),
|
||||||
|
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.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", {
|
export const roles = sqliteTable("roles", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
@@ -621,22 +634,29 @@ export const roles = sqliteTable("roles", {
|
|||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userRoles = sqliteTable("user_roles", {
|
export const userRoles = sqliteTable(
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
"user_roles",
|
||||||
userId: text("user_id")
|
{
|
||||||
.notNull()
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
userId: text("user_id")
|
||||||
roleId: integer("role_id")
|
.notNull()
|
||||||
.notNull()
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
.references(() => roles.id, { onDelete: "cascade" }),
|
roleId: integer("role_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => roles.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
grantedBy: text("granted_by").references(() => users.id, {
|
grantedBy: text("granted_by").references(() => users.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
grantedAt: text("granted_at")
|
grantedAt: text("granted_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.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", {
|
export const auditLogs = sqliteTable("audit_logs", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
@@ -751,29 +771,36 @@ export const sessionShareParticipants = sqliteTable(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const opksshTokens = sqliteTable("opkssh_tokens", {
|
export const opksshTokens = sqliteTable(
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
"opkssh_tokens",
|
||||||
userId: text("user_id")
|
{
|
||||||
.notNull()
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
userId: text("user_id")
|
||||||
hostId: integer("host_id")
|
.notNull()
|
||||||
.notNull()
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
hostId: integer("host_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
sshCert: text("ssh_cert", { length: 8192 }).notNull(),
|
sshCert: text("ssh_cert", { length: 8192 }).notNull(),
|
||||||
privateKey: text("private_key", { length: 8192 }).notNull(),
|
privateKey: text("private_key", { length: 8192 }).notNull(),
|
||||||
|
|
||||||
email: text("email"),
|
email: text("email"),
|
||||||
sub: text("sub"),
|
sub: text("sub"),
|
||||||
issuer: text("issuer"),
|
issuer: text("issuer"),
|
||||||
audience: text("audience"),
|
audience: text("audience"),
|
||||||
|
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
expiresAt: text("expires_at").notNull(),
|
expiresAt: text("expires_at").notNull(),
|
||||||
lastUsed: text("last_used"),
|
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
|
// Vault SSH signer profiles. These hold ONLY non-secret connection settings and
|
||||||
// are intended to be shared across users (shared === true makes a profile
|
// 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.
|
// Per-user cache of the ephemeral SSH private key + Vault-signed certificate.
|
||||||
// Transient: rows live only until the certificate expires. Secret fields are
|
// Transient: rows live only until the certificate expires. Secret fields are
|
||||||
// encrypted under the user's data-encryption key (see field-crypto.ts).
|
// encrypted under the user's data-encryption key (see field-crypto.ts).
|
||||||
export const vaultTokens = sqliteTable("vault_tokens", {
|
export const vaultTokens = sqliteTable(
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
"vault_tokens",
|
||||||
userId: text("user_id")
|
{
|
||||||
.notNull()
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
userId: text("user_id")
|
||||||
profileId: integer("profile_id")
|
.notNull()
|
||||||
.notNull()
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
.references(() => vaultProfiles.id, { onDelete: "cascade" }),
|
profileId: integer("profile_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => vaultProfiles.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
sshCert: text("ssh_cert", { length: 8192 }).notNull(),
|
sshCert: text("ssh_cert", { length: 8192 }).notNull(),
|
||||||
privateKey: text("private_key", { length: 8192 }).notNull(),
|
privateKey: text("private_key", { length: 8192 }).notNull(),
|
||||||
|
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
expiresAt: text("expires_at").notNull(),
|
expiresAt: text("expires_at").notNull(),
|
||||||
lastUsed: text("last_used"),
|
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", {
|
export const apiKeys = sqliteTable("api_keys", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
@@ -899,7 +933,9 @@ export const userPreferences = sqliteTable("user_preferences", {
|
|||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const hostMetricsPreferences = sqliteTable("host_metrics_preferences", {
|
export const hostMetricsPreferences = sqliteTable(
|
||||||
|
"host_metrics_preferences",
|
||||||
|
{
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -916,9 +952,18 @@ export const hostMetricsPreferences = sqliteTable("host_metrics_preferences", {
|
|||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.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 }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -935,7 +980,12 @@ export const hostHealthChecks = sqliteTable("host_health_checks", {
|
|||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.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", {
|
export const hostHealthHistory = sqliteTable("host_health_history", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
} from "../db/schema.js";
|
} from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
import { sqlTimestampDaysAgo } from "./sql-timestamp.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 AlertRuleRecord = typeof alertRules.$inferSelect;
|
||||||
type NotificationChannelRecord = typeof notificationChannels.$inferSelect;
|
type NotificationChannelRecord = typeof notificationChannels.$inferSelect;
|
||||||
@@ -118,16 +120,17 @@ export class AlertRepository {
|
|||||||
config: string;
|
config: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}): Promise<NotificationChannelRow> {
|
}): Promise<NotificationChannelRow> {
|
||||||
const [created] = await this.context.drizzle
|
const [created] = await insertReturning(
|
||||||
.insert(notificationChannels)
|
this.context,
|
||||||
.values({
|
notificationChannels,
|
||||||
|
{
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
config: input.config,
|
config: input.config,
|
||||||
enabled: input.enabled,
|
enabled: input.enabled,
|
||||||
})
|
},
|
||||||
.returning();
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return mapChannelRow(created);
|
return mapChannelRow(created);
|
||||||
@@ -147,16 +150,15 @@ export class AlertRepository {
|
|||||||
return this.findNotificationChannelForUser(id, userId);
|
return this.findNotificationChannelForUser(id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updated] = await this.context.drizzle
|
const [updated] = await updateReturning(
|
||||||
.update(notificationChannels)
|
this.context,
|
||||||
.set(input)
|
notificationChannels,
|
||||||
.where(
|
input,
|
||||||
and(
|
and(
|
||||||
eq(notificationChannels.id, id),
|
eq(notificationChannels.id, id),
|
||||||
eq(notificationChannels.userId, userId),
|
eq(notificationChannels.userId, userId),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!updated) return null;
|
if (!updated) return null;
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
@@ -167,17 +169,16 @@ export class AlertRepository {
|
|||||||
id: number,
|
id: number,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const deleted = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(notificationChannels)
|
.delete(notificationChannels)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(notificationChannels.id, id),
|
eq(notificationChannels.id, id),
|
||||||
eq(notificationChannels.userId, userId),
|
eq(notificationChannels.userId, userId),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: notificationChannels.id });
|
|
||||||
|
|
||||||
if (deleted.length === 0) return false;
|
if (rowsAffected(result) === 0) return false;
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -211,21 +212,18 @@ export class AlertRepository {
|
|||||||
channels: number[];
|
channels: number[];
|
||||||
now: string;
|
now: string;
|
||||||
}): Promise<AlertRuleWithChannelsRow> {
|
}): Promise<AlertRuleWithChannelsRow> {
|
||||||
const [created] = await this.context.drizzle
|
const [created] = await insertReturning(this.context, alertRules, {
|
||||||
.insert(alertRules)
|
userId: input.userId,
|
||||||
.values({
|
hostId: input.hostId,
|
||||||
userId: input.userId,
|
name: input.name,
|
||||||
hostId: input.hostId,
|
enabled: input.enabled,
|
||||||
name: input.name,
|
triggerType: input.triggerType,
|
||||||
enabled: input.enabled,
|
thresholdValue: input.thresholdValue,
|
||||||
triggerType: input.triggerType,
|
thresholdDurationSeconds: input.thresholdDurationSeconds,
|
||||||
thresholdValue: input.thresholdValue,
|
cooldownMinutes: input.cooldownMinutes,
|
||||||
thresholdDurationSeconds: input.thresholdDurationSeconds,
|
createdAt: input.now,
|
||||||
cooldownMinutes: input.cooldownMinutes,
|
updatedAt: input.now,
|
||||||
createdAt: input.now,
|
});
|
||||||
updatedAt: input.now,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
const channels = await this.replaceRuleChannels(
|
const channels = await this.replaceRuleChannels(
|
||||||
created.id,
|
created.id,
|
||||||
@@ -264,9 +262,10 @@ export class AlertRepository {
|
|||||||
now: string;
|
now: string;
|
||||||
},
|
},
|
||||||
): Promise<AlertRuleWithChannelsRow | null> {
|
): Promise<AlertRuleWithChannelsRow | null> {
|
||||||
const [updated] = await this.context.drizzle
|
const [updated] = await updateReturning(
|
||||||
.update(alertRules)
|
this.context,
|
||||||
.set({
|
alertRules,
|
||||||
|
{
|
||||||
...(input.name !== undefined ? { name: input.name } : {}),
|
...(input.name !== undefined ? { name: input.name } : {}),
|
||||||
...(input.hostId !== undefined ? { hostId: input.hostId } : {}),
|
...(input.hostId !== undefined ? { hostId: input.hostId } : {}),
|
||||||
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
|
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
|
||||||
@@ -283,9 +282,9 @@ export class AlertRepository {
|
|||||||
? { cooldownMinutes: input.cooldownMinutes }
|
? { cooldownMinutes: input.cooldownMinutes }
|
||||||
: {}),
|
: {}),
|
||||||
updatedAt: input.now,
|
updatedAt: input.now,
|
||||||
})
|
},
|
||||||
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)))
|
and(eq(alertRules.id, id), eq(alertRules.userId, userId)),
|
||||||
.returning();
|
);
|
||||||
|
|
||||||
if (!updated) return null;
|
if (!updated) return null;
|
||||||
|
|
||||||
@@ -299,12 +298,11 @@ export class AlertRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteAlertRule(id: number, userId: string): Promise<boolean> {
|
async deleteAlertRule(id: number, userId: string): Promise<boolean> {
|
||||||
const deleted = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(alertRules)
|
.delete(alertRules)
|
||||||
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)))
|
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)));
|
||||||
.returning({ id: alertRules.id });
|
|
||||||
|
|
||||||
if (deleted.length === 0) return false;
|
if (rowsAffected(result) === 0) return false;
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -442,10 +440,9 @@ export class AlertRepository {
|
|||||||
.where(eq(notificationChannels.userId, userId))
|
.where(eq(notificationChannels.userId, userId))
|
||||||
).map((row) => row.id);
|
).map((row) => row.id);
|
||||||
|
|
||||||
const firingRows = await this.context.drizzle
|
const firingResult = await this.context.drizzle
|
||||||
.delete(alertFirings)
|
.delete(alertFirings)
|
||||||
.where(eq(alertFirings.userId, userId))
|
.where(eq(alertFirings.userId, userId));
|
||||||
.returning({ id: alertFirings.id });
|
|
||||||
|
|
||||||
const linkFilters = [
|
const linkFilters = [
|
||||||
...(ruleIds.length > 0
|
...(ruleIds.length > 0
|
||||||
@@ -455,37 +452,34 @@ export class AlertRepository {
|
|||||||
? [inArray(alertRuleChannels.channelId, channelIds)]
|
? [inArray(alertRuleChannels.channelId, channelIds)]
|
||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
const linkRows =
|
const linkResult =
|
||||||
linkFilters.length === 0
|
linkFilters.length === 0
|
||||||
? []
|
? null
|
||||||
: await this.context.drizzle
|
: await this.context.drizzle
|
||||||
.delete(alertRuleChannels)
|
.delete(alertRuleChannels)
|
||||||
.where(or(...linkFilters))
|
.where(or(...linkFilters));
|
||||||
.returning({ id: alertRuleChannels.id });
|
|
||||||
|
|
||||||
const ruleRows = await this.context.drizzle
|
const ruleResult = await this.context.drizzle
|
||||||
.delete(alertRules)
|
.delete(alertRules)
|
||||||
.where(eq(alertRules.userId, userId))
|
.where(eq(alertRules.userId, userId));
|
||||||
.returning({ id: alertRules.id });
|
const result = await this.context.drizzle
|
||||||
const channelRows = await this.context.drizzle
|
|
||||||
.delete(notificationChannels)
|
.delete(notificationChannels)
|
||||||
.where(eq(notificationChannels.userId, userId))
|
.where(eq(notificationChannels.userId, userId));
|
||||||
.returning({ id: notificationChannels.id });
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
firingRows.length > 0 ||
|
rowsAffected(firingResult) > 0 ||
|
||||||
linkRows.length > 0 ||
|
rowsAffected(linkResult) > 0 ||
|
||||||
ruleRows.length > 0 ||
|
rowsAffected(ruleResult) > 0 ||
|
||||||
channelRows.length > 0
|
rowsAffected(result) > 0
|
||||||
) {
|
) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
firingsDeleted: firingRows.length,
|
firingsDeleted: rowsAffected(firingResult),
|
||||||
ruleLinksDeleted: linkRows.length,
|
ruleLinksDeleted: rowsAffected(linkResult),
|
||||||
rulesDeleted: ruleRows.length,
|
rulesDeleted: rowsAffected(ruleResult),
|
||||||
channelsDeleted: channelRows.length,
|
channelsDeleted: rowsAffected(result),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { apiKeys, users } from "../db/schema.js";
|
import { apiKeys, users } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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 ApiKeyRecord = typeof apiKeys.$inferSelect;
|
||||||
export type NewApiKeyRecord = typeof apiKeys.$inferInsert;
|
export type NewApiKeyRecord = typeof apiKeys.$inferInsert;
|
||||||
@@ -24,10 +26,7 @@ export class ApiKeyRepository {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(apiKey: NewApiKeyRecord): Promise<ApiKeyRecord> {
|
async create(apiKey: NewApiKeyRecord): Promise<ApiKeyRecord> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await insertReturning(this.context, apiKeys, apiKey);
|
||||||
.insert(apiKeys)
|
|
||||||
.values(apiKey)
|
|
||||||
.returning();
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
@@ -78,23 +77,23 @@ export class ApiKeyRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: string): Promise<ApiKeyRecord | null> {
|
async delete(id: string): Promise<ApiKeyRecord | null> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await deleteReturning(
|
||||||
.delete(apiKeys)
|
this.context,
|
||||||
.where(eq(apiKeys.id, id))
|
apiKeys,
|
||||||
.returning();
|
eq(apiKeys.id, id),
|
||||||
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(apiKeys)
|
.delete(apiKeys)
|
||||||
.where(eq(apiKeys.userId, userId))
|
.where(eq(apiKeys.userId, userId));
|
||||||
.returning({ id: apiKeys.id });
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { auditLogs } from "../db/schema.js";
|
|||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
|
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
|
||||||
import { databaseLogger } from "../../utils/logger.js";
|
import { databaseLogger } from "../../utils/logger.js";
|
||||||
|
import { countValue, rowsAffected } from "./mutation-result.js";
|
||||||
|
|
||||||
export type AuditLogRecord = typeof auditLogs.$inferSelect;
|
export type AuditLogRecord = typeof auditLogs.$inferSelect;
|
||||||
export type NewAuditLogRecord = typeof auditLogs.$inferInsert;
|
export type NewAuditLogRecord = typeof auditLogs.$inferInsert;
|
||||||
@@ -82,7 +83,7 @@ export class AuditLogRepository {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
logs,
|
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.
|
* asked. `username` is denormalised, so the entry stays attributable.
|
||||||
*/
|
*/
|
||||||
async anonymizeByUserId(userId: string): Promise<number> {
|
async anonymizeByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.update(auditLogs)
|
.update(auditLogs)
|
||||||
.set({ userId: null })
|
.set({ userId: null })
|
||||||
.where(eq(auditLogs.userId, userId))
|
.where(eq(auditLogs.userId, userId));
|
||||||
.returning({ id: auditLogs.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(auditLogs)
|
.delete(auditLogs)
|
||||||
.where(eq(auditLogs.userId, userId))
|
.where(eq(auditLogs.userId, userId));
|
||||||
.returning({ id: auditLogs.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildWhere(filters: AuditLogFilters) {
|
private buildWhere(filters: AuditLogFilters) {
|
||||||
@@ -184,17 +183,16 @@ export class AuditLogRepository {
|
|||||||
if (days === null) return;
|
if (days === null) return;
|
||||||
|
|
||||||
const cutoff = sqlTimestampDaysAgo(days);
|
const cutoff = sqlTimestampDaysAgo(days);
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(auditLogs)
|
.delete(auditLogs)
|
||||||
.where(lt(auditLogs.timestamp, cutoff))
|
.where(lt(auditLogs.timestamp, cutoff));
|
||||||
.returning({ id: auditLogs.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
databaseLogger.info(
|
databaseLogger.info(
|
||||||
`Pruned ${rows.length} audit entries past retention`,
|
`Pruned ${rowsAffected(result)} audit entries past retention`,
|
||||||
{
|
{
|
||||||
operation: "audit_retention_prune",
|
operation: "audit_retention_prune",
|
||||||
removed: rows.length,
|
removed: rowsAffected(result),
|
||||||
retentionDays: days,
|
retentionDays: days,
|
||||||
cutoff,
|
cutoff,
|
||||||
},
|
},
|
||||||
@@ -212,7 +210,7 @@ export class AuditLogRepository {
|
|||||||
const countResult = await this.context.drizzle
|
const countResult = await this.context.drizzle
|
||||||
.select({ count: sql<number>`COUNT(*)` })
|
.select({ count: sql<number>`COUNT(*)` })
|
||||||
.from(auditLogs);
|
.from(auditLogs);
|
||||||
const count = countResult[0]?.count ?? 0;
|
const count = countValue(countResult[0]?.count);
|
||||||
|
|
||||||
if (count < max) return;
|
if (count < max) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { and, asc, eq, sql } from "drizzle-orm";
|
import { and, asc, eq, sql } from "drizzle-orm";
|
||||||
import { c2sTunnelPresets } from "../db/schema.js";
|
import { c2sTunnelPresets } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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;
|
export type C2sTunnelPresetRecord = typeof c2sTunnelPresets.$inferSelect;
|
||||||
|
|
||||||
@@ -64,16 +66,13 @@ export class C2sTunnelPresetRepository {
|
|||||||
userId: string,
|
userId: string,
|
||||||
input: C2sTunnelPresetCreateInput,
|
input: C2sTunnelPresetCreateInput,
|
||||||
): Promise<C2sTunnelPresetRecord> {
|
): Promise<C2sTunnelPresetRecord> {
|
||||||
const [created] = await this.context.drizzle
|
const [created] = await insertReturning(this.context, c2sTunnelPresets, {
|
||||||
.insert(c2sTunnelPresets)
|
userId,
|
||||||
.values({
|
name: input.name,
|
||||||
userId,
|
config: input.config,
|
||||||
name: input.name,
|
platform: input.platform ?? null,
|
||||||
config: input.config,
|
computerName: input.computerName ?? null,
|
||||||
platform: input.platform ?? null,
|
});
|
||||||
computerName: input.computerName ?? null,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return created;
|
return created;
|
||||||
@@ -84,16 +83,15 @@ export class C2sTunnelPresetRepository {
|
|||||||
id: number,
|
id: number,
|
||||||
updates: C2sTunnelPresetUpdateInput,
|
updates: C2sTunnelPresetUpdateInput,
|
||||||
): Promise<C2sTunnelPresetRecord | null> {
|
): Promise<C2sTunnelPresetRecord | null> {
|
||||||
const [updated] = await this.context.drizzle
|
const [updated] = await updateReturning(
|
||||||
.update(c2sTunnelPresets)
|
this.context,
|
||||||
.set({
|
c2sTunnelPresets,
|
||||||
|
{
|
||||||
...updates,
|
...updates,
|
||||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||||
})
|
},
|
||||||
.where(
|
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
);
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
@@ -103,31 +101,29 @@ export class C2sTunnelPresetRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(c2sTunnelPresets)
|
.delete(c2sTunnelPresets)
|
||||||
.where(
|
.where(
|
||||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||||
)
|
);
|
||||||
.returning({ id: c2sTunnelPresets.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length > 0;
|
return rowsAffected(result) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(c2sTunnelPresets)
|
.delete(c2sTunnelPresets)
|
||||||
.where(eq(c2sTunnelPresets.userId, userId))
|
.where(eq(c2sTunnelPresets.userId, userId));
|
||||||
.returning({ id: c2sTunnelPresets.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||||
import { commandHistory } from "../db/schema.js";
|
import { commandHistory } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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;
|
export type CommandHistoryRecord = typeof commandHistory.$inferSelect;
|
||||||
|
|
||||||
@@ -16,10 +18,12 @@ export class CommandHistoryRepository {
|
|||||||
command: string,
|
command: string,
|
||||||
executedAt = new Date().toISOString(),
|
executedAt = new Date().toISOString(),
|
||||||
): Promise<CommandHistoryRecord> {
|
): Promise<CommandHistoryRecord> {
|
||||||
const [created] = await this.context.drizzle
|
const [created] = await insertReturning(this.context, commandHistory, {
|
||||||
.insert(commandHistory)
|
userId,
|
||||||
.values({ userId, hostId, command, executedAt })
|
hostId,
|
||||||
.returning();
|
command,
|
||||||
|
executedAt,
|
||||||
|
});
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
@@ -76,7 +80,7 @@ export class CommandHistoryRepository {
|
|||||||
hostId: number,
|
hostId: number,
|
||||||
command: string,
|
command: string,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(commandHistory)
|
.delete(commandHistory)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -84,45 +88,42 @@ export class CommandHistoryRepository {
|
|||||||
eq(commandHistory.hostId, hostId),
|
eq(commandHistory.hostId, hostId),
|
||||||
eq(commandHistory.command, command),
|
eq(commandHistory.command, command),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: commandHistory.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserAndHost(userId: string, hostId: number): Promise<number> {
|
async deleteByUserAndHost(userId: string, hostId: number): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(commandHistory)
|
.delete(commandHistory)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(commandHistory.userId, userId),
|
eq(commandHistory.userId, userId),
|
||||||
eq(commandHistory.hostId, hostId),
|
eq(commandHistory.hostId, hostId),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: commandHistory.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByHostId(hostId: number): Promise<number> {
|
async deleteByHostId(hostId: number): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(commandHistory)
|
.delete(commandHistory)
|
||||||
.where(eq(commandHistory.hostId, hostId))
|
.where(eq(commandHistory.hostId, hostId));
|
||||||
.returning({ id: commandHistory.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||||
@@ -130,29 +131,27 @@ export class CommandHistoryRepository {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(commandHistory)
|
.delete(commandHistory)
|
||||||
.where(inArray(commandHistory.hostId, hostIds))
|
.where(inArray(commandHistory.hostId, hostIds));
|
||||||
.returning({ id: commandHistory.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(commandHistory)
|
.delete(commandHistory)
|
||||||
.where(eq(commandHistory.userId, userId))
|
.where(eq(commandHistory.userId, userId));
|
||||||
.returning({ id: commandHistory.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { randomUUID } from "crypto";
|
|||||||
import { sshCredentials, sshCredentialUsage } from "../db/schema.js";
|
import { sshCredentials, sshCredentialUsage } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
import { DataCrypto } from "../../utils/data-crypto.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 CredentialRecord = typeof sshCredentials.$inferSelect;
|
||||||
export type NewCredentialRecord = typeof sshCredentials.$inferInsert;
|
export type NewCredentialRecord = typeof sshCredentials.$inferInsert;
|
||||||
@@ -17,10 +23,10 @@ export class CredentialRepository {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(credential: NewCredentialRecord): Promise<CredentialRecord> {
|
async create(credential: NewCredentialRecord): Promise<CredentialRecord> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await insertReturning(this.context, sshCredentials, {
|
||||||
.insert(sshCredentials)
|
syncId: randomUUID(),
|
||||||
.values({ syncId: randomUUID(), ...credential })
|
...credential,
|
||||||
.returning();
|
});
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
@@ -46,10 +52,11 @@ export class CredentialRepository {
|
|||||||
delete (encryptedCredential as Partial<NewCredentialRecord>).id;
|
delete (encryptedCredential as Partial<NewCredentialRecord>).id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const rows = await insertReturning(
|
||||||
.insert(sshCredentials)
|
this.context,
|
||||||
.values(encryptedCredential as NewCredentialRecord)
|
sshCredentials,
|
||||||
.returning();
|
encryptedCredential as NewCredentialRecord,
|
||||||
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return DataCrypto.decryptRecord(
|
return DataCrypto.decryptRecord(
|
||||||
@@ -143,7 +150,7 @@ export class CredentialRepository {
|
|||||||
oldName: string,
|
oldName: string,
|
||||||
newName: string,
|
newName: string,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.update(sshCredentials)
|
.update(sshCredentials)
|
||||||
.set({ folder: newName, updatedAt: sql`CURRENT_TIMESTAMP` })
|
.set({ folder: newName, updatedAt: sql`CURRENT_TIMESTAMP` })
|
||||||
.where(
|
.where(
|
||||||
@@ -151,14 +158,13 @@ export class CredentialRepository {
|
|||||||
eq(sshCredentials.userId, userId),
|
eq(sshCredentials.userId, userId),
|
||||||
eq(sshCredentials.folder, oldName),
|
eq(sshCredentials.folder, oldName),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: sshCredentials.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateForUser(
|
async updateForUser(
|
||||||
@@ -166,16 +172,15 @@ export class CredentialRepository {
|
|||||||
credentialId: number,
|
credentialId: number,
|
||||||
update: CredentialUpdate,
|
update: CredentialUpdate,
|
||||||
): Promise<CredentialRecord | null> {
|
): Promise<CredentialRecord | null> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await updateReturning(
|
||||||
.update(sshCredentials)
|
this.context,
|
||||||
.set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` })
|
sshCredentials,
|
||||||
.where(
|
{ ...update, updatedAt: sql`CURRENT_TIMESTAMP` },
|
||||||
and(
|
and(
|
||||||
eq(sshCredentials.id, credentialId),
|
eq(sshCredentials.id, credentialId),
|
||||||
eq(sshCredentials.userId, userId),
|
eq(sshCredentials.userId, userId),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning();
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
@@ -193,16 +198,15 @@ export class CredentialRepository {
|
|||||||
userDataKey,
|
userDataKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const rows = await updateReturning(
|
||||||
.update(sshCredentials)
|
this.context,
|
||||||
.set({ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` })
|
sshCredentials,
|
||||||
.where(
|
{ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` },
|
||||||
and(
|
and(
|
||||||
eq(sshCredentials.id, credentialId),
|
eq(sshCredentials.id, credentialId),
|
||||||
eq(sshCredentials.userId, userId),
|
eq(sshCredentials.userId, userId),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning();
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return this.decryptOne(rows[0] ?? null, userId);
|
return this.decryptOne(rows[0] ?? null, userId);
|
||||||
@@ -212,31 +216,29 @@ export class CredentialRepository {
|
|||||||
userId: string,
|
userId: string,
|
||||||
credentialId: number,
|
credentialId: number,
|
||||||
): Promise<{ syncId: string | null } | null> {
|
): Promise<{ syncId: string | null } | null> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await deleteReturning(
|
||||||
.delete(sshCredentials)
|
this.context,
|
||||||
.where(
|
sshCredentials,
|
||||||
and(
|
and(
|
||||||
eq(sshCredentials.id, credentialId),
|
eq(sshCredentials.id, credentialId),
|
||||||
eq(sshCredentials.userId, userId),
|
eq(sshCredentials.userId, userId),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ syncId: sshCredentials.syncId });
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0] ?? null;
|
return rows[0] ? { syncId: rows[0].syncId } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(sshCredentials)
|
.delete(sshCredentials)
|
||||||
.where(eq(sshCredentials.userId, userId))
|
.where(eq(sshCredentials.userId, userId));
|
||||||
.returning({ id: sshCredentials.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async recordUsage(
|
async recordUsage(
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { and, asc, eq } from "drizzle-orm";
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { dashboardServiceLinks } from "../db/schema.js";
|
import { dashboardServiceLinks } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { rowsAffected } from "./mutation-result.js";
|
||||||
|
import {
|
||||||
|
deleteReturning,
|
||||||
|
insertReturning,
|
||||||
|
updateReturning,
|
||||||
|
} from "./returning.js";
|
||||||
|
|
||||||
export type DashboardServiceLinkRecord =
|
export type DashboardServiceLinkRecord =
|
||||||
typeof dashboardServiceLinks.$inferSelect;
|
typeof dashboardServiceLinks.$inferSelect;
|
||||||
@@ -38,9 +44,10 @@ export class DashboardServiceLinkRepository {
|
|||||||
const nextOrder =
|
const nextOrder =
|
||||||
existing.length > 0 ? existing[existing.length - 1].order + 1 : 0;
|
existing.length > 0 ? existing[existing.length - 1].order + 1 : 0;
|
||||||
|
|
||||||
const [created] = await this.context.drizzle
|
const [created] = await insertReturning(
|
||||||
.insert(dashboardServiceLinks)
|
this.context,
|
||||||
.values({
|
dashboardServiceLinks,
|
||||||
|
{
|
||||||
syncId: randomUUID(),
|
syncId: randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
label: input.label,
|
label: input.label,
|
||||||
@@ -48,8 +55,8 @@ export class DashboardServiceLinkRepository {
|
|||||||
order: nextOrder,
|
order: nextOrder,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt: createdAt,
|
updatedAt: createdAt,
|
||||||
})
|
},
|
||||||
.returning();
|
);
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
@@ -77,16 +84,15 @@ export class DashboardServiceLinkRepository {
|
|||||||
id: number,
|
id: number,
|
||||||
updates: DashboardServiceLinkUpdate,
|
updates: DashboardServiceLinkUpdate,
|
||||||
): Promise<DashboardServiceLinkRecord | null> {
|
): Promise<DashboardServiceLinkRecord | null> {
|
||||||
const [updated] = await this.context.drizzle
|
const [updated] = await updateReturning(
|
||||||
.update(dashboardServiceLinks)
|
this.context,
|
||||||
.set({ ...updates, updatedAt: new Date().toISOString() })
|
dashboardServiceLinks,
|
||||||
.where(
|
{ ...updates, updatedAt: new Date().toISOString() },
|
||||||
and(
|
and(
|
||||||
eq(dashboardServiceLinks.id, id),
|
eq(dashboardServiceLinks.id, id),
|
||||||
eq(dashboardServiceLinks.userId, userId),
|
eq(dashboardServiceLinks.userId, userId),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
@@ -99,32 +105,30 @@ export class DashboardServiceLinkRepository {
|
|||||||
userId: string,
|
userId: string,
|
||||||
id: number,
|
id: number,
|
||||||
): Promise<{ syncId: string | null } | null> {
|
): Promise<{ syncId: string | null } | null> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await deleteReturning(
|
||||||
.delete(dashboardServiceLinks)
|
this.context,
|
||||||
.where(
|
dashboardServiceLinks,
|
||||||
and(
|
and(
|
||||||
eq(dashboardServiceLinks.id, id),
|
eq(dashboardServiceLinks.id, id),
|
||||||
eq(dashboardServiceLinks.userId, userId),
|
eq(dashboardServiceLinks.userId, userId),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ syncId: dashboardServiceLinks.syncId });
|
|
||||||
|
|
||||||
if (rows.length === 0) return null;
|
if (rows.length === 0) return null;
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0];
|
return { syncId: rows[0].syncId };
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(dashboardServiceLinks)
|
.delete(dashboardServiceLinks)
|
||||||
.where(eq(dashboardServiceLinks.userId, userId))
|
.where(eq(dashboardServiceLinks.userId, userId));
|
||||||
.returning({ id: dashboardServiceLinks.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
||||||
import type * as schema from "../db/schema.js";
|
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
|
* The database handle repositories work against.
|
||||||
* today; the alias exists so that adding another is a change in one place
|
*
|
||||||
* rather than a hunt for string literals.
|
* 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<typeof schema>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a repository is allowed to touch.
|
* What a repository is allowed to touch.
|
||||||
@@ -18,5 +37,5 @@ export type DatabaseDialect = "sqlite";
|
|||||||
*/
|
*/
|
||||||
export interface DatabaseContext {
|
export interface DatabaseContext {
|
||||||
dialect: DatabaseDialect;
|
dialect: DatabaseDialect;
|
||||||
drizzle: BetterSQLite3Database<typeof schema>;
|
drizzle: PortableDatabase;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { dismissedAlerts } from "../db/schema.js";
|
import { dismissedAlerts } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { rowsAffected } from "./mutation-result.js";
|
||||||
|
|
||||||
export type DismissedAlertRecord = typeof dismissedAlerts.$inferSelect;
|
export type DismissedAlertRecord = typeof dismissedAlerts.$inferSelect;
|
||||||
|
|
||||||
@@ -72,34 +73,32 @@ export class DismissedAlertRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteForUser(userId: string, alertId: string): Promise<boolean> {
|
async deleteForUser(userId: string, alertId: string): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(dismissedAlerts)
|
.delete(dismissedAlerts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(dismissedAlerts.userId, userId),
|
eq(dismissedAlerts.userId, userId),
|
||||||
eq(dismissedAlerts.alertId, alertId),
|
eq(dismissedAlerts.alertId, alertId),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: dismissedAlerts.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length > 0;
|
return rowsAffected(result) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(dismissedAlerts)
|
.delete(dismissedAlerts)
|
||||||
.where(eq(dismissedAlerts.userId, userId))
|
.where(eq(dismissedAlerts.userId, userId));
|
||||||
.returning({ id: dismissedAlerts.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||||
import { getDb, getSqlite } from "../db/index.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 type { DatabaseContext } from "./database-context.js";
|
||||||
import { WebauthnCredentialRepository } from "./webauthn-credential-repository.js";
|
import { WebauthnCredentialRepository } from "./webauthn-credential-repository.js";
|
||||||
import { AlertRepository } from "./alert-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(
|
export function createCurrentRepositoryWriteHook(
|
||||||
reason: string,
|
reason: string,
|
||||||
): () => Promise<void> {
|
): (() => Promise<void>) | undefined {
|
||||||
|
if (!needsExplicitPersist(resolveDatabaseDialect())) return undefined;
|
||||||
return () => DatabaseSaveTrigger.forceSave(reason);
|
return () => DatabaseSaveTrigger.forceSave(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +79,18 @@ export function getCurrentRepositorySqlite() {
|
|||||||
return getSqlite();
|
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 {
|
export function getCurrentSettingValue(key: string): string | null {
|
||||||
|
if (!needsExplicitPersist(resolveDatabaseDialect())) {
|
||||||
|
return readCachedSetting(key);
|
||||||
|
}
|
||||||
|
|
||||||
const row = getCurrentRepositorySqlite()
|
const row = getCurrentRepositorySqlite()
|
||||||
.prepare("SELECT value FROM settings WHERE key = ?")
|
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||||
.get(key) as { value?: string } | undefined;
|
.get(key) as { value?: string } | undefined;
|
||||||
@@ -375,3 +397,69 @@ export function createCurrentVaultTokenRepository(): VaultTokenRepository {
|
|||||||
createCurrentRepositoryWriteHook("vault_token_repository_write"),
|
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<void> {
|
||||||
|
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<void> = 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
fileManagerShortcuts,
|
fileManagerShortcuts,
|
||||||
} from "../db/schema.js";
|
} from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { rowsAffected } from "./mutation-result.js";
|
||||||
|
|
||||||
export type FileManagerRecentRecord = typeof fileManagerRecent.$inferSelect;
|
export type FileManagerRecentRecord = typeof fileManagerRecent.$inferSelect;
|
||||||
export type FileManagerPinnedRecord = typeof fileManagerPinned.$inferSelect;
|
export type FileManagerPinnedRecord = typeof fileManagerPinned.$inferSelect;
|
||||||
@@ -112,7 +113,7 @@ export class FileManagerBookmarkRepository {
|
|||||||
userId: string,
|
userId: string,
|
||||||
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
|
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(fileManagerRecent)
|
.delete(fileManagerRecent)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -120,14 +121,13 @@ export class FileManagerBookmarkRepository {
|
|||||||
eq(fileManagerRecent.hostId, input.hostId),
|
eq(fileManagerRecent.hostId, input.hostId),
|
||||||
eq(fileManagerRecent.path, input.path),
|
eq(fileManagerRecent.path, input.path),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: fileManagerRecent.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listPinnedForHost(
|
async listPinnedForHost(
|
||||||
@@ -199,7 +199,7 @@ export class FileManagerBookmarkRepository {
|
|||||||
userId: string,
|
userId: string,
|
||||||
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
|
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(fileManagerPinned)
|
.delete(fileManagerPinned)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -207,14 +207,13 @@ export class FileManagerBookmarkRepository {
|
|||||||
eq(fileManagerPinned.hostId, input.hostId),
|
eq(fileManagerPinned.hostId, input.hostId),
|
||||||
eq(fileManagerPinned.path, input.path),
|
eq(fileManagerPinned.path, input.path),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: fileManagerPinned.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listShortcutsForHost(
|
async listShortcutsForHost(
|
||||||
@@ -288,7 +287,7 @@ export class FileManagerBookmarkRepository {
|
|||||||
userId: string,
|
userId: string,
|
||||||
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
|
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(fileManagerShortcuts)
|
.delete(fileManagerShortcuts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -296,14 +295,13 @@ export class FileManagerBookmarkRepository {
|
|||||||
eq(fileManagerShortcuts.hostId, input.hostId),
|
eq(fileManagerShortcuts.hostId, input.hostId),
|
||||||
eq(fileManagerShortcuts.path, input.path),
|
eq(fileManagerShortcuts.path, input.path),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: fileManagerShortcuts.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
@@ -456,75 +454,66 @@ export class FileManagerBookmarkRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async deleteRecentByUserId(userId: string): Promise<number> {
|
private async deleteRecentByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(fileManagerRecent)
|
.delete(fileManagerRecent)
|
||||||
.where(eq(fileManagerRecent.userId, userId))
|
.where(eq(fileManagerRecent.userId, userId));
|
||||||
.returning({ id: fileManagerRecent.id });
|
return rowsAffected(result);
|
||||||
return rows.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deletePinnedByUserId(userId: string): Promise<number> {
|
private async deletePinnedByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(fileManagerPinned)
|
.delete(fileManagerPinned)
|
||||||
.where(eq(fileManagerPinned.userId, userId))
|
.where(eq(fileManagerPinned.userId, userId));
|
||||||
.returning({ id: fileManagerPinned.id });
|
return rowsAffected(result);
|
||||||
return rows.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deleteShortcutsByUserId(userId: string): Promise<number> {
|
private async deleteShortcutsByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(fileManagerShortcuts)
|
.delete(fileManagerShortcuts)
|
||||||
.where(eq(fileManagerShortcuts.userId, userId))
|
.where(eq(fileManagerShortcuts.userId, userId));
|
||||||
.returning({ id: fileManagerShortcuts.id });
|
return rowsAffected(result);
|
||||||
return rows.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deleteRecentByHostId(hostId: number): Promise<number> {
|
private async deleteRecentByHostId(hostId: number): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(fileManagerRecent)
|
.delete(fileManagerRecent)
|
||||||
.where(eq(fileManagerRecent.hostId, hostId))
|
.where(eq(fileManagerRecent.hostId, hostId));
|
||||||
.returning({ id: fileManagerRecent.id });
|
return rowsAffected(result);
|
||||||
return rows.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deletePinnedByHostId(hostId: number): Promise<number> {
|
private async deletePinnedByHostId(hostId: number): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(fileManagerPinned)
|
.delete(fileManagerPinned)
|
||||||
.where(eq(fileManagerPinned.hostId, hostId))
|
.where(eq(fileManagerPinned.hostId, hostId));
|
||||||
.returning({ id: fileManagerPinned.id });
|
return rowsAffected(result);
|
||||||
return rows.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deleteShortcutsByHostId(hostId: number): Promise<number> {
|
private async deleteShortcutsByHostId(hostId: number): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(fileManagerShortcuts)
|
.delete(fileManagerShortcuts)
|
||||||
.where(eq(fileManagerShortcuts.hostId, hostId))
|
.where(eq(fileManagerShortcuts.hostId, hostId));
|
||||||
.returning({ id: fileManagerShortcuts.id });
|
return rowsAffected(result);
|
||||||
return rows.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deleteRecentByHostIds(hostIds: number[]): Promise<number> {
|
private async deleteRecentByHostIds(hostIds: number[]): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(fileManagerRecent)
|
.delete(fileManagerRecent)
|
||||||
.where(inArray(fileManagerRecent.hostId, hostIds))
|
.where(inArray(fileManagerRecent.hostId, hostIds));
|
||||||
.returning({ id: fileManagerRecent.id });
|
return rowsAffected(result);
|
||||||
return rows.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deletePinnedByHostIds(hostIds: number[]): Promise<number> {
|
private async deletePinnedByHostIds(hostIds: number[]): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(fileManagerPinned)
|
.delete(fileManagerPinned)
|
||||||
.where(inArray(fileManagerPinned.hostId, hostIds))
|
.where(inArray(fileManagerPinned.hostId, hostIds));
|
||||||
.returning({ id: fileManagerPinned.id });
|
return rowsAffected(result);
|
||||||
return rows.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deleteShortcutsByHostIds(hostIds: number[]): Promise<number> {
|
private async deleteShortcutsByHostIds(hostIds: number[]): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(fileManagerShortcuts)
|
.delete(fileManagerShortcuts)
|
||||||
.where(inArray(fileManagerShortcuts.hostId, hostIds))
|
.where(inArray(fileManagerShortcuts.hostId, hostIds));
|
||||||
.returning({ id: fileManagerShortcuts.id });
|
return rowsAffected(result);
|
||||||
return rows.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { and, asc, eq } from "drizzle-orm";
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { homepageItems } from "../db/schema.js";
|
import { homepageItems } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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;
|
export type HomepageItemRecord = typeof homepageItems.$inferSelect;
|
||||||
|
|
||||||
@@ -35,18 +41,15 @@ export class HomepageItemRepository {
|
|||||||
input: HomepageItemCreateInput,
|
input: HomepageItemCreateInput,
|
||||||
now = new Date().toISOString(),
|
now = new Date().toISOString(),
|
||||||
): Promise<HomepageItemRecord> {
|
): Promise<HomepageItemRecord> {
|
||||||
const [created] = await this.context.drizzle
|
const [created] = await insertReturning(this.context, homepageItems, {
|
||||||
.insert(homepageItems)
|
syncId: randomUUID(),
|
||||||
.values({
|
userId,
|
||||||
syncId: randomUUID(),
|
typeId: input.typeId,
|
||||||
userId,
|
title: input.title,
|
||||||
typeId: input.typeId,
|
config: input.config,
|
||||||
title: input.title,
|
createdAt: now,
|
||||||
config: input.config,
|
updatedAt: now,
|
||||||
createdAt: now,
|
});
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return created;
|
return created;
|
||||||
@@ -71,11 +74,12 @@ export class HomepageItemRepository {
|
|||||||
updates: HomepageItemUpdateInput,
|
updates: HomepageItemUpdateInput,
|
||||||
updatedAt = new Date().toISOString(),
|
updatedAt = new Date().toISOString(),
|
||||||
): Promise<HomepageItemRecord | null> {
|
): Promise<HomepageItemRecord | null> {
|
||||||
const [updated] = await this.context.drizzle
|
const [updated] = await updateReturning(
|
||||||
.update(homepageItems)
|
this.context,
|
||||||
.set({ ...updates, updatedAt })
|
homepageItems,
|
||||||
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
|
{ ...updates, updatedAt },
|
||||||
.returning();
|
and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)),
|
||||||
|
);
|
||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
@@ -88,27 +92,27 @@ export class HomepageItemRepository {
|
|||||||
userId: string,
|
userId: string,
|
||||||
id: number,
|
id: number,
|
||||||
): Promise<{ syncId: string | null } | null> {
|
): Promise<{ syncId: string | null } | null> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await deleteReturning(
|
||||||
.delete(homepageItems)
|
this.context,
|
||||||
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
|
homepageItems,
|
||||||
.returning({ syncId: homepageItems.syncId });
|
and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)),
|
||||||
|
);
|
||||||
|
|
||||||
if (rows.length === 0) return null;
|
if (rows.length === 0) return null;
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0];
|
return { syncId: rows[0].syncId };
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(homepageItems)
|
.delete(homepageItems)
|
||||||
.where(eq(homepageItems.userId, userId))
|
.where(eq(homepageItems.userId, userId));
|
||||||
.returning({ id: homepageItems.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { homepageLayouts } from "../db/schema.js";
|
import { homepageLayouts } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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;
|
export type HomepageLayoutRecord = typeof homepageLayouts.$inferSelect;
|
||||||
|
|
||||||
@@ -28,34 +30,35 @@ export class HomepageLayoutRepository {
|
|||||||
const existing = await this.findByUserId(userId);
|
const existing = await this.findByUserId(userId);
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const [created] = await this.context.drizzle
|
const [created] = await insertReturning(this.context, homepageLayouts, {
|
||||||
.insert(homepageLayouts)
|
userId,
|
||||||
.values({ userId, layout, updatedAt })
|
layout,
|
||||||
.returning();
|
updatedAt,
|
||||||
|
});
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updated] = await this.context.drizzle
|
const [updated] = await updateReturning(
|
||||||
.update(homepageLayouts)
|
this.context,
|
||||||
.set({ layout, updatedAt })
|
homepageLayouts,
|
||||||
.where(eq(homepageLayouts.userId, userId))
|
{ layout, updatedAt },
|
||||||
.returning();
|
eq(homepageLayouts.userId, userId),
|
||||||
|
);
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(homepageLayouts)
|
.delete(homepageLayouts)
|
||||||
.where(eq(homepageLayouts.userId, userId))
|
.where(eq(homepageLayouts.userId, userId));
|
||||||
.returning({ id: homepageLayouts.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { randomUUID } from "crypto";
|
|||||||
import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
|
import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||||
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
|
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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 HostFolderRecord = typeof sshFolders.$inferSelect;
|
||||||
export type HostFolderHostRecord = typeof hosts.$inferSelect;
|
export type HostFolderHostRecord = typeof hosts.$inferSelect;
|
||||||
@@ -24,19 +30,31 @@ export class HostFolderRepository {
|
|||||||
newName: string,
|
newName: string,
|
||||||
now = new Date().toISOString(),
|
now = new Date().toISOString(),
|
||||||
): Promise<RenameFolderResult> {
|
): Promise<RenameFolderResult> {
|
||||||
|
// 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 oldPrefix = `${oldName} / `;
|
||||||
const newPrefix = `${newName} / `;
|
const newPrefix = `${newName} / `;
|
||||||
const childLike = `${oldPrefix}%`;
|
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) =>
|
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) =>
|
const folderMatch = (col: SQLiteColumn) =>
|
||||||
or(eq(col, oldName), like(col, childLike));
|
or(eq(col, oldName), like(col, childLike));
|
||||||
|
|
||||||
const updatedHosts = await this.context.drizzle
|
const updatedHosts = await this.context.drizzle
|
||||||
.update(hosts)
|
.update(hosts)
|
||||||
.set({ folder: renameExpr(hosts.folder), updatedAt: now })
|
.set({ folder: renameExpr(hosts.folder), updatedAt: now })
|
||||||
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)))
|
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
|
||||||
.returning({ id: hosts.id });
|
|
||||||
|
|
||||||
const updatedCredentials = await this.context.drizzle
|
const updatedCredentials = await this.context.drizzle
|
||||||
.update(sshCredentials)
|
.update(sshCredentials)
|
||||||
@@ -46,8 +64,7 @@ export class HostFolderRepository {
|
|||||||
eq(sshCredentials.userId, userId),
|
eq(sshCredentials.userId, userId),
|
||||||
folderMatch(sshCredentials.folder),
|
folderMatch(sshCredentials.folder),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: sshCredentials.id });
|
|
||||||
|
|
||||||
await this.context.drizzle
|
await this.context.drizzle
|
||||||
.update(sshFolders)
|
.update(sshFolders)
|
||||||
@@ -56,8 +73,8 @@ export class HostFolderRepository {
|
|||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return {
|
return {
|
||||||
updatedHosts: updatedHosts.length,
|
updatedHosts: rowsAffected(updatedHosts),
|
||||||
updatedCredentials: updatedCredentials.length,
|
updatedCredentials: rowsAffected(updatedCredentials),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,35 +95,33 @@ export class HostFolderRepository {
|
|||||||
): Promise<{ folder: HostFolderRecord; created: boolean }> {
|
): Promise<{ folder: HostFolderRecord; created: boolean }> {
|
||||||
const existing = await this.findFolder(userId, name);
|
const existing = await this.findFolder(userId, name);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const [updated] = await this.context.drizzle
|
const [updated] = await updateReturning(
|
||||||
.update(sshFolders)
|
this.context,
|
||||||
.set({
|
sshFolders,
|
||||||
|
{
|
||||||
color,
|
color,
|
||||||
icon,
|
icon,
|
||||||
credentialId:
|
credentialId:
|
||||||
credentialId === undefined ? existing.credentialId : credentialId,
|
credentialId === undefined ? existing.credentialId : credentialId,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
},
|
||||||
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)),
|
||||||
.returning();
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return { folder: updated, created: false };
|
return { folder: updated, created: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const [created] = await this.context.drizzle
|
const [created] = await insertReturning(this.context, sshFolders, {
|
||||||
.insert(sshFolders)
|
syncId: randomUUID(),
|
||||||
.values({
|
userId,
|
||||||
syncId: randomUUID(),
|
name,
|
||||||
userId,
|
color,
|
||||||
name,
|
icon,
|
||||||
color,
|
credentialId: credentialId ?? null,
|
||||||
icon,
|
createdAt: now,
|
||||||
credentialId: credentialId ?? null,
|
updatedAt: now,
|
||||||
createdAt: now,
|
});
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return { folder: created, created: true };
|
return { folder: created, created: true };
|
||||||
@@ -139,10 +154,11 @@ export class HostFolderRepository {
|
|||||||
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
|
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletedFolders = await this.context.drizzle
|
const deletedFolders = await deleteReturning(
|
||||||
.delete(sshFolders)
|
this.context,
|
||||||
.where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)))
|
sshFolders,
|
||||||
.returning({ syncId: sshFolders.syncId });
|
and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)),
|
||||||
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
|
|
||||||
@@ -157,16 +173,15 @@ export class HostFolderRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(sshFolders)
|
.delete(sshFolders)
|
||||||
.where(eq(sshFolders.userId, userId))
|
.where(eq(sshFolders.userId, userId));
|
||||||
.returning({ id: sshFolders.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findFolder(
|
private async findFolder(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { and, desc, eq, notInArray } from "drizzle-orm";
|
import { and, desc, eq, notInArray } from "drizzle-orm";
|
||||||
import { hostHealthChecks, hostHealthHistory } from "../db/schema.js";
|
import { hostHealthChecks, hostHealthHistory } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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 HostHealthCheckRecord = typeof hostHealthChecks.$inferSelect;
|
||||||
export type HostHealthHistoryRecord = typeof hostHealthHistory.$inferSelect;
|
export type HostHealthHistoryRecord = typeof hostHealthHistory.$inferSelect;
|
||||||
@@ -45,27 +47,25 @@ export class HostHealthRepository {
|
|||||||
): Promise<HostHealthCheckRecord> {
|
): Promise<HostHealthCheckRecord> {
|
||||||
const existing = await this.findChecksByUserAndHost(userId, hostId);
|
const existing = await this.findChecksByUserAndHost(userId, hostId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const [updated] = await this.context.drizzle
|
const [updated] = await updateReturning(
|
||||||
.update(hostHealthChecks)
|
this.context,
|
||||||
.set({ checks, intervalSeconds, updatedAt: now })
|
hostHealthChecks,
|
||||||
.where(eq(hostHealthChecks.id, existing.id))
|
{ checks, intervalSeconds, updatedAt: now },
|
||||||
.returning();
|
eq(hostHealthChecks.id, existing.id),
|
||||||
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [created] = await this.context.drizzle
|
const [created] = await insertReturning(this.context, hostHealthChecks, {
|
||||||
.insert(hostHealthChecks)
|
userId,
|
||||||
.values({
|
hostId,
|
||||||
userId,
|
checks,
|
||||||
hostId,
|
intervalSeconds,
|
||||||
checks,
|
createdAt: now,
|
||||||
intervalSeconds,
|
updatedAt: now,
|
||||||
createdAt: now,
|
});
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return created;
|
return created;
|
||||||
@@ -121,23 +121,21 @@ export class HostHealthRepository {
|
|||||||
checksDeleted: number;
|
checksDeleted: number;
|
||||||
historyDeleted: number;
|
historyDeleted: number;
|
||||||
}> {
|
}> {
|
||||||
const historyRows = await this.context.drizzle
|
const historyResult = await this.context.drizzle
|
||||||
.delete(hostHealthHistory)
|
.delete(hostHealthHistory)
|
||||||
.where(eq(hostHealthHistory.userId, userId))
|
.where(eq(hostHealthHistory.userId, userId));
|
||||||
.returning({ id: hostHealthHistory.id });
|
|
||||||
|
|
||||||
const checkRows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(hostHealthChecks)
|
.delete(hostHealthChecks)
|
||||||
.where(eq(hostHealthChecks.userId, userId))
|
.where(eq(hostHealthChecks.userId, userId));
|
||||||
.returning({ id: hostHealthChecks.id });
|
|
||||||
|
|
||||||
if (historyRows.length > 0 || checkRows.length > 0) {
|
if (rowsAffected(historyResult) > 0 || rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
checksDeleted: checkRows.length,
|
checksDeleted: rowsAffected(result),
|
||||||
historyDeleted: historyRows.length,
|
historyDeleted: rowsAffected(historyResult),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { hostMetricsPreferences, hosts } from "../db/schema.js";
|
import { hostMetricsPreferences, hosts } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { rowsAffected } from "./mutation-result.js";
|
||||||
|
import { insertReturning, updateReturning } from "./returning.js";
|
||||||
|
|
||||||
export type HostMetricsPreferenceRecord =
|
export type HostMetricsPreferenceRecord =
|
||||||
typeof hostMetricsPreferences.$inferSelect;
|
typeof hostMetricsPreferences.$inferSelect;
|
||||||
@@ -37,26 +39,28 @@ export class HostMetricsPreferenceRepository {
|
|||||||
): Promise<HostMetricsPreferenceRecord> {
|
): Promise<HostMetricsPreferenceRecord> {
|
||||||
const existing = await this.findByUserAndHost(userId, hostId);
|
const existing = await this.findByUserAndHost(userId, hostId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const [updated] = await this.context.drizzle
|
const [updated] = await updateReturning(
|
||||||
.update(hostMetricsPreferences)
|
this.context,
|
||||||
.set({ layout, updatedAt: now })
|
hostMetricsPreferences,
|
||||||
.where(eq(hostMetricsPreferences.id, existing.id))
|
{ layout, updatedAt: now },
|
||||||
.returning();
|
eq(hostMetricsPreferences.id, existing.id),
|
||||||
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [created] = await this.context.drizzle
|
const [created] = await insertReturning(
|
||||||
.insert(hostMetricsPreferences)
|
this.context,
|
||||||
.values({
|
hostMetricsPreferences,
|
||||||
|
{
|
||||||
userId,
|
userId,
|
||||||
hostId,
|
hostId,
|
||||||
layout,
|
layout,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
},
|
||||||
.returning();
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return created;
|
return created;
|
||||||
@@ -67,28 +71,26 @@ export class HostMetricsPreferenceRepository {
|
|||||||
hostId: number,
|
hostId: number,
|
||||||
statsConfig: string,
|
statsConfig: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.update(hosts)
|
.update(hosts)
|
||||||
.set({ statsConfig })
|
.set({ statsConfig })
|
||||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)));
|
||||||
.returning({ id: hosts.id });
|
|
||||||
|
|
||||||
if (rows.length === 0) return false;
|
if (rowsAffected(result) === 0) return false;
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(hostMetricsPreferences)
|
.delete(hostMetricsPreferences)
|
||||||
.where(eq(hostMetricsPreferences.userId, userId))
|
.where(eq(hostMetricsPreferences.userId, userId));
|
||||||
.returning({ id: hostMetricsPreferences.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { randomUUID } from "crypto";
|
|||||||
import { hostAccess, hosts } from "../db/schema.js";
|
import { hostAccess, hosts } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
import { DataCrypto } from "../../utils/data-crypto.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 HostRecord = typeof hosts.$inferSelect;
|
||||||
export type NewHostRecord = typeof hosts.$inferInsert;
|
export type NewHostRecord = typeof hosts.$inferInsert;
|
||||||
@@ -21,10 +27,10 @@ export class HostRepository {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(host: NewHostRecord): Promise<HostRecord> {
|
async create(host: NewHostRecord): Promise<HostRecord> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await insertReturning(this.context, hosts, {
|
||||||
.insert(hosts)
|
syncId: randomUUID(),
|
||||||
.values({ syncId: randomUUID(), ...host })
|
...host,
|
||||||
.returning();
|
});
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
@@ -51,10 +57,11 @@ export class HostRepository {
|
|||||||
delete (encryptedHost as Partial<NewHostRecord>).id;
|
delete (encryptedHost as Partial<NewHostRecord>).id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const rows = await insertReturning(
|
||||||
.insert(hosts)
|
this.context,
|
||||||
.values(encryptedHost as NewHostRecord)
|
hosts,
|
||||||
.returning();
|
encryptedHost as NewHostRecord,
|
||||||
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return DataCrypto.decryptRecord("ssh_data", rows[0], userId, userDataKey);
|
return DataCrypto.decryptRecord("ssh_data", rows[0], userId, userDataKey);
|
||||||
@@ -150,11 +157,12 @@ export class HostRepository {
|
|||||||
hostId: number,
|
hostId: number,
|
||||||
update: HostUpdate,
|
update: HostUpdate,
|
||||||
): Promise<HostRecord | null> {
|
): Promise<HostRecord | null> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await updateReturning(
|
||||||
.update(hosts)
|
this.context,
|
||||||
.set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` })
|
hosts,
|
||||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
{ ...update, updatedAt: sql`CURRENT_TIMESTAMP` },
|
||||||
.returning();
|
and(eq(hosts.id, hostId), eq(hosts.userId, userId)),
|
||||||
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
@@ -173,11 +181,12 @@ export class HostRepository {
|
|||||||
userDataKey,
|
userDataKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const rows = await updateReturning(
|
||||||
.update(hosts)
|
this.context,
|
||||||
.set({ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` })
|
hosts,
|
||||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
{ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` },
|
||||||
.returning();
|
and(eq(hosts.id, hostId), eq(hosts.userId, userId)),
|
||||||
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0]
|
return rows[0]
|
||||||
@@ -213,17 +222,16 @@ export class HostRepository {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.update(hosts)
|
.update(hosts)
|
||||||
.set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` })
|
.set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` })
|
||||||
.where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)))
|
.where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)));
|
||||||
.returning({ id: hosts.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteForUser(
|
async deleteForUser(
|
||||||
@@ -232,39 +240,38 @@ export class HostRepository {
|
|||||||
): Promise<{ syncId: string | null } | null> {
|
): Promise<{ syncId: string | null } | null> {
|
||||||
await this.deleteAccessForHost(hostId);
|
await this.deleteAccessForHost(hostId);
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const rows = await deleteReturning(
|
||||||
.delete(hosts)
|
this.context,
|
||||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
hosts,
|
||||||
.returning({ syncId: hosts.syncId });
|
and(eq(hosts.id, hostId), eq(hosts.userId, userId)),
|
||||||
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0] ?? null;
|
return rows[0] ? { syncId: rows[0].syncId } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(hosts)
|
.delete(hosts)
|
||||||
.where(eq(hosts.userId, userId))
|
.where(eq(hosts.userId, userId));
|
||||||
.returning({ id: hosts.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAccessForHost(hostId: number): Promise<number> {
|
async deleteAccessForHost(hostId: number): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(hostAccess)
|
.delete(hostAccess)
|
||||||
.where(eq(hostAccess.hostId, hostId))
|
.where(eq(hostAccess.hostId, hostId));
|
||||||
.returning({ id: hostAccess.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -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<number>` 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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { networkTopology } from "../db/schema.js";
|
import { networkTopology } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { rowsAffected } from "./mutation-result.js";
|
||||||
|
|
||||||
export type NetworkTopologyRecord = typeof networkTopology.$inferSelect;
|
export type NetworkTopologyRecord = typeof networkTopology.$inferSelect;
|
||||||
|
|
||||||
@@ -45,16 +46,15 @@ export class NetworkTopologyRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(networkTopology)
|
.delete(networkTopology)
|
||||||
.where(eq(networkTopology.userId, userId))
|
.where(eq(networkTopology.userId, userId));
|
||||||
.returning({ id: networkTopology.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { and, eq, gt } from "drizzle-orm";
|
import { and, eq, gt } from "drizzle-orm";
|
||||||
import { userOpenTabs } from "../db/schema.js";
|
import { userOpenTabs } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { rowsAffected } from "./mutation-result.js";
|
||||||
|
|
||||||
export type OpenTabRecord = typeof userOpenTabs.$inferSelect;
|
export type OpenTabRecord = typeof userOpenTabs.$inferSelect;
|
||||||
export type NewOpenTabRecord = typeof userOpenTabs.$inferInsert;
|
export type NewOpenTabRecord = typeof userOpenTabs.$inferInsert;
|
||||||
@@ -111,43 +112,40 @@ export class OpenTabRepository {
|
|||||||
update: OpenTabUpdate,
|
update: OpenTabUpdate,
|
||||||
updatedAt = new Date().toISOString(),
|
updatedAt = new Date().toISOString(),
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.update(userOpenTabs)
|
.update(userOpenTabs)
|
||||||
.set({ ...update, updatedAt })
|
.set({ ...update, updatedAt })
|
||||||
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
|
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)));
|
||||||
.returning({ id: userOpenTabs.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length > 0;
|
return rowsAffected(result) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteForUser(userId: string, id: string): Promise<number> {
|
async deleteForUser(userId: string, id: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(userOpenTabs)
|
.delete(userOpenTabs)
|
||||||
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
|
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)));
|
||||||
.returning({ id: userOpenTabs.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(userOpenTabs)
|
.delete(userOpenTabs)
|
||||||
.where(eq(userOpenTabs.userId, userId))
|
.where(eq(userOpenTabs.userId, userId));
|
||||||
.returning({ id: userOpenTabs.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findByIdForUser(
|
private async findByIdForUser(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { opksshTokens } from "../db/schema.js";
|
import { opksshTokens } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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;
|
export type OpksshTokenRecord = typeof opksshTokens.$inferSelect;
|
||||||
|
|
||||||
@@ -26,9 +28,10 @@ export class OpksshTokenRepository {
|
|||||||
async upsert(input: OpksshTokenUpsertInput): Promise<void> {
|
async upsert(input: OpksshTokenUpsertInput): Promise<void> {
|
||||||
const createdAt = input.createdAt ?? new Date().toISOString();
|
const createdAt = input.createdAt ?? new Date().toISOString();
|
||||||
|
|
||||||
await this.context.drizzle
|
await upsert(
|
||||||
.insert(opksshTokens)
|
this.context,
|
||||||
.values({
|
opksshTokens,
|
||||||
|
{
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
hostId: input.hostId,
|
hostId: input.hostId,
|
||||||
sshCert: input.sshCert,
|
sshCert: input.sshCert,
|
||||||
@@ -38,8 +41,8 @@ export class OpksshTokenRepository {
|
|||||||
issuer: input.issuer,
|
issuer: input.issuer,
|
||||||
audience: input.audience,
|
audience: input.audience,
|
||||||
expiresAt: input.expiresAt,
|
expiresAt: input.expiresAt,
|
||||||
})
|
},
|
||||||
.onConflictDoUpdate({
|
{
|
||||||
target: [opksshTokens.userId, opksshTokens.hostId],
|
target: [opksshTokens.userId, opksshTokens.hostId],
|
||||||
set: {
|
set: {
|
||||||
sshCert: input.sshCert,
|
sshCert: input.sshCert,
|
||||||
@@ -51,7 +54,8 @@ export class OpksshTokenRepository {
|
|||||||
expiresAt: input.expiresAt,
|
expiresAt: input.expiresAt,
|
||||||
createdAt,
|
createdAt,
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
@@ -76,47 +80,44 @@ export class OpksshTokenRepository {
|
|||||||
hostId: number,
|
hostId: number,
|
||||||
lastUsed = new Date().toISOString(),
|
lastUsed = new Date().toISOString(),
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.update(opksshTokens)
|
.update(opksshTokens)
|
||||||
.set({ lastUsed })
|
.set({ lastUsed })
|
||||||
.where(
|
.where(
|
||||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||||
)
|
);
|
||||||
.returning({ id: opksshTokens.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length > 0;
|
return rowsAffected(result) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserAndHost(userId: string, hostId: number): Promise<boolean> {
|
async deleteByUserAndHost(userId: string, hostId: number): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(opksshTokens)
|
.delete(opksshTokens)
|
||||||
.where(
|
.where(
|
||||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||||
)
|
);
|
||||||
.returning({ id: opksshTokens.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length > 0;
|
return rowsAffected(result) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(opksshTokens)
|
.delete(opksshTokens)
|
||||||
.where(eq(opksshTokens.userId, userId))
|
.where(eq(opksshTokens.userId, userId));
|
||||||
.returning({ id: opksshTokens.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
users,
|
users,
|
||||||
} from "../db/schema.js";
|
} from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { rowsAffected } from "./mutation-result.js";
|
||||||
|
import { insertReturning } from "./returning.js";
|
||||||
|
|
||||||
export type RbacAccessTargetType = "user" | "role";
|
export type RbacAccessTargetType = "user" | "role";
|
||||||
|
|
||||||
@@ -156,7 +158,7 @@ export class RbacAccessRepository {
|
|||||||
return { id: existing.id, created: false };
|
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,
|
hostId: input.hostId,
|
||||||
userId: input.targetType === "user" ? input.targetUserId : null,
|
userId: input.targetType === "user" ? input.targetUserId : null,
|
||||||
roleId: input.targetType === "role" ? input.targetRoleId : null,
|
roleId: input.targetType === "role" ? input.targetRoleId : null,
|
||||||
@@ -166,7 +168,7 @@ export class RbacAccessRepository {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return { id: Number(result.lastInsertRowid), created: true };
|
return { id: created.id, created: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeHostAccess(accessId: number, hostId: number): Promise<void> {
|
async revokeHostAccess(accessId: number, hostId: number): Promise<void> {
|
||||||
@@ -177,16 +179,15 @@ export class RbacAccessRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteHostAccessForHost(hostId: number): Promise<number> {
|
async deleteHostAccessForHost(hostId: number): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(hostAccess)
|
.delete(hostAccess)
|
||||||
.where(eq(hostAccess.hostId, hostId))
|
.where(eq(hostAccess.hostId, hostId));
|
||||||
.returning({ id: hostAccess.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteHostAccessForHosts(hostIds: number[]): Promise<number> {
|
async deleteHostAccessForHosts(hostIds: number[]): Promise<number> {
|
||||||
@@ -194,30 +195,27 @@ export class RbacAccessRepository {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(hostAccess)
|
.delete(hostAccess)
|
||||||
.where(inArray(hostAccess.hostId, hostIds))
|
.where(inArray(hostAccess.hostId, hostIds));
|
||||||
.returning({ id: hostAccess.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteHostAccessForUserReferences(userId: string): Promise<number> {
|
async deleteHostAccessForUserReferences(userId: string): Promise<number> {
|
||||||
const directRows = await this.context.drizzle
|
const directResult = await this.context.drizzle
|
||||||
.delete(hostAccess)
|
.delete(hostAccess)
|
||||||
.where(eq(hostAccess.userId, userId))
|
.where(eq(hostAccess.userId, userId));
|
||||||
.returning({ id: hostAccess.id });
|
|
||||||
|
|
||||||
const grantedRows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(hostAccess)
|
.delete(hostAccess)
|
||||||
.where(eq(hostAccess.grantedBy, userId))
|
.where(eq(hostAccess.grantedBy, userId));
|
||||||
.returning({ id: hostAccess.id });
|
|
||||||
|
|
||||||
const deletedCount = directRows.length + grantedRows.length;
|
const deletedCount = rowsAffected(directResult) + rowsAffected(result);
|
||||||
if (deletedCount > 0) {
|
if (deletedCount > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
@@ -291,7 +289,7 @@ export class RbacAccessRepository {
|
|||||||
return { id: existing.id, created: false };
|
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,
|
snippetId: input.snippetId,
|
||||||
userId: input.targetType === "user" ? input.targetUserId : null,
|
userId: input.targetType === "user" ? input.targetUserId : null,
|
||||||
roleId: input.targetType === "role" ? input.targetRoleId : null,
|
roleId: input.targetType === "role" ? input.targetRoleId : null,
|
||||||
@@ -301,7 +299,7 @@ export class RbacAccessRepository {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return { id: Number(result.lastInsertRowid), created: true };
|
return { id: created.id, created: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeSnippetAccess(
|
async revokeSnippetAccess(
|
||||||
@@ -512,21 +510,20 @@ export class RbacAccessRepository {
|
|||||||
async deleteExpiredHostAccess(
|
async deleteExpiredHostAccess(
|
||||||
now = new Date().toISOString(),
|
now = new Date().toISOString(),
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(hostAccess)
|
.delete(hostAccess)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
sql`${hostAccess.expiresAt} IS NOT NULL`,
|
sql`${hostAccess.expiresAt} IS NOT NULL`,
|
||||||
sql`${hostAccess.expiresAt} <= ${now}`,
|
sql`${hostAccess.expiresAt} <= ${now}`,
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: hostAccess.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findActiveHostAccess(
|
async findActiveHostAccess(
|
||||||
@@ -635,17 +632,16 @@ export class RbacAccessRepository {
|
|||||||
hostId: number,
|
hostId: number,
|
||||||
update: { permissionLevel?: string; expiresAt?: string | null },
|
update: { permissionLevel?: string; expiresAt?: string | null },
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.update(hostAccess)
|
.update(hostAccess)
|
||||||
.set(update)
|
.set(update)
|
||||||
.where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId)))
|
.where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId)));
|
||||||
.returning({ id: hostAccess.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length > 0;
|
return rowsAffected(result) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findHostAccessOwnerId(hostAccessId: number): Promise<string | null> {
|
async findHostAccessOwnerId(hostAccessId: number): Promise<string | null> {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { desc, eq, inArray } from "drizzle-orm";
|
import { desc, eq, inArray } from "drizzle-orm";
|
||||||
import { recentActivity } from "../db/schema.js";
|
import { recentActivity } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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 RecentActivityRecord = typeof recentActivity.$inferSelect;
|
||||||
export type NewRecentActivityRecord = typeof recentActivity.$inferInsert;
|
export type NewRecentActivityRecord = typeof recentActivity.$inferInsert;
|
||||||
@@ -26,10 +28,7 @@ export class RecentActivityRepository {
|
|||||||
async create(
|
async create(
|
||||||
activity: NewRecentActivityRecord,
|
activity: NewRecentActivityRecord,
|
||||||
): Promise<RecentActivityRecord> {
|
): Promise<RecentActivityRecord> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await insertReturning(this.context, recentActivity, activity);
|
||||||
.insert(recentActivity)
|
|
||||||
.values(activity)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0];
|
return rows[0];
|
||||||
@@ -51,42 +50,39 @@ export class RecentActivityRepository {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletedRows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(recentActivity)
|
.delete(recentActivity)
|
||||||
.where(inArray(recentActivity.id, idsToDelete))
|
.where(inArray(recentActivity.id, idsToDelete));
|
||||||
.returning({ id: recentActivity.id });
|
|
||||||
|
|
||||||
if (deletedRows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return deletedRows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(recentActivity)
|
.delete(recentActivity)
|
||||||
.where(eq(recentActivity.userId, userId))
|
.where(eq(recentActivity.userId, userId));
|
||||||
.returning({ id: recentActivity.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByHostId(hostId: number): Promise<number> {
|
async deleteByHostId(hostId: number): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(recentActivity)
|
.delete(recentActivity)
|
||||||
.where(eq(recentActivity.hostId, hostId))
|
.where(eq(recentActivity.hostId, hostId));
|
||||||
.returning({ id: recentActivity.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||||
@@ -94,16 +90,15 @@ export class RecentActivityRepository {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(recentActivity)
|
.delete(recentActivity)
|
||||||
.where(inArray(recentActivity.hostId, hostIds))
|
.where(inArray(recentActivity.hostId, hostIds));
|
||||||
.returning({ id: recentActivity.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -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<T extends SQLiteTable> = {
|
||||||
|
[K in keyof T["$inferInsert"]]?: T["$inferInsert"][K] | SQL;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function updateReturning<T extends SQLiteTable>(
|
||||||
|
context: DatabaseContext,
|
||||||
|
table: T,
|
||||||
|
values: UpdateValues<T>,
|
||||||
|
where: SQL,
|
||||||
|
): Promise<T["$inferSelect"][]> {
|
||||||
|
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<T extends SQLiteTable>(
|
||||||
|
context: DatabaseContext,
|
||||||
|
table: T,
|
||||||
|
where: SQL,
|
||||||
|
): Promise<T["$inferSelect"][]> {
|
||||||
|
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<T extends Keyed>(
|
||||||
|
context: DatabaseContext,
|
||||||
|
table: T,
|
||||||
|
values: T["$inferInsert"],
|
||||||
|
): Promise<T["$inferSelect"][]> {
|
||||||
|
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<T extends SQLiteTable>(
|
||||||
|
context: DatabaseContext,
|
||||||
|
table: T,
|
||||||
|
values: T["$inferInsert"],
|
||||||
|
where: SQL,
|
||||||
|
): Promise<T["$inferSelect"][]> {
|
||||||
|
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<T extends SQLiteTable>(
|
||||||
|
context: DatabaseContext,
|
||||||
|
table: T,
|
||||||
|
values: T["$inferInsert"],
|
||||||
|
conflict: { target: SQLiteColumn[]; set: UpdateValues<T> },
|
||||||
|
): Promise<void> {
|
||||||
|
const db = context.drizzle;
|
||||||
|
|
||||||
|
if (context.dialect === "mysql") {
|
||||||
|
const insert = db.insert(table).values(values) as unknown as {
|
||||||
|
onDuplicateKeyUpdate: (config: { set: UpdateValues<T> }) => Promise<void>;
|
||||||
|
};
|
||||||
|
await insert.onDuplicateKeyUpdate({ set: conflict.set });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(table)
|
||||||
|
.values(values)
|
||||||
|
.onConflictDoUpdate({ target: conflict.target, set: conflict.set });
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { hostAccess, roles, userRoles } from "../db/schema.js";
|
import { hostAccess, roles, userRoles } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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 RoleRecord = typeof roles.$inferSelect;
|
||||||
export type NewRoleRecord = typeof roles.$inferInsert;
|
export type NewRoleRecord = typeof roles.$inferInsert;
|
||||||
@@ -62,27 +64,27 @@ export class RoleRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createRole(role: NewRoleRecord): Promise<number> {
|
async createRole(role: NewRoleRecord): Promise<number> {
|
||||||
const result = await this.context.drizzle.insert(roles).values(role);
|
const [created] = await insertReturning(this.context, roles, role);
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return Number(result.lastInsertRowid);
|
return created.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateRole(id: number, update: RoleUpdate): Promise<boolean> {
|
async updateRole(id: number, update: RoleUpdate): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.update(roles)
|
.update(roles)
|
||||||
.set(update)
|
.set(update)
|
||||||
.where(eq(roles.id, id))
|
.where(eq(roles.id, id));
|
||||||
.returning({ id: roles.id });
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows.length > 0;
|
return rowsAffected(result) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteRole(id: number): Promise<{ deletedUserIds: string[] }> {
|
async deleteRole(id: number): Promise<{ deletedUserIds: string[] }> {
|
||||||
const deletedUserRoles = await this.context.drizzle
|
const deletedUserRoles = await deleteReturning(
|
||||||
.delete(userRoles)
|
this.context,
|
||||||
.where(eq(userRoles.roleId, id))
|
userRoles,
|
||||||
.returning({ userId: userRoles.userId });
|
eq(userRoles.roleId, id),
|
||||||
|
);
|
||||||
|
|
||||||
await this.context.drizzle
|
await this.context.drizzle
|
||||||
.delete(hostAccess)
|
.delete(hostAccess)
|
||||||
@@ -169,16 +171,15 @@ export class RoleRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (removeRole) {
|
if (removeRole) {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(userRoles)
|
.delete(userRoles)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(userRoles.userId, input.userId),
|
eq(userRoles.userId, input.userId),
|
||||||
eq(userRoles.roleId, removeRole.id),
|
eq(userRoles.roleId, removeRole.id),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: userRoles.id });
|
removed = rowsAffected(result) > 0;
|
||||||
removed = rows.length > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (added || removed) {
|
if (added || removed) {
|
||||||
@@ -196,16 +197,15 @@ export class RoleRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async removeAllRolesFromUser(userId: string): Promise<number> {
|
async removeAllRolesFromUser(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(userRoles)
|
.delete(userRoles)
|
||||||
.where(eq(userRoles.userId, userId))
|
.where(eq(userRoles.userId, userId));
|
||||||
.returning({ id: userRoles.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listUserRoleIds(userId: string): Promise<number[]> {
|
async listUserRoleIds(userId: string): Promise<number[]> {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { and, desc, eq, inArray, lt } from "drizzle-orm";
|
import { and, desc, eq, inArray, lt } from "drizzle-orm";
|
||||||
import { hosts, sessionRecordings } from "../db/schema.js";
|
import { hosts, sessionRecordings } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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;
|
export type SessionRecordingRecord = typeof sessionRecordings.$inferSelect;
|
||||||
|
|
||||||
@@ -47,10 +49,11 @@ export class SessionRecordingRepository {
|
|||||||
async create(
|
async create(
|
||||||
input: SessionRecordingCreateInput,
|
input: SessionRecordingCreateInput,
|
||||||
): Promise<SessionRecordingRecord> {
|
): Promise<SessionRecordingRecord> {
|
||||||
const [created] = await this.context.drizzle
|
const [created] = await insertReturning(
|
||||||
.insert(sessionRecordings)
|
this.context,
|
||||||
.values(input)
|
sessionRecordings,
|
||||||
.returning();
|
input,
|
||||||
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return created;
|
return created;
|
||||||
@@ -170,31 +173,29 @@ export class SessionRecordingRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteById(id: number): Promise<boolean> {
|
async deleteById(id: number): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(sessionRecordings)
|
.delete(sessionRecordings)
|
||||||
.where(eq(sessionRecordings.id, id))
|
.where(eq(sessionRecordings.id, id));
|
||||||
.returning({ id: sessionRecordings.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length > 0;
|
return rowsAffected(result) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(sessionRecordings)
|
.delete(sessionRecordings)
|
||||||
.where(
|
.where(
|
||||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
||||||
)
|
);
|
||||||
.returning({ id: sessionRecordings.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
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.
|
* file stays on disk regardless — deleting only the row would orphan it.
|
||||||
*/
|
*/
|
||||||
async anonymizeByUserId(userId: string): Promise<number> {
|
async anonymizeByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.update(sessionRecordings)
|
.update(sessionRecordings)
|
||||||
.set({ userId: null })
|
.set({ userId: null })
|
||||||
.where(eq(sessionRecordings.userId, userId))
|
.where(eq(sessionRecordings.userId, userId));
|
||||||
.returning({ id: sessionRecordings.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(sessionRecordings)
|
.delete(sessionRecordings)
|
||||||
.where(eq(sessionRecordings.userId, userId))
|
.where(eq(sessionRecordings.userId, userId));
|
||||||
.returning({ id: sessionRecordings.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByHostId(hostId: number): Promise<number> {
|
async deleteByHostId(hostId: number): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(sessionRecordings)
|
.delete(sessionRecordings)
|
||||||
.where(eq(sessionRecordings.hostId, hostId))
|
.where(eq(sessionRecordings.hostId, hostId));
|
||||||
.returning({ id: sessionRecordings.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||||
@@ -247,16 +245,15 @@ export class SessionRecordingRepository {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(sessionRecordings)
|
.delete(sessionRecordings)
|
||||||
.where(inArray(sessionRecordings.hostId, hostIds))
|
.where(inArray(sessionRecordings.hostId, hostIds));
|
||||||
.returning({ id: sessionRecordings.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { and, eq, lte, ne } from "drizzle-orm";
|
import { and, eq, lte, ne } from "drizzle-orm";
|
||||||
import { sessions } from "../db/schema.js";
|
import { sessions } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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 SessionRecord = typeof sessions.$inferSelect;
|
||||||
export type NewSessionRecord = typeof sessions.$inferInsert;
|
export type NewSessionRecord = typeof sessions.$inferInsert;
|
||||||
@@ -12,10 +14,7 @@ export class SessionRepository {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(session: NewSessionRecord): Promise<SessionRecord> {
|
async create(session: NewSessionRecord): Promise<SessionRecord> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await insertReturning(this.context, sessions, session);
|
||||||
.insert(sessions)
|
|
||||||
.values(session)
|
|
||||||
.returning();
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
@@ -72,13 +71,12 @@ export class SessionRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async revoke(id: string): Promise<boolean> {
|
async revoke(id: string): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(sessions)
|
.delete(sessions)
|
||||||
.where(eq(sessions.id, id))
|
.where(eq(sessions.id, id));
|
||||||
.returning({ id: sessions.id });
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows.length > 0;
|
return rowsAffected(result) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeAllForUser(
|
async revokeAllForUser(
|
||||||
@@ -89,23 +87,19 @@ export class SessionRepository {
|
|||||||
? and(eq(sessions.userId, userId), ne(sessions.id, exceptSessionId))
|
? and(eq(sessions.userId, userId), ne(sessions.id, exceptSessionId))
|
||||||
: eq(sessions.userId, userId);
|
: eq(sessions.userId, userId);
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle.delete(sessions).where(where);
|
||||||
.delete(sessions)
|
|
||||||
.where(where)
|
|
||||||
.returning({ id: sessions.id });
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteExpired(now = new Date()): Promise<number> {
|
async deleteExpired(now = new Date()): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(sessions)
|
.delete(sessions)
|
||||||
.where(lte(sessions.expiresAt, now.toISOString()))
|
.where(lte(sessions.expiresAt, now.toISOString()));
|
||||||
.returning({ id: sessions.id });
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
users,
|
users,
|
||||||
} from "../db/schema.js";
|
} from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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 SessionShareRecord = typeof sessionShares.$inferSelect;
|
||||||
export type SessionShareParticipantRecord =
|
export type SessionShareParticipantRecord =
|
||||||
@@ -49,22 +51,19 @@ export class SessionShareRepository {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(input: SessionShareCreateInput): Promise<SessionShareRecord> {
|
async create(input: SessionShareCreateInput): Promise<SessionShareRecord> {
|
||||||
const [created] = await this.context.drizzle
|
const [created] = await insertReturning(this.context, sessionShares, {
|
||||||
.insert(sessionShares)
|
id: input.id,
|
||||||
.values({
|
hostId: input.hostId,
|
||||||
id: input.id,
|
ownerUserId: input.ownerUserId,
|
||||||
hostId: input.hostId,
|
protocol: input.protocol,
|
||||||
ownerUserId: input.ownerUserId,
|
sessionId: input.sessionId,
|
||||||
protocol: input.protocol,
|
tabInstanceId: input.tabInstanceId ?? null,
|
||||||
sessionId: input.sessionId,
|
shareType: input.shareType,
|
||||||
tabInstanceId: input.tabInstanceId ?? null,
|
targetUserId: input.targetUserId ?? null,
|
||||||
shareType: input.shareType,
|
linkToken: input.linkToken ?? null,
|
||||||
targetUserId: input.targetUserId ?? null,
|
permissionLevel: input.permissionLevel,
|
||||||
linkToken: input.linkToken ?? null,
|
expiresAt: input.expiresAt,
|
||||||
permissionLevel: input.permissionLevel,
|
});
|
||||||
expiresAt: input.expiresAt,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return created;
|
return created;
|
||||||
@@ -151,7 +150,7 @@ export class SessionShareRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async revoke(shareId: string, requestingUserId: string): Promise<boolean> {
|
async revoke(shareId: string, requestingUserId: string): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.update(sessionShares)
|
.update(sessionShares)
|
||||||
.set({ revokedAt: new Date().toISOString() })
|
.set({ revokedAt: new Date().toISOString() })
|
||||||
.where(
|
.where(
|
||||||
@@ -159,38 +158,35 @@ export class SessionShareRepository {
|
|||||||
eq(sessionShares.id, shareId),
|
eq(sessionShares.id, shareId),
|
||||||
eq(sessionShares.ownerUserId, requestingUserId),
|
eq(sessionShares.ownerUserId, requestingUserId),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: sessionShares.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
return rows.length > 0;
|
return rowsAffected(result) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeAsAdmin(shareId: string): Promise<boolean> {
|
async revokeAsAdmin(shareId: string): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.update(sessionShares)
|
.update(sessionShares)
|
||||||
.set({ revokedAt: new Date().toISOString() })
|
.set({ revokedAt: new Date().toISOString() })
|
||||||
.where(eq(sessionShares.id, shareId))
|
.where(eq(sessionShares.id, shareId));
|
||||||
.returning({ id: sessionShares.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
return rows.length > 0;
|
return rowsAffected(result) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteExpiredShares(now = new Date().toISOString()): Promise<number> {
|
async deleteExpiredShares(now = new Date().toISOString()): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(sessionShares)
|
.delete(sessionShares)
|
||||||
.where(lt(sessionShares.expiresAt, now))
|
.where(lt(sessionShares.expiresAt, now));
|
||||||
.returning({ id: sessionShares.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async touchShareUsage(
|
async touchShareUsage(
|
||||||
@@ -213,10 +209,11 @@ export class SessionShareRepository {
|
|||||||
userId: string | null,
|
userId: string | null,
|
||||||
guestLabel: string | null,
|
guestLabel: string | null,
|
||||||
): Promise<SessionShareParticipantRecord> {
|
): Promise<SessionShareParticipantRecord> {
|
||||||
const [created] = await this.context.drizzle
|
const [created] = await insertReturning(
|
||||||
.insert(sessionShareParticipants)
|
this.context,
|
||||||
.values({ shareId, userId, guestLabel })
|
sessionShareParticipants,
|
||||||
.returning();
|
{ shareId, userId, guestLabel },
|
||||||
|
);
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
@@ -230,15 +227,14 @@ export class SessionShareRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteSharesForHost(hostId: number): Promise<number> {
|
async deleteSharesForHost(hostId: number): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(sessionShares)
|
.delete(sessionShares)
|
||||||
.where(eq(sessionShares.hostId, hostId))
|
.where(eq(sessionShares.hostId, hostId));
|
||||||
.returning({ id: sessionShares.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -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<string, string> | 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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { eq, like } from "drizzle-orm";
|
import { eq, like } from "drizzle-orm";
|
||||||
import { settings } from "../db/schema.js";
|
import { settings } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { forgetCachedSetting, updateCachedSetting } from "./settings-cache.js";
|
||||||
|
import { deleteReturning } from "./returning.js";
|
||||||
|
|
||||||
export class SettingsRepository {
|
export class SettingsRepository {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -34,6 +36,9 @@ export class SettingsRepository {
|
|||||||
const existing = await this.get(key);
|
const existing = await this.get(key);
|
||||||
if (existing === null) {
|
if (existing === null) {
|
||||||
await this.context.drizzle.insert(settings).values({ key, value });
|
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();
|
await this.afterWrite();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -42,6 +47,7 @@ export class SettingsRepository {
|
|||||||
.update(settings)
|
.update(settings)
|
||||||
.set({ value })
|
.set({ value })
|
||||||
.where(eq(settings.key, key));
|
.where(eq(settings.key, key));
|
||||||
|
updateCachedSetting(key, value);
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,14 +57,17 @@ export class SettingsRepository {
|
|||||||
|
|
||||||
async delete(key: string): Promise<void> {
|
async delete(key: string): Promise<void> {
|
||||||
await this.context.drizzle.delete(settings).where(eq(settings.key, key));
|
await this.context.drizzle.delete(settings).where(eq(settings.key, key));
|
||||||
|
forgetCachedSetting(key);
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteLike(pattern: string): Promise<number> {
|
async deleteLike(pattern: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await deleteReturning(
|
||||||
.delete(settings)
|
this.context,
|
||||||
.where(like(settings.key, pattern))
|
settings,
|
||||||
.returning({ key: settings.key });
|
like(settings.key, pattern),
|
||||||
|
);
|
||||||
|
for (const row of rows) forgetCachedSetting(row.key);
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows.length;
|
return rows.length;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { and, eq, inArray, or } from "drizzle-orm";
|
import { and, eq, inArray, or } from "drizzle-orm";
|
||||||
import { hostAccess, hosts, sharedHostSecrets } from "../db/schema.js";
|
import { hostAccess, hosts, sharedHostSecrets } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { rowsAffected } from "./mutation-result.js";
|
||||||
|
|
||||||
export type SharedHostSecretRecord = typeof sharedHostSecrets.$inferSelect;
|
export type SharedHostSecretRecord = typeof sharedHostSecrets.$inferSelect;
|
||||||
export type NewSharedHostSecretRecord = typeof sharedHostSecrets.$inferInsert;
|
export type NewSharedHostSecretRecord = typeof sharedHostSecrets.$inferInsert;
|
||||||
@@ -108,16 +109,15 @@ export class SharedHostSecretsRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteByHostAccessId(hostAccessId: number): Promise<number> {
|
async deleteByHostAccessId(hostAccessId: number): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(sharedHostSecrets)
|
.delete(sharedHostSecrets)
|
||||||
.where(eq(sharedHostSecrets.hostAccessId, hostAccessId))
|
.where(eq(sharedHostSecrets.hostAccessId, hostAccessId));
|
||||||
.returning({ id: sharedHostSecrets.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteForRoleMember(
|
async deleteForRoleMember(
|
||||||
@@ -148,29 +148,27 @@ export class SharedHostSecretsRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteByOriginalCredentialId(credentialId: number): Promise<number> {
|
async deleteByOriginalCredentialId(credentialId: number): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(sharedHostSecrets)
|
.delete(sharedHostSecrets)
|
||||||
.where(eq(sharedHostSecrets.originalCredentialId, credentialId))
|
.where(eq(sharedHostSecrets.originalCredentialId, credentialId));
|
||||||
.returning({ id: sharedHostSecrets.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByTargetUserId(userId: string): Promise<number> {
|
async deleteByTargetUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(sharedHostSecrets)
|
.delete(sharedHostSecrets)
|
||||||
.where(eq(sharedHostSecrets.targetUserId, userId))
|
.where(eq(sharedHostSecrets.targetUserId, userId));
|
||||||
.returning({ id: sharedHostSecrets.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findHostIdsReferencingCredential(
|
async findHostIdsReferencingCredential(
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { and, asc, eq, sql } from "drizzle-orm";
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { snippetFolders, snippets } from "../db/schema.js";
|
import { snippetFolders, snippets } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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 SnippetRecord = typeof snippets.$inferSelect;
|
||||||
export type SnippetFolderRecord = typeof snippetFolders.$inferSelect;
|
export type SnippetFolderRecord = typeof snippetFolders.$inferSelect;
|
||||||
@@ -84,11 +90,16 @@ export class SnippetRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listSnippetsForExport(userId: string): Promise<SnippetRecord[]> {
|
async listSnippetsForExport(userId: string): Promise<SnippetRecord[]> {
|
||||||
return this.context.drizzle
|
return (
|
||||||
.select()
|
this.context.drizzle
|
||||||
.from(snippets)
|
.select()
|
||||||
.where(eq(snippets.userId, userId))
|
.from(snippets)
|
||||||
.orderBy(asc(snippets.folder), asc(snippets.order));
|
.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<SnippetFolderRecord[]> {
|
async listFoldersForExport(userId: string): Promise<SnippetFolderRecord[]> {
|
||||||
@@ -149,19 +160,16 @@ export class SnippetRepository {
|
|||||||
? await this.nextOrderForFolder(userId, folderValue)
|
? await this.nextOrderForFolder(userId, folderValue)
|
||||||
: input.order;
|
: input.order;
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const rows = await insertReturning(this.context, snippets, {
|
||||||
.insert(snippets)
|
syncId: randomUUID(),
|
||||||
.values({
|
userId,
|
||||||
syncId: randomUUID(),
|
name: input.name.trim(),
|
||||||
userId,
|
content: input.content.trim(),
|
||||||
name: input.name.trim(),
|
description: input.description?.trim() || null,
|
||||||
content: input.content.trim(),
|
folder: input.folder?.trim() || null,
|
||||||
description: input.description?.trim() || null,
|
order,
|
||||||
folder: input.folder?.trim() || null,
|
hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null,
|
||||||
order,
|
});
|
||||||
hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0];
|
return rows[0];
|
||||||
@@ -200,11 +208,12 @@ export class SnippetRepository {
|
|||||||
? JSON.stringify(input.hostFilter)
|
? JSON.stringify(input.hostFilter)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const rows = await updateReturning(
|
||||||
.update(snippets)
|
this.context,
|
||||||
.set(updateFields)
|
snippets,
|
||||||
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
|
updateFields,
|
||||||
.returning();
|
and(eq(snippets.id, snippetId), eq(snippets.userId, userId)),
|
||||||
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return { existing, updated: rows[0] };
|
return { existing, updated: rows[0] };
|
||||||
@@ -229,23 +238,21 @@ export class SnippetRepository {
|
|||||||
snippetsDeleted: number;
|
snippetsDeleted: number;
|
||||||
foldersDeleted: number;
|
foldersDeleted: number;
|
||||||
}> {
|
}> {
|
||||||
const deletedSnippets = await this.context.drizzle
|
const snippetResult = await this.context.drizzle
|
||||||
.delete(snippets)
|
.delete(snippets)
|
||||||
.where(eq(snippets.userId, userId))
|
.where(eq(snippets.userId, userId));
|
||||||
.returning({ id: snippets.id });
|
|
||||||
|
|
||||||
const deletedFolders = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(snippetFolders)
|
.delete(snippetFolders)
|
||||||
.where(eq(snippetFolders.userId, userId))
|
.where(eq(snippetFolders.userId, userId));
|
||||||
.returning({ id: snippetFolders.id });
|
|
||||||
|
|
||||||
if (deletedSnippets.length > 0 || deletedFolders.length > 0) {
|
if (rowsAffected(snippetResult) > 0 || rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
snippetsDeleted: deletedSnippets.length,
|
snippetsDeleted: rowsAffected(snippetResult),
|
||||||
foldersDeleted: deletedFolders.length,
|
foldersDeleted: rowsAffected(result),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,16 +384,13 @@ export class SnippetRepository {
|
|||||||
const existing = await this.findFolderByName(userId, name);
|
const existing = await this.findFolderByName(userId, name);
|
||||||
if (existing) return null;
|
if (existing) return null;
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const rows = await insertReturning(this.context, snippetFolders, {
|
||||||
.insert(snippetFolders)
|
syncId: randomUUID(),
|
||||||
.values({
|
userId,
|
||||||
syncId: randomUUID(),
|
name: name.trim(),
|
||||||
userId,
|
color: color?.trim() || null,
|
||||||
name: name.trim(),
|
icon: icon?.trim() || null,
|
||||||
color: color?.trim() || null,
|
});
|
||||||
icon: icon?.trim() || null,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (triggerSave) {
|
if (triggerSave) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
@@ -414,13 +418,12 @@ export class SnippetRepository {
|
|||||||
if (color !== undefined) updateFields.color = color?.trim() || null;
|
if (color !== undefined) updateFields.color = color?.trim() || null;
|
||||||
if (icon !== undefined) updateFields.icon = icon?.trim() || null;
|
if (icon !== undefined) updateFields.icon = icon?.trim() || null;
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const rows = await updateReturning(
|
||||||
.update(snippetFolders)
|
this.context,
|
||||||
.set(updateFields)
|
snippetFolders,
|
||||||
.where(
|
updateFields,
|
||||||
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
|
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
|
||||||
)
|
);
|
||||||
.returning();
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
@@ -465,15 +468,14 @@ export class SnippetRepository {
|
|||||||
.set({ folder: null })
|
.set({ folder: null })
|
||||||
.where(and(eq(snippets.userId, userId), eq(snippets.folder, name)));
|
.where(and(eq(snippets.userId, userId), eq(snippets.folder, name)));
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const rows = await deleteReturning(
|
||||||
.delete(snippetFolders)
|
this.context,
|
||||||
.where(
|
snippetFolders,
|
||||||
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
|
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
|
||||||
)
|
);
|
||||||
.returning({ syncId: snippetFolders.syncId });
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0] ?? null;
|
return rows[0] ? { syncId: rows[0].syncId } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findFolderByName(
|
private async findFolderByName(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getCurrentRepositorySqlite } from "./factory.js";
|
import { getCurrentRepositorySqlite } from "./factory.js";
|
||||||
|
import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
|
||||||
|
|
||||||
export interface SqliteForeignKeyClient {
|
export interface SqliteForeignKeyClient {
|
||||||
exec(sql: string): unknown;
|
exec(sql: string): unknown;
|
||||||
@@ -16,8 +17,28 @@ export async function withSqliteForeignKeysDisabled<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<T>(
|
export async function withCurrentSqliteForeignKeysDisabled<T>(
|
||||||
operation: () => Promise<T>,
|
operation: () => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
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);
|
return withSqliteForeignKeysDisabled(getCurrentRepositorySqlite(), operation);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import { sshCredentialUsage } from "../db/schema.js";
|
import { sshCredentialUsage } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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;
|
export type SshCredentialUsageRecord = typeof sshCredentialUsage.$inferSelect;
|
||||||
|
|
||||||
@@ -22,38 +24,37 @@ export class SshCredentialUsageRepository {
|
|||||||
hostId: number,
|
hostId: number,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<SshCredentialUsageRecord> {
|
): Promise<SshCredentialUsageRecord> {
|
||||||
const [created] = await this.context.drizzle
|
const [created] = await insertReturning(this.context, sshCredentialUsage, {
|
||||||
.insert(sshCredentialUsage)
|
credentialId,
|
||||||
.values({ credentialId, hostId, userId })
|
hostId,
|
||||||
.returning();
|
userId,
|
||||||
|
});
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(sshCredentialUsage)
|
.delete(sshCredentialUsage)
|
||||||
.where(eq(sshCredentialUsage.userId, userId))
|
.where(eq(sshCredentialUsage.userId, userId));
|
||||||
.returning({ id: sshCredentialUsage.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByHostId(hostId: number): Promise<number> {
|
async deleteByHostId(hostId: number): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(sshCredentialUsage)
|
.delete(sshCredentialUsage)
|
||||||
.where(eq(sshCredentialUsage.hostId, hostId))
|
.where(eq(sshCredentialUsage.hostId, hostId));
|
||||||
.returning({ id: sshCredentialUsage.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||||
@@ -61,16 +62,15 @@ export class SshCredentialUsageRepository {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(sshCredentialUsage)
|
.delete(sshCredentialUsage)
|
||||||
.where(inArray(sshCredentialUsage.hostId, hostIds))
|
.where(inArray(sshCredentialUsage.hostId, hostIds));
|
||||||
.returning({ id: sshCredentialUsage.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { asc, eq } from "drizzle-orm";
|
import { asc, eq } from "drizzle-orm";
|
||||||
import { ssoProviders, users } from "../db/schema.js";
|
import { ssoProviders, users } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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 SsoProviderRecord = typeof ssoProviders.$inferSelect;
|
||||||
export type NewSsoProviderRecord = typeof ssoProviders.$inferInsert;
|
export type NewSsoProviderRecord = typeof ssoProviders.$inferInsert;
|
||||||
@@ -76,10 +78,7 @@ export class SsoProviderRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(provider: NewSsoProviderRecord): Promise<SsoProviderRecord> {
|
async create(provider: NewSsoProviderRecord): Promise<SsoProviderRecord> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await insertReturning(this.context, ssoProviders, provider);
|
||||||
.insert(ssoProviders)
|
|
||||||
.values(provider)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0];
|
return rows[0];
|
||||||
@@ -89,27 +88,27 @@ export class SsoProviderRepository {
|
|||||||
id: number,
|
id: number,
|
||||||
update: SsoProviderUpdate,
|
update: SsoProviderUpdate,
|
||||||
): Promise<SsoProviderRecord | null> {
|
): Promise<SsoProviderRecord | null> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await updateReturning(
|
||||||
.update(ssoProviders)
|
this.context,
|
||||||
.set(update)
|
ssoProviders,
|
||||||
.where(eq(ssoProviders.id, id))
|
update,
|
||||||
.returning();
|
eq(ssoProviders.id, id),
|
||||||
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: number): Promise<boolean> {
|
async delete(id: number): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(ssoProviders)
|
.delete(ssoProviders)
|
||||||
.where(eq(ssoProviders.id, id))
|
.where(eq(ssoProviders.id, id));
|
||||||
.returning({ id: ssoProviders.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length > 0;
|
return rowsAffected(result) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async countUsersByProviderId(providerId: number): Promise<number> {
|
async countUsersByProviderId(providerId: number): Promise<number> {
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { eq } from "drizzle-orm";
|
|||||||
import { termixIdentityCa } from "../db/schema.js";
|
import { termixIdentityCa } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
import { DataCrypto } from "../../utils/data-crypto.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 TermixIdentityCaRecord = typeof termixIdentityCa.$inferSelect;
|
||||||
export type NewTermixIdentityCaRecord = typeof termixIdentityCa.$inferInsert;
|
export type NewTermixIdentityCaRecord = typeof termixIdentityCa.$inferInsert;
|
||||||
@@ -54,27 +60,7 @@ export class TermixIdentityCaRepository {
|
|||||||
ca: NewTermixIdentityCaRecord,
|
ca: NewTermixIdentityCaRecord,
|
||||||
): Promise<TermixIdentityCaRecord> {
|
): Promise<TermixIdentityCaRecord> {
|
||||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||||
const result = this.context.drizzle.transaction((tx) => {
|
const result = await this.insertThenEncrypt(userId, ca, userDataKey);
|
||||||
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];
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return DataCrypto.decryptRecord(
|
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<TermixIdentityCaRecord> {
|
||||||
|
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(
|
async updateEncryptedForIdentity(
|
||||||
userId: string,
|
userId: string,
|
||||||
identityId: number,
|
identityId: number,
|
||||||
@@ -103,43 +164,42 @@ export class TermixIdentityCaRepository {
|
|||||||
).privateKey
|
).privateKey
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const rows = await updateReturning(
|
||||||
.update(termixIdentityCa)
|
this.context,
|
||||||
.set({
|
termixIdentityCa,
|
||||||
|
{
|
||||||
...update,
|
...update,
|
||||||
...(encryptedPrivateKey ? { privateKey: encryptedPrivateKey } : {}),
|
...(encryptedPrivateKey ? { privateKey: encryptedPrivateKey } : {}),
|
||||||
})
|
},
|
||||||
.where(eq(termixIdentityCa.identityId, identityId))
|
eq(termixIdentityCa.identityId, identityId),
|
||||||
.returning();
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return this.decryptOne(rows[0] ?? null, userId);
|
return this.decryptOne(rows[0] ?? null, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByIdentityId(identityId: number): Promise<boolean> {
|
async deleteByIdentityId(identityId: number): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(termixIdentityCa)
|
.delete(termixIdentityCa)
|
||||||
.where(eq(termixIdentityCa.identityId, identityId))
|
.where(eq(termixIdentityCa.identityId, identityId));
|
||||||
.returning({ id: termixIdentityCa.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length > 0;
|
return rowsAffected(result) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(termixIdentityCa)
|
.delete(termixIdentityCa)
|
||||||
.where(eq(termixIdentityCa.userId, userId))
|
.where(eq(termixIdentityCa.userId, userId));
|
||||||
.returning({ id: termixIdentityCa.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private decryptOne<T extends Record<string, unknown>>(
|
private decryptOne<T extends Record<string, unknown>>(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { and, asc, eq } from "drizzle-orm";
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
import { termixIdentities, termixIdentityKeys } from "../db/schema.js";
|
import { termixIdentities, termixIdentityKeys } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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 TermixIdentityRecord = typeof termixIdentities.$inferSelect;
|
||||||
export type NewTermixIdentityRecord = typeof termixIdentities.$inferInsert;
|
export type NewTermixIdentityRecord = typeof termixIdentities.$inferInsert;
|
||||||
@@ -57,10 +59,11 @@ export class TermixIdentityRepository {
|
|||||||
async createIdentity(
|
async createIdentity(
|
||||||
identity: NewTermixIdentityRecord,
|
identity: NewTermixIdentityRecord,
|
||||||
): Promise<TermixIdentityRecord> {
|
): Promise<TermixIdentityRecord> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await insertReturning(
|
||||||
.insert(termixIdentities)
|
this.context,
|
||||||
.values(identity)
|
termixIdentities,
|
||||||
.returning();
|
identity,
|
||||||
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0];
|
return rows[0];
|
||||||
@@ -70,11 +73,12 @@ export class TermixIdentityRepository {
|
|||||||
userId: string,
|
userId: string,
|
||||||
update: TermixIdentityUpdate,
|
update: TermixIdentityUpdate,
|
||||||
): Promise<TermixIdentityRecord | null> {
|
): Promise<TermixIdentityRecord | null> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await updateReturning(
|
||||||
.update(termixIdentities)
|
this.context,
|
||||||
.set(update)
|
termixIdentities,
|
||||||
.where(eq(termixIdentities.userId, userId))
|
update,
|
||||||
.returning();
|
eq(termixIdentities.userId, userId),
|
||||||
|
);
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
@@ -84,39 +88,36 @@ export class TermixIdentityRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteIdentityForUser(userId: string): Promise<boolean> {
|
async deleteIdentityForUser(userId: string): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(termixIdentities)
|
.delete(termixIdentities)
|
||||||
.where(eq(termixIdentities.userId, userId))
|
.where(eq(termixIdentities.userId, userId));
|
||||||
.returning({ id: termixIdentities.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length > 0;
|
return rowsAffected(result) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<{
|
async deleteByUserId(userId: string): Promise<{
|
||||||
identitiesDeleted: number;
|
identitiesDeleted: number;
|
||||||
keysDeleted: number;
|
keysDeleted: number;
|
||||||
}> {
|
}> {
|
||||||
const keyRows = await this.context.drizzle
|
const keyResult = await this.context.drizzle
|
||||||
.delete(termixIdentityKeys)
|
.delete(termixIdentityKeys)
|
||||||
.where(eq(termixIdentityKeys.userId, userId))
|
.where(eq(termixIdentityKeys.userId, userId));
|
||||||
.returning({ id: termixIdentityKeys.id });
|
|
||||||
|
|
||||||
const identityRows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(termixIdentities)
|
.delete(termixIdentities)
|
||||||
.where(eq(termixIdentities.userId, userId))
|
.where(eq(termixIdentities.userId, userId));
|
||||||
.returning({ id: termixIdentities.id });
|
|
||||||
|
|
||||||
if (keyRows.length > 0 || identityRows.length > 0) {
|
if (rowsAffected(keyResult) > 0 || rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
identitiesDeleted: identityRows.length,
|
identitiesDeleted: rowsAffected(result),
|
||||||
keysDeleted: keyRows.length,
|
keysDeleted: rowsAffected(keyResult),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,10 +171,7 @@ export class TermixIdentityRepository {
|
|||||||
async createKey(
|
async createKey(
|
||||||
key: NewTermixIdentityKeyRecord,
|
key: NewTermixIdentityKeyRecord,
|
||||||
): Promise<TermixIdentityKeyRecord> {
|
): Promise<TermixIdentityKeyRecord> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await insertReturning(this.context, termixIdentityKeys, key);
|
||||||
.insert(termixIdentityKeys)
|
|
||||||
.values(key)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0];
|
return rows[0];
|
||||||
@@ -184,16 +182,12 @@ export class TermixIdentityRepository {
|
|||||||
id: number,
|
id: number,
|
||||||
update: TermixIdentityKeyUpdate,
|
update: TermixIdentityKeyUpdate,
|
||||||
): Promise<TermixIdentityKeyRecord | null> {
|
): Promise<TermixIdentityKeyRecord | null> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await updateReturning(
|
||||||
.update(termixIdentityKeys)
|
this.context,
|
||||||
.set(update)
|
termixIdentityKeys,
|
||||||
.where(
|
update,
|
||||||
and(
|
and(eq(termixIdentityKeys.id, id), eq(termixIdentityKeys.userId, userId)),
|
||||||
eq(termixIdentityKeys.id, id),
|
);
|
||||||
eq(termixIdentityKeys.userId, userId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
@@ -203,21 +197,20 @@ export class TermixIdentityRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteKeyForUser(userId: string, id: number): Promise<boolean> {
|
async deleteKeyForUser(userId: string, id: number): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(termixIdentityKeys)
|
.delete(termixIdentityKeys)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(termixIdentityKeys.id, id),
|
eq(termixIdentityKeys.id, id),
|
||||||
eq(termixIdentityKeys.userId, userId),
|
eq(termixIdentityKeys.userId, userId),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: termixIdentityKeys.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length > 0;
|
return rowsAffected(result) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findKeyForUser(
|
async findKeyForUser(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { tmuxSessionTags } from "../db/schema.js";
|
import { tmuxSessionTags } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { rowsAffected } from "./mutation-result.js";
|
||||||
|
|
||||||
export type TmuxSessionTagRecord = typeof tmuxSessionTags.$inferSelect;
|
export type TmuxSessionTagRecord = typeof tmuxSessionTags.$inferSelect;
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ export class TmuxSessionTagRepository {
|
|||||||
sessionName: string,
|
sessionName: string,
|
||||||
newSessionName: string,
|
newSessionName: string,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.update(tmuxSessionTags)
|
.update(tmuxSessionTags)
|
||||||
.set({ sessionName: newSessionName })
|
.set({ sessionName: newSessionName })
|
||||||
.where(
|
.where(
|
||||||
@@ -53,35 +54,33 @@ export class TmuxSessionTagRepository {
|
|||||||
eq(tmuxSessionTags.hostId, hostId),
|
eq(tmuxSessionTags.hostId, hostId),
|
||||||
eq(tmuxSessionTags.sessionName, sessionName),
|
eq(tmuxSessionTags.sessionName, sessionName),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: tmuxSessionTags.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSessionForHost(
|
async deleteSessionForHost(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
sessionName: string,
|
sessionName: string,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(tmuxSessionTags)
|
.delete(tmuxSessionTags)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(tmuxSessionTags.hostId, hostId),
|
eq(tmuxSessionTags.hostId, hostId),
|
||||||
eq(tmuxSessionTags.sessionName, sessionName),
|
eq(tmuxSessionTags.sessionName, sessionName),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: tmuxSessionTags.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async replaceForUserHostSession(
|
async replaceForUserHostSession(
|
||||||
@@ -90,7 +89,7 @@ export class TmuxSessionTagRepository {
|
|||||||
sessionName: string,
|
sessionName: string,
|
||||||
tags: string[],
|
tags: string[],
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const deletedRows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(tmuxSessionTags)
|
.delete(tmuxSessionTags)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -98,8 +97,7 @@ export class TmuxSessionTagRepository {
|
|||||||
eq(tmuxSessionTags.hostId, hostId),
|
eq(tmuxSessionTags.hostId, hostId),
|
||||||
eq(tmuxSessionTags.sessionName, sessionName),
|
eq(tmuxSessionTags.sessionName, sessionName),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: tmuxSessionTags.id });
|
|
||||||
|
|
||||||
if (tags.length > 0) {
|
if (tags.length > 0) {
|
||||||
await this.context.drizzle.insert(tmuxSessionTags).values(
|
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) {
|
if (changedRows > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
@@ -121,16 +119,15 @@ export class TmuxSessionTagRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(tmuxSessionTags)
|
.delete(tmuxSessionTags)
|
||||||
.where(eq(tmuxSessionTags.userId, userId))
|
.where(eq(tmuxSessionTags.userId, userId));
|
||||||
.returning({ id: tmuxSessionTags.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { and, desc, eq, inArray, or } from "drizzle-orm";
|
import { and, desc, eq, inArray, or } from "drizzle-orm";
|
||||||
import { transferRecent } from "../db/schema.js";
|
import { transferRecent } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { rowsAffected } from "./mutation-result.js";
|
||||||
|
|
||||||
export type TransferRecentRecord = typeof transferRecent.$inferSelect;
|
export type TransferRecentRecord = typeof transferRecent.$inferSelect;
|
||||||
|
|
||||||
@@ -100,47 +101,44 @@ export class TransferRecentRepository {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleted = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(transferRecent)
|
.delete(transferRecent)
|
||||||
.where(inArray(transferRecent.id, idsToDelete))
|
.where(inArray(transferRecent.id, idsToDelete));
|
||||||
.returning({ id: transferRecent.id });
|
|
||||||
|
|
||||||
if (deleted.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return deleted.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(transferRecent)
|
.delete(transferRecent)
|
||||||
.where(eq(transferRecent.userId, userId))
|
.where(eq(transferRecent.userId, userId));
|
||||||
.returning({ id: transferRecent.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByHostId(hostId: number): Promise<number> {
|
async deleteByHostId(hostId: number): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(transferRecent)
|
.delete(transferRecent)
|
||||||
.where(
|
.where(
|
||||||
or(
|
or(
|
||||||
eq(transferRecent.sourceHostId, hostId),
|
eq(transferRecent.sourceHostId, hostId),
|
||||||
eq(transferRecent.destHostId, hostId),
|
eq(transferRecent.destHostId, hostId),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: transferRecent.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||||
@@ -148,21 +146,20 @@ export class TransferRecentRepository {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(transferRecent)
|
.delete(transferRecent)
|
||||||
.where(
|
.where(
|
||||||
or(
|
or(
|
||||||
inArray(transferRecent.sourceHostId, hostIds),
|
inArray(transferRecent.sourceHostId, hostIds),
|
||||||
inArray(transferRecent.destHostId, hostIds),
|
inArray(transferRecent.destHostId, hostIds),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: transferRecent.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { userPreferences } from "../db/schema.js";
|
import { userPreferences } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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 UserPreferenceRecord = typeof userPreferences.$inferSelect;
|
||||||
export type NewUserPreferenceRecord = typeof userPreferences.$inferInsert;
|
export type NewUserPreferenceRecord = typeof userPreferences.$inferInsert;
|
||||||
@@ -31,34 +33,36 @@ export class UserPreferenceRepository {
|
|||||||
const existing = await this.findByUserId(userId);
|
const existing = await this.findByUserId(userId);
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const rows = await this.context.drizzle
|
const rows = await insertReturningWhere(
|
||||||
.insert(userPreferences)
|
this.context,
|
||||||
.values({ userId, ...update })
|
userPreferences,
|
||||||
.returning();
|
{ userId, ...update },
|
||||||
|
eq(userPreferences.userId, userId),
|
||||||
|
);
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const rows = await updateReturning(
|
||||||
.update(userPreferences)
|
this.context,
|
||||||
.set(update)
|
userPreferences,
|
||||||
.where(eq(userPreferences.userId, userId))
|
update,
|
||||||
.returning();
|
eq(userPreferences.userId, userId),
|
||||||
|
);
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(userPreferences)
|
.delete(userPreferences)
|
||||||
.where(eq(userPreferences.userId, userId))
|
.where(eq(userPreferences.userId, userId));
|
||||||
.returning({ userId: userPreferences.userId });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import { users } from "../db/schema.js";
|
import { users } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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 UserRecord = typeof users.$inferSelect;
|
||||||
export type NewUserRecord = typeof users.$inferInsert;
|
export type NewUserRecord = typeof users.$inferInsert;
|
||||||
@@ -62,10 +64,7 @@ export class UserRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(user: NewUserRecord): Promise<UserRecord> {
|
async create(user: NewUserRecord): Promise<UserRecord> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await insertReturning(this.context, users, user);
|
||||||
.insert(users)
|
|
||||||
.values(user)
|
|
||||||
.returning();
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
@@ -73,17 +72,10 @@ export class UserRepository {
|
|||||||
async createFirstLocalUser(
|
async createFirstLocalUser(
|
||||||
user: NewFirstLocalUserRecord,
|
user: NewFirstLocalUserRecord,
|
||||||
): Promise<{ user: UserRecord; isFirstUser: boolean }> {
|
): Promise<{ user: UserRecord; isFirstUser: boolean }> {
|
||||||
const result = this.context.drizzle.transaction((tx) => {
|
const result = await this.createCheckingIfFirst((isFirstUser) => ({
|
||||||
const existingUsers = tx.select({ id: users.id }).from(users).all();
|
...user,
|
||||||
const isFirstUser = existingUsers.length === 0;
|
isAdmin: isFirstUser,
|
||||||
const rows = tx
|
}));
|
||||||
.insert(users)
|
|
||||||
.values({ ...user, isAdmin: isFirstUser })
|
|
||||||
.returning()
|
|
||||||
.all();
|
|
||||||
|
|
||||||
return { user: rows[0], isFirstUser };
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return result;
|
return result;
|
||||||
@@ -92,41 +84,87 @@ export class UserRepository {
|
|||||||
async createFirstSsoUser(
|
async createFirstSsoUser(
|
||||||
user: NewUserRecord,
|
user: NewUserRecord,
|
||||||
): Promise<{ user: UserRecord; isFirstUser: boolean }> {
|
): Promise<{ user: UserRecord; isFirstUser: boolean }> {
|
||||||
const result = this.context.drizzle.transaction((tx) => {
|
const result = await this.createCheckingIfFirst((isFirstUser) => ({
|
||||||
const existingUsers = tx.select({ id: users.id }).from(users).all();
|
...user,
|
||||||
const isFirstUser = existingUsers.length === 0;
|
isAdmin: isFirstUser || Boolean(user.isAdmin),
|
||||||
const rows = tx
|
}));
|
||||||
.insert(users)
|
|
||||||
.values({ ...user, isAdmin: isFirstUser || Boolean(user.isAdmin) })
|
|
||||||
.returning()
|
|
||||||
.all();
|
|
||||||
|
|
||||||
return { user: rows[0], isFirstUser };
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return result;
|
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<UserRecord | null> {
|
async update(id: string, update: UserUpdate): Promise<UserRecord | null> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await updateReturning(
|
||||||
.update(users)
|
this.context,
|
||||||
.set(update)
|
users,
|
||||||
.where(eq(users.id, id))
|
update,
|
||||||
.returning();
|
eq(users.id, id),
|
||||||
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: string): Promise<boolean> {
|
async delete(id: string): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(users)
|
.delete(users)
|
||||||
.where(eq(users.id, id))
|
.where(eq(users.id, id));
|
||||||
.returning({ id: users.id });
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows.length > 0;
|
return rowsAffected(result) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async countAdmins(): Promise<number> {
|
async countAdmins(): Promise<number> {
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { desc, eq, or } from "drizzle-orm";
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { vaultProfiles } from "../db/schema.js";
|
import { vaultProfiles } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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;
|
export type VaultProfileRecord = typeof vaultProfiles.$inferSelect;
|
||||||
|
|
||||||
@@ -45,26 +51,23 @@ export class VaultProfileRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(input: VaultProfileCreateInput): Promise<VaultProfileRecord> {
|
async create(input: VaultProfileCreateInput): Promise<VaultProfileRecord> {
|
||||||
const [created] = await this.context.drizzle
|
const [created] = await insertReturning(this.context, vaultProfiles, {
|
||||||
.insert(vaultProfiles)
|
syncId: randomUUID(),
|
||||||
.values({
|
userId: input.userId,
|
||||||
syncId: randomUUID(),
|
name: input.name,
|
||||||
userId: input.userId,
|
description: input.description,
|
||||||
name: input.name,
|
folder: input.folder,
|
||||||
description: input.description,
|
tags: input.tags,
|
||||||
folder: input.folder,
|
vaultAddr: input.vaultAddr,
|
||||||
tags: input.tags,
|
vaultNamespace: input.vaultNamespace,
|
||||||
vaultAddr: input.vaultAddr,
|
oidcMount: input.oidcMount,
|
||||||
vaultNamespace: input.vaultNamespace,
|
oidcRole: input.oidcRole,
|
||||||
oidcMount: input.oidcMount,
|
sshMount: input.sshMount,
|
||||||
oidcRole: input.oidcRole,
|
sshRole: input.sshRole,
|
||||||
sshMount: input.sshMount,
|
validPrincipals: input.validPrincipals,
|
||||||
sshRole: input.sshRole,
|
keyType: input.keyType,
|
||||||
validPrincipals: input.validPrincipals,
|
shared: input.shared ?? false,
|
||||||
keyType: input.keyType,
|
});
|
||||||
shared: input.shared ?? false,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return created;
|
return created;
|
||||||
@@ -84,14 +87,15 @@ export class VaultProfileRepository {
|
|||||||
id: number,
|
id: number,
|
||||||
input: VaultProfileUpdateInput,
|
input: VaultProfileUpdateInput,
|
||||||
): Promise<VaultProfileRecord | null> {
|
): Promise<VaultProfileRecord | null> {
|
||||||
const [updated] = await this.context.drizzle
|
const [updated] = await updateReturning(
|
||||||
.update(vaultProfiles)
|
this.context,
|
||||||
.set({
|
vaultProfiles,
|
||||||
|
{
|
||||||
...input,
|
...input,
|
||||||
updatedAt: input.updatedAt ?? new Date().toISOString(),
|
updatedAt: input.updatedAt ?? new Date().toISOString(),
|
||||||
})
|
},
|
||||||
.where(eq(vaultProfiles.id, id))
|
eq(vaultProfiles.id, id),
|
||||||
.returning();
|
);
|
||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
@@ -101,27 +105,27 @@ export class VaultProfileRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteById(id: number): Promise<{ syncId: string | null } | null> {
|
async deleteById(id: number): Promise<{ syncId: string | null } | null> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await deleteReturning(
|
||||||
.delete(vaultProfiles)
|
this.context,
|
||||||
.where(eq(vaultProfiles.id, id))
|
vaultProfiles,
|
||||||
.returning({ syncId: vaultProfiles.syncId });
|
eq(vaultProfiles.id, id),
|
||||||
|
);
|
||||||
|
|
||||||
if (rows.length === 0) return null;
|
if (rows.length === 0) return null;
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0];
|
return { syncId: rows[0].syncId };
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(vaultProfiles)
|
.delete(vaultProfiles)
|
||||||
.where(eq(vaultProfiles.userId, userId))
|
.where(eq(vaultProfiles.userId, userId));
|
||||||
.returning({ id: vaultProfiles.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { vaultTokens } from "../db/schema.js";
|
import { vaultTokens } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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;
|
export type VaultTokenRecord = typeof vaultTokens.$inferSelect;
|
||||||
|
|
||||||
@@ -22,16 +24,17 @@ export class VaultTokenRepository {
|
|||||||
async upsert(input: VaultTokenUpsertInput): Promise<void> {
|
async upsert(input: VaultTokenUpsertInput): Promise<void> {
|
||||||
const createdAt = input.createdAt ?? new Date().toISOString();
|
const createdAt = input.createdAt ?? new Date().toISOString();
|
||||||
|
|
||||||
await this.context.drizzle
|
await upsert(
|
||||||
.insert(vaultTokens)
|
this.context,
|
||||||
.values({
|
vaultTokens,
|
||||||
|
{
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
profileId: input.profileId,
|
profileId: input.profileId,
|
||||||
sshCert: input.sshCert,
|
sshCert: input.sshCert,
|
||||||
privateKey: input.privateKey,
|
privateKey: input.privateKey,
|
||||||
expiresAt: input.expiresAt,
|
expiresAt: input.expiresAt,
|
||||||
})
|
},
|
||||||
.onConflictDoUpdate({
|
{
|
||||||
target: [vaultTokens.userId, vaultTokens.profileId],
|
target: [vaultTokens.userId, vaultTokens.profileId],
|
||||||
set: {
|
set: {
|
||||||
sshCert: input.sshCert,
|
sshCert: input.sshCert,
|
||||||
@@ -39,7 +42,8 @@ export class VaultTokenRepository {
|
|||||||
expiresAt: input.expiresAt,
|
expiresAt: input.expiresAt,
|
||||||
createdAt,
|
createdAt,
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
@@ -67,7 +71,7 @@ export class VaultTokenRepository {
|
|||||||
profileId: number,
|
profileId: number,
|
||||||
lastUsed = new Date().toISOString(),
|
lastUsed = new Date().toISOString(),
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.update(vaultTokens)
|
.update(vaultTokens)
|
||||||
.set({ lastUsed })
|
.set({ lastUsed })
|
||||||
.where(
|
.where(
|
||||||
@@ -75,48 +79,45 @@ export class VaultTokenRepository {
|
|||||||
eq(vaultTokens.userId, userId),
|
eq(vaultTokens.userId, userId),
|
||||||
eq(vaultTokens.profileId, profileId),
|
eq(vaultTokens.profileId, profileId),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: vaultTokens.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length > 0;
|
return rowsAffected(result) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserAndProfile(
|
async deleteByUserAndProfile(
|
||||||
userId: string,
|
userId: string,
|
||||||
profileId: number,
|
profileId: number,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(vaultTokens)
|
.delete(vaultTokens)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(vaultTokens.userId, userId),
|
eq(vaultTokens.userId, userId),
|
||||||
eq(vaultTokens.profileId, profileId),
|
eq(vaultTokens.profileId, profileId),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: vaultTokens.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length > 0;
|
return rowsAffected(result) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(vaultTokens)
|
.delete(vaultTokens)
|
||||||
.where(eq(vaultTokens.userId, userId))
|
.where(eq(vaultTokens.userId, userId));
|
||||||
.returning({ id: vaultTokens.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length;
|
return rowsAffected(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { webauthnCredentials } from "../db/schema.js";
|
import { webauthnCredentials } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.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 WebauthnCredentialRecord = typeof webauthnCredentials.$inferSelect;
|
||||||
export type NewWebauthnCredentialRecord =
|
export type NewWebauthnCredentialRecord =
|
||||||
@@ -41,10 +43,11 @@ export class WebauthnCredentialRepository {
|
|||||||
async create(
|
async create(
|
||||||
record: NewWebauthnCredentialRecord,
|
record: NewWebauthnCredentialRecord,
|
||||||
): Promise<WebauthnCredentialRecord> {
|
): Promise<WebauthnCredentialRecord> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await insertReturning(
|
||||||
.insert(webauthnCredentials)
|
this.context,
|
||||||
.values(record)
|
webauthnCredentials,
|
||||||
.returning();
|
record,
|
||||||
|
);
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0];
|
return rows[0];
|
||||||
@@ -63,21 +66,20 @@ export class WebauthnCredentialRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteForUser(userId: string, id: string): Promise<boolean> {
|
async deleteForUser(userId: string, id: string): Promise<boolean> {
|
||||||
const rows = await this.context.drizzle
|
const result = await this.context.drizzle
|
||||||
.delete(webauthnCredentials)
|
.delete(webauthnCredentials)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(webauthnCredentials.id, id),
|
eq(webauthnCredentials.id, id),
|
||||||
eq(webauthnCredentials.userId, userId),
|
eq(webauthnCredentials.userId, userId),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.returning({ id: webauthnCredentials.id });
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rowsAffected(result) > 0) {
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.length > 0;
|
return rowsAffected(result) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
private async afterWrite(): Promise<void> {
|
||||||
|
|||||||
@@ -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"/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>) =>
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,68 +17,11 @@ describe("AlertRepository", () => {
|
|||||||
): Promise<AlertRepository> {
|
): Promise<AlertRepository> {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await 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
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash)
|
INSERT INTO users (id, username, password_hash)
|
||||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||||
INSERT INTO ssh_data (id, user_id, name, ip)
|
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||||
VALUES (1, 'user-1', 'alpha', '127.0.0.1');
|
VALUES (1, 'user-1', 'alpha', '127.0.0.1', 22, 'root', 'password');
|
||||||
`);
|
`);
|
||||||
|
|
||||||
return new AlertRepository(context, onWrite);
|
return new AlertRepository(context, onWrite);
|
||||||
|
|||||||
@@ -17,28 +17,7 @@ describe("ApiKeyRepository", () => {
|
|||||||
}> {
|
}> {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await 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
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash) VALUES
|
INSERT INTO users (id, username, password_hash) VALUES
|
||||||
('user-1', 'admin', 'hash'),
|
('user-1', 'admin', 'hash'),
|
||||||
('user-2', 'target', 'hash');
|
('user-2', 'target', 'hash');
|
||||||
|
|||||||
@@ -17,31 +17,7 @@ describe("AuditLogRepository", () => {
|
|||||||
): Promise<AuditLogRepository> {
|
): Promise<AuditLogRepository> {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await 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
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash)
|
INSERT INTO users (id, username, password_hash)
|
||||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -41,23 +41,10 @@ afterEach(async () => {
|
|||||||
async function createRepository() {
|
async function createRepository() {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await adapter.exec(`
|
||||||
CREATE TABLE audit_logs (
|
INSERT INTO users (id, username, password_hash) VALUES
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
('u-1', 'u-1', 'hash');
|
||||||
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
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
return new AuditLogRepository(context);
|
return new AuditLogRepository(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,24 +17,7 @@ describe("C2sTunnelPresetRepository", () => {
|
|||||||
): Promise<C2sTunnelPresetRepository> {
|
): Promise<C2sTunnelPresetRepository> {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await 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
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash)
|
INSERT INTO users (id, username, password_hash)
|
||||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -17,33 +17,11 @@ describe("CommandHistoryRepository", () => {
|
|||||||
): Promise<CommandHistoryRepository> {
|
): Promise<CommandHistoryRepository> {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await 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
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash)
|
INSERT INTO users (id, username, password_hash)
|
||||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||||
INSERT INTO hosts (id, user_id, name)
|
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||||
VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other');
|
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);
|
return new CommandHistoryRepository(context, onWrite);
|
||||||
|
|||||||
@@ -17,26 +17,7 @@ describe("DashboardServiceLinkRepository", () => {
|
|||||||
): Promise<DashboardServiceLinkRepository> {
|
): Promise<DashboardServiceLinkRepository> {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await 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
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash)
|
INSERT INTO users (id, username, password_hash)
|
||||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -17,22 +17,7 @@ describe("DismissedAlertRepository", () => {
|
|||||||
): Promise<DismissedAlertRepository> {
|
): Promise<DismissedAlertRepository> {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await 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
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash)
|
INSERT INTO users (id, username, password_hash)
|
||||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -17,50 +17,11 @@ describe("FileManagerBookmarkRepository", () => {
|
|||||||
): Promise<FileManagerBookmarkRepository> {
|
): Promise<FileManagerBookmarkRepository> {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await 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
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash)
|
INSERT INTO users (id, username, password_hash)
|
||||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||||
INSERT INTO hosts (id, user_id, name)
|
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||||
VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other');
|
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);
|
return new FileManagerBookmarkRepository(context, onWrite);
|
||||||
|
|||||||
@@ -17,25 +17,7 @@ describe("HomepageItemRepository", () => {
|
|||||||
): Promise<HomepageItemRepository> {
|
): Promise<HomepageItemRepository> {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await 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
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash)
|
INSERT INTO users (id, username, password_hash)
|
||||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -17,22 +17,7 @@ describe("HomepageLayoutRepository", () => {
|
|||||||
): Promise<HomepageLayoutRepository> {
|
): Promise<HomepageLayoutRepository> {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await 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
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash)
|
INSERT INTO users (id, username, password_hash)
|
||||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { TestSqliteDatabase } from "./test-support.js";
|
import { TestSqliteDatabase } from "./test-support.js";
|
||||||
import { CredentialRepository } from "../../../database/repositories/credential-repository.js";
|
import { CredentialRepository } from "../../../database/repositories/credential-repository.js";
|
||||||
@@ -21,174 +22,10 @@ describe("HostRepository and CredentialRepository", () => {
|
|||||||
): Promise<{
|
): Promise<{
|
||||||
credentials: CredentialRepository;
|
credentials: CredentialRepository;
|
||||||
hosts: HostRepository;
|
hosts: HostRepository;
|
||||||
sqlite: NonNullable<
|
|
||||||
Awaited<ReturnType<TestSqliteDatabase["connect"]>>["sqlite"]
|
|
||||||
>;
|
|
||||||
}> {
|
}> {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await 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
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash) VALUES
|
INSERT INTO users (id, username, password_hash) VALUES
|
||||||
('user-1', 'user', 'hash'),
|
('user-1', 'user', 'hash'),
|
||||||
('user-2', 'other', 'hash');
|
('user-2', 'other', 'hash');
|
||||||
@@ -197,7 +34,6 @@ describe("HostRepository and CredentialRepository", () => {
|
|||||||
return {
|
return {
|
||||||
credentials: new CredentialRepository(context, onCredentialWrite),
|
credentials: new CredentialRepository(context, onCredentialWrite),
|
||||||
hosts: new HostRepository(context, onHostWrite),
|
hosts: new HostRepository(context, onHostWrite),
|
||||||
sqlite: adapter.raw,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,9 +60,9 @@ describe("HostRepository and CredentialRepository", () => {
|
|||||||
// deterministically observable regardless of clock resolution --
|
// deterministically observable regardless of clock resolution --
|
||||||
// the sync engine's last-write-wins conflict resolution depends on
|
// the sync engine's last-write-wins conflict resolution depends on
|
||||||
// every mutating update actually advancing this column.
|
// every mutating update actually advancing this column.
|
||||||
repo.sqlite
|
await adapter!.run(
|
||||||
.prepare("UPDATE ssh_credentials SET updated_at = ? WHERE id = ?")
|
sql`UPDATE ssh_credentials SET updated_at = ${"2000-01-01 00:00:00"} WHERE id = ${created.id}`,
|
||||||
.run("2000-01-01 00:00:00", created.id);
|
);
|
||||||
|
|
||||||
const updated = await repo.credentials.updateForUser("user-1", created.id, {
|
const updated = await repo.credentials.updateForUser("user-1", created.id, {
|
||||||
folder: "ops",
|
folder: "ops",
|
||||||
@@ -342,23 +178,27 @@ describe("HostRepository and CredentialRepository", () => {
|
|||||||
password: "secret",
|
password: "secret",
|
||||||
});
|
});
|
||||||
|
|
||||||
const raw = repo.sqlite
|
const raw = (
|
||||||
.prepare("SELECT password FROM ssh_credentials WHERE id = ?")
|
await adapter!.query(
|
||||||
.get(created.id) as { password: string };
|
sql`SELECT password FROM ssh_credentials WHERE id = ${created.id}`,
|
||||||
|
)
|
||||||
|
)[0] as { password: string };
|
||||||
|
|
||||||
expect(raw.password).toBe("user-encrypted-password");
|
expect(raw.password).toBe("user-encrypted-password");
|
||||||
|
|
||||||
repo.sqlite
|
await adapter!.run(
|
||||||
.prepare("UPDATE ssh_credentials SET updated_at = ? WHERE id = ?")
|
sql`UPDATE ssh_credentials SET updated_at = ${"2000-01-01 00:00:00"} WHERE id = ${created.id}`,
|
||||||
.run("2000-01-01 00:00:00", created.id);
|
);
|
||||||
|
|
||||||
await repo.credentials.updateEncryptedForUser("user-1", created.id, {
|
await repo.credentials.updateEncryptedForUser("user-1", created.id, {
|
||||||
password: "updated-secret",
|
password: "updated-secret",
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedRaw = repo.sqlite
|
const updatedRaw = (
|
||||||
.prepare("SELECT password, updated_at FROM ssh_credentials WHERE id = ?")
|
await adapter!.query(
|
||||||
.get(created.id) as { password: string; updated_at: string };
|
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.password).toBe("user-encrypted-password");
|
||||||
expect(updatedRaw.updated_at).not.toBe("2000-01-01 00:00:00");
|
expect(updatedRaw.updated_at).not.toBe("2000-01-01 00:00:00");
|
||||||
@@ -410,9 +250,9 @@ describe("HostRepository and CredentialRepository", () => {
|
|||||||
authType: "password",
|
authType: "password",
|
||||||
folder: "prod",
|
folder: "prod",
|
||||||
});
|
});
|
||||||
repo.sqlite
|
await adapter!.run(
|
||||||
.prepare("UPDATE ssh_credentials SET updated_at = ? WHERE id = ?")
|
sql`UPDATE ssh_credentials SET updated_at = ${"2000-01-01 00:00:00"} WHERE id = ${primary.id}`,
|
||||||
.run("2000-01-01 00:00:00", primary.id);
|
);
|
||||||
onWrite.mockClear();
|
onWrite.mockClear();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -423,9 +263,11 @@ describe("HostRepository and CredentialRepository", () => {
|
|||||||
expect(await repo.credentials.listFolders("user-2")).toEqual(["prod"]);
|
expect(await repo.credentials.listFolders("user-2")).toEqual(["prod"]);
|
||||||
expect(onWrite).toHaveBeenCalledTimes(1);
|
expect(onWrite).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
const renamedRow = repo.sqlite
|
const renamedRow = (
|
||||||
.prepare("SELECT updated_at FROM ssh_credentials WHERE id = ?")
|
await adapter!.query(
|
||||||
.get(primary.id) as { updated_at: string };
|
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");
|
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),
|
(await repo.hosts.listByUserId("user-1")).map((item) => item.id),
|
||||||
).toEqual([host.id]);
|
).toEqual([host.id]);
|
||||||
|
|
||||||
repo.sqlite
|
await adapter!.run(
|
||||||
.prepare("UPDATE ssh_data SET updated_at = ? WHERE id = ?")
|
sql`UPDATE ssh_data SET updated_at = ${"2000-01-01 00:00:00"} WHERE id = ${host.id}`,
|
||||||
.run("2000-01-01 00:00:00", host.id);
|
);
|
||||||
|
|
||||||
const updated = await repo.hosts.updateForUser("user-1", host.id, {
|
const updated = await repo.hosts.updateForUser("user-1", host.id, {
|
||||||
name: "web-1-renamed",
|
name: "web-1-renamed",
|
||||||
@@ -511,23 +353,27 @@ describe("HostRepository and CredentialRepository", () => {
|
|||||||
password: "secret",
|
password: "secret",
|
||||||
});
|
});
|
||||||
|
|
||||||
const raw = repo.sqlite
|
const raw = (
|
||||||
.prepare("SELECT password FROM ssh_data WHERE id = ?")
|
await adapter!.query(
|
||||||
.get(created.id) as { password: string };
|
sql`SELECT password FROM ssh_data WHERE id = ${created.id}`,
|
||||||
|
)
|
||||||
|
)[0] as { password: string };
|
||||||
|
|
||||||
expect(raw.password).toBe("encrypted-host-password");
|
expect(raw.password).toBe("encrypted-host-password");
|
||||||
|
|
||||||
repo.sqlite
|
await adapter!.run(
|
||||||
.prepare("UPDATE ssh_data SET updated_at = ? WHERE id = ?")
|
sql`UPDATE ssh_data SET updated_at = ${"2000-01-01 00:00:00"} WHERE id = ${created.id}`,
|
||||||
.run("2000-01-01 00:00:00", created.id);
|
);
|
||||||
|
|
||||||
await repo.hosts.updateEncryptedForUser("user-1", created.id, {
|
await repo.hosts.updateEncryptedForUser("user-1", created.id, {
|
||||||
password: "updated-secret",
|
password: "updated-secret",
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedRaw = repo.sqlite
|
const updatedRaw = (
|
||||||
.prepare("SELECT password, updated_at FROM ssh_data WHERE id = ?")
|
await adapter!.query(
|
||||||
.get(created.id) as { password: string; updated_at: string };
|
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.password).toBe("encrypted-host-password");
|
||||||
expect(updatedRaw.updated_at).not.toBe("2000-01-01 00:00:00");
|
expect(updatedRaw.updated_at).not.toBe("2000-01-01 00:00:00");
|
||||||
@@ -655,9 +501,9 @@ describe("HostRepository and CredentialRepository", () => {
|
|||||||
username: "root",
|
username: "root",
|
||||||
authType: "password",
|
authType: "password",
|
||||||
});
|
});
|
||||||
repo.sqlite
|
await adapter!.run(
|
||||||
.prepare("UPDATE ssh_data SET updated_at = ? WHERE id IN (?, ?)")
|
sql`UPDATE ssh_data SET updated_at = ${"2000-01-01 00:00:00"} WHERE id IN (${first.id}, ${second.id})`,
|
||||||
.run("2000-01-01 00:00:00", first.id, second.id);
|
);
|
||||||
onWrite.mockClear();
|
onWrite.mockClear();
|
||||||
|
|
||||||
const states = await repo.hosts.listBulkUpdateState("user-1", [
|
const states = await repo.hosts.listBulkUpdateState("user-1", [
|
||||||
@@ -726,11 +572,9 @@ describe("HostRepository and CredentialRepository", () => {
|
|||||||
authType: "password",
|
authType: "password",
|
||||||
});
|
});
|
||||||
|
|
||||||
repo.sqlite
|
await adapter!.run(
|
||||||
.prepare(
|
sql`INSERT INTO host_access (host_id, user_id, granted_by) VALUES (${host.id}, ${"user-2"}, ${"user-1"})`,
|
||||||
"INSERT INTO host_access (host_id, user_id, granted_by) VALUES (?, ?, ?)",
|
);
|
||||||
)
|
|
||||||
.run(host.id, "user-2", "user-1");
|
|
||||||
|
|
||||||
expect(await repo.hosts.deleteAccessForHost(host.id)).toBe(1);
|
expect(await repo.hosts.deleteAccessForHost(host.id)).toBe(1);
|
||||||
expect(await repo.hosts.deleteForUser("user-1", host.id)).toEqual({
|
expect(await repo.hosts.deleteForUser("user-1", host.id)).toEqual({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { TestSqliteDatabase } from "./test-support.js";
|
import { TestSqliteDatabase } from "./test-support.js";
|
||||||
import { HostFolderRepository } from "../../../database/repositories/host-folder-repository.js";
|
import { HostFolderRepository } from "../../../database/repositories/host-folder-repository.js";
|
||||||
@@ -14,153 +15,21 @@ describe("HostFolderRepository", () => {
|
|||||||
|
|
||||||
async function createRepository(
|
async function createRepository(
|
||||||
onWrite?: () => void | Promise<void>,
|
onWrite?: () => void | Promise<void>,
|
||||||
): Promise<{
|
): Promise<{ repository: HostFolderRepository }> {
|
||||||
repository: HostFolderRepository;
|
|
||||||
sqlite: NonNullable<
|
|
||||||
Awaited<ReturnType<TestSqliteDatabase["connect"]>>["sqlite"]
|
|
||||||
>;
|
|
||||||
}> {
|
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await 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
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash)
|
INSERT INTO users (id, username, password_hash)
|
||||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', '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)
|
INSERT INTO ssh_data (id, user_id, name, ip, port, username, folder, auth_type)
|
||||||
VALUES
|
VALUES
|
||||||
(1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'prod', 'password'),
|
(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'),
|
(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');
|
(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)
|
INSERT INTO ssh_folders (id, user_id, name, color, icon)
|
||||||
VALUES
|
VALUES
|
||||||
(1, 'user-1', 'prod', '#111111', 'server'),
|
(1, 'user-1', 'prod', '#111111', 'server'),
|
||||||
@@ -168,15 +37,12 @@ describe("HostFolderRepository", () => {
|
|||||||
(3, 'user-2', 'prod', '#333333', 'user');
|
(3, 'user-2', 'prod', '#333333', 'user');
|
||||||
`);
|
`);
|
||||||
|
|
||||||
return {
|
return { repository: new HostFolderRepository(context, onWrite) };
|
||||||
repository: new HostFolderRepository(context, onWrite),
|
|
||||||
sqlite: adapter.raw,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
it("renames folders across hosts, credentials, and folder records", async () => {
|
it("renames folders across hosts, credentials, and folder records", async () => {
|
||||||
let writes = 0;
|
let writes = 0;
|
||||||
const { repository, sqlite } = await createRepository(() => {
|
const { repository } = await createRepository(() => {
|
||||||
writes += 1;
|
writes += 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -189,22 +55,23 @@ describe("HostFolderRepository", () => {
|
|||||||
),
|
),
|
||||||
).resolves.toEqual({ updatedHosts: 2, updatedCredentials: 2 });
|
).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(
|
expect(
|
||||||
sqlite
|
await adapter!.query(
|
||||||
.prepare("SELECT folder FROM ssh_data WHERE user_id = ? ORDER BY id")
|
sql`SELECT folder FROM ssh_data WHERE user_id = 'user-1' ORDER BY id`,
|
||||||
.all("user-1"),
|
),
|
||||||
).toEqual([{ folder: "ops" }, { folder: "ops / api" }]);
|
).toEqual([{ folder: "ops" }, { folder: "ops / api" }]);
|
||||||
expect(
|
expect(
|
||||||
sqlite
|
await adapter!.query(
|
||||||
.prepare(
|
sql`SELECT folder FROM ssh_credentials WHERE user_id = 'user-1' ORDER BY id`,
|
||||||
"SELECT folder FROM ssh_credentials WHERE user_id = ? ORDER BY id",
|
),
|
||||||
)
|
|
||||||
.all("user-1"),
|
|
||||||
).toEqual([{ folder: "ops" }, { folder: "ops / api" }]);
|
).toEqual([{ folder: "ops" }, { folder: "ops / api" }]);
|
||||||
expect(
|
expect(
|
||||||
sqlite
|
await adapter!.query(
|
||||||
.prepare("SELECT name FROM ssh_folders WHERE user_id = ? ORDER BY id")
|
sql`SELECT name FROM ssh_folders WHERE user_id = 'user-1' ORDER BY id`,
|
||||||
.all("user-1"),
|
),
|
||||||
).toEqual([{ name: "ops" }, { name: "ops / api" }]);
|
).toEqual([{ name: "ops" }, { name: "ops / api" }]);
|
||||||
expect(writes).toBe(1);
|
expect(writes).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -269,7 +136,7 @@ describe("HostFolderRepository", () => {
|
|||||||
|
|
||||||
it("lists and deletes hosts and folder records in a folder tree", async () => {
|
it("lists and deletes hosts and folder records in a folder tree", async () => {
|
||||||
let writes = 0;
|
let writes = 0;
|
||||||
const { repository, sqlite } = await createRepository(() => {
|
const { repository } = await createRepository(() => {
|
||||||
writes += 1;
|
writes += 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -278,28 +145,28 @@ describe("HostFolderRepository", () => {
|
|||||||
|
|
||||||
await repository.deleteHostsAndFolderRecords("user-1", "prod");
|
await repository.deleteHostsAndFolderRecords("user-1", "prod");
|
||||||
|
|
||||||
expect(sqlite.prepare("SELECT id FROM ssh_data ORDER BY id").all()).toEqual(
|
|
||||||
[{ id: 3 }],
|
|
||||||
);
|
|
||||||
expect(
|
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 }]);
|
).toEqual([{ id: 3 }]);
|
||||||
expect(writes).toBe(1);
|
expect(writes).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deletes folder records for a user", async () => {
|
it("deletes folder records for a user", async () => {
|
||||||
let writes = 0;
|
let writes = 0;
|
||||||
const { repository, sqlite } = await createRepository(() => {
|
const { repository } = await createRepository(() => {
|
||||||
writes += 1;
|
writes += 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(repository.deleteByUserId("user-1")).resolves.toBe(2);
|
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(
|
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 }]);
|
).toEqual([{ id: 3 }]);
|
||||||
expect(writes).toBe(1);
|
expect(writes).toBe(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,44 +17,11 @@ describe("HostHealthRepository", () => {
|
|||||||
): Promise<HostHealthRepository> {
|
): Promise<HostHealthRepository> {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await 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
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash)
|
INSERT INTO users (id, username, password_hash)
|
||||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||||
INSERT INTO hosts (id, user_id, name)
|
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||||
VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two');
|
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 (
|
INSERT INTO host_health_checks (
|
||||||
user_id, host_id, checks, interval_seconds, created_at, updated_at
|
user_id, host_id, checks, interval_seconds, created_at, updated_at
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,26 +17,13 @@ describe("HostMetricsHistoryRepository", () => {
|
|||||||
): Promise<HostMetricsHistoryRepository> {
|
): Promise<HostMetricsHistoryRepository> {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await adapter.exec(`
|
||||||
CREATE TABLE hosts (
|
INSERT INTO users (id, username, password_hash) VALUES
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
('user-1', 'user-1', 'hash'),
|
||||||
user_id TEXT NOT NULL,
|
('user-2', 'user-2', 'hash');
|
||||||
name TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE host_metrics_history (
|
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '10.0.0.1', 22, 'root', 'password');
|
||||||
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 host_metrics_history (
|
INSERT INTO host_metrics_history (
|
||||||
host_id, ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes
|
host_id, ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,33 +17,11 @@ describe("HostMetricsPreferenceRepository", () => {
|
|||||||
): Promise<HostMetricsPreferenceRepository> {
|
): Promise<HostMetricsPreferenceRepository> {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await 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
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash)
|
INSERT INTO users (id, username, password_hash)
|
||||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||||
INSERT INTO ssh_data (id, user_id, name, stats_config)
|
INSERT INTO ssh_data (id, user_id, name, stats_config, ip, port, username, auth_type)
|
||||||
VALUES (1, 'user-1', 'one', '{}'), (2, 'user-2', 'two', '{}');
|
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 (
|
INSERT INTO host_metrics_preferences (
|
||||||
user_id, host_id, layout, created_at, updated_at
|
user_id, host_id, layout, created_at, updated_at
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,162 +27,15 @@ describe("HostResolutionRepository", () => {
|
|||||||
): Promise<HostResolutionRepository> {
|
): Promise<HostResolutionRepository> {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await 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
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash)
|
INSERT INTO users (id, username, password_hash)
|
||||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', '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 (
|
INSERT INTO ssh_data (
|
||||||
id, user_id, name, ip, port, username, auth_type, credential_id,
|
id, user_id, name, ip, port, username, auth_type, credential_id,
|
||||||
tunnel_connections
|
tunnel_connections
|
||||||
@@ -191,21 +44,15 @@ describe("HostResolutionRepository", () => {
|
|||||||
(1, 'user-1', 'web', '10.0.0.1', 22, 'root', 'password', 7, '[{"autoStart":true}]'),
|
(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),
|
(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}]');
|
(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)
|
INSERT INTO ssh_folders (user_id, name, credential_id)
|
||||||
VALUES
|
VALUES
|
||||||
('user-1', 'switches', 7),
|
('user-1', 'switches', 7),
|
||||||
('user-1', 'switches / floor1', NULL),
|
('user-1', 'switches / floor1', NULL),
|
||||||
('user-1', 'no-cred', 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);
|
return new HostResolutionRepository(context, onWrite);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,23 +17,7 @@ describe("NetworkTopologyRepository", () => {
|
|||||||
): Promise<NetworkTopologyRepository> {
|
): Promise<NetworkTopologyRepository> {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await 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
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash)
|
INSERT INTO users (id, username, password_hash)
|
||||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -17,29 +17,12 @@ describe("OpenTabRepository", () => {
|
|||||||
): Promise<OpenTabRepository> {
|
): Promise<OpenTabRepository> {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await 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
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash)
|
INSERT INTO users (id, username, password_hash)
|
||||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', '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);
|
return new OpenTabRepository(context, onWrite);
|
||||||
|
|||||||
@@ -17,39 +17,11 @@ describe("OpksshTokenRepository", () => {
|
|||||||
): Promise<OpksshTokenRepository> {
|
): Promise<OpksshTokenRepository> {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await 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)
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash)
|
INSERT INTO users (id, username, password_hash)
|
||||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||||
INSERT INTO hosts (id, user_id, name)
|
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||||
VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other');
|
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 (
|
INSERT INTO opkssh_tokens (
|
||||||
user_id, host_id, ssh_cert, private_key, email, expires_at
|
user_id, host_id, ssh_cert, private_key, email, expires_at
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,112 +18,30 @@ describe("RbacAccessRepository", () => {
|
|||||||
): Promise<RbacAccessRepository> {
|
): Promise<RbacAccessRepository> {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await 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
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash, is_admin, is_oidc)
|
INSERT INTO users (id, username, password_hash, is_admin, is_oidc)
|
||||||
VALUES
|
VALUES
|
||||||
('admin', 'admin', 'hash', 1, 0),
|
('admin', 'admin', 'hash', 1, 0),
|
||||||
('user-1', 'alice', 'hash', 0, 0),
|
('user-1', 'alice', 'hash', 0, 0),
|
||||||
('owner-1', 'owner', 'hash', 0, 0);
|
('owner-1', 'owner', 'hash', 0, 0);
|
||||||
|
|
||||||
INSERT INTO roles (id, name, display_name, is_system)
|
INSERT INTO roles (id, name, display_name, is_system)
|
||||||
VALUES (7, 'ops', 'Operations', 0);
|
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 (
|
INSERT INTO ssh_data (
|
||||||
id, user_id, name, ip, port, username, credential_id, rdp_credential_id, vnc_credential_id, telnet_credential_id, folder, tags
|
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');
|
||||||
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 125, 126, 'servers', 'linux');
|
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 (
|
INSERT INTO host_access (
|
||||||
id, host_id, user_id, role_id, granted_by, permission_level, expires_at, created_at
|
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'),
|
(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'),
|
(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');
|
(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 (
|
INSERT INTO snippet_access (
|
||||||
id, snippet_id, user_id, role_id, granted_by, permission_level, expires_at, created_at
|
id, snippet_id, user_id, role_id, granted_by, permission_level, expires_at, created_at
|
||||||
)
|
)
|
||||||
VALUES
|
VALUES
|
||||||
(3, 99, 'user-1', NULL, 'admin', 'view', NULL, '2026-06-26T00:00:00.000Z'),
|
(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');
|
(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);
|
return new RbacAccessRepository(context, onWrite);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { TestSqliteDatabase } from "./test-support.js";
|
import { TestSqliteDatabase } from "./test-support.js";
|
||||||
import { RecentActivityRepository } from "../../../database/repositories/recent-activity-repository.js";
|
import { RecentActivityRepository } from "../../../database/repositories/recent-activity-repository.js";
|
||||||
@@ -16,40 +17,14 @@ describe("RecentActivityRepository", () => {
|
|||||||
onWrite?: () => void | Promise<void>,
|
onWrite?: () => void | Promise<void>,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
repository: RecentActivityRepository;
|
repository: RecentActivityRepository;
|
||||||
sqlite: NonNullable<
|
|
||||||
Awaited<ReturnType<TestSqliteDatabase["connect"]>>["sqlite"]
|
|
||||||
>;
|
|
||||||
}> {
|
}> {
|
||||||
adapter = new TestSqliteDatabase();
|
adapter = new TestSqliteDatabase();
|
||||||
const context = await adapter.connect();
|
const context = await adapter.connect();
|
||||||
adapter.exec(`
|
await 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
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash)
|
INSERT INTO users (id, username, password_hash)
|
||||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||||
INSERT INTO hosts (id, user_id, name)
|
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||||
VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other');
|
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)
|
INSERT INTO recent_activity (id, user_id, type, host_id, host_name, timestamp)
|
||||||
VALUES
|
VALUES
|
||||||
(1, 'user-1', 'connect', 1, 'one', '2026-06-26T00:00:00.000Z'),
|
(1, 'user-1', 'connect', 1, 'one', '2026-06-26T00:00:00.000Z'),
|
||||||
@@ -59,13 +34,12 @@ describe("RecentActivityRepository", () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
repository: new RecentActivityRepository(context, onWrite),
|
repository: new RecentActivityRepository(context, onWrite),
|
||||||
sqlite: adapter.raw,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
it("lists, creates, and trims recent activity", async () => {
|
it("lists, creates, and trims recent activity", async () => {
|
||||||
let writeCount = 0;
|
let writeCount = 0;
|
||||||
const { repository, sqlite } = await createRepository(() => {
|
const { repository } = await createRepository(() => {
|
||||||
writeCount += 1;
|
writeCount += 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,11 +62,9 @@ describe("RecentActivityRepository", () => {
|
|||||||
|
|
||||||
expect(await repository.trimUserActivity("user-1", 2)).toBe(1);
|
expect(await repository.trimUserActivity("user-1", 2)).toBe(1);
|
||||||
expect(
|
expect(
|
||||||
sqlite
|
await adapter!.query(
|
||||||
.prepare(
|
sql`SELECT id FROM recent_activity WHERE user_id = 'user-1' ORDER BY timestamp DESC`,
|
||||||
"SELECT id FROM recent_activity WHERE user_id = ? ORDER BY timestamp DESC",
|
),
|
||||||
)
|
|
||||||
.all("user-1"),
|
|
||||||
).toEqual([{ id: created.id }, { id: 2 }]);
|
).toEqual([{ id: created.id }, { id: 2 }]);
|
||||||
expect(writeCount).toBe(2);
|
expect(writeCount).toBe(2);
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user