diff --git a/docs/reference/architecture/ARCHITECTURE.md b/docs/reference/architecture/ARCHITECTURE.md index e04dc3c..cd6844e 100644 --- a/docs/reference/architecture/ARCHITECTURE.md +++ b/docs/reference/architecture/ARCHITECTURE.md @@ -333,6 +333,39 @@ stores the result. The route guard (`auth/auth.guard.ts`) just reads `store.isAuthenticated()` and redirects to `/login` if you're not signed in. Protected routes list `canActivate: [authGuard]` in `app.routes.ts`. +### 2g. Autosave — keystroke → model → debounced sync (not on blur) + +A common assumption is "the form saves on blur." It doesn't. **Blur only marks a field +_touched_** so validation can show; it never writes the value or hits the network. In the +shared atoms, `(blur)="onTouched()"` is the `ControlValueAccessor` touched callback and +nothing more; the value is pushed on `(input)`, every keystroke +([`text-input.component.ts`](../../../src/app/shared/ui/text-input/text-input.component.ts): +`(input)` L29 → `onChange` L62, vs `(blur)="onTouched()"` L30). + +The real flow has two stages, neither keyed on focus: + +1. **Keystroke → Model.** A field binds `(ngModelChange)`/`(input)` and dispatches + `{ tag: 'SetField', key, value }`. The pure reducer stores it immediately — so the + Model is always current, on every keystroke, while editing. + ([`herregistratie-wizard.component.ts`](../../../src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts) + L78 → [`herregistratie.machine.ts`](../../../src/app/herregistratie/domain/herregistratie.machine.ts) + L138-142, `setField`.) +2. **Model → backend (600 ms debounce).** A signal `effect` tracks the machine + `snapshot()`; each change resets a 600 ms timer whose callback does I/O **only** (it + never dispatches, so it can't livelock the store). On the first save it lazily creates + the application and stamps `?aanvraag=` into the URL, so a reload resumes the draft. + ([`draft-sync.ts`](../../../src/app/registratie/application/draft-sync.ts): + `DEBOUNCE_MS` L34, `effect` L102-108, `flush` L88-98 → `ApplicationsAdapter.syncDraft`.) + +The **brief** context uses the same 600 ms idiom in its own store: `edit()` applies the +edit optimistically in the reducer and records an undo step, then `scheduleSave()` → +`flushSave()` flips a `saveState` (Saving/Saved/Error) and calls `adapter.save` +([`brief.store.ts`](../../../src/app/brief/application/brief.store.ts) L157-166, L192-209). + +So it _feels_ like save-on-blur only because you usually stop typing when you leave a +field, and the debounce fires ~600 ms later. The trigger is **"stopped changing," not +"lost focus."** Submit is a separate, explicit action (§2d). + --- ## 3. "Parse, don't validate" — value objects @@ -469,6 +502,50 @@ Practical notes, kept lazy: single place the wire format meets our types. - Nothing else moves: ``, the stores, and every page keep working unchanged. +### 6a. The request lifecycle today + +The sketch above is the _rationale_; the shipped shape has since firmed up. The contract is +no longer hand-written DTOs — it's an **NSwag-generated typed client** +([`api-client.ts`](../../../src/app/shared/infrastructure/api-client.ts), regenerate with +`npm run gen:api` per [`nswag.json`](../../../nswag.json)) — and the boundary is a +`parse*` returning `Result` rather than `httpResource({ parse })`. End to end: + +- **Proxy.** The app uses a relative base URL (`apiBaseUrl: ''`), so `/api` calls are + same-origin and `ng serve` proxies them to the backend on `:5000`. + ([`environment.ts`](../../../src/environments/environment.ts), + [`proxy.conf.json`](../../../proxy.conf.json); `proxy.conf.docker.json` targets the + compose service.) +- **Client → HttpClient seam.** The NSwag client's `fetch` is routed through Angular's + `HttpClient` by `httpClientFetch` — the one place cross-cutting concerns live: + `X-Correlation-Id` on every call, `Idempotency-Key` on non-GETs, a 10 s timeout, and + GET-only retry. Routing through `HttpClient` is exactly what lets the interceptors see API + traffic. ([`api-client.provider.ts`](../../../src/app/shared/infrastructure/api-client.provider.ts): + `httpClientFetch` L47-82, `provideApiClient` L86-92; registered in + [`app.config.ts`](../../../src/app/app.config.ts) L37.) +- **Interceptors (dev-only, stripped in prod).** `scenario.interceptor.ts` (the `?scenario=` + toggle) and `role.interceptor.ts` (`X-Role` on role-aware endpoints). + +**A read (dashboard):** `` → +[`BigProfileStore`](../../../src/app/registratie/application/big-profile.store.ts) → +`DashboardViewAdapter.dashboardViewResource()` = `resource({ loader: () => +client.dashboardView() })` +([`dashboard-view.adapter.ts`](../../../src/app/registratie/infrastructure/dashboard-view.adapter.ts)) +→ GET `/api/v1/dashboard-view` → `httpClientFetch` → proxy → backend → back through the +`parseDashboardView(json): Result` trust boundary → `RemoteData` → rendered. + +**A write (change address):** `runIfSubmitting()` (§2d) → `createSubmitChangeRequest` +([`submit-change-request.ts`](../../../src/app/registratie/application/submit-change-request.ts)) +→ `runSubmit` — the one try/catch that mints the `Idempotency-Key` and maps RFC-7807 +ProblemDetails → string ([`submit.ts`](../../../src/app/shared/application/submit.ts)) → +[`change-request.adapter.ts`](../../../src/app/registratie/infrastructure/change-request.adapter.ts) +→ POST `/api/v1/change-requests` → `ok(referentie)` / `err(detail)` → dispatch +`SubmitConfirmed` / `SubmitFailed`. + +**Backend.** A single minimal-API host computes business decisions server-side (BFF-lite), +returns ProblemDetails on rule rejection, and dedupes replays via `Idempotency-Key` +([`Program.cs`](../../../backend/src/BigRegister.Api/Program.cs): `/dashboard-view` L80, +`/change-requests` L120). + --- ## 7. Mini-glossary diff --git a/src/docs/learning-path.mdx b/src/docs/learning-path.mdx index b33e104..e368c86 100644 --- a/src/docs/learning-path.mdx +++ b/src/docs/learning-path.mdx @@ -153,6 +153,11 @@ the *outcome*. Reducer = "what the new state is"; command = "go do it, then say happened." And **derive, don't store** anything you can compute — e.g. a wizard's visible steps are `visibleSteps(answers)`, not a stored field. +A field's value lands in the Model on **every keystroke** (not on blur — blur only marks +the field "touched"); a separate 600 ms debounce off the model snapshot autosaves the +draft to the backend, an effect that lives *outside* the reducer. See +`docs/reference/architecture/ARCHITECTURE.md` §2g. + **Do:** run `/form-machine` for a toy single field (say a "nickname" field with a max length). Read the generated Model / Msg / reduce and its spec. @@ -359,7 +364,8 @@ one is the **authority**? **Go deeper:** `docs/reference/architecture/0001-bff-lite-decision-dtos.md`; `docs/reference/fp-tea-atomic-design.md` Part 7 (the copy-paste recipes); -`docs/reference/architecture/ARCHITECTURE.md` §4. For how contexts scale to a second +`docs/reference/architecture/ARCHITECTURE.md` §4 (the recipe) and §6a (the full FE⇄BE +request lifecycle, read + write, with file links). For how contexts scale to a second app and actor-based authorization, ADR-0002 (the advanced read). ---