Soft-deleted chats are hard-deleted with their children after a 30d
window by the maintenance sweep, and organization deletion enqueues a
job that soft-deletes the org's chats so the same sweep removes them.
The FK-free cascade lives in a single reusable deleteChatsByIds helper.
At the apiBuilder verify site the user-actor claims can come from the RBAC plugin; a plugin image predating the pat claim would deliver pat-less claims and no-op revocation there. Re-verify the bearer locally so the recheck reads the token's authoritative pat. Add recheck tests for the apiBuilder and UAT-preamble sites.
Extract the limiter override into an exported function so the actor?.sub decision is testable without redis, and assert both branches (act present -> jwt-actor key; no act -> hashed-token fallback).
A delegated env JWT rotates its token value every turn, so keying the limiter on the token handed each turn a fresh bucket. Key on env+acting-user instead, namespaced so it can't collide with private-key buckets.
Stamp the source PAT id on a minted user-actor token and recheck it is still live at every verify site, so revoking the PAT invalidates any token created from it. Tokens with no source PAT (e.g. the dashboard agent's) skip the recheck.
The mint route rejected TTLs only above 365 days; shorten the ceiling to 7 days so a delegated token can't be minted long-lived. Update the CLI --ttl help to match.
A CSP_IMG_SRC_ALLOWLIST entry containing ';' passed URL parsing and landed verbatim in the space-joined img-src directive, injecting or truncating a directive. Reject entries containing ';' or ',' alongside the existing wildcard and whitespace checks.
Model-authored markdown could emit a remote image whose URL the browser fetches on render — a zero-click data beacon. Constrain the shared markdown renderer with a urlTransform that strips the src of any non-local image and limits links to safe schemes.
A user-actor token could be exchanged for an env JWT that outlived it by
requesting a longer expirationTime. Surface the token's exp as expiresAt
and clamp the minted JWT to it. Non-UAT exchanges are unchanged.
A delegated user-actor token with no scope cap could exchange for an env
JWT carrying any requested scopes (admin included). Ceiling every UAT
exchange by its cap, or read-only when it has none, projecting requested
scopes through the scope grammar so read:all still admits reads.
Resolve conflicts in report-health: keep our richer fallback/trust model
(telemetry_absent trust, snapshot fallback, finished/completed throughput)
and adopt main's start-latency-unknown behavior so an absent measurement
reports as unknown instead of a confident 0ms. renderMarkdown keeps our
layout rewrite; main's compactFact change is covered by report-layout's
metricValue. Adapt main's two render assertions to our current output.
## Why this change
`EnvironmentVariableValue.valueReference` is an `onDelete: SetNull`
foreign key. Deleting a `SecretReference` (the env var edit/delete path
for secret values) fires the cascade `UPDATE ONLY
"EnvironmentVariableValue" SET "valueReferenceId" = NULL WHERE $1 =
"valueReferenceId"`. That cascade is scan-shaped: with no index on
`valueReferenceId`, it reads the entire table to find the rows
referencing the deleted secret. The parent `SecretReference` delete does
almost no work itself; its latency is dominated by this cascade.
## Diagnosis
`EnvironmentVariableValue` was indexed on `environmentId` and
`(variableId, environmentId)`, but not on `valueReferenceId`. The SET
NULL cascade therefore did a full sequential scan of the whole table.
Two sibling SET NULL cascades on the same delete
(`OrganizationIntegration.tokenReferenceId`,
`User.mfaSecretReferenceId`) are index-backed and stay fast, which
isolates the missing index as the cause.
## Change
Add `@@index([valueReferenceId])` on `EnvironmentVariableValue`, created
with `CREATE INDEX CONCURRENTLY IF NOT EXISTS` so `prisma migrate
deploy` stays safe on a live table.
## Benchmark (local, seeded)
Local Postgres seeded with 1,000,000 `EnvironmentVariableValue` rows,
`EXPLAIN (ANALYZE, BUFFERS)` on the SET NULL cascade with zero matching
rows (the worst case: reads the whole table, affects nothing):
| | before | after |
|---|---|---|
| plan | Seq Scan (1M rows) | Bitmap Index Scan |
| execution | 183 ms | 2.8 ms |
In a variant where the secret matched several thousand rows, the parent
`SecretReference` delete's
`EnvironmentVariableValue_valueReferenceId_fkey` trigger dropped from
216 ms to 88 ms (the residual is the heap work of nulling those rows).
## Expected impact
The cascade drops from a full-table sequential scan to a targeted index
lookup. The win grows with the table, so the benefit is larger than the
seeded numbers above.
## Risks
- One extra btree to maintain on `EnvironmentVariableValue` writes;
small, single-column, and it should be pre-created before the migration
deploys (per the repo index rules).
- No behavior change: same rows nulled, no ordering or result-set
change, read paths untouched.
Companion to the same fix on `ProjectAlert.channelId`.
## Why this change
Deleting a `ProjectAlertChannel` fires the FK cascade `DELETE FROM ONLY
"ProjectAlert" WHERE $1 = "channelId"`. That cascade is scan-shaped:
with no index on `channelId`, it reads the entire `ProjectAlert` table
to find the few child rows belonging to the deleted channel. The parent
`DELETE ProjectAlertChannel` does almost no work itself; its latency is
dominated by this cascade. `ProjectAlert` is append-heavy and grows over
time, so the scan cost only increases.
## Diagnosis
`ProjectAlert` had no index on `channelId` (only `pkey` + a `friendlyId`
unique). The cascade therefore did a full sequential scan of the whole
table. The sibling `ProjectAlertStorage` cascade on the same delete is
index-backed and stays fast, which isolates the missing index as the
cause.
## Change
Add `@@index([channelId])` on `ProjectAlert`, created with `CREATE INDEX
CONCURRENTLY IF NOT EXISTS` so `prisma migrate deploy` stays safe on a
live table.
## Benchmark (local, seeded)
Local Postgres seeded with 1,000,000 `ProjectAlert` rows across 50
channels (~20k rows per channel), `EXPLAIN (ANALYZE, BUFFERS)` on the
cascade delete:
| | before | after |
|---|---|---|
| plan | Seq Scan (1M rows) | Bitmap Index Scan |
| direct child delete | 740 ms | 22 ms |
| parent delete `ProjectAlert_channelId_fkey` trigger | 77.7 ms | 23.8
ms |
## Expected impact
The cascade drops from a full-table sequential scan to a targeted index
lookup. The win grows with the table: the more rows in `ProjectAlert`,
the more a scan costs and the more the index saves, so the benefit is
larger than the seeded numbers above.
## Risks
- One extra btree to maintain on every `ProjectAlert` insert; acceptable
for a single-column index on a high-insert table, and it should be
pre-created before the migration deploys (per the repo index rules).
- No behavior change: no rows orphaned, no ordering or result-set
change, read paths untouched.
## Follow-up
`ProjectAlert`'s other cascade FK columns (`projectId`, `environmentId`,
`workerDeploymentId`) are also unindexed, but their parents are
soft-deleted rather than physically removed, so those cascades do not
currently fire. Lower priority unless a hard-delete path is introduced.
## What
Follow-up to #4539. The driver-adapter work is inert until a client
flips to the pg driver adapter, but the moment one does, our database
observability degrades: the OTel metrics pipeline reads pool stats from
Prisma's `$metrics`, which is owned by the Rust engine's `quaint` pool.
Under the adapter, `pg.Pool` owns the pool, so those gauges read zero.
The pipeline also only ever scraped a single client (the control-plane
writer singleton).
This PR makes database metrics driver-agnostic and per-client:
- Every configured client registers a metrics source: control-plane
writer/replica, run-ops writer/replica, legacy writer/replica.
Previously only the control-plane writer singleton was scraped.
- Each OTel instrument is observed per client with `db_client` and
`db_driver` (`quaint` | `pg-adapter`) attributes. `db_client` uses our
canonical datasource-role labels (`control-plane-writer`,
`control-plane-replica`, `run-ops-writer`, `run-ops-replica`,
`legacy-run-ops-writer`, `legacy-run-ops-replica`) — the same strings
used for the `db.datasource` span attribute, so a metric and a trace
point at the same pool.
- Pool figures come from the authoritative source per driver:
- **pg-adapter**: `pg.Pool` (`totalCount`/`idleCount`/`waitingCount`,
plus cumulative opened/closed from `connect`/`remove` events).
- **quaint**: the Rust engine's `$metrics` pool gauges/counters, exactly
as before.
- Query counters and duration histograms still come from `$metrics` for
both drivers (the Rust engine executes queries in both cases).
- New `db.pool.connections.waiting` gauge (pg.Pool exposes this; quaint
reports 0).
- Stops exporting Prisma metrics from the Prometheus `/metrics` route.
Pool observability now lives entirely in the OTel pipeline, per driver,
per client.
## Why
So we can flip any client (including the control-plane writer, the
primary desync-fix target) to the driver adapter without losing pool
visibility. Existing dashboards keyed on the same metric names keep
working; they gain a per-client dimension.
## Testing
Unit (`apps/webapp/app/utils/databaseMetrics.server.test.ts`): the pure
normalizer — quaint reads pool from `$metrics`; adapter reads pool from
`pg.Pool` and keeps engine query metrics; `busy` never goes negative;
graceful zeroing when `$metrics` is unavailable (adapter still reports
live pool figures).
Live smoke test against a prod-shaped local stack: three
physically-distinct Postgres DBs (control-plane, run-ops, legacy) behind
dual PgBouncers, split mode on, with a mix of adapter and quaint
clients. Reading the actual emitted OTel metrics, every pool shows up as
its own series:
```
db.pool.connections.total{db_client="control-plane-writer", db_driver="pg-adapter"} = 1
db.pool.connections.total{db_client="control-plane-replica", db_driver="quaint"} = 1
db.pool.connections.total{db_client="run-ops-writer", db_driver="pg-adapter"} = 1
db.pool.connections.total{db_client="run-ops-replica", db_driver="quaint"} = 1
db.pool.connections.total{db_client="legacy-run-ops-writer", db_driver="quaint"} = 1
db.pool.connections.total{db_client="legacy-run-ops-replica",db_driver="quaint"} = 1
db.client.queries.total{db_client="control-plane-writer",db_driver="pg-adapter"} = incrementing
db.client.queries.duration.count{db_client="control-plane-writer",db_driver="pg-adapter"} = incrementing
```
Confirms: metrics are attributed per pool with the correct driver;
adapter pools' figures come from `pg.Pool`; and query counters/duration
histograms keep incrementing under the pg adapter. Also verified
`/metrics` (Prometheus) now returns zero `prisma_*` series while still
serving the app's own metrics.
`pnpm run typecheck --filter webapp` passes.
## Notes
- `/metrics` (Prometheus) no longer includes `prisma_*` series. Anything
scraping that endpoint for Prisma metrics should read the equivalent
`db.*` metrics from the OTel exporter instead.
- **PgBouncer + `?schema=` gotcha (separate from this PR, worth flagging
for rollout):** since #4539 parses `?schema=` from the DSN and passes `{
schema }` to the adapter, node-postgres sends `search_path` as a startup
parameter. A transaction-mode PgBouncer rejects that with `FATAL:
unsupported startup parameter: search_path`. Our prod control-plane DSNs
use the default `public` schema with no `?schema=` param, so this is
latent, but any client we flip to the adapter must not carry `?schema=`
in its DSN (or the pooler needs `ignore_startup_parameters =
search_path`).
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deploy images previously shipped node_modules and the bundled task code
in a single layer, so every deploy re-pushed and re-pulled the full
dependency tree even when nothing in it changed. The generated
Containerfile now copies `/app/node_modules` as its own layer and the
app files separately. With unchanged dependencies the dependency layer
is identical across deploys, so registries and workers already have it
and only the code layer moves.
When the health report had no start-latency measurement for the window,
it printed a confident "p95 0ms" and graded it healthy. It now shows
"unknown" for that metric and skips grading it, so an absent measurement
can't read as a green signal.
A genuinely measured 0ms is still shown as 0ms: the loader keeps "no
measurement" distinct from a measured zero instead of coercing both to
0.
## Summary
Bumps the transitive `mermaid` in the lockfile from `11.14.0` to
`11.16.1`.
`mermaid` has no direct dependents here. It arrives through
`streamdown`,
which declares it as a hard dependency even though diagram rendering is
gated
behind the optional `@streamdown/mermaid` plugin, which we don't
install.
`streamdown@2.5.0` is its latest release, and its declared range
(`^11.12.2`)
already permits `11.16.1`, so this was a stale lockfile pin rather than
a
range conflict.
Done as a scoped override rather than a bare lockfile refresh, so the
floor
survives a lockfile regenerated from an older base:
```json
"mermaid@>=11 <11.16.1": "^11.16.1"
```
Net effect is 96 fewer lockfile lines, contained to mermaid's own
subtree.
`11.16.1` swapped out its parser, so the `langium` / `chevrotain@12` /
`vscode-languageserver-*` chain drops in favour of a single
`@chevrotain/types`, and `lodash-es` and `uuid@11` are no longer pulled
at
all.
The override goes away once `streamdown` makes `mermaid` an optional
peer of
its diagram plugin instead of a hard dependency.