[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. 🫖 Status page with uptime monitoring & API monitoring as code 🫖 openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor

feat(services): migrate status-report domain (PR 1/N) (#2101) master

* feat(services): migrate status-report domain onto service layer First domain migration on top of the services scaffold (PR 0). Freezes the `ServiceContext` shape and the single-object `{ ctx, input }` argument convention across all three surfaces — tRPC / Connect / Slack. ## Services - `packages/services/src/status-report/` — create, add-update, resolve (convenience wrapper over add-update), update (metadata+associations), update-update (edit a single update row), delete, delete-update, list, get, notify. - Shared internals: `validatePageComponentIds`, `updatePageComponentAssociations`, `getReportInWorkspace`, `getReportUpdateInWorkspace`, `getUpdatesForReport`, `getPageComponentIdsForReport`. - All mutations run inside `withTransaction`, emit an `emitAudit` row before returning, and throw the typed `ServiceError` hierarchy. - Zod schemas are the contract; `.parse()` runs at every service entry as defence-in-depth. - `notifyStatusReport` is the separate-call notification dispatch that the dashboard invokes as its second mutation — preserves today's two-call pattern required by the Edge runtime. ## Surfaces - **tRPC** (`packages/api/src/router/statusReport.ts`): every procedure now calls a service. External contract is preserved bit-for-bit — `create` still returns `{ ...initialUpdate, notifySubscribers }`, `list` still returns the enriched `{updates,pageComponents,page}` shape the dashboard consumes, etc. `emailRouter.sendStatusReport` becomes a thin wrapper over `notifyStatusReport`. - **Connect RPC** (`apps/server/src/routes/rpc/services/status-report/`): handler extracts proto → parse → service call → convert back. Domain helpers previously exported from this folder (and consumed by Slack) are gone; callers go through services directly. - **Slack** (`apps/server/src/routes/slack/interactions.ts`): the four status-report branches (create / add-update / update / resolve) call services. The maintenance branch still writes to db directly — that migrates in PR 2. ## Adapters - `packages/api/src/service-adapter.ts` — `toServiceCtx(ctx)` + `toTRPCError(err)` (ServiceError → TRPCError). - `apps/server/src/routes/rpc/adapter.ts` — `toServiceCtx(rpcCtx)` + `toConnectError(err)`. Synthesises `actor.keyId` as `ws:<workspaceId>` until the auth interceptor captures the real key id (called out in the plan's open questions). - `apps/server/src/routes/slack/service-adapter.ts` — refetches the full workspace for the `PendingAction`'s workspaceId and builds a Slack actor context; `toSlackMessage(err)` renders human-readable failures. ## Enforcement - Biome `noRestrictedImports` (nursery rule) scoped to `packages/api/src/router/statusReport.ts` + `apps/server/src/routes/rpc/services/status-report/**`. Tests are ignored since setup/cleanup legitimately manipulates db state. - Subpath export `@openstatus/services/status-report` so surfaces import the domain barrel directly. - `packages/api` / `apps/server` now declare `@openstatus/services` as a dependency. ## Tests - Integration tests in `packages/services/src/status-report/__tests__/` cover happy paths, workspace isolation, cascading deletes, the `ForbiddenError` / `NotFoundError` paths, and the slack-actor audit branch. `expectAuditRow` asserts on the in-memory audit buffer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(services): address Cubic review + unblock dashboard build - **Dashboard build**: `data-table-row-actions.tsx:35` accesses `row.original.page.customDomain`, which the old tRPC zod schema made non-nullable. My migrated service returned `page: Page | null`, breaking the type. Narrow `page` to non-null in the tRPC `get` / `list` wrappers via a tiny `narrowPage` helper that throws on the shouldn't- happen case (status report without its page) — matches the old zod parse behaviour. - **`listStatusReports` silent truncation (Cubic finding)**: the tRPC wrapper hard-coded `limit: 100`, silently dropping reports beyond that ceiling for workspaces with >100 reports. Service now allows an unbounded `limit` (min 1, no max); tRPC passes a `10_000` sentinel and Connect keeps its external `max=100` cap in the handler. - **`LIMIT_EXCEEDED` Connect mapping (Cubic finding)**: map to `Code.ResourceExhausted` rather than `Code.PermissionDenied` — quota/rate-limit is the documented meaning of ResourceExhausted in Connect/gRPC, while PermissionDenied means "authorised but not entitled". Correct semantics on the wire. Biome `include` key (Cubic's third finding) is verified correct for Biome 1.8.3 — OverridePattern schema documents `include` as the glob selector, and our override fires as expected (it flags the Connect test file in the scoped dir). No change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(services/status-report): address Cubic findings on the maintenance PR Applies to status-report since the same patterns were copy-pasted across both domains: - **Dedupe pageComponentIds** in `validatePageComponentIds` — duplicate ids in the input would hit the composite-PK constraint on `status_report_to_page_component` during insert. - **Batch list enrichment** — `listStatusReports` previously fanned out 3 queries per row (updates / component associations / page + page components). Dashboard calls it with `limit: 10_000`, so the N+1 was real. Rewritten as three batched `IN` queries regardless of row count; pages are deduplicated so reports sharing a page share the Page object. `getStatusReport` reuses the batch path with a singleton. - **Idempotent `delete`** — the old tRPC `statusReport.delete` silently succeeded when the row was missing; my service throws `NotFoundError`. Catch and swallow in the tRPC wrapper only. Connect still returns 404 on missing — external API semantics preserved. - **Connect numeric-id error** — `parsePageComponentIds` was throwing `invalidDateFormatError` for non-numeric input. Replaced with an inline `ConnectError(Code.InvalidArgument)` with the correct message. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(server): bind services + importers packages in dofigen (#2113) Adds `packages/services/package.json` and `packages/importers/package.json` to the `install` stage bind list so `bun install --frozen-lockfile` can resolve the dep graph inside the Docker build. Without this, the moment main receives: - `@openstatus/services` as a dep of `apps/server` (landing via the status-report PR that this branch targets), bun install fails with a missing workspace package. - `@openstatus/importers` becomes a transitive dep of `@openstatus/services` later in the stack (via the import-domain migration), so we bind it proactively in the same commit rather than landing a second dofigen touch-up. `packages/importers` already exists in the repo and isn't used by apps/server directly at this point, so binding it early is a no-op for bun until it actually becomes a dep. Alternative was two sequential fix PRs; one covers the whole stack cleaner. `Dockerfile` + `dofigen.lock` are regenerated from `dofigen.yml` via `dofigen update` — both are generated outputs that must stay in sync with the yml source. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(services): stub RESEND_API_KEY for tests `packages/services/src/status-report/notify.ts` imports from `@openstatus/subscriptions`, which transitively pulls in `@openstatus/emails`. The emails package validates `RESEND_API_KEY` via `@t3-oss/env-core` at module-load time and throws on missing — which killed `@openstatus/services#test` in CI (neither the test.yml workflow nor local `pnpm test` sets the var). Adds a bun test preload that stubs the env var with a dummy value before any test file is imported. Tests never exercise the real Resend dispatch path, so this is scoped to unblocking the import graph rather than mocking the dispatcher itself. Added: - packages/services/bunfig.toml — points bun:test at the preload. - packages/services/test/preload.ts — sets the dummy env var. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(status-report): align with new service-layer error semantics Two CI failures surfaced after the services migration changed error shapes for status-report writes: - **tRPC `statusReport.create` / `.updateStatus` cross-workspace tests** expected `FORBIDDEN` when a caller passed pageComponentIds belonging to another workspace. The service's `validatePageComponentIds` now throws `NotFoundError` for the same case — intentionally, so cross- workspace lookups don't leak resource existence. Updated both tests to assert `NOT_FOUND` and left a comment pointing at the migration rationale. - **Connect `CreateStatusReport` page-mismatch test** asserted the error message contained "does not match the page ID". The service throws `ConflictError("pageId X does not match the page (Y) of the selected components.")` — no "ID" in the wording. Loosened the assertion to "does not match the page" so it matches the actual service string. Both are pure test updates — the new service behavior is the intended contract; the tests had drifted from the pre-migration wording. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(services): address post-migration review on #2101 Five findings from the PR review, fixed in one commit: **P1 — tRPC `LIMIT_EXCEEDED` → `TOO_MANY_REQUESTS`** `packages/api/src/service-adapter.ts` mapped `LIMIT_EXCEEDED` to `FORBIDDEN`. `FORBIDDEN` means "authenticated but not allowed to do this on this resource" — plan quota exhaustion is semantically different. Mirrors the Cubic fix on the Connect side (which moved from `PermissionDenied` to `ResourceExhausted`). **P2 — fragile Drizzle join result key in `enrichReportsBatch`** `packages/services/src/status-report/list.ts` read `row.status_report_to_page_component.statusReportId` — Drizzle auto-derives that key from the schema variable name at import time, so a rename would silently produce `undefined` at runtime. Replaced with explicit-column select (`reportId: statusReportsToPageComponents.statusReportId`) which is typed and rename-safe. **P2 — Slack `NOT_FOUND` messages leak internal row IDs** `NotFoundError` formats as `"<entity> <id> not found"` (e.g. `"page_component 17 not found"`). For API consumers that's fine — they're developers. For Slack workspace users those IDs are meaningless noise and surface internals. `toSlackMessage` now returns a fixed `"Couldn't find what you were looking for."` for `NOT_FOUND`; other `ServiceError` codes keep their hand-written messages (they're sentence-style to start with). **P3 — test cleanup without try/finally** `packages/services/src/status-report/__tests__/status-report.test.ts` had 13 tests that did inline `db.delete(statusReport)` cleanup at the end of their body — skipped on assertion failure. Replaced with a shared `createdReportIds` array + `afterEach` drain so cleanup runs regardless of test outcome. **P3 — hoisted `toServiceCtx` out of Slack `executeAction` switch** Every service-backed branch loaded the workspace row via `toServiceCtx`. Hoisted the call above the switch — halves db lookups per Slack action execution. The `createMaintenance` branch (still on db until PR 2) doesn't use the ctx but the extra lookup there is negligible. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(services/status-report): pageId follows components on update Two server-side tests were failing after the migration: - `updates pageId when changing components to different page` - `clears pageId when removing all components` Both expected `report.pageId` to track the association set: - Swap components to a different page → `pageId` moves. - Empty the association set → `pageId` becomes null. The service was enforcing a stricter invariant — it *threw* a `ConflictError` when the new components' page differed from the report's current page, and left `pageId` untouched when associations were cleared. That was an overcautious choice in the initial migration; no caller today wants a "report pinned to page X regardless of its components" guarantee. Simplified to: whenever `pageComponentIds` is supplied, `updateValues.pageId = validated.pageId`. The zero-component case sets `pageId = null`; the one-page case moves the report to that page. Mixed-page inputs are still rejected by `validatePageComponentIds`'s `componentPageIds.size > 1` check, so the only reachable states here are "one page" or "no components". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>