Files
Daniel Aschwanden cf699bf3ad feat(api): decouple the UI from Hasura behind an anti-corruption layer (#1782)
* chore(typecheck): make tsc a CI gate, fix 169 pre-existing type errors

- tsconfig.json: add "types": ["vitest/globals"] (fixes ~165 errors where
  describe/it/expect were unresolved in test files); add api/* path alias
  for the upcoming anti-corruption layer
- package.json: add "typecheck": "tsc --noEmit" script; add
  @maplibre/maplibre-gl-style-spec to resolutions to dedupe the two
  conflicting versions (26.3.0 vs 26.2.1) that caused the remaining errors
- yarn.lock: updated (23 packages removed by deduplication)
- ui-workflow.yml: run "yarn typecheck" after lint, before tests
- map/graphql.ts: rename duplicate mutation UpdateFeature → DeleteFeature
  in the DELETE_FEATURE document (defect 7)
- journal/Message.tsx: remove unused React import (noUnusedLocals)

tsc --noEmit now exits 0. The typecheck step is the gate that makes
codegen and typed documents meaningful, and is the prerequisite for all
subsequent Hasura-decoupling work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(api): draft SDL contract for gqlgen backend migration

Add api/schema.graphql as the shared schema contract between the
TypeScript UI (ui/codegen.ts) and the future Go/gqlgen backend
(gqlgen.yml). Neither side owns it.

Key design decisions vs. current Hasura schema:
- ID everywhere; uuid/timestamptz/jsonb scalars gone from 14 of 19 ops
- DateTime scalar (RFC 3339 UTC) so mapper converts to real Date,
  fixing the defect where Date-typed fields are ISO strings at runtime
- Geometry + JSONObject scalars (opaque to the API layer)
- No deletedAt — soft-delete is a storage detail; deleted rows are
  invisible to the API surface
- Flat divisions: [Division!]! on Message — join-table wrapper gone
- triage/priority instead of triageId/priorityId (never were IDs)
- Mutations intent-named: closeIncident, reopenIncident, deleteIncident,
  triageMessage, addFeature, updateFeature, deleteFeature
- closeIncident/closeJournal take no timestamp — server stamps it
- deleteIncident raises INCIDENT_NOT_DELETABLE instead of silent no-op
- PriorityStatus.CRITICAL added (present in DB seed, missing from UI)

Also adds PriorityStatus.Critical = "CRITICAL" to the TS enum and the
corresponding i18n keys in all four locales (de/en/fr/it).

This SDL is a living draft; the ACL mappers in subsequent phases will
surface requirements that revise it before Go implementation begins.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(codegen): set up graphql-codegen for Hasura→gqlgen migration

Install @graphql-codegen/cli + @graphql-codegen/client-preset.

Two configs:
- codegen.ts: for the current Hasura schema. Schema source is a
  committed SDL snapshot at hasura/schema/hasura.graphql (not yet
  generated — needs a one-time introspection run as role "editor").
  strictScalars: true maps uuid/timestamptz/jsonb explicitly so an
  unmapped scalar is a hard error, not 'any'.

- codegen.next.ts: pointed at api/schema.graphql (the draft SDL).
  Validates documents in src/api/ against the future schema while
  Hasura still serves production. Any document the Go server cannot
  answer fails at codegen time, not at runtime after cutover.

Add scripts to package.json:
  yarn codegen          - generate from Hasura snapshot
  yarn codegen:check    - CI gate (fails if output is stale)
  yarn codegen:next     - validate future documents
  yarn codegen:next:check

Commit the initial generated output (src/gql/next/ — empty until
documents are added to src/api/). Exclude src/gql/ from tsc's
noUnusedLocals so generated boilerplate doesn't fail the gate.

Also fix the file-level block-comment syntax in api/schema.graphql
(GraphQL SDL does not allow free-floating triple-quote strings;
converted to # comments).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(api): scaffold anti-corruption layer with lint enforcement

Create ui/src/api/ — the single lint-enforced boundary where @apollo/client
may be imported. Components and views import only from the "api" path alias.

Files added:
- api/index.ts         — public surface: QueryResult, CommandHook, ApiError
- api/result.ts        — QueryResult discriminated union and CommandHook type
- api/errors.ts        — ApiError class with typed error codes
- api/common/mapper.ts — toDate / toOptionalDate / toEnum utilities
- api/testing/results.ts — readyResult / loadingResult / errorResult test helpers

Lint rule:
- .oxlintrc.jsonc gains no-restricted-imports(warn) for @apollo/client and
  @apollo/client/react everywhere except src/api/, src/client.tsx,
  src/cache.tsx. Currently 18 warnings (the burn-down list); zero errors.
  Promote to "error" once the last aggregate is migrated.

The "api" and "api/*" path aliases are already wired in tsconfig.json
from the Phase 0 commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(api): migrate message aggregate to anti-corruption layer

Implements the message ACL per the decoupling plan. All message-level
GraphQL operations are moved from `views/journal/graphql.ts` into
`src/api/message/` with explicit wire types, domain mappers, and
intent-named hooks. Consumer components (List, Editor, TriageModal) are
rewritten to use the new API surface and no longer import Apollo directly.

Key design choices:
- QueryResult discriminated union replaces ad-hoc loading/error checks
- useMessageForTriage(id) with skip replaces useLazyQuery pattern
- TriageModal split into shell (loading/error) + TriageForm (keyed by
  message.id) to eliminate the set-state-in-effect lint warning
- handleSave wraps await in try/catch so the resolved promise never needs
  void suppression and the modal stays open for retry on error
- afterMessageWrite(journalId) centralises cache invalidation (fixes the
  no-variables refetchQueries defect at all message write sites)
- journal/graphql.ts now contains only the 3 journal-level operations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(api): migrate incident aggregate to anti-corruption layer

Adds src/api/incident/ with typed wire types, domain mappers, and
intent-named hooks covering all 6 incident operations. Removes
views/incident/graphql.ts and rewrites all 5 consumers (List, New,
Editor, Dashboard, IncidentContext) plus journal/New to use the ACL.

Behavioural improvements over the raw Hasura layer:
- useDeleteIncident checks affectedRows === 0 and throws
  ApiError("INCIDENT_NOT_DELETABLE") — previously a silent no-op (plan §L3)
- useCloseIncident / useReopenIncident replace a single mutation called
  with closedAt: null to reopen — wire artefact removed from call sites
- afterIncidentWrite(incidentId) fixes the no-variables refetchQueries
  defect at all write sites (plan defect #6)
- useIncidentDetails returns a discriminated QueryResult, eliminating
  the unguarded data?.incidentsByPk.name in Dashboard (plan defect #8)
- IncidentContext now uses the ACL hook, removing its Apollo import

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(api): migrate journal aggregate to anti-corruption layer

Adds src/api/journal/ with wire types, mapper, documents, and hooks for
the 3 journal operations. Removes views/journal/graphql.ts and rewrites
both consumers (Overview, New) to use the ACL.

Behavioural improvements:
- useCloseJournal / useReopenJournal replace a single mutation called
  with closedAt: undefined to reopen — removing the wire artefact from
  call sites
- afterJournalWrite(incidentId) centralises cache invalidation with
  proper variables (was correct previously but now named and composable)
- useCreateJournal refetchQueries also include afterIncidentWrite so the
  incident detail cache reflects the new journal immediately (fixes the
  no-variables defect from the previous call site)
- journal/New.tsx handleSave is now async with try/catch — error state
  renders without crashing; navigation only on success
- Overview splits the load/render concern: useJournals returns a
  discriminated QueryResult including incidentName, eliminating the
  incidents.length === 1 guard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(api): implement layer aggregate ACL and replace Apollo reactive vars

Completes Phase 6 of the Hasura decoupling plan. Creates the layer
anti-corruption layer under src/api/layer/ (wire types, GQL documents,
mapper, invalidate, queries, commands) and migrates Map.tsx,
ActiveLayersControl.tsx off raw Apollo usage. Deletes the last
views/*/graphql.ts file — the lint-enforced boundary is now complete.

Also replaces makeVar/useReactiveVar in StyleController.tsx with a plain
React context (MapStyleProvider + useMapStyle), removing the last Apollo
import from the view layer. StyleController.tsx no longer needs the
oxlint override; only App.tsx (ApolloProvider) remains.

The toLayer() mapper folds FilterActiveFeatures so deleted features are
never visible to the view; useMemo keyed on Apollo's data reference
keeps the isEqual guard in LayerFetcher stable across polls. Optimistic
responses (wrong __typename, already a no-op) are dropped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(api): add mapper tests for all four aggregates

47 tests covering common utilities (toDate, toOptionalDate, toEnum),
and each aggregate's mapper:
- message: enum fallbacks, join-table unwrap, deletedAt null→epoch, no __typename
- incident: summary vs details shape, optional date nullability, location id handling
- journal: closedAt/deletedAt null preservation (runtime truth vs domain type lie)
- layer: soft-delete filter, geometry passthrough, feature date parsing, no __typename

These are pure-function tests with no Apollo or React dependency — the first
tests to be deleted when Hasura is replaced and mappers become identity functions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(api): add container tests and useDeleteIncident behavior test

Container tests (vi.mock("api") pattern):
- incident/List — loading spinner, error notification, ready state, empty list, closed-incident filter
- journal/Overview — loading, error, open/closed filter toggle
- journal/List — loading, error, message content present/absent

useDeleteIncident behavior test:
- resolves when affectedRows > 0
- throws INCIDENT_NOT_DELETABLE when affectedRows === 0 (the named invariant that was
  previously a silent no-op encoded as a Hasura where-clause)

These tests were impossible before the ACL because they required MockedProvider
to reach loading/error branches. With vi.mock("api") each branch is one line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(api): purge wire types, wire codegen gates, and complete ACL cleanup

- types/: remove all *Data/*Vars/*Mutation interfaces; keep only domain types.
  Feature.id narrowed to string. editorState.ts migrated to CreateMessageArgs.
- api/layer/mapper: add layerToFeatureCollection + convertFeatureToGeoJsonFeature
  (moved from views/map/utils.ts); FilterActiveFeatures deleted (folded into toLayer)
- api/layer/commands: add cleanFeature (moved from views/map/utils.ts CleanFeature)
- views/map/utils.ts: reduced to LayersToLayerMap only (view concern)
- Map.tsx + BabsIconController: import conversion helpers from api path alias
- client.tsx/cache.tsx moved to src/api/ (within ACL boundary); tsconfig paths
  and oxlintrc updated accordingly
- fix(api): CLOSE_INCIDENT and DELETE_INCIDENT used incidentId filter on journals
  which is not a column in JournalsBoolExp; fixed to incident: { id: { _eq: ... } }
- codegen.ts: scan src/api/**/*.ts (documents moved from views/ in prior commits)
- codegen.schema.ts + codegen:schema script: introspect Hasura as editor role to
  refresh hasura/schema/hasura.graphql without needing the raw CLI command
- hasura/schema/hasura.graphql: committed SDL snapshot (editor-role introspection)
- src/gql/: committed generated output from Hasura schema + api documents
- CI: add codegen:next:check step to validate future-facing documents on every push
- ARCHITECTURE.md: documents ACL structure, schema files, hook shapes, mapper
  philosophy, testing tiers, and per-aggregate gqlgen migration guide

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(api): replace hand-written wire types with graphql-codegen generated types

Delete all four wire.ts files and replace with inline type extraction from the
generated graphql.ts (via the new "gql" tsconfig path alias). Mapper parameter
types are now derived directly from query result shapes (e.g.
FetchIncidentsQuery["incidents"][0]) so the compiler enforces wire/domain
alignment automatically.

Null coercions added where generated types accurately reflect Hasura nullable
fields (description, senderDetail, receiverDetail, coordinates). Two pre-existing
bugs surfaced and fixed: insertIncidentsOne is | null in the generated type
(commands.ts optional-chain), and journal can be null on messagesByPk
(queries.ts optional-chain with fallback to []).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(api): add enum conformance assertions + codegen:check CI gate

Add a bidirectional enum conformance test: compile-time AssertSubset
ensures domain enum values are assignable to the generated schema union
(catches renames), and runtime assertions ensure every schema value is
represented in the domain enum (catches new DB enum additions).

Wire codegen:check (validates generated src/gql against the committed
Hasura SDL snapshot) into the UI CI workflow alongside the existing
codegen:next:check step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(codegen): regenerate stale gql output and scope next-check to future documents

codegen:check: regenerate src/gql/ which was stale after the formatter
reformatted whitespace inside gql template literals.

codegen:next:check: change the documents glob from src/api/**/*.ts to
src/api/**/*.next.ts. The current Hasura documents use vocabulary
(byPk, _eq, uuid, affectedRows, …) that does not exist in
api/schema.graphql, so they must never be validated against the future
schema. With no *.next.ts files yet, the check passes with
ignoreNoDocuments:true — the correct starting state. As each aggregate
is ported (Phases 3–6), its future documents are added as *.next.ts
and picked up automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(fmt): exclude src/gql/** from oxfmt

Generated files under src/gql/ use single quotes (codegen's output style)
while oxfmt enforces double quotes, causing codegen:check to report stale
files on every fmt run. Generated files should only be written by codegen,
never hand-formatted. Adding the glob to ignorePatterns is the correct fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(types): remove PriorityStatus.Critical from domain enum

CRITICAL exists in the DB seed and Hasura schema but has never been used
in the triage module. Remove it from the domain enum so it cannot appear
in the UI. The schema→domain conformance test is updated to reflect that
CRITICAL is intentionally not represented in the domain.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(i18n): remove CRITICAL priority and lock the omission in the conformance test

CRITICAL exists in the priority_status DB enum but has never been used in the
triage module. It was already removed from the PriorityStatus domain enum;
this removes the now-orphaned priority.CRITICAL key from all four locale files.

Rather than dropping CRITICAL from ALL_PRIORITY_SCHEMA — which would silently
weaken the schema-to-domain check — the full schema value list is restored and
an explicit INTENTIONALLY_UNEXPOSED list excuses only this one value. A newly
added schema value still fails the check. A further test asserts CRITICAL is
absent from the domain enum, so re-introducing it requires a deliberate change.

Also restructures the assertions to report the offending values as an array,
which lints cleanly (the jest plugin rejects Vitest's two-argument expect) and
names what is missing on failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(ui): add AGENTS.md, refresh ARCHITECTURE.md, clear stale migration comments

Add ui/AGENTS.md covering setup (docker compose + Go server + vite, with the
real port map), the four mandatory commit gates, the directory layout, all 13
path aliases, the domain concepts (incident, journal, message, map/layers and
how features are drawn), and a gotchas section. Notes explicitly that the
measures/ and resource/ views are stubs, not working features.

Refresh ARCHITECTURE.md, which had gone stale after the wire-type migration:
 - drop wire.ts from the aggregate structure and the cutover steps
 - document that mapper and document types now come from generated gql types
 - correct codegen.next.ts, which does emit (to src/gql/next/), and record the
   *.next.ts document scoping
 - correct the afterIncidentWrite example signature
 - mark the OpenFeature/ApolloLink.split cutover as not yet implemented, since
   client.ts currently has a single link

Clear planning-era language that had leaked into shipped code: a comment
referencing "the defect" by its number in the migration plan (and asserting
timestamps were mistyped as Date, which codegen has since made false), and a
test named for a "domain type lie". Both now state the actual constraint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-29 13:31:02 +02:00
..