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:
ZacharyZcR
2026-07-29 18:56:14 +08:00
committed by GitHub
parent 32fb7487df
commit 8a79e6af53
127 changed files with 29299 additions and 3003 deletions
@@ -8,6 +8,8 @@ import {
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
type AlertRuleRecord = typeof alertRules.$inferSelect;
type NotificationChannelRecord = typeof notificationChannels.$inferSelect;
@@ -118,16 +120,17 @@ export class AlertRepository {
config: string;
enabled: boolean;
}): Promise<NotificationChannelRow> {
const [created] = await this.context.drizzle
.insert(notificationChannels)
.values({
const [created] = await insertReturning(
this.context,
notificationChannels,
{
userId: input.userId,
name: input.name,
type: input.type,
config: input.config,
enabled: input.enabled,
})
.returning();
},
);
await this.afterWrite();
return mapChannelRow(created);
@@ -147,16 +150,15 @@ export class AlertRepository {
return this.findNotificationChannelForUser(id, userId);
}
const [updated] = await this.context.drizzle
.update(notificationChannels)
.set(input)
.where(
and(
eq(notificationChannels.id, id),
eq(notificationChannels.userId, userId),
),
)
.returning();
const [updated] = await updateReturning(
this.context,
notificationChannels,
input,
and(
eq(notificationChannels.id, id),
eq(notificationChannels.userId, userId),
),
);
if (!updated) return null;
await this.afterWrite();
@@ -167,17 +169,16 @@ export class AlertRepository {
id: number,
userId: string,
): Promise<boolean> {
const deleted = await this.context.drizzle
const result = await this.context.drizzle
.delete(notificationChannels)
.where(
and(
eq(notificationChannels.id, id),
eq(notificationChannels.userId, userId),
),
)
.returning({ id: notificationChannels.id });
);
if (deleted.length === 0) return false;
if (rowsAffected(result) === 0) return false;
await this.afterWrite();
return true;
}
@@ -211,21 +212,18 @@ export class AlertRepository {
channels: number[];
now: string;
}): Promise<AlertRuleWithChannelsRow> {
const [created] = await this.context.drizzle
.insert(alertRules)
.values({
userId: input.userId,
hostId: input.hostId,
name: input.name,
enabled: input.enabled,
triggerType: input.triggerType,
thresholdValue: input.thresholdValue,
thresholdDurationSeconds: input.thresholdDurationSeconds,
cooldownMinutes: input.cooldownMinutes,
createdAt: input.now,
updatedAt: input.now,
})
.returning();
const [created] = await insertReturning(this.context, alertRules, {
userId: input.userId,
hostId: input.hostId,
name: input.name,
enabled: input.enabled,
triggerType: input.triggerType,
thresholdValue: input.thresholdValue,
thresholdDurationSeconds: input.thresholdDurationSeconds,
cooldownMinutes: input.cooldownMinutes,
createdAt: input.now,
updatedAt: input.now,
});
const channels = await this.replaceRuleChannels(
created.id,
@@ -264,9 +262,10 @@ export class AlertRepository {
now: string;
},
): Promise<AlertRuleWithChannelsRow | null> {
const [updated] = await this.context.drizzle
.update(alertRules)
.set({
const [updated] = await updateReturning(
this.context,
alertRules,
{
...(input.name !== undefined ? { name: input.name } : {}),
...(input.hostId !== undefined ? { hostId: input.hostId } : {}),
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
@@ -283,9 +282,9 @@ export class AlertRepository {
? { cooldownMinutes: input.cooldownMinutes }
: {}),
updatedAt: input.now,
})
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)))
.returning();
},
and(eq(alertRules.id, id), eq(alertRules.userId, userId)),
);
if (!updated) return null;
@@ -299,12 +298,11 @@ export class AlertRepository {
}
async deleteAlertRule(id: number, userId: string): Promise<boolean> {
const deleted = await this.context.drizzle
const result = await this.context.drizzle
.delete(alertRules)
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)))
.returning({ id: alertRules.id });
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)));
if (deleted.length === 0) return false;
if (rowsAffected(result) === 0) return false;
await this.afterWrite();
return true;
}
@@ -442,10 +440,9 @@ export class AlertRepository {
.where(eq(notificationChannels.userId, userId))
).map((row) => row.id);
const firingRows = await this.context.drizzle
const firingResult = await this.context.drizzle
.delete(alertFirings)
.where(eq(alertFirings.userId, userId))
.returning({ id: alertFirings.id });
.where(eq(alertFirings.userId, userId));
const linkFilters = [
...(ruleIds.length > 0
@@ -455,37 +452,34 @@ export class AlertRepository {
? [inArray(alertRuleChannels.channelId, channelIds)]
: []),
];
const linkRows =
const linkResult =
linkFilters.length === 0
? []
? null
: await this.context.drizzle
.delete(alertRuleChannels)
.where(or(...linkFilters))
.returning({ id: alertRuleChannels.id });
.where(or(...linkFilters));
const ruleRows = await this.context.drizzle
const ruleResult = await this.context.drizzle
.delete(alertRules)
.where(eq(alertRules.userId, userId))
.returning({ id: alertRules.id });
const channelRows = await this.context.drizzle
.where(eq(alertRules.userId, userId));
const result = await this.context.drizzle
.delete(notificationChannels)
.where(eq(notificationChannels.userId, userId))
.returning({ id: notificationChannels.id });
.where(eq(notificationChannels.userId, userId));
if (
firingRows.length > 0 ||
linkRows.length > 0 ||
ruleRows.length > 0 ||
channelRows.length > 0
rowsAffected(firingResult) > 0 ||
rowsAffected(linkResult) > 0 ||
rowsAffected(ruleResult) > 0 ||
rowsAffected(result) > 0
) {
await this.afterWrite();
}
return {
firingsDeleted: firingRows.length,
ruleLinksDeleted: linkRows.length,
rulesDeleted: ruleRows.length,
channelsDeleted: channelRows.length,
firingsDeleted: rowsAffected(firingResult),
ruleLinksDeleted: rowsAffected(linkResult),
rulesDeleted: rowsAffected(ruleResult),
channelsDeleted: rowsAffected(result),
};
}
@@ -1,6 +1,8 @@
import { eq, and } from "drizzle-orm";
import { apiKeys, users } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { deleteReturning, insertReturning } from "./returning.js";
export type ApiKeyRecord = typeof apiKeys.$inferSelect;
export type NewApiKeyRecord = typeof apiKeys.$inferInsert;
@@ -24,10 +26,7 @@ export class ApiKeyRepository {
) {}
async create(apiKey: NewApiKeyRecord): Promise<ApiKeyRecord> {
const rows = await this.context.drizzle
.insert(apiKeys)
.values(apiKey)
.returning();
const rows = await insertReturning(this.context, apiKeys, apiKey);
await this.afterWrite();
return rows[0];
}
@@ -78,23 +77,23 @@ export class ApiKeyRepository {
}
async delete(id: string): Promise<ApiKeyRecord | null> {
const rows = await this.context.drizzle
.delete(apiKeys)
.where(eq(apiKeys.id, id))
.returning();
const rows = await deleteReturning(
this.context,
apiKeys,
eq(apiKeys.id, id),
);
await this.afterWrite();
return rows[0] ?? null;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(apiKeys)
.where(eq(apiKeys.userId, userId))
.returning({ id: apiKeys.id });
.where(eq(apiKeys.userId, userId));
await this.afterWrite();
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -3,6 +3,7 @@ import { auditLogs } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
import { databaseLogger } from "../../utils/logger.js";
import { countValue, rowsAffected } from "./mutation-result.js";
export type AuditLogRecord = typeof auditLogs.$inferSelect;
export type NewAuditLogRecord = typeof auditLogs.$inferInsert;
@@ -82,7 +83,7 @@ export class AuditLogRepository {
return {
logs,
total: totalResult[0]?.count ?? 0,
total: countValue(totalResult[0]?.count),
};
}
@@ -126,30 +127,28 @@ export class AuditLogRepository {
* asked. `username` is denormalised, so the entry stays attributable.
*/
async anonymizeByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(auditLogs)
.set({ userId: null })
.where(eq(auditLogs.userId, userId))
.returning({ id: auditLogs.id });
.where(eq(auditLogs.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(auditLogs)
.where(eq(auditLogs.userId, userId))
.returning({ id: auditLogs.id });
.where(eq(auditLogs.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private buildWhere(filters: AuditLogFilters) {
@@ -184,17 +183,16 @@ export class AuditLogRepository {
if (days === null) return;
const cutoff = sqlTimestampDaysAgo(days);
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(auditLogs)
.where(lt(auditLogs.timestamp, cutoff))
.returning({ id: auditLogs.id });
.where(lt(auditLogs.timestamp, cutoff));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
databaseLogger.info(
`Pruned ${rows.length} audit entries past retention`,
`Pruned ${rowsAffected(result)} audit entries past retention`,
{
operation: "audit_retention_prune",
removed: rows.length,
removed: rowsAffected(result),
retentionDays: days,
cutoff,
},
@@ -212,7 +210,7 @@ export class AuditLogRepository {
const countResult = await this.context.drizzle
.select({ count: sql<number>`COUNT(*)` })
.from(auditLogs);
const count = countResult[0]?.count ?? 0;
const count = countValue(countResult[0]?.count);
if (count < max) return;
@@ -1,6 +1,8 @@
import { and, asc, eq, sql } from "drizzle-orm";
import { c2sTunnelPresets } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type C2sTunnelPresetRecord = typeof c2sTunnelPresets.$inferSelect;
@@ -64,16 +66,13 @@ export class C2sTunnelPresetRepository {
userId: string,
input: C2sTunnelPresetCreateInput,
): Promise<C2sTunnelPresetRecord> {
const [created] = await this.context.drizzle
.insert(c2sTunnelPresets)
.values({
userId,
name: input.name,
config: input.config,
platform: input.platform ?? null,
computerName: input.computerName ?? null,
})
.returning();
const [created] = await insertReturning(this.context, c2sTunnelPresets, {
userId,
name: input.name,
config: input.config,
platform: input.platform ?? null,
computerName: input.computerName ?? null,
});
await this.afterWrite();
return created;
@@ -84,16 +83,15 @@ export class C2sTunnelPresetRepository {
id: number,
updates: C2sTunnelPresetUpdateInput,
): Promise<C2sTunnelPresetRecord | null> {
const [updated] = await this.context.drizzle
.update(c2sTunnelPresets)
.set({
const [updated] = await updateReturning(
this.context,
c2sTunnelPresets,
{
...updates,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
)
.returning();
},
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
);
if (updated) {
await this.afterWrite();
@@ -103,31 +101,29 @@ export class C2sTunnelPresetRepository {
}
async deleteForUser(userId: string, id: number): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(c2sTunnelPresets)
.where(
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
)
.returning({ id: c2sTunnelPresets.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(c2sTunnelPresets)
.where(eq(c2sTunnelPresets.userId, userId))
.returning({ id: c2sTunnelPresets.id });
.where(eq(c2sTunnelPresets.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,8 @@
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { commandHistory } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type CommandHistoryRecord = typeof commandHistory.$inferSelect;
@@ -16,10 +18,12 @@ export class CommandHistoryRepository {
command: string,
executedAt = new Date().toISOString(),
): Promise<CommandHistoryRecord> {
const [created] = await this.context.drizzle
.insert(commandHistory)
.values({ userId, hostId, command, executedAt })
.returning();
const [created] = await insertReturning(this.context, commandHistory, {
userId,
hostId,
command,
executedAt,
});
await this.afterWrite();
return created;
}
@@ -76,7 +80,7 @@ export class CommandHistoryRepository {
hostId: number,
command: string,
): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(commandHistory)
.where(
and(
@@ -84,45 +88,42 @@ export class CommandHistoryRepository {
eq(commandHistory.hostId, hostId),
eq(commandHistory.command, command),
),
)
.returning({ id: commandHistory.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByUserAndHost(userId: string, hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostId),
),
)
.returning({ id: commandHistory.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByHostId(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(commandHistory)
.where(eq(commandHistory.hostId, hostId))
.returning({ id: commandHistory.id });
.where(eq(commandHistory.hostId, hostId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
@@ -130,29 +131,27 @@ export class CommandHistoryRepository {
return 0;
}
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(commandHistory)
.where(inArray(commandHistory.hostId, hostIds))
.returning({ id: commandHistory.id });
.where(inArray(commandHistory.hostId, hostIds));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(commandHistory)
.where(eq(commandHistory.userId, userId))
.returning({ id: commandHistory.id });
.where(eq(commandHistory.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -3,6 +3,12 @@ import { randomUUID } from "crypto";
import { sshCredentials, sshCredentialUsage } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type CredentialRecord = typeof sshCredentials.$inferSelect;
export type NewCredentialRecord = typeof sshCredentials.$inferInsert;
@@ -17,10 +23,10 @@ export class CredentialRepository {
) {}
async create(credential: NewCredentialRecord): Promise<CredentialRecord> {
const rows = await this.context.drizzle
.insert(sshCredentials)
.values({ syncId: randomUUID(), ...credential })
.returning();
const rows = await insertReturning(this.context, sshCredentials, {
syncId: randomUUID(),
...credential,
});
await this.afterWrite();
return rows[0];
}
@@ -46,10 +52,11 @@ export class CredentialRepository {
delete (encryptedCredential as Partial<NewCredentialRecord>).id;
}
const rows = await this.context.drizzle
.insert(sshCredentials)
.values(encryptedCredential as NewCredentialRecord)
.returning();
const rows = await insertReturning(
this.context,
sshCredentials,
encryptedCredential as NewCredentialRecord,
);
await this.afterWrite();
return DataCrypto.decryptRecord(
@@ -143,7 +150,7 @@ export class CredentialRepository {
oldName: string,
newName: string,
): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(sshCredentials)
.set({ folder: newName, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(
@@ -151,14 +158,13 @@ export class CredentialRepository {
eq(sshCredentials.userId, userId),
eq(sshCredentials.folder, oldName),
),
)
.returning({ id: sshCredentials.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async updateForUser(
@@ -166,16 +172,15 @@ export class CredentialRepository {
credentialId: number,
update: CredentialUpdate,
): Promise<CredentialRecord | null> {
const rows = await this.context.drizzle
.update(sshCredentials)
.set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
)
.returning();
const rows = await updateReturning(
this.context,
sshCredentials,
{ ...update, updatedAt: sql`CURRENT_TIMESTAMP` },
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
);
await this.afterWrite();
return rows[0] ?? null;
@@ -193,16 +198,15 @@ export class CredentialRepository {
userDataKey,
);
const rows = await this.context.drizzle
.update(sshCredentials)
.set({ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
)
.returning();
const rows = await updateReturning(
this.context,
sshCredentials,
{ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` },
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
);
await this.afterWrite();
return this.decryptOne(rows[0] ?? null, userId);
@@ -212,31 +216,29 @@ export class CredentialRepository {
userId: string,
credentialId: number,
): Promise<{ syncId: string | null } | null> {
const rows = await this.context.drizzle
.delete(sshCredentials)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
)
.returning({ syncId: sshCredentials.syncId });
const rows = await deleteReturning(
this.context,
sshCredentials,
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
);
await this.afterWrite();
return rows[0] ?? null;
return rows[0] ? { syncId: rows[0].syncId } : null;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sshCredentials)
.where(eq(sshCredentials.userId, userId))
.returning({ id: sshCredentials.id });
.where(eq(sshCredentials.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async recordUsage(
@@ -2,6 +2,12 @@ import { and, asc, eq } from "drizzle-orm";
import { randomUUID } from "crypto";
import { dashboardServiceLinks } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type DashboardServiceLinkRecord =
typeof dashboardServiceLinks.$inferSelect;
@@ -38,9 +44,10 @@ export class DashboardServiceLinkRepository {
const nextOrder =
existing.length > 0 ? existing[existing.length - 1].order + 1 : 0;
const [created] = await this.context.drizzle
.insert(dashboardServiceLinks)
.values({
const [created] = await insertReturning(
this.context,
dashboardServiceLinks,
{
syncId: randomUUID(),
userId,
label: input.label,
@@ -48,8 +55,8 @@ export class DashboardServiceLinkRepository {
order: nextOrder,
createdAt,
updatedAt: createdAt,
})
.returning();
},
);
await this.afterWrite();
return created;
}
@@ -77,16 +84,15 @@ export class DashboardServiceLinkRepository {
id: number,
updates: DashboardServiceLinkUpdate,
): Promise<DashboardServiceLinkRecord | null> {
const [updated] = await this.context.drizzle
.update(dashboardServiceLinks)
.set({ ...updates, updatedAt: new Date().toISOString() })
.where(
and(
eq(dashboardServiceLinks.id, id),
eq(dashboardServiceLinks.userId, userId),
),
)
.returning();
const [updated] = await updateReturning(
this.context,
dashboardServiceLinks,
{ ...updates, updatedAt: new Date().toISOString() },
and(
eq(dashboardServiceLinks.id, id),
eq(dashboardServiceLinks.userId, userId),
),
);
if (updated) {
await this.afterWrite();
@@ -99,32 +105,30 @@ export class DashboardServiceLinkRepository {
userId: string,
id: number,
): Promise<{ syncId: string | null } | null> {
const rows = await this.context.drizzle
.delete(dashboardServiceLinks)
.where(
and(
eq(dashboardServiceLinks.id, id),
eq(dashboardServiceLinks.userId, userId),
),
)
.returning({ syncId: dashboardServiceLinks.syncId });
const rows = await deleteReturning(
this.context,
dashboardServiceLinks,
and(
eq(dashboardServiceLinks.id, id),
eq(dashboardServiceLinks.userId, userId),
),
);
if (rows.length === 0) return null;
await this.afterWrite();
return rows[0];
return { syncId: rows[0].syncId };
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(dashboardServiceLinks)
.where(eq(dashboardServiceLinks.userId, userId))
.returning({ id: dashboardServiceLinks.id });
.where(eq(dashboardServiceLinks.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,12 +1,31 @@
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import type * as schema from "../db/schema.js";
// Re-exported so repositories can keep importing it from here, but defined in
// db/dialect.ts — a local copy that said "sqlite" survived here for a while and
// typed every context as SQLite-only while the runtime already carried all
// three, which silently made the dialect branches unreachable to the checker.
export type { DatabaseDialect } from "../db/dialect.js";
import type { DatabaseDialect } from "../db/dialect.js";
/**
* Engines the repository layer can run against. SQLite is the only one wired up
* today; the alias exists so that adding another is a change in one place
* rather than a hunt for string literals.
* The database handle repositories work against.
*
* Typed as the SQLite instance on purpose. drizzle's three Database classes
* share no base class and their signatures are incompatible: a union is not
* callable, and a generic would have to be threaded through all 43
* repositories and every method on them.
*
* This is a deliberate approximation, not an accident. The query-builder
* surface the repositories actually use is the same on all three engines, and
* that equivalence is asserted in multi-dialect.test.ts rather than assumed —
* identifier quoting, placeholder style and value coercion are all covered
* there. At runtime this may hold a Postgres or MySQL instance.
*
* The one place the surfaces genuinely differ is RETURNING, which MySQL lacks;
* see mutation-result.ts for how that is absorbed.
*/
export type DatabaseDialect = "sqlite";
export type PortableDatabase = BetterSQLite3Database<typeof schema>;
/**
* What a repository is allowed to touch.
@@ -18,5 +37,5 @@ export type DatabaseDialect = "sqlite";
*/
export interface DatabaseContext {
dialect: DatabaseDialect;
drizzle: BetterSQLite3Database<typeof schema>;
drizzle: PortableDatabase;
}
@@ -1,6 +1,7 @@
import { and, eq } from "drizzle-orm";
import { dismissedAlerts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type DismissedAlertRecord = typeof dismissedAlerts.$inferSelect;
@@ -72,34 +73,32 @@ export class DismissedAlertRepository {
}
async deleteForUser(userId: string, alertId: string): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(dismissedAlerts)
.where(
and(
eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alertId),
),
)
.returning({ id: dismissedAlerts.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId))
.returning({ id: dismissedAlerts.id });
.where(eq(dismissedAlerts.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
+89 -1
View File
@@ -1,5 +1,7 @@
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { getDb, getSqlite } from "../db/index.js";
import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
import { primeSettingsCache, readCachedSetting } from "./settings-cache.js";
import type { DatabaseContext } from "./database-context.js";
import { WebauthnCredentialRepository } from "./webauthn-credential-repository.js";
import { AlertRepository } from "./alert-repository.js";
@@ -52,9 +54,18 @@ export function createCurrentRepositoryContext(): DatabaseContext {
};
}
/**
* Post-write hook handed to every repository.
*
* Only meaningful for SQLite, where the database lives in memory and has to be
* serialised back to its encrypted file. On Postgres and MySQL the write is
* already durable, so no hook is installed at all rather than one that does
* nothing — repositories call it as `this.onWrite?.()`.
*/
export function createCurrentRepositoryWriteHook(
reason: string,
): () => Promise<void> {
): (() => Promise<void>) | undefined {
if (!needsExplicitPersist(resolveDatabaseDialect())) return undefined;
return () => DatabaseSaveTrigger.forceSave(reason);
}
@@ -68,7 +79,18 @@ export function getCurrentRepositorySqlite() {
return getSqlite();
}
/**
* Synchronous settings read.
*
* SQLite can be queried synchronously, so it is read directly and stays
* authoritative. Other engines have no synchronous query, so the value comes
* from the cache primed at startup and kept current by SettingsRepository.
*/
export function getCurrentSettingValue(key: string): string | null {
if (!needsExplicitPersist(resolveDatabaseDialect())) {
return readCachedSetting(key);
}
const row = getCurrentRepositorySqlite()
.prepare("SELECT value FROM settings WHERE key = ?")
.get(key) as { value?: string } | undefined;
@@ -375,3 +397,69 @@ export function createCurrentVaultTokenRepository(): VaultTokenRepository {
createCurrentRepositoryWriteHook("vault_token_repository_write"),
);
}
/**
* Loads the settings cache. Must run during startup on engines without a
* synchronous read, before anything calls getCurrentSettingValue.
*/
export async function primeCurrentSettingsCache(): Promise<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,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type FileManagerRecentRecord = typeof fileManagerRecent.$inferSelect;
export type FileManagerPinnedRecord = typeof fileManagerPinned.$inferSelect;
@@ -112,7 +113,7 @@ export class FileManagerBookmarkRepository {
userId: string,
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerRecent)
.where(
and(
@@ -120,14 +121,13 @@ export class FileManagerBookmarkRepository {
eq(fileManagerRecent.hostId, input.hostId),
eq(fileManagerRecent.path, input.path),
),
)
.returning({ id: fileManagerRecent.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async listPinnedForHost(
@@ -199,7 +199,7 @@ export class FileManagerBookmarkRepository {
userId: string,
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerPinned)
.where(
and(
@@ -207,14 +207,13 @@ export class FileManagerBookmarkRepository {
eq(fileManagerPinned.hostId, input.hostId),
eq(fileManagerPinned.path, input.path),
),
)
.returning({ id: fileManagerPinned.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async listShortcutsForHost(
@@ -288,7 +287,7 @@ export class FileManagerBookmarkRepository {
userId: string,
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerShortcuts)
.where(
and(
@@ -296,14 +295,13 @@ export class FileManagerBookmarkRepository {
eq(fileManagerShortcuts.hostId, input.hostId),
eq(fileManagerShortcuts.path, input.path),
),
)
.returning({ id: fileManagerShortcuts.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
@@ -456,75 +454,66 @@ export class FileManagerBookmarkRepository {
}
private async deleteRecentByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerRecent)
.where(eq(fileManagerRecent.userId, userId))
.returning({ id: fileManagerRecent.id });
return rows.length;
.where(eq(fileManagerRecent.userId, userId));
return rowsAffected(result);
}
private async deletePinnedByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerPinned)
.where(eq(fileManagerPinned.userId, userId))
.returning({ id: fileManagerPinned.id });
return rows.length;
.where(eq(fileManagerPinned.userId, userId));
return rowsAffected(result);
}
private async deleteShortcutsByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, userId))
.returning({ id: fileManagerShortcuts.id });
return rows.length;
.where(eq(fileManagerShortcuts.userId, userId));
return rowsAffected(result);
}
private async deleteRecentByHostId(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerRecent)
.where(eq(fileManagerRecent.hostId, hostId))
.returning({ id: fileManagerRecent.id });
return rows.length;
.where(eq(fileManagerRecent.hostId, hostId));
return rowsAffected(result);
}
private async deletePinnedByHostId(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerPinned)
.where(eq(fileManagerPinned.hostId, hostId))
.returning({ id: fileManagerPinned.id });
return rows.length;
.where(eq(fileManagerPinned.hostId, hostId));
return rowsAffected(result);
}
private async deleteShortcutsByHostId(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.hostId, hostId))
.returning({ id: fileManagerShortcuts.id });
return rows.length;
.where(eq(fileManagerShortcuts.hostId, hostId));
return rowsAffected(result);
}
private async deleteRecentByHostIds(hostIds: number[]): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerRecent)
.where(inArray(fileManagerRecent.hostId, hostIds))
.returning({ id: fileManagerRecent.id });
return rows.length;
.where(inArray(fileManagerRecent.hostId, hostIds));
return rowsAffected(result);
}
private async deletePinnedByHostIds(hostIds: number[]): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerPinned)
.where(inArray(fileManagerPinned.hostId, hostIds))
.returning({ id: fileManagerPinned.id });
return rows.length;
.where(inArray(fileManagerPinned.hostId, hostIds));
return rowsAffected(result);
}
private async deleteShortcutsByHostIds(hostIds: number[]): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerShortcuts)
.where(inArray(fileManagerShortcuts.hostId, hostIds))
.returning({ id: fileManagerShortcuts.id });
return rows.length;
.where(inArray(fileManagerShortcuts.hostId, hostIds));
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -2,6 +2,12 @@ import { and, asc, eq } from "drizzle-orm";
import { randomUUID } from "crypto";
import { homepageItems } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type HomepageItemRecord = typeof homepageItems.$inferSelect;
@@ -35,18 +41,15 @@ export class HomepageItemRepository {
input: HomepageItemCreateInput,
now = new Date().toISOString(),
): Promise<HomepageItemRecord> {
const [created] = await this.context.drizzle
.insert(homepageItems)
.values({
syncId: randomUUID(),
userId,
typeId: input.typeId,
title: input.title,
config: input.config,
createdAt: now,
updatedAt: now,
})
.returning();
const [created] = await insertReturning(this.context, homepageItems, {
syncId: randomUUID(),
userId,
typeId: input.typeId,
title: input.title,
config: input.config,
createdAt: now,
updatedAt: now,
});
await this.afterWrite();
return created;
@@ -71,11 +74,12 @@ export class HomepageItemRepository {
updates: HomepageItemUpdateInput,
updatedAt = new Date().toISOString(),
): Promise<HomepageItemRecord | null> {
const [updated] = await this.context.drizzle
.update(homepageItems)
.set({ ...updates, updatedAt })
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
.returning();
const [updated] = await updateReturning(
this.context,
homepageItems,
{ ...updates, updatedAt },
and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)),
);
if (updated) {
await this.afterWrite();
@@ -88,27 +92,27 @@ export class HomepageItemRepository {
userId: string,
id: number,
): Promise<{ syncId: string | null } | null> {
const rows = await this.context.drizzle
.delete(homepageItems)
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
.returning({ syncId: homepageItems.syncId });
const rows = await deleteReturning(
this.context,
homepageItems,
and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)),
);
if (rows.length === 0) return null;
await this.afterWrite();
return rows[0];
return { syncId: rows[0].syncId };
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(homepageItems)
.where(eq(homepageItems.userId, userId))
.returning({ id: homepageItems.id });
.where(eq(homepageItems.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,8 @@
import { eq } from "drizzle-orm";
import { homepageLayouts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type HomepageLayoutRecord = typeof homepageLayouts.$inferSelect;
@@ -28,34 +30,35 @@ export class HomepageLayoutRepository {
const existing = await this.findByUserId(userId);
if (!existing) {
const [created] = await this.context.drizzle
.insert(homepageLayouts)
.values({ userId, layout, updatedAt })
.returning();
const [created] = await insertReturning(this.context, homepageLayouts, {
userId,
layout,
updatedAt,
});
await this.afterWrite();
return created;
}
const [updated] = await this.context.drizzle
.update(homepageLayouts)
.set({ layout, updatedAt })
.where(eq(homepageLayouts.userId, userId))
.returning();
const [updated] = await updateReturning(
this.context,
homepageLayouts,
{ layout, updatedAt },
eq(homepageLayouts.userId, userId),
);
await this.afterWrite();
return updated;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(homepageLayouts)
.where(eq(homepageLayouts.userId, userId))
.returning({ id: homepageLayouts.id });
.where(eq(homepageLayouts.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -3,6 +3,12 @@ import { randomUUID } from "crypto";
import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type HostFolderRecord = typeof sshFolders.$inferSelect;
export type HostFolderHostRecord = typeof hosts.$inferSelect;
@@ -24,19 +30,31 @@ export class HostFolderRepository {
newName: string,
now = new Date().toISOString(),
): Promise<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 newPrefix = `${newName} / `;
const childLike = `${oldPrefix}%`;
// CONCAT, not `||`: MySQL reads `||` as logical OR unless the server runs
// with PIPES_AS_CONCAT, so the child paths would have been rewritten to 0.
// No error, just wrong folder names. CONCAT and SUBSTR mean the same thing
// on all three engines.
//
// The prefix is inlined rather than bound: CONCAT is variadic, so Postgres
// cannot infer a parameter's type from its position and rejects the
// statement with 42P18 before it runs. The value is a folder name the
// caller supplied, so it goes through a bound placeholder in a plain
// concatenation instead of sql.raw.
const renameExpr = (col: SQLiteColumn) =>
sql`CASE WHEN ${col} = ${oldName} THEN ${newName} ELSE ${newPrefix} || substr(${col}, ${oldPrefix.length + 1}) END`;
sql`CASE WHEN ${col} = ${oldName} THEN ${newName} ELSE CONCAT(CAST(${newPrefix} AS ${sql.raw(textType)}), SUBSTR(${col}, ${sql.raw(String(oldPrefix.length + 1))})) END`;
const folderMatch = (col: SQLiteColumn) =>
or(eq(col, oldName), like(col, childLike));
const updatedHosts = await this.context.drizzle
.update(hosts)
.set({ folder: renameExpr(hosts.folder), updatedAt: now })
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)))
.returning({ id: hosts.id });
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
const updatedCredentials = await this.context.drizzle
.update(sshCredentials)
@@ -46,8 +64,7 @@ export class HostFolderRepository {
eq(sshCredentials.userId, userId),
folderMatch(sshCredentials.folder),
),
)
.returning({ id: sshCredentials.id });
);
await this.context.drizzle
.update(sshFolders)
@@ -56,8 +73,8 @@ export class HostFolderRepository {
await this.afterWrite();
return {
updatedHosts: updatedHosts.length,
updatedCredentials: updatedCredentials.length,
updatedHosts: rowsAffected(updatedHosts),
updatedCredentials: rowsAffected(updatedCredentials),
};
}
@@ -78,35 +95,33 @@ export class HostFolderRepository {
): Promise<{ folder: HostFolderRecord; created: boolean }> {
const existing = await this.findFolder(userId, name);
if (existing) {
const [updated] = await this.context.drizzle
.update(sshFolders)
.set({
const [updated] = await updateReturning(
this.context,
sshFolders,
{
color,
icon,
credentialId:
credentialId === undefined ? existing.credentialId : credentialId,
updatedAt: now,
})
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
.returning();
},
and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)),
);
await this.afterWrite();
return { folder: updated, created: false };
}
const [created] = await this.context.drizzle
.insert(sshFolders)
.values({
syncId: randomUUID(),
userId,
name,
color,
icon,
credentialId: credentialId ?? null,
createdAt: now,
updatedAt: now,
})
.returning();
const [created] = await insertReturning(this.context, sshFolders, {
syncId: randomUUID(),
userId,
name,
color,
icon,
credentialId: credentialId ?? null,
createdAt: now,
updatedAt: now,
});
await this.afterWrite();
return { folder: created, created: true };
@@ -139,10 +154,11 @@ export class HostFolderRepository {
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
}
const deletedFolders = await this.context.drizzle
.delete(sshFolders)
.where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)))
.returning({ syncId: sshFolders.syncId });
const deletedFolders = await deleteReturning(
this.context,
sshFolders,
and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)),
);
await this.afterWrite();
@@ -157,16 +173,15 @@ export class HostFolderRepository {
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sshFolders)
.where(eq(sshFolders.userId, userId))
.returning({ id: sshFolders.id });
.where(eq(sshFolders.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async findFolder(
@@ -1,6 +1,8 @@
import { and, desc, eq, notInArray } from "drizzle-orm";
import { hostHealthChecks, hostHealthHistory } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type HostHealthCheckRecord = typeof hostHealthChecks.$inferSelect;
export type HostHealthHistoryRecord = typeof hostHealthHistory.$inferSelect;
@@ -45,27 +47,25 @@ export class HostHealthRepository {
): Promise<HostHealthCheckRecord> {
const existing = await this.findChecksByUserAndHost(userId, hostId);
if (existing) {
const [updated] = await this.context.drizzle
.update(hostHealthChecks)
.set({ checks, intervalSeconds, updatedAt: now })
.where(eq(hostHealthChecks.id, existing.id))
.returning();
const [updated] = await updateReturning(
this.context,
hostHealthChecks,
{ checks, intervalSeconds, updatedAt: now },
eq(hostHealthChecks.id, existing.id),
);
await this.afterWrite();
return updated;
}
const [created] = await this.context.drizzle
.insert(hostHealthChecks)
.values({
userId,
hostId,
checks,
intervalSeconds,
createdAt: now,
updatedAt: now,
})
.returning();
const [created] = await insertReturning(this.context, hostHealthChecks, {
userId,
hostId,
checks,
intervalSeconds,
createdAt: now,
updatedAt: now,
});
await this.afterWrite();
return created;
@@ -121,23 +121,21 @@ export class HostHealthRepository {
checksDeleted: number;
historyDeleted: number;
}> {
const historyRows = await this.context.drizzle
const historyResult = await this.context.drizzle
.delete(hostHealthHistory)
.where(eq(hostHealthHistory.userId, userId))
.returning({ id: hostHealthHistory.id });
.where(eq(hostHealthHistory.userId, userId));
const checkRows = await this.context.drizzle
const result = await this.context.drizzle
.delete(hostHealthChecks)
.where(eq(hostHealthChecks.userId, userId))
.returning({ id: hostHealthChecks.id });
.where(eq(hostHealthChecks.userId, userId));
if (historyRows.length > 0 || checkRows.length > 0) {
if (rowsAffected(historyResult) > 0 || rowsAffected(result) > 0) {
await this.afterWrite();
}
return {
checksDeleted: checkRows.length,
historyDeleted: historyRows.length,
checksDeleted: rowsAffected(result),
historyDeleted: rowsAffected(historyResult),
};
}
@@ -1,6 +1,8 @@
import { and, eq } from "drizzle-orm";
import { hostMetricsPreferences, hosts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type HostMetricsPreferenceRecord =
typeof hostMetricsPreferences.$inferSelect;
@@ -37,26 +39,28 @@ export class HostMetricsPreferenceRepository {
): Promise<HostMetricsPreferenceRecord> {
const existing = await this.findByUserAndHost(userId, hostId);
if (existing) {
const [updated] = await this.context.drizzle
.update(hostMetricsPreferences)
.set({ layout, updatedAt: now })
.where(eq(hostMetricsPreferences.id, existing.id))
.returning();
const [updated] = await updateReturning(
this.context,
hostMetricsPreferences,
{ layout, updatedAt: now },
eq(hostMetricsPreferences.id, existing.id),
);
await this.afterWrite();
return updated;
}
const [created] = await this.context.drizzle
.insert(hostMetricsPreferences)
.values({
const [created] = await insertReturning(
this.context,
hostMetricsPreferences,
{
userId,
hostId,
layout,
createdAt: now,
updatedAt: now,
})
.returning();
},
);
await this.afterWrite();
return created;
@@ -67,28 +71,26 @@ export class HostMetricsPreferenceRepository {
hostId: number,
statsConfig: string,
): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(hosts)
.set({ statsConfig })
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.returning({ id: hosts.id });
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)));
if (rows.length === 0) return false;
if (rowsAffected(result) === 0) return false;
await this.afterWrite();
return true;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(hostMetricsPreferences)
.where(eq(hostMetricsPreferences.userId, userId))
.returning({ id: hostMetricsPreferences.id });
.where(eq(hostMetricsPreferences.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -3,6 +3,12 @@ import { randomUUID } from "crypto";
import { hostAccess, hosts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type HostRecord = typeof hosts.$inferSelect;
export type NewHostRecord = typeof hosts.$inferInsert;
@@ -21,10 +27,10 @@ export class HostRepository {
) {}
async create(host: NewHostRecord): Promise<HostRecord> {
const rows = await this.context.drizzle
.insert(hosts)
.values({ syncId: randomUUID(), ...host })
.returning();
const rows = await insertReturning(this.context, hosts, {
syncId: randomUUID(),
...host,
});
await this.afterWrite();
return rows[0];
}
@@ -51,10 +57,11 @@ export class HostRepository {
delete (encryptedHost as Partial<NewHostRecord>).id;
}
const rows = await this.context.drizzle
.insert(hosts)
.values(encryptedHost as NewHostRecord)
.returning();
const rows = await insertReturning(
this.context,
hosts,
encryptedHost as NewHostRecord,
);
await this.afterWrite();
return DataCrypto.decryptRecord("ssh_data", rows[0], userId, userDataKey);
@@ -150,11 +157,12 @@ export class HostRepository {
hostId: number,
update: HostUpdate,
): Promise<HostRecord | null> {
const rows = await this.context.drizzle
.update(hosts)
.set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.returning();
const rows = await updateReturning(
this.context,
hosts,
{ ...update, updatedAt: sql`CURRENT_TIMESTAMP` },
and(eq(hosts.id, hostId), eq(hosts.userId, userId)),
);
await this.afterWrite();
return rows[0] ?? null;
@@ -173,11 +181,12 @@ export class HostRepository {
userDataKey,
);
const rows = await this.context.drizzle
.update(hosts)
.set({ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.returning();
const rows = await updateReturning(
this.context,
hosts,
{ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` },
and(eq(hosts.id, hostId), eq(hosts.userId, userId)),
);
await this.afterWrite();
return rows[0]
@@ -213,17 +222,16 @@ export class HostRepository {
return 0;
}
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(hosts)
.set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)))
.returning({ id: hosts.id });
.where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteForUser(
@@ -232,39 +240,38 @@ export class HostRepository {
): Promise<{ syncId: string | null } | null> {
await this.deleteAccessForHost(hostId);
const rows = await this.context.drizzle
.delete(hosts)
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.returning({ syncId: hosts.syncId });
const rows = await deleteReturning(
this.context,
hosts,
and(eq(hosts.id, hostId), eq(hosts.userId, userId)),
);
await this.afterWrite();
return rows[0] ?? null;
return rows[0] ? { syncId: rows[0].syncId } : null;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(hosts)
.where(eq(hosts.userId, userId))
.returning({ id: hosts.id });
.where(eq(hosts.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteAccessForHost(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(hostAccess)
.where(eq(hostAccess.hostId, hostId))
.returning({ id: hostAccess.id });
.where(eq(hostAccess.hostId, hostId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<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 { networkTopology } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type NetworkTopologyRecord = typeof networkTopology.$inferSelect;
@@ -45,16 +46,15 @@ export class NetworkTopologyRepository {
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(networkTopology)
.where(eq(networkTopology.userId, userId))
.returning({ id: networkTopology.id });
.where(eq(networkTopology.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,7 @@
import { and, eq, gt } from "drizzle-orm";
import { userOpenTabs } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type OpenTabRecord = typeof userOpenTabs.$inferSelect;
export type NewOpenTabRecord = typeof userOpenTabs.$inferInsert;
@@ -111,43 +112,40 @@ export class OpenTabRepository {
update: OpenTabUpdate,
updatedAt = new Date().toISOString(),
): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(userOpenTabs)
.set({ ...update, updatedAt })
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
.returning({ id: userOpenTabs.id });
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteForUser(userId: string, id: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(userOpenTabs)
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
.returning({ id: userOpenTabs.id });
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(userOpenTabs)
.where(eq(userOpenTabs.userId, userId))
.returning({ id: userOpenTabs.id });
.where(eq(userOpenTabs.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async findByIdForUser(
@@ -1,6 +1,8 @@
import { and, eq } from "drizzle-orm";
import { opksshTokens } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { upsert } from "./returning.js";
export type OpksshTokenRecord = typeof opksshTokens.$inferSelect;
@@ -26,9 +28,10 @@ export class OpksshTokenRepository {
async upsert(input: OpksshTokenUpsertInput): Promise<void> {
const createdAt = input.createdAt ?? new Date().toISOString();
await this.context.drizzle
.insert(opksshTokens)
.values({
await upsert(
this.context,
opksshTokens,
{
userId: input.userId,
hostId: input.hostId,
sshCert: input.sshCert,
@@ -38,8 +41,8 @@ export class OpksshTokenRepository {
issuer: input.issuer,
audience: input.audience,
expiresAt: input.expiresAt,
})
.onConflictDoUpdate({
},
{
target: [opksshTokens.userId, opksshTokens.hostId],
set: {
sshCert: input.sshCert,
@@ -51,7 +54,8 @@ export class OpksshTokenRepository {
expiresAt: input.expiresAt,
createdAt,
},
});
},
);
await this.afterWrite();
}
@@ -76,47 +80,44 @@ export class OpksshTokenRepository {
hostId: number,
lastUsed = new Date().toISOString(),
): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(opksshTokens)
.set({ lastUsed })
.where(
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
)
.returning({ id: opksshTokens.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteByUserAndHost(userId: string, hostId: number): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(opksshTokens)
.where(
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
)
.returning({ id: opksshTokens.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(opksshTokens)
.where(eq(opksshTokens.userId, userId))
.returning({ id: opksshTokens.id });
.where(eq(opksshTokens.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -9,6 +9,8 @@ import {
users,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type RbacAccessTargetType = "user" | "role";
@@ -156,7 +158,7 @@ export class RbacAccessRepository {
return { id: existing.id, created: false };
}
const result = await this.context.drizzle.insert(hostAccess).values({
const [created] = await insertReturning(this.context, hostAccess, {
hostId: input.hostId,
userId: input.targetType === "user" ? input.targetUserId : null,
roleId: input.targetType === "role" ? input.targetRoleId : null,
@@ -166,7 +168,7 @@ export class RbacAccessRepository {
});
await this.afterWrite();
return { id: Number(result.lastInsertRowid), created: true };
return { id: created.id, created: true };
}
async revokeHostAccess(accessId: number, hostId: number): Promise<void> {
@@ -177,16 +179,15 @@ export class RbacAccessRepository {
}
async deleteHostAccessForHost(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(hostAccess)
.where(eq(hostAccess.hostId, hostId))
.returning({ id: hostAccess.id });
.where(eq(hostAccess.hostId, hostId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteHostAccessForHosts(hostIds: number[]): Promise<number> {
@@ -194,30 +195,27 @@ export class RbacAccessRepository {
return 0;
}
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(hostAccess)
.where(inArray(hostAccess.hostId, hostIds))
.returning({ id: hostAccess.id });
.where(inArray(hostAccess.hostId, hostIds));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteHostAccessForUserReferences(userId: string): Promise<number> {
const directRows = await this.context.drizzle
const directResult = await this.context.drizzle
.delete(hostAccess)
.where(eq(hostAccess.userId, userId))
.returning({ id: hostAccess.id });
.where(eq(hostAccess.userId, userId));
const grantedRows = await this.context.drizzle
const result = await this.context.drizzle
.delete(hostAccess)
.where(eq(hostAccess.grantedBy, userId))
.returning({ id: hostAccess.id });
.where(eq(hostAccess.grantedBy, userId));
const deletedCount = directRows.length + grantedRows.length;
const deletedCount = rowsAffected(directResult) + rowsAffected(result);
if (deletedCount > 0) {
await this.afterWrite();
}
@@ -291,7 +289,7 @@ export class RbacAccessRepository {
return { id: existing.id, created: false };
}
const result = await this.context.drizzle.insert(snippetAccess).values({
const [created] = await insertReturning(this.context, snippetAccess, {
snippetId: input.snippetId,
userId: input.targetType === "user" ? input.targetUserId : null,
roleId: input.targetType === "role" ? input.targetRoleId : null,
@@ -301,7 +299,7 @@ export class RbacAccessRepository {
});
await this.afterWrite();
return { id: Number(result.lastInsertRowid), created: true };
return { id: created.id, created: true };
}
async revokeSnippetAccess(
@@ -512,21 +510,20 @@ export class RbacAccessRepository {
async deleteExpiredHostAccess(
now = new Date().toISOString(),
): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(hostAccess)
.where(
and(
sql`${hostAccess.expiresAt} IS NOT NULL`,
sql`${hostAccess.expiresAt} <= ${now}`,
),
)
.returning({ id: hostAccess.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async findActiveHostAccess(
@@ -635,17 +632,16 @@ export class RbacAccessRepository {
hostId: number,
update: { permissionLevel?: string; expiresAt?: string | null },
): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(hostAccess)
.set(update)
.where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId)))
.returning({ id: hostAccess.id });
.where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId)));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async findHostAccessOwnerId(hostAccessId: number): Promise<string | null> {
@@ -1,6 +1,8 @@
import { desc, eq, inArray } from "drizzle-orm";
import { recentActivity } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type RecentActivityRecord = typeof recentActivity.$inferSelect;
export type NewRecentActivityRecord = typeof recentActivity.$inferInsert;
@@ -26,10 +28,7 @@ export class RecentActivityRepository {
async create(
activity: NewRecentActivityRecord,
): Promise<RecentActivityRecord> {
const rows = await this.context.drizzle
.insert(recentActivity)
.values(activity)
.returning();
const rows = await insertReturning(this.context, recentActivity, activity);
await this.afterWrite();
return rows[0];
@@ -51,42 +50,39 @@ export class RecentActivityRepository {
return 0;
}
const deletedRows = await this.context.drizzle
const result = await this.context.drizzle
.delete(recentActivity)
.where(inArray(recentActivity.id, idsToDelete))
.returning({ id: recentActivity.id });
.where(inArray(recentActivity.id, idsToDelete));
if (deletedRows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return deletedRows.length;
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(recentActivity)
.where(eq(recentActivity.userId, userId))
.returning({ id: recentActivity.id });
.where(eq(recentActivity.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByHostId(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(recentActivity)
.where(eq(recentActivity.hostId, hostId))
.returning({ id: recentActivity.id });
.where(eq(recentActivity.hostId, hostId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
@@ -94,16 +90,15 @@ export class RecentActivityRepository {
return 0;
}
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(recentActivity)
.where(inArray(recentActivity.hostId, hostIds))
.returning({ id: recentActivity.id });
.where(inArray(recentActivity.hostId, hostIds));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<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 { hostAccess, roles, userRoles } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { deleteReturning, insertReturning } from "./returning.js";
export type RoleRecord = typeof roles.$inferSelect;
export type NewRoleRecord = typeof roles.$inferInsert;
@@ -62,27 +64,27 @@ export class RoleRepository {
}
async createRole(role: NewRoleRecord): Promise<number> {
const result = await this.context.drizzle.insert(roles).values(role);
const [created] = await insertReturning(this.context, roles, role);
await this.afterWrite();
return Number(result.lastInsertRowid);
return created.id;
}
async updateRole(id: number, update: RoleUpdate): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(roles)
.set(update)
.where(eq(roles.id, id))
.returning({ id: roles.id });
.where(eq(roles.id, id));
await this.afterWrite();
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteRole(id: number): Promise<{ deletedUserIds: string[] }> {
const deletedUserRoles = await this.context.drizzle
.delete(userRoles)
.where(eq(userRoles.roleId, id))
.returning({ userId: userRoles.userId });
const deletedUserRoles = await deleteReturning(
this.context,
userRoles,
eq(userRoles.roleId, id),
);
await this.context.drizzle
.delete(hostAccess)
@@ -169,16 +171,15 @@ export class RoleRepository {
}
if (removeRole) {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(userRoles)
.where(
and(
eq(userRoles.userId, input.userId),
eq(userRoles.roleId, removeRole.id),
),
)
.returning({ id: userRoles.id });
removed = rows.length > 0;
);
removed = rowsAffected(result) > 0;
}
if (added || removed) {
@@ -196,16 +197,15 @@ export class RoleRepository {
}
async removeAllRolesFromUser(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(userRoles)
.where(eq(userRoles.userId, userId))
.returning({ id: userRoles.id });
.where(eq(userRoles.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async listUserRoleIds(userId: string): Promise<number[]> {
@@ -1,6 +1,8 @@
import { and, desc, eq, inArray, lt } from "drizzle-orm";
import { hosts, sessionRecordings } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type SessionRecordingRecord = typeof sessionRecordings.$inferSelect;
@@ -47,10 +49,11 @@ export class SessionRecordingRepository {
async create(
input: SessionRecordingCreateInput,
): Promise<SessionRecordingRecord> {
const [created] = await this.context.drizzle
.insert(sessionRecordings)
.values(input)
.returning();
const [created] = await insertReturning(
this.context,
sessionRecordings,
input,
);
await this.afterWrite();
return created;
@@ -170,31 +173,29 @@ export class SessionRecordingRepository {
}
async deleteById(id: number): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(eq(sessionRecordings.id, id))
.returning({ id: sessionRecordings.id });
.where(eq(sessionRecordings.id, id));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteForUser(userId: string, id: number): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
)
.returning({ id: sessionRecordings.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
/**
@@ -203,43 +204,40 @@ export class SessionRecordingRepository {
* file stays on disk regardless — deleting only the row would orphan it.
*/
async anonymizeByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(sessionRecordings)
.set({ userId: null })
.where(eq(sessionRecordings.userId, userId))
.returning({ id: sessionRecordings.id });
.where(eq(sessionRecordings.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(eq(sessionRecordings.userId, userId))
.returning({ id: sessionRecordings.id });
.where(eq(sessionRecordings.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByHostId(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(eq(sessionRecordings.hostId, hostId))
.returning({ id: sessionRecordings.id });
.where(eq(sessionRecordings.hostId, hostId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
@@ -247,16 +245,15 @@ export class SessionRecordingRepository {
return 0;
}
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(inArray(sessionRecordings.hostId, hostIds))
.returning({ id: sessionRecordings.id });
.where(inArray(sessionRecordings.hostId, hostIds));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,8 @@
import { and, eq, lte, ne } from "drizzle-orm";
import { sessions } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type SessionRecord = typeof sessions.$inferSelect;
export type NewSessionRecord = typeof sessions.$inferInsert;
@@ -12,10 +14,7 @@ export class SessionRepository {
) {}
async create(session: NewSessionRecord): Promise<SessionRecord> {
const rows = await this.context.drizzle
.insert(sessions)
.values(session)
.returning();
const rows = await insertReturning(this.context, sessions, session);
await this.afterWrite();
return rows[0];
}
@@ -72,13 +71,12 @@ export class SessionRepository {
}
async revoke(id: string): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sessions)
.where(eq(sessions.id, id))
.returning({ id: sessions.id });
.where(eq(sessions.id, id));
await this.afterWrite();
return rows.length > 0;
return rowsAffected(result) > 0;
}
async revokeAllForUser(
@@ -89,23 +87,19 @@ export class SessionRepository {
? and(eq(sessions.userId, userId), ne(sessions.id, exceptSessionId))
: eq(sessions.userId, userId);
const rows = await this.context.drizzle
.delete(sessions)
.where(where)
.returning({ id: sessions.id });
const result = await this.context.drizzle.delete(sessions).where(where);
await this.afterWrite();
return rows.length;
return rowsAffected(result);
}
async deleteExpired(now = new Date()): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sessions)
.where(lte(sessions.expiresAt, now.toISOString()))
.returning({ id: sessions.id });
.where(lte(sessions.expiresAt, now.toISOString()));
await this.afterWrite();
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -6,6 +6,8 @@ import {
users,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type SessionShareRecord = typeof sessionShares.$inferSelect;
export type SessionShareParticipantRecord =
@@ -49,22 +51,19 @@ export class SessionShareRepository {
) {}
async create(input: SessionShareCreateInput): Promise<SessionShareRecord> {
const [created] = await this.context.drizzle
.insert(sessionShares)
.values({
id: input.id,
hostId: input.hostId,
ownerUserId: input.ownerUserId,
protocol: input.protocol,
sessionId: input.sessionId,
tabInstanceId: input.tabInstanceId ?? null,
shareType: input.shareType,
targetUserId: input.targetUserId ?? null,
linkToken: input.linkToken ?? null,
permissionLevel: input.permissionLevel,
expiresAt: input.expiresAt,
})
.returning();
const [created] = await insertReturning(this.context, sessionShares, {
id: input.id,
hostId: input.hostId,
ownerUserId: input.ownerUserId,
protocol: input.protocol,
sessionId: input.sessionId,
tabInstanceId: input.tabInstanceId ?? null,
shareType: input.shareType,
targetUserId: input.targetUserId ?? null,
linkToken: input.linkToken ?? null,
permissionLevel: input.permissionLevel,
expiresAt: input.expiresAt,
});
await this.afterWrite();
return created;
@@ -151,7 +150,7 @@ export class SessionShareRepository {
}
async revoke(shareId: string, requestingUserId: string): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(sessionShares)
.set({ revokedAt: new Date().toISOString() })
.where(
@@ -159,38 +158,35 @@ export class SessionShareRepository {
eq(sessionShares.id, shareId),
eq(sessionShares.ownerUserId, requestingUserId),
),
)
.returning({ id: sessionShares.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async revokeAsAdmin(shareId: string): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(sessionShares)
.set({ revokedAt: new Date().toISOString() })
.where(eq(sessionShares.id, shareId))
.returning({ id: sessionShares.id });
.where(eq(sessionShares.id, shareId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteExpiredShares(now = new Date().toISOString()): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sessionShares)
.where(lt(sessionShares.expiresAt, now))
.returning({ id: sessionShares.id });
.where(lt(sessionShares.expiresAt, now));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async touchShareUsage(
@@ -213,10 +209,11 @@ export class SessionShareRepository {
userId: string | null,
guestLabel: string | null,
): Promise<SessionShareParticipantRecord> {
const [created] = await this.context.drizzle
.insert(sessionShareParticipants)
.values({ shareId, userId, guestLabel })
.returning();
const [created] = await insertReturning(
this.context,
sessionShareParticipants,
{ shareId, userId, guestLabel },
);
await this.afterWrite();
return created;
}
@@ -230,15 +227,14 @@ export class SessionShareRepository {
}
async deleteSharesForHost(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sessionShares)
.where(eq(sessionShares.hostId, hostId))
.returning({ id: sessionShares.id });
.where(eq(sessionShares.hostId, hostId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<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 { settings } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { forgetCachedSetting, updateCachedSetting } from "./settings-cache.js";
import { deleteReturning } from "./returning.js";
export class SettingsRepository {
constructor(
@@ -34,6 +36,9 @@ export class SettingsRepository {
const existing = await this.get(key);
if (existing === null) {
await this.context.drizzle.insert(settings).values({ key, value });
// Kept in step here so the synchronous readers cannot observe a stale
// value after a write in the same process.
updateCachedSetting(key, value);
await this.afterWrite();
return;
}
@@ -42,6 +47,7 @@ export class SettingsRepository {
.update(settings)
.set({ value })
.where(eq(settings.key, key));
updateCachedSetting(key, value);
await this.afterWrite();
}
@@ -51,14 +57,17 @@ export class SettingsRepository {
async delete(key: string): Promise<void> {
await this.context.drizzle.delete(settings).where(eq(settings.key, key));
forgetCachedSetting(key);
await this.afterWrite();
}
async deleteLike(pattern: string): Promise<number> {
const rows = await this.context.drizzle
.delete(settings)
.where(like(settings.key, pattern))
.returning({ key: settings.key });
const rows = await deleteReturning(
this.context,
settings,
like(settings.key, pattern),
);
for (const row of rows) forgetCachedSetting(row.key);
await this.afterWrite();
return rows.length;
}
@@ -1,6 +1,7 @@
import { and, eq, inArray, or } from "drizzle-orm";
import { hostAccess, hosts, sharedHostSecrets } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type SharedHostSecretRecord = typeof sharedHostSecrets.$inferSelect;
export type NewSharedHostSecretRecord = typeof sharedHostSecrets.$inferInsert;
@@ -108,16 +109,15 @@ export class SharedHostSecretsRepository {
}
async deleteByHostAccessId(hostAccessId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sharedHostSecrets)
.where(eq(sharedHostSecrets.hostAccessId, hostAccessId))
.returning({ id: sharedHostSecrets.id });
.where(eq(sharedHostSecrets.hostAccessId, hostAccessId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteForRoleMember(
@@ -148,29 +148,27 @@ export class SharedHostSecretsRepository {
}
async deleteByOriginalCredentialId(credentialId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sharedHostSecrets)
.where(eq(sharedHostSecrets.originalCredentialId, credentialId))
.returning({ id: sharedHostSecrets.id });
.where(eq(sharedHostSecrets.originalCredentialId, credentialId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByTargetUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sharedHostSecrets)
.where(eq(sharedHostSecrets.targetUserId, userId))
.returning({ id: sharedHostSecrets.id });
.where(eq(sharedHostSecrets.targetUserId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async findHostIdsReferencingCredential(
@@ -2,6 +2,12 @@ import { and, asc, eq, sql } from "drizzle-orm";
import { randomUUID } from "crypto";
import { snippetFolders, snippets } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type SnippetRecord = typeof snippets.$inferSelect;
export type SnippetFolderRecord = typeof snippetFolders.$inferSelect;
@@ -84,11 +90,16 @@ export class SnippetRepository {
}
async listSnippetsForExport(userId: string): Promise<SnippetRecord[]> {
return this.context.drizzle
.select()
.from(snippets)
.where(eq(snippets.userId, userId))
.orderBy(asc(snippets.folder), asc(snippets.order));
return (
this.context.drizzle
.select()
.from(snippets)
.where(eq(snippets.userId, userId))
// coalesce, not asc(folder): folder is nullable, and NULLs sort first on
// SQLite and MySQL but last on Postgres. An export whose row order depends
// on the engine is not much of an export.
.orderBy(sql`coalesce(${snippets.folder}, '')`, asc(snippets.order))
);
}
async listFoldersForExport(userId: string): Promise<SnippetFolderRecord[]> {
@@ -149,19 +160,16 @@ export class SnippetRepository {
? await this.nextOrderForFolder(userId, folderValue)
: input.order;
const rows = await this.context.drizzle
.insert(snippets)
.values({
syncId: randomUUID(),
userId,
name: input.name.trim(),
content: input.content.trim(),
description: input.description?.trim() || null,
folder: input.folder?.trim() || null,
order,
hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null,
})
.returning();
const rows = await insertReturning(this.context, snippets, {
syncId: randomUUID(),
userId,
name: input.name.trim(),
content: input.content.trim(),
description: input.description?.trim() || null,
folder: input.folder?.trim() || null,
order,
hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null,
});
await this.afterWrite();
return rows[0];
@@ -200,11 +208,12 @@ export class SnippetRepository {
? JSON.stringify(input.hostFilter)
: null;
const rows = await this.context.drizzle
.update(snippets)
.set(updateFields)
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
.returning();
const rows = await updateReturning(
this.context,
snippets,
updateFields,
and(eq(snippets.id, snippetId), eq(snippets.userId, userId)),
);
await this.afterWrite();
return { existing, updated: rows[0] };
@@ -229,23 +238,21 @@ export class SnippetRepository {
snippetsDeleted: number;
foldersDeleted: number;
}> {
const deletedSnippets = await this.context.drizzle
const snippetResult = await this.context.drizzle
.delete(snippets)
.where(eq(snippets.userId, userId))
.returning({ id: snippets.id });
.where(eq(snippets.userId, userId));
const deletedFolders = await this.context.drizzle
const result = await this.context.drizzle
.delete(snippetFolders)
.where(eq(snippetFolders.userId, userId))
.returning({ id: snippetFolders.id });
.where(eq(snippetFolders.userId, userId));
if (deletedSnippets.length > 0 || deletedFolders.length > 0) {
if (rowsAffected(snippetResult) > 0 || rowsAffected(result) > 0) {
await this.afterWrite();
}
return {
snippetsDeleted: deletedSnippets.length,
foldersDeleted: deletedFolders.length,
snippetsDeleted: rowsAffected(snippetResult),
foldersDeleted: rowsAffected(result),
};
}
@@ -377,16 +384,13 @@ export class SnippetRepository {
const existing = await this.findFolderByName(userId, name);
if (existing) return null;
const rows = await this.context.drizzle
.insert(snippetFolders)
.values({
syncId: randomUUID(),
userId,
name: name.trim(),
color: color?.trim() || null,
icon: icon?.trim() || null,
})
.returning();
const rows = await insertReturning(this.context, snippetFolders, {
syncId: randomUUID(),
userId,
name: name.trim(),
color: color?.trim() || null,
icon: icon?.trim() || null,
});
if (triggerSave) {
await this.afterWrite();
@@ -414,13 +418,12 @@ export class SnippetRepository {
if (color !== undefined) updateFields.color = color?.trim() || null;
if (icon !== undefined) updateFields.icon = icon?.trim() || null;
const rows = await this.context.drizzle
.update(snippetFolders)
.set(updateFields)
.where(
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
)
.returning();
const rows = await updateReturning(
this.context,
snippetFolders,
updateFields,
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
);
await this.afterWrite();
return rows[0] ?? null;
@@ -465,15 +468,14 @@ export class SnippetRepository {
.set({ folder: null })
.where(and(eq(snippets.userId, userId), eq(snippets.folder, name)));
const rows = await this.context.drizzle
.delete(snippetFolders)
.where(
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
)
.returning({ syncId: snippetFolders.syncId });
const rows = await deleteReturning(
this.context,
snippetFolders,
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
);
await this.afterWrite();
return rows[0] ?? null;
return rows[0] ? { syncId: rows[0].syncId } : null;
}
private async findFolderByName(
@@ -1,4 +1,5 @@
import { getCurrentRepositorySqlite } from "./factory.js";
import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
export interface SqliteForeignKeyClient {
exec(sql: string): unknown;
@@ -16,8 +17,28 @@ export async function withSqliteForeignKeysDisabled<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>(
operation: () => 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);
}
@@ -1,6 +1,8 @@
import { eq, inArray } from "drizzle-orm";
import { sshCredentialUsage } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type SshCredentialUsageRecord = typeof sshCredentialUsage.$inferSelect;
@@ -22,38 +24,37 @@ export class SshCredentialUsageRepository {
hostId: number,
userId: string,
): Promise<SshCredentialUsageRecord> {
const [created] = await this.context.drizzle
.insert(sshCredentialUsage)
.values({ credentialId, hostId, userId })
.returning();
const [created] = await insertReturning(this.context, sshCredentialUsage, {
credentialId,
hostId,
userId,
});
await this.afterWrite();
return created;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sshCredentialUsage)
.where(eq(sshCredentialUsage.userId, userId))
.returning({ id: sshCredentialUsage.id });
.where(eq(sshCredentialUsage.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByHostId(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sshCredentialUsage)
.where(eq(sshCredentialUsage.hostId, hostId))
.returning({ id: sshCredentialUsage.id });
.where(eq(sshCredentialUsage.hostId, hostId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
@@ -61,16 +62,15 @@ export class SshCredentialUsageRepository {
return 0;
}
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sshCredentialUsage)
.where(inArray(sshCredentialUsage.hostId, hostIds))
.returning({ id: sshCredentialUsage.id });
.where(inArray(sshCredentialUsage.hostId, hostIds));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,8 @@
import { asc, eq } from "drizzle-orm";
import { ssoProviders, users } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type SsoProviderRecord = typeof ssoProviders.$inferSelect;
export type NewSsoProviderRecord = typeof ssoProviders.$inferInsert;
@@ -76,10 +78,7 @@ export class SsoProviderRepository {
}
async create(provider: NewSsoProviderRecord): Promise<SsoProviderRecord> {
const rows = await this.context.drizzle
.insert(ssoProviders)
.values(provider)
.returning();
const rows = await insertReturning(this.context, ssoProviders, provider);
await this.afterWrite();
return rows[0];
@@ -89,27 +88,27 @@ export class SsoProviderRepository {
id: number,
update: SsoProviderUpdate,
): Promise<SsoProviderRecord | null> {
const rows = await this.context.drizzle
.update(ssoProviders)
.set(update)
.where(eq(ssoProviders.id, id))
.returning();
const rows = await updateReturning(
this.context,
ssoProviders,
update,
eq(ssoProviders.id, id),
);
await this.afterWrite();
return rows[0] ?? null;
}
async delete(id: number): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(ssoProviders)
.where(eq(ssoProviders.id, id))
.returning({ id: ssoProviders.id });
.where(eq(ssoProviders.id, id));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async countUsersByProviderId(providerId: number): Promise<number> {
@@ -2,6 +2,12 @@ import { eq } from "drizzle-orm";
import { termixIdentityCa } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import {
insertedId,
rowsAffected,
supportsReturning,
} from "./mutation-result.js";
import { updateReturning } from "./returning.js";
export type TermixIdentityCaRecord = typeof termixIdentityCa.$inferSelect;
export type NewTermixIdentityCaRecord = typeof termixIdentityCa.$inferInsert;
@@ -54,27 +60,7 @@ export class TermixIdentityCaRepository {
ca: NewTermixIdentityCaRecord,
): Promise<TermixIdentityCaRecord> {
const userDataKey = DataCrypto.validateUserAccess(userId);
const result = this.context.drizzle.transaction((tx) => {
const inserted = tx
.insert(termixIdentityCa)
.values({ ...ca, privateKey: "" })
.returning()
.all();
const row = inserted[0];
const encrypted = DataCrypto.encryptRecord(
"termix_identity_ca",
{ id: row.id, privateKey: ca.privateKey },
userId,
userDataKey,
);
return tx
.update(termixIdentityCa)
.set({ privateKey: encrypted.privateKey })
.where(eq(termixIdentityCa.id, row.id))
.returning()
.all()[0];
});
const result = await this.insertThenEncrypt(userId, ca, userDataKey);
await this.afterWrite();
return DataCrypto.decryptRecord(
@@ -85,6 +71,81 @@ export class TermixIdentityCaRepository {
);
}
/**
* Writes a CA in two steps, because the ciphertext depends on the id.
*
* The private key is encrypted with the row's own id as context, which does
* not exist until the row does. So: insert with an empty key, encrypt, update.
* The empty key must never be observable, hence the transaction.
*
* Two branches because better-sqlite3 rejects an async transaction callback —
* see the same note in UserRepository.
*/
private async insertThenEncrypt(
userId: string,
ca: NewTermixIdentityCaRecord,
userDataKey: Buffer,
): Promise<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(
userId: string,
identityId: number,
@@ -103,43 +164,42 @@ export class TermixIdentityCaRepository {
).privateKey
: undefined;
const rows = await this.context.drizzle
.update(termixIdentityCa)
.set({
const rows = await updateReturning(
this.context,
termixIdentityCa,
{
...update,
...(encryptedPrivateKey ? { privateKey: encryptedPrivateKey } : {}),
})
.where(eq(termixIdentityCa.identityId, identityId))
.returning();
},
eq(termixIdentityCa.identityId, identityId),
);
await this.afterWrite();
return this.decryptOne(rows[0] ?? null, userId);
}
async deleteByIdentityId(identityId: number): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(termixIdentityCa)
.where(eq(termixIdentityCa.identityId, identityId))
.returning({ id: termixIdentityCa.id });
.where(eq(termixIdentityCa.identityId, identityId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(termixIdentityCa)
.where(eq(termixIdentityCa.userId, userId))
.returning({ id: termixIdentityCa.id });
.where(eq(termixIdentityCa.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private decryptOne<T extends Record<string, unknown>>(
@@ -1,6 +1,8 @@
import { and, asc, eq } from "drizzle-orm";
import { termixIdentities, termixIdentityKeys } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type TermixIdentityRecord = typeof termixIdentities.$inferSelect;
export type NewTermixIdentityRecord = typeof termixIdentities.$inferInsert;
@@ -57,10 +59,11 @@ export class TermixIdentityRepository {
async createIdentity(
identity: NewTermixIdentityRecord,
): Promise<TermixIdentityRecord> {
const rows = await this.context.drizzle
.insert(termixIdentities)
.values(identity)
.returning();
const rows = await insertReturning(
this.context,
termixIdentities,
identity,
);
await this.afterWrite();
return rows[0];
@@ -70,11 +73,12 @@ export class TermixIdentityRepository {
userId: string,
update: TermixIdentityUpdate,
): Promise<TermixIdentityRecord | null> {
const rows = await this.context.drizzle
.update(termixIdentities)
.set(update)
.where(eq(termixIdentities.userId, userId))
.returning();
const rows = await updateReturning(
this.context,
termixIdentities,
update,
eq(termixIdentities.userId, userId),
);
if (rows.length > 0) {
await this.afterWrite();
@@ -84,39 +88,36 @@ export class TermixIdentityRepository {
}
async deleteIdentityForUser(userId: string): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(termixIdentities)
.where(eq(termixIdentities.userId, userId))
.returning({ id: termixIdentities.id });
.where(eq(termixIdentities.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteByUserId(userId: string): Promise<{
identitiesDeleted: number;
keysDeleted: number;
}> {
const keyRows = await this.context.drizzle
const keyResult = await this.context.drizzle
.delete(termixIdentityKeys)
.where(eq(termixIdentityKeys.userId, userId))
.returning({ id: termixIdentityKeys.id });
.where(eq(termixIdentityKeys.userId, userId));
const identityRows = await this.context.drizzle
const result = await this.context.drizzle
.delete(termixIdentities)
.where(eq(termixIdentities.userId, userId))
.returning({ id: termixIdentities.id });
.where(eq(termixIdentities.userId, userId));
if (keyRows.length > 0 || identityRows.length > 0) {
if (rowsAffected(keyResult) > 0 || rowsAffected(result) > 0) {
await this.afterWrite();
}
return {
identitiesDeleted: identityRows.length,
keysDeleted: keyRows.length,
identitiesDeleted: rowsAffected(result),
keysDeleted: rowsAffected(keyResult),
};
}
@@ -170,10 +171,7 @@ export class TermixIdentityRepository {
async createKey(
key: NewTermixIdentityKeyRecord,
): Promise<TermixIdentityKeyRecord> {
const rows = await this.context.drizzle
.insert(termixIdentityKeys)
.values(key)
.returning();
const rows = await insertReturning(this.context, termixIdentityKeys, key);
await this.afterWrite();
return rows[0];
@@ -184,16 +182,12 @@ export class TermixIdentityRepository {
id: number,
update: TermixIdentityKeyUpdate,
): Promise<TermixIdentityKeyRecord | null> {
const rows = await this.context.drizzle
.update(termixIdentityKeys)
.set(update)
.where(
and(
eq(termixIdentityKeys.id, id),
eq(termixIdentityKeys.userId, userId),
),
)
.returning();
const rows = await updateReturning(
this.context,
termixIdentityKeys,
update,
and(eq(termixIdentityKeys.id, id), eq(termixIdentityKeys.userId, userId)),
);
if (rows.length > 0) {
await this.afterWrite();
@@ -203,21 +197,20 @@ export class TermixIdentityRepository {
}
async deleteKeyForUser(userId: string, id: number): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(termixIdentityKeys)
.where(
and(
eq(termixIdentityKeys.id, id),
eq(termixIdentityKeys.userId, userId),
),
)
.returning({ id: termixIdentityKeys.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async findKeyForUser(
@@ -1,6 +1,7 @@
import { and, eq } from "drizzle-orm";
import { tmuxSessionTags } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type TmuxSessionTagRecord = typeof tmuxSessionTags.$inferSelect;
@@ -45,7 +46,7 @@ export class TmuxSessionTagRepository {
sessionName: string,
newSessionName: string,
): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(tmuxSessionTags)
.set({ sessionName: newSessionName })
.where(
@@ -53,35 +54,33 @@ export class TmuxSessionTagRepository {
eq(tmuxSessionTags.hostId, hostId),
eq(tmuxSessionTags.sessionName, sessionName),
),
)
.returning({ id: tmuxSessionTags.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteSessionForHost(
hostId: number,
sessionName: string,
): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(tmuxSessionTags)
.where(
and(
eq(tmuxSessionTags.hostId, hostId),
eq(tmuxSessionTags.sessionName, sessionName),
),
)
.returning({ id: tmuxSessionTags.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async replaceForUserHostSession(
@@ -90,7 +89,7 @@ export class TmuxSessionTagRepository {
sessionName: string,
tags: string[],
): Promise<number> {
const deletedRows = await this.context.drizzle
const result = await this.context.drizzle
.delete(tmuxSessionTags)
.where(
and(
@@ -98,8 +97,7 @@ export class TmuxSessionTagRepository {
eq(tmuxSessionTags.hostId, hostId),
eq(tmuxSessionTags.sessionName, sessionName),
),
)
.returning({ id: tmuxSessionTags.id });
);
if (tags.length > 0) {
await this.context.drizzle.insert(tmuxSessionTags).values(
@@ -112,7 +110,7 @@ export class TmuxSessionTagRepository {
);
}
const changedRows = deletedRows.length + tags.length;
const changedRows = rowsAffected(result) + tags.length;
if (changedRows > 0) {
await this.afterWrite();
}
@@ -121,16 +119,15 @@ export class TmuxSessionTagRepository {
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(tmuxSessionTags)
.where(eq(tmuxSessionTags.userId, userId))
.returning({ id: tmuxSessionTags.id });
.where(eq(tmuxSessionTags.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,7 @@
import { and, desc, eq, inArray, or } from "drizzle-orm";
import { transferRecent } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type TransferRecentRecord = typeof transferRecent.$inferSelect;
@@ -100,47 +101,44 @@ export class TransferRecentRepository {
return 0;
}
const deleted = await this.context.drizzle
const result = await this.context.drizzle
.delete(transferRecent)
.where(inArray(transferRecent.id, idsToDelete))
.returning({ id: transferRecent.id });
.where(inArray(transferRecent.id, idsToDelete));
if (deleted.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return deleted.length;
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(transferRecent)
.where(eq(transferRecent.userId, userId))
.returning({ id: transferRecent.id });
.where(eq(transferRecent.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByHostId(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(transferRecent)
.where(
or(
eq(transferRecent.sourceHostId, hostId),
eq(transferRecent.destHostId, hostId),
),
)
.returning({ id: transferRecent.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
@@ -148,21 +146,20 @@ export class TransferRecentRepository {
return 0;
}
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(transferRecent)
.where(
or(
inArray(transferRecent.sourceHostId, hostIds),
inArray(transferRecent.destHostId, hostIds),
),
)
.returning({ id: transferRecent.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,8 @@
import { eq } from "drizzle-orm";
import { userPreferences } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturningWhere, updateReturning } from "./returning.js";
export type UserPreferenceRecord = typeof userPreferences.$inferSelect;
export type NewUserPreferenceRecord = typeof userPreferences.$inferInsert;
@@ -31,34 +33,36 @@ export class UserPreferenceRepository {
const existing = await this.findByUserId(userId);
if (!existing) {
const rows = await this.context.drizzle
.insert(userPreferences)
.values({ userId, ...update })
.returning();
const rows = await insertReturningWhere(
this.context,
userPreferences,
{ userId, ...update },
eq(userPreferences.userId, userId),
);
await this.afterWrite();
return rows[0];
}
const rows = await this.context.drizzle
.update(userPreferences)
.set(update)
.where(eq(userPreferences.userId, userId))
.returning();
const rows = await updateReturning(
this.context,
userPreferences,
update,
eq(userPreferences.userId, userId),
);
await this.afterWrite();
return rows[0];
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(userPreferences)
.where(eq(userPreferences.userId, userId))
.returning({ userId: userPreferences.userId });
.where(eq(userPreferences.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,8 @@
import { eq, inArray } from "drizzle-orm";
import { users } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected, supportsReturning } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type UserRecord = typeof users.$inferSelect;
export type NewUserRecord = typeof users.$inferInsert;
@@ -62,10 +64,7 @@ export class UserRepository {
}
async create(user: NewUserRecord): Promise<UserRecord> {
const rows = await this.context.drizzle
.insert(users)
.values(user)
.returning();
const rows = await insertReturning(this.context, users, user);
await this.afterWrite();
return rows[0];
}
@@ -73,17 +72,10 @@ export class UserRepository {
async createFirstLocalUser(
user: NewFirstLocalUserRecord,
): Promise<{ user: UserRecord; isFirstUser: boolean }> {
const result = this.context.drizzle.transaction((tx) => {
const existingUsers = tx.select({ id: users.id }).from(users).all();
const isFirstUser = existingUsers.length === 0;
const rows = tx
.insert(users)
.values({ ...user, isAdmin: isFirstUser })
.returning()
.all();
return { user: rows[0], isFirstUser };
});
const result = await this.createCheckingIfFirst((isFirstUser) => ({
...user,
isAdmin: isFirstUser,
}));
await this.afterWrite();
return result;
@@ -92,41 +84,87 @@ export class UserRepository {
async createFirstSsoUser(
user: NewUserRecord,
): Promise<{ user: UserRecord; isFirstUser: boolean }> {
const result = this.context.drizzle.transaction((tx) => {
const existingUsers = tx.select({ id: users.id }).from(users).all();
const isFirstUser = existingUsers.length === 0;
const rows = tx
.insert(users)
.values({ ...user, isAdmin: isFirstUser || Boolean(user.isAdmin) })
.returning()
.all();
return { user: rows[0], isFirstUser };
});
const result = await this.createCheckingIfFirst((isFirstUser) => ({
...user,
isAdmin: isFirstUser || Boolean(user.isAdmin),
}));
await this.afterWrite();
return result;
}
/**
* Creates a user, making them an admin if the table was empty.
*
* The check and the insert have to be one transaction: two people signing up
* at once would otherwise both see an empty table and both become admin.
*
* The two branches are not a style choice. better-sqlite3 is synchronous and
* rejects an async transaction callback outright — "Transaction function
* cannot return a promise" — so a single body cannot serve both. It fails
* loudly rather than silently skipping the write, which is the one mercy here.
*/
private async createCheckingIfFirst(
build: (isFirstUser: boolean) => NewUserRecord,
): Promise<{ user: UserRecord; isFirstUser: boolean }> {
if (this.context.dialect === "sqlite") {
/* eslint-disable no-restricted-syntax -- sqlite-only branch: the dialect
is checked directly above, and better-sqlite3 rejects an async
transaction callback, so this cannot use the shared helpers. */
return this.context.drizzle.transaction((tx) => {
const isFirstUser =
tx.select({ id: users.id }).from(users).all().length === 0;
const rows = tx
.insert(users)
.values(build(isFirstUser))
.returning()
.all();
return { user: rows[0], isFirstUser };
});
/* eslint-enable no-restricted-syntax */
}
return this.context.drizzle.transaction(async (tx) => {
const existing = await tx.select({ id: users.id }).from(users);
const isFirstUser = existing.length === 0;
const values = build(isFirstUser);
if (supportsReturning(this.context.dialect)) {
// eslint-disable-next-line no-restricted-syntax -- guarded by the check on this line
const rows = await tx.insert(users).values(values).returning();
return { user: rows[0], isFirstUser };
}
// users is keyed by a text id the caller supplies, so there is something
// to read back by even without RETURNING.
await tx.insert(users).values(values);
const [user] = await tx
.select()
.from(users)
.where(eq(users.id, values.id));
return { user, isFirstUser };
});
}
async update(id: string, update: UserUpdate): Promise<UserRecord | null> {
const rows = await this.context.drizzle
.update(users)
.set(update)
.where(eq(users.id, id))
.returning();
const rows = await updateReturning(
this.context,
users,
update,
eq(users.id, id),
);
await this.afterWrite();
return rows[0] ?? null;
}
async delete(id: string): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(users)
.where(eq(users.id, id))
.returning({ id: users.id });
.where(eq(users.id, id));
await this.afterWrite();
return rows.length > 0;
return rowsAffected(result) > 0;
}
async countAdmins(): Promise<number> {
@@ -2,6 +2,12 @@ import { desc, eq, or } from "drizzle-orm";
import { randomUUID } from "crypto";
import { vaultProfiles } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type VaultProfileRecord = typeof vaultProfiles.$inferSelect;
@@ -45,26 +51,23 @@ export class VaultProfileRepository {
}
async create(input: VaultProfileCreateInput): Promise<VaultProfileRecord> {
const [created] = await this.context.drizzle
.insert(vaultProfiles)
.values({
syncId: randomUUID(),
userId: input.userId,
name: input.name,
description: input.description,
folder: input.folder,
tags: input.tags,
vaultAddr: input.vaultAddr,
vaultNamespace: input.vaultNamespace,
oidcMount: input.oidcMount,
oidcRole: input.oidcRole,
sshMount: input.sshMount,
sshRole: input.sshRole,
validPrincipals: input.validPrincipals,
keyType: input.keyType,
shared: input.shared ?? false,
})
.returning();
const [created] = await insertReturning(this.context, vaultProfiles, {
syncId: randomUUID(),
userId: input.userId,
name: input.name,
description: input.description,
folder: input.folder,
tags: input.tags,
vaultAddr: input.vaultAddr,
vaultNamespace: input.vaultNamespace,
oidcMount: input.oidcMount,
oidcRole: input.oidcRole,
sshMount: input.sshMount,
sshRole: input.sshRole,
validPrincipals: input.validPrincipals,
keyType: input.keyType,
shared: input.shared ?? false,
});
await this.afterWrite();
return created;
@@ -84,14 +87,15 @@ export class VaultProfileRepository {
id: number,
input: VaultProfileUpdateInput,
): Promise<VaultProfileRecord | null> {
const [updated] = await this.context.drizzle
.update(vaultProfiles)
.set({
const [updated] = await updateReturning(
this.context,
vaultProfiles,
{
...input,
updatedAt: input.updatedAt ?? new Date().toISOString(),
})
.where(eq(vaultProfiles.id, id))
.returning();
},
eq(vaultProfiles.id, id),
);
if (updated) {
await this.afterWrite();
@@ -101,27 +105,27 @@ export class VaultProfileRepository {
}
async deleteById(id: number): Promise<{ syncId: string | null } | null> {
const rows = await this.context.drizzle
.delete(vaultProfiles)
.where(eq(vaultProfiles.id, id))
.returning({ syncId: vaultProfiles.syncId });
const rows = await deleteReturning(
this.context,
vaultProfiles,
eq(vaultProfiles.id, id),
);
if (rows.length === 0) return null;
await this.afterWrite();
return rows[0];
return { syncId: rows[0].syncId };
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(vaultProfiles)
.where(eq(vaultProfiles.userId, userId))
.returning({ id: vaultProfiles.id });
.where(eq(vaultProfiles.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,8 @@
import { and, eq } from "drizzle-orm";
import { vaultTokens } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { upsert } from "./returning.js";
export type VaultTokenRecord = typeof vaultTokens.$inferSelect;
@@ -22,16 +24,17 @@ export class VaultTokenRepository {
async upsert(input: VaultTokenUpsertInput): Promise<void> {
const createdAt = input.createdAt ?? new Date().toISOString();
await this.context.drizzle
.insert(vaultTokens)
.values({
await upsert(
this.context,
vaultTokens,
{
userId: input.userId,
profileId: input.profileId,
sshCert: input.sshCert,
privateKey: input.privateKey,
expiresAt: input.expiresAt,
})
.onConflictDoUpdate({
},
{
target: [vaultTokens.userId, vaultTokens.profileId],
set: {
sshCert: input.sshCert,
@@ -39,7 +42,8 @@ export class VaultTokenRepository {
expiresAt: input.expiresAt,
createdAt,
},
});
},
);
await this.afterWrite();
}
@@ -67,7 +71,7 @@ export class VaultTokenRepository {
profileId: number,
lastUsed = new Date().toISOString(),
): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(vaultTokens)
.set({ lastUsed })
.where(
@@ -75,48 +79,45 @@ export class VaultTokenRepository {
eq(vaultTokens.userId, userId),
eq(vaultTokens.profileId, profileId),
),
)
.returning({ id: vaultTokens.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteByUserAndProfile(
userId: string,
profileId: number,
): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(vaultTokens)
.where(
and(
eq(vaultTokens.userId, userId),
eq(vaultTokens.profileId, profileId),
),
)
.returning({ id: vaultTokens.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(vaultTokens)
.where(eq(vaultTokens.userId, userId))
.returning({ id: vaultTokens.id });
.where(eq(vaultTokens.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,8 @@
import { and, eq } from "drizzle-orm";
import { webauthnCredentials } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type WebauthnCredentialRecord = typeof webauthnCredentials.$inferSelect;
export type NewWebauthnCredentialRecord =
@@ -41,10 +43,11 @@ export class WebauthnCredentialRepository {
async create(
record: NewWebauthnCredentialRecord,
): Promise<WebauthnCredentialRecord> {
const rows = await this.context.drizzle
.insert(webauthnCredentials)
.values(record)
.returning();
const rows = await insertReturning(
this.context,
webauthnCredentials,
record,
);
await this.afterWrite();
return rows[0];
@@ -63,21 +66,20 @@ export class WebauthnCredentialRepository {
}
async deleteForUser(userId: string, id: string): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(webauthnCredentials)
.where(
and(
eq(webauthnCredentials.id, id),
eq(webauthnCredentials.userId, userId),
),
)
.returning({ id: webauthnCredentials.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
private async afterWrite(): Promise<void> {