diff --git a/CLAUDE.md b/CLAUDE.md index 9dd344d..13be507 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,14 @@ model a discriminated union instead.** Three tools, all in `shared/application`: - **Elm-style store** (`store.ts` → `createStore(initial, reduce)`) — all state in one Model; change only by `dispatch(msg)` → **pure** `reduce(model, msg)`. Models are tagged unions (see `herregistratie.machine.ts`, `intake.machine.ts`). Templates - send messages, never mutate. + send messages, never mutate. **`createStore` is the one wiring idiom** — a page + never hand-rolls `signal(model)` + a local `dispatch()`. Naming: a top-level + machine's State/Msg types are context-prefixed (`ChangeRequestState`, + `ChangeRequestMsg`), never bare `State`/`Msg`; a top-level machine exports + `initial` + `reduce`. A **composable sub-machine** embedded inside a parent + model keeps prefixed *value* exports instead (`initialUpload`/`reduceUpload`, + see `upload.machine.ts`) — prefixing there avoids alias noise at the + composition site. - **`Result` + value objects** ("parse, don't validate") — raw input becomes a branded type only via a parser returning `Result` (`registratie/domain/value-objects/`: `Postcode`, `Uren`, `BigNummer`). Once you hold the type, never re-check it. diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 4af0fb8..2b2f62d 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -47,7 +47,7 @@ for its existing violations, so every WP ends green. | [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done | | [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done | | [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done | -| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | todo | +| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done | | [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | todo | | [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | todo | | [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done | diff --git a/docs/backlog/WP-08-store-idiom.md b/docs/backlog/WP-08-store-idiom.md index afe058e..92133b8 100644 --- a/docs/backlog/WP-08-store-idiom.md +++ b/docs/backlog/WP-08-store-idiom.md @@ -1,6 +1,6 @@ # WP-08 — One store idiom + machine naming + TEA MDX -Status: todo +Status: done Phase: 1 — FP/DDD core ## Why @@ -49,16 +49,34 @@ two idioms and copy the wrong one. Machine naming also drifts: ## Acceptance criteria -- [ ] `grep -rn "export type State\b\|export type Msg\b" src/app` → empty. -- [ ] Every machine consumer wires via `createStore`; no local `signal(model)` + +- [x] `grep -rn "export type State\b\|export type Msg\b" src/app` → empty. +- [x] Every machine consumer wires via `createStore`; no local `signal(model)` + hand-rolled dispatch remains. -- [ ] Convention documented in CLAUDE.md; MDX renders. -- [ ] All machine specs pass unchanged (reducers untouched). +- [x] Convention documented in CLAUDE.md; MDX renders. +- [x] All machine specs pass unchanged (reducers untouched). ## Verification -GREEN + `npm run test-storybook:ci`. Smoke: all three wizards step forward/back and -submit. +GREEN + `npm run test-storybook:ci` (197 unit / 137 Storybook, unchanged from WP-07 — +this WP touched no reducer logic). Manual smoke via a running `docker compose` stack + +Playwright: the change-request form (the renamed machine) submitted end-to-end with a +referentie shown; the intake wizard stepped forward and back; the herregistratie wizard +loaded its first step — no console errors across all three. + +## Deviation from the original plan + +**Step 2 (migrate wizard pages off hand-wired `signal(model)`+`dispatch()` onto +`createStore`) turned out to already be done.** `registratie-wizard.component.ts`, +`intake-wizard.component.ts`, `herregistratie-wizard.component.ts`, and +`change-request-form.component.ts` all already wire +`createStore(initial, reduce)` — confirmed both by reading each file and by +`git log -p` on `registratie-wizard.component.ts`, which shows `createStore` present +since the file's introduction. `grep -rn "= signal<.*State>\|= signal(init" src/app` +(excluding specs) turns up nothing outside `store.ts` itself and `brief.store.ts`'s two +unrelated transient-state signals (WP-07). The WP's "Why" section was accurate for an +earlier snapshot of the codebase but stale by the time this WP ran — only the +`change-request.machine.ts` naming fix (Step 1) and the CLAUDE.md/MDX documentation +(Steps 3–4) had real work left. ## Out of scope diff --git a/documentation.json b/documentation.json index 94cf9d2..b2e4a00 100644 --- a/documentation.json +++ b/documentation.json @@ -2129,12 +2129,12 @@ }, { "name": "Draft", - "id": "interface-Draft-e2705c063a865160d8b4effa4448b7409aad1ea6065acd928ddce1cf628f34fd7984fe8d1078d372fb64c525f4288babd5bdf5ff700d1c7405eecb15ea85550c-1", + "id": "interface-Draft-51c29a3fca3c5bd3eba53c9bc57714c79817ae8c86b1ed9c1cd76652d31b45ee7c635d35f7eb0a3a7e5bf9ea7c5da5c066cee3908c8fe4b9fe053586f834b133-1", "file": "src/app/registratie/domain/change-request.machine.ts", "deprecated": false, "deprecationMessage": "", "type": "interface", - "sourceCode": "import { Result, assertNever } from '@shared/kernel/fp';\nimport { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';\n\n/** What the user is typing (raw, possibly invalid). */\nexport interface Draft {\n straat: string;\n postcode: string;\n woonplaats: string;\n}\n\n/** After parsing — postcode is the branded type, so downstream can't get a raw one. */\nexport interface Valid {\n straat: string;\n postcode: Postcode;\n woonplaats: string;\n}\n\nexport type Errors = Partial>;\n\n/**\n * The change-request (adreswijziging) form as one tagged union — the SAME idiom\n * as the wizards, just single-step. `draft`/`errors` exist only while Editing;\n * Submitting/Submitted/Failed carry the parsed `Valid`. Illegal states (submitting\n * an invalid draft, a success screen with errors) are unrepresentable.\n */\nexport type State =\n | { tag: 'Editing'; draft: Draft; errors: Errors }\n | { tag: 'Submitting'; data: Valid }\n | { tag: 'Submitted'; data: Valid; referentie: string }\n | { tag: 'Failed'; data: Valid; error: string };\n\nexport const initial: State = {\n tag: 'Editing',\n draft: { straat: '', postcode: '', woonplaats: '' },\n errors: {},\n};\n\n/** Parse via the value objects; on success hand back a Valid, else per-field errors. */\nfunction validate(draft: Draft): Result {\n const straat = draft.straat.trim();\n const postcode = parsePostcode(draft.postcode);\n const errors: Errors = {};\n if (!straat) errors.straat = $localize`:@@validation.straat:Vul straat en huisnummer in.`;\n if (!postcode.ok) errors.postcode = postcode.error;\n if (straat && postcode.ok) {\n return {\n ok: true,\n value: { straat, postcode: postcode.value, woonplaats: draft.woonplaats.trim() },\n };\n }\n return { ok: false, error: errors };\n}\n\nexport type Msg =\n | { tag: 'SetField'; key: keyof Draft; value: string }\n | { tag: 'Submit' }\n | { tag: 'Retry' }\n | { tag: 'SubmitConfirmed'; referentie: string }\n | { tag: 'SubmitFailed'; error: string }\n | { tag: 'Reset' }\n | { tag: 'Seed'; state: State }; // mount a specific state (stories/tests)\n\nexport function reduce(s: State, m: Msg): State {\n switch (m.tag) {\n case 'SetField':\n return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;\n case 'Submit': {\n if (s.tag !== 'Editing') return s;\n const r = validate(s.draft);\n return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };\n }\n case 'Retry':\n return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;\n case 'SubmitConfirmed':\n return s.tag === 'Submitting'\n ? { tag: 'Submitted', data: s.data, referentie: m.referentie }\n : s;\n case 'SubmitFailed':\n return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;\n case 'Reset':\n return initial;\n case 'Seed':\n return m.state;\n default:\n return assertNever(m);\n }\n}\n", + "sourceCode": "import { Result, assertNever } from '@shared/kernel/fp';\nimport { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';\n\n/** What the user is typing (raw, possibly invalid). */\nexport interface Draft {\n straat: string;\n postcode: string;\n woonplaats: string;\n}\n\n/** After parsing — postcode is the branded type, so downstream can't get a raw one. */\nexport interface Valid {\n straat: string;\n postcode: Postcode;\n woonplaats: string;\n}\n\nexport type Errors = Partial>;\n\n/**\n * The change-request (adreswijziging) form as one tagged union — the SAME idiom\n * as the wizards, just single-step. `draft`/`errors` exist only while Editing;\n * Submitting/Submitted/Failed carry the parsed `Valid`. Illegal states (submitting\n * an invalid draft, a success screen with errors) are unrepresentable.\n */\nexport type ChangeRequestState =\n | { tag: 'Editing'; draft: Draft; errors: Errors }\n | { tag: 'Submitting'; data: Valid }\n | { tag: 'Submitted'; data: Valid; referentie: string }\n | { tag: 'Failed'; data: Valid; error: string };\n\nexport const initial: ChangeRequestState = {\n tag: 'Editing',\n draft: { straat: '', postcode: '', woonplaats: '' },\n errors: {},\n};\n\n/** Parse via the value objects; on success hand back a Valid, else per-field errors. */\nfunction validate(draft: Draft): Result {\n const straat = draft.straat.trim();\n const postcode = parsePostcode(draft.postcode);\n const errors: Errors = {};\n if (!straat) errors.straat = $localize`:@@validation.straat:Vul straat en huisnummer in.`;\n if (!postcode.ok) errors.postcode = postcode.error;\n if (straat && postcode.ok) {\n return {\n ok: true,\n value: { straat, postcode: postcode.value, woonplaats: draft.woonplaats.trim() },\n };\n }\n return { ok: false, error: errors };\n}\n\nexport type ChangeRequestMsg =\n | { tag: 'SetField'; key: keyof Draft; value: string }\n | { tag: 'Submit' }\n | { tag: 'Retry' }\n | { tag: 'SubmitConfirmed'; referentie: string }\n | { tag: 'SubmitFailed'; error: string }\n | { tag: 'Reset' }\n | { tag: 'Seed'; state: ChangeRequestState }; // mount a specific state (stories/tests)\n\nexport function reduce(s: ChangeRequestState, m: ChangeRequestMsg): ChangeRequestState {\n switch (m.tag) {\n case 'SetField':\n return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;\n case 'Submit': {\n if (s.tag !== 'Editing') return s;\n const r = validate(s.draft);\n return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };\n }\n case 'Retry':\n return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;\n case 'SubmitConfirmed':\n return s.tag === 'Submitting'\n ? { tag: 'Submitted', data: s.data, referentie: m.referentie }\n : s;\n case 'SubmitFailed':\n return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;\n case 'Reset':\n return initial;\n case 'Seed':\n return m.state;\n default:\n return assertNever(m);\n }\n}\n", "properties": [ { "name": "postcode", @@ -5590,12 +5590,12 @@ }, { "name": "Valid", - "id": "interface-Valid-e2705c063a865160d8b4effa4448b7409aad1ea6065acd928ddce1cf628f34fd7984fe8d1078d372fb64c525f4288babd5bdf5ff700d1c7405eecb15ea85550c-1", + "id": "interface-Valid-51c29a3fca3c5bd3eba53c9bc57714c79817ae8c86b1ed9c1cd76652d31b45ee7c635d35f7eb0a3a7e5bf9ea7c5da5c066cee3908c8fe4b9fe053586f834b133-1", "file": "src/app/registratie/domain/change-request.machine.ts", "deprecated": false, "deprecationMessage": "", "type": "interface", - "sourceCode": "import { Result, assertNever } from '@shared/kernel/fp';\nimport { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';\n\n/** What the user is typing (raw, possibly invalid). */\nexport interface Draft {\n straat: string;\n postcode: string;\n woonplaats: string;\n}\n\n/** After parsing — postcode is the branded type, so downstream can't get a raw one. */\nexport interface Valid {\n straat: string;\n postcode: Postcode;\n woonplaats: string;\n}\n\nexport type Errors = Partial>;\n\n/**\n * The change-request (adreswijziging) form as one tagged union — the SAME idiom\n * as the wizards, just single-step. `draft`/`errors` exist only while Editing;\n * Submitting/Submitted/Failed carry the parsed `Valid`. Illegal states (submitting\n * an invalid draft, a success screen with errors) are unrepresentable.\n */\nexport type State =\n | { tag: 'Editing'; draft: Draft; errors: Errors }\n | { tag: 'Submitting'; data: Valid }\n | { tag: 'Submitted'; data: Valid; referentie: string }\n | { tag: 'Failed'; data: Valid; error: string };\n\nexport const initial: State = {\n tag: 'Editing',\n draft: { straat: '', postcode: '', woonplaats: '' },\n errors: {},\n};\n\n/** Parse via the value objects; on success hand back a Valid, else per-field errors. */\nfunction validate(draft: Draft): Result {\n const straat = draft.straat.trim();\n const postcode = parsePostcode(draft.postcode);\n const errors: Errors = {};\n if (!straat) errors.straat = $localize`:@@validation.straat:Vul straat en huisnummer in.`;\n if (!postcode.ok) errors.postcode = postcode.error;\n if (straat && postcode.ok) {\n return {\n ok: true,\n value: { straat, postcode: postcode.value, woonplaats: draft.woonplaats.trim() },\n };\n }\n return { ok: false, error: errors };\n}\n\nexport type Msg =\n | { tag: 'SetField'; key: keyof Draft; value: string }\n | { tag: 'Submit' }\n | { tag: 'Retry' }\n | { tag: 'SubmitConfirmed'; referentie: string }\n | { tag: 'SubmitFailed'; error: string }\n | { tag: 'Reset' }\n | { tag: 'Seed'; state: State }; // mount a specific state (stories/tests)\n\nexport function reduce(s: State, m: Msg): State {\n switch (m.tag) {\n case 'SetField':\n return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;\n case 'Submit': {\n if (s.tag !== 'Editing') return s;\n const r = validate(s.draft);\n return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };\n }\n case 'Retry':\n return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;\n case 'SubmitConfirmed':\n return s.tag === 'Submitting'\n ? { tag: 'Submitted', data: s.data, referentie: m.referentie }\n : s;\n case 'SubmitFailed':\n return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;\n case 'Reset':\n return initial;\n case 'Seed':\n return m.state;\n default:\n return assertNever(m);\n }\n}\n", + "sourceCode": "import { Result, assertNever } from '@shared/kernel/fp';\nimport { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';\n\n/** What the user is typing (raw, possibly invalid). */\nexport interface Draft {\n straat: string;\n postcode: string;\n woonplaats: string;\n}\n\n/** After parsing — postcode is the branded type, so downstream can't get a raw one. */\nexport interface Valid {\n straat: string;\n postcode: Postcode;\n woonplaats: string;\n}\n\nexport type Errors = Partial>;\n\n/**\n * The change-request (adreswijziging) form as one tagged union — the SAME idiom\n * as the wizards, just single-step. `draft`/`errors` exist only while Editing;\n * Submitting/Submitted/Failed carry the parsed `Valid`. Illegal states (submitting\n * an invalid draft, a success screen with errors) are unrepresentable.\n */\nexport type ChangeRequestState =\n | { tag: 'Editing'; draft: Draft; errors: Errors }\n | { tag: 'Submitting'; data: Valid }\n | { tag: 'Submitted'; data: Valid; referentie: string }\n | { tag: 'Failed'; data: Valid; error: string };\n\nexport const initial: ChangeRequestState = {\n tag: 'Editing',\n draft: { straat: '', postcode: '', woonplaats: '' },\n errors: {},\n};\n\n/** Parse via the value objects; on success hand back a Valid, else per-field errors. */\nfunction validate(draft: Draft): Result {\n const straat = draft.straat.trim();\n const postcode = parsePostcode(draft.postcode);\n const errors: Errors = {};\n if (!straat) errors.straat = $localize`:@@validation.straat:Vul straat en huisnummer in.`;\n if (!postcode.ok) errors.postcode = postcode.error;\n if (straat && postcode.ok) {\n return {\n ok: true,\n value: { straat, postcode: postcode.value, woonplaats: draft.woonplaats.trim() },\n };\n }\n return { ok: false, error: errors };\n}\n\nexport type ChangeRequestMsg =\n | { tag: 'SetField'; key: keyof Draft; value: string }\n | { tag: 'Submit' }\n | { tag: 'Retry' }\n | { tag: 'SubmitConfirmed'; referentie: string }\n | { tag: 'SubmitFailed'; error: string }\n | { tag: 'Reset' }\n | { tag: 'Seed'; state: ChangeRequestState }; // mount a specific state (stories/tests)\n\nexport function reduce(s: ChangeRequestState, m: ChangeRequestMsg): ChangeRequestState {\n switch (m.tag) {\n case 'SetField':\n return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;\n case 'Submit': {\n if (s.tag !== 'Editing') return s;\n const r = validate(s.draft);\n return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };\n }\n case 'Retry':\n return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;\n case 'SubmitConfirmed':\n return s.tag === 'Submitting'\n ? { tag: 'Submitted', data: s.data, referentie: m.referentie }\n : s;\n case 'SubmitFailed':\n return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;\n case 'Reset':\n return initial;\n case 'Seed':\n return m.state;\n default:\n return assertNever(m);\n }\n}\n", "properties": [ { "name": "postcode", @@ -12907,7 +12907,7 @@ }, { "name": "ChangeRequestFormComponent", - "id": "component-ChangeRequestFormComponent-3c3d214169d3f0ca90addfcce0fb584a077c0c02f16f035b740a99c23dc6846a8c3119716878f2d11849333cafa0c088b10c675d376fb494df7b2710fc2b8b47", + "id": "component-ChangeRequestFormComponent-78b1231d045dffc3ed8c816ee2a56852ae5bb1dfcfbb679e9b8e4d3c2a441f29d5b692970cf27b7d78c75ac488a2083b07dd10593d78889174827c70aa18c5e5", "file": "src/app/registratie/ui/change-request-form/change-request-form.component.ts", "encapsulation": [], "entryComponents": [], @@ -12927,7 +12927,7 @@ "defaultValue": "initial", "deprecated": false, "deprecationMessage": "", - "type": "State", + "type": "ChangeRequestState", "indexKey": "", "optional": false, "description": "

Optional seed so Storybook / tests can mount any state directly.

\n", @@ -13039,7 +13039,7 @@ }, { "name": "store", - "defaultValue": "createStore(initial, reduce)", + "defaultValue": "createStore(initial, reduce)", "deprecated": false, "deprecationMessage": "", "type": "unknown", @@ -13154,7 +13154,7 @@ "description": "

Organism: change-request (adreswijziging) form. Uses the SAME idiom as the\nwizards — all state in one signal driven by the pure reduce\n(change-request.machine.ts), submitted via a submit-* command returning\nResult. Renders the shared <app-address-fields>; the server re-validates.

\n", "rawdescription": "\n\nOrganism: change-request (adreswijziging) form. Uses the SAME idiom as the\nwizards — all state in one signal driven by the pure `reduce`\n(change-request.machine.ts), submitted via a `submit-*` command returning\n`Result`. Renders the shared ``; the server re-validates.\n", "type": "component", - "sourceCode": "import { Component, computed, input } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { HeadingComponent } from '@shared/ui/heading/heading.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport {\n AddressFieldsComponent,\n AdresValue,\n AdresErrors,\n} from '@registratie/ui/address-fields/address-fields.component';\nimport { createStore } from '@shared/application/store';\nimport { whenTag } from '@shared/kernel/fp';\nimport { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine';\nimport { createSubmitChangeRequest } from '@registratie/application/submit-change-request';\n\n/**\n * Organism: change-request (adreswijziging) form. Uses the SAME idiom as the\n * wizards — all state in one signal driven by the pure `reduce`\n * (change-request.machine.ts), submitted via a `submit-*` command returning\n * `Result`. Renders the shared ``; the server re-validates.\n */\n@Component({\n selector: 'app-change-request-form',\n imports: [FormsModule, ButtonComponent, HeadingComponent, AlertComponent, AddressFieldsComponent],\n template: `\n @if (state().tag === 'Submitted') {\n \n Uw adreswijziging is ontvangen (referentie {{ referentie() }}). U ontvangt binnen 5\n werkdagen bericht.\n \n
\n Nieuwe wijziging doorgeven\n
\n } @else {\n Adreswijziging doorgeven\n
\n
\n
\n * verplichte velden\n
\n
\n \n\n @if (failedError()) {\n Het indienen is niet gelukt:\n {{ failedError() }}\n }\n\n \n {{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }}\n \n \n }\n `,\n})\nexport class ChangeRequestFormComponent {\n // The submit command owns the ApiClient dependency (via the change-request\n // adapter); the UI holds only this bound command. Field initializer = injection\n // context, like createStore below.\n private submit = createSubmitChangeRequest();\n private store = createStore(initial, reduce);\n\n /** Optional seed so Storybook / tests can mount any state directly. */\n seed = input(initial);\n\n readonly state = this.store.model;\n protected dispatch = this.store.dispatch;\n\n protected readonly submitLabel = $localize`:@@changeRequest.submit:Wijziging indienen`;\n protected readonly submitBezigLabel = $localize`:@@changeRequest.submitBezig:Bezig met indienen…`;\n\n private editing = computed(() => whenTag(this.state(), 'Editing'));\n protected errors = computed(() => this.editing()?.errors ?? {});\n protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');\n protected referentie = computed(() => whenTag(this.state(), 'Submitted')?.referentie ?? '');\n\n /** The address shown in the fields — the live draft while editing, the parsed\n data while submitting/failed (so the user sees what they sent). */\n protected adres = computed(() => {\n const s = this.state();\n if (s.tag === 'Editing') return s.draft;\n if (s.tag === 'Submitting' || s.tag === 'Failed') {\n return { straat: s.data.straat, postcode: s.data.postcode, woonplaats: s.data.woonplaats };\n }\n return { straat: '', postcode: '', woonplaats: '' }; // Submitted shows the success alert, not the fields\n });\n\n constructor() {\n queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));\n }\n\n onSubmit() {\n this.dispatch({ tag: 'Submit' });\n this.runIfSubmitting();\n }\n\n /** Effect: when we entered Submitting, call the command, then dispatch the outcome. */\n private async runIfSubmitting() {\n const s = this.state();\n if (s.tag !== 'Submitting') return;\n const r = await this.submit(s.data);\n if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });\n else this.dispatch({ tag: 'SubmitFailed', error: r.error });\n }\n}\n", + "sourceCode": "import { Component, computed, input } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { HeadingComponent } from '@shared/ui/heading/heading.component';\nimport { AlertComponent } from '@shared/ui/alert/alert.component';\nimport {\n AddressFieldsComponent,\n AdresValue,\n AdresErrors,\n} from '@registratie/ui/address-fields/address-fields.component';\nimport { createStore } from '@shared/application/store';\nimport { whenTag } from '@shared/kernel/fp';\nimport { ChangeRequestState, ChangeRequestMsg, initial, reduce } from '@registratie/domain/change-request.machine';\nimport { createSubmitChangeRequest } from '@registratie/application/submit-change-request';\n\n/**\n * Organism: change-request (adreswijziging) form. Uses the SAME idiom as the\n * wizards — all state in one signal driven by the pure `reduce`\n * (change-request.machine.ts), submitted via a `submit-*` command returning\n * `Result`. Renders the shared ``; the server re-validates.\n */\n@Component({\n selector: 'app-change-request-form',\n imports: [FormsModule, ButtonComponent, HeadingComponent, AlertComponent, AddressFieldsComponent],\n template: `\n @if (state().tag === 'Submitted') {\n \n Uw adreswijziging is ontvangen (referentie {{ referentie() }}). U ontvangt binnen 5\n werkdagen bericht.\n \n
\n Nieuwe wijziging doorgeven\n
\n } @else {\n Adreswijziging doorgeven\n
\n
\n
\n * verplichte velden\n
\n
\n \n\n @if (failedError()) {\n Het indienen is niet gelukt:\n {{ failedError() }}\n }\n\n \n {{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }}\n \n \n }\n `,\n})\nexport class ChangeRequestFormComponent {\n // The submit command owns the ApiClient dependency (via the change-request\n // adapter); the UI holds only this bound command. Field initializer = injection\n // context, like createStore below.\n private submit = createSubmitChangeRequest();\n private store = createStore(initial, reduce);\n\n /** Optional seed so Storybook / tests can mount any state directly. */\n seed = input(initial);\n\n readonly state = this.store.model;\n protected dispatch = this.store.dispatch;\n\n protected readonly submitLabel = $localize`:@@changeRequest.submit:Wijziging indienen`;\n protected readonly submitBezigLabel = $localize`:@@changeRequest.submitBezig:Bezig met indienen…`;\n\n private editing = computed(() => whenTag(this.state(), 'Editing'));\n protected errors = computed(() => this.editing()?.errors ?? {});\n protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');\n protected referentie = computed(() => whenTag(this.state(), 'Submitted')?.referentie ?? '');\n\n /** The address shown in the fields — the live draft while editing, the parsed\n data while submitting/failed (so the user sees what they sent). */\n protected adres = computed(() => {\n const s = this.state();\n if (s.tag === 'Editing') return s.draft;\n if (s.tag === 'Submitting' || s.tag === 'Failed') {\n return { straat: s.data.straat, postcode: s.data.postcode, woonplaats: s.data.woonplaats };\n }\n return { straat: '', postcode: '', woonplaats: '' }; // Submitted shows the success alert, not the fields\n });\n\n constructor() {\n queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));\n }\n\n onSubmit() {\n this.dispatch({ tag: 'Submit' });\n this.runIfSubmitting();\n }\n\n /** Effect: when we entered Submitting, call the command, then dispatch the outcome. */\n private async runIfSubmitting() {\n const s = this.state();\n if (s.tag !== 'Submitting') return;\n const r = await this.submit(s.data);\n if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });\n else this.dispatch({ tag: 'SubmitFailed', error: r.error });\n }\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "", @@ -23028,16 +23028,6 @@ "rawdescription": "Used by the shell to find what to poll on return: still-in-flight uploads.", "description": "

Used by the shell to find what to poll on return: still-in-flight uploads.

\n" }, - { - "name": "initial", - "ctype": "miscellaneous", - "subtype": "variable", - "file": "src/app/brief/domain/brief.machine.ts", - "deprecated": false, - "deprecationMessage": "", - "type": "BriefState", - "defaultValue": "{ tag: 'loading' }" - }, { "name": "initial", "ctype": "miscellaneous", @@ -23058,6 +23048,16 @@ "type": "IntakeState", "defaultValue": "{\n tag: 'Answering',\n answers: {},\n cursor: 0,\n errors: {},\n scholingThreshold: SCHOLING_THRESHOLD_DEFAULT,\n}" }, + { + "name": "initial", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/domain/brief.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "BriefState", + "defaultValue": "{ tag: 'loading' }" + }, { "name": "initial", "ctype": "miscellaneous", @@ -23065,7 +23065,7 @@ "file": "src/app/registratie/domain/change-request.machine.ts", "deprecated": false, "deprecationMessage": "", - "type": "State", + "type": "ChangeRequestState", "defaultValue": "{\n tag: 'Editing',\n draft: { straat: '', postcode: '', woonplaats: '' },\n errors: {},\n}" }, { @@ -27474,50 +27474,6 @@ } ] }, - { - "name": "reduce", - "file": "src/app/brief/domain/brief.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "args": [ - { - "name": "s", - "type": "BriefState", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "m", - "type": "BriefMsg", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "BriefState", - "jsdoctags": [ - { - "name": "s", - "type": "BriefState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "m", - "type": "BriefMsg", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, { "name": "reduce", "file": "src/app/herregistratie/domain/herregistratie.machine.ts", @@ -27606,6 +27562,50 @@ } ] }, + { + "name": "reduce", + "file": "src/app/brief/domain/brief.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "BriefState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "m", + "type": "BriefMsg", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "BriefState", + "jsdoctags": [ + { + "name": "s", + "type": "BriefState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "m", + "type": "BriefMsg", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "reduce", "file": "src/app/registratie/domain/change-request.machine.ts", @@ -27617,22 +27617,22 @@ "args": [ { "name": "s", - "type": "State", + "type": "ChangeRequestState", "deprecated": false, "deprecationMessage": "" }, { "name": "m", - "type": "Msg", + "type": "ChangeRequestMsg", "deprecated": false, "deprecationMessage": "" } ], - "returnType": "State", + "returnType": "ChangeRequestState", "jsdoctags": [ { "name": "s", - "type": "State", + "type": "ChangeRequestState", "deprecated": false, "deprecationMessage": "", "tagName": { @@ -27641,7 +27641,7 @@ }, { "name": "m", - "type": "Msg", + "type": "ChangeRequestMsg", "deprecated": false, "deprecationMessage": "", "tagName": { @@ -29797,6 +29797,28 @@ "description": "

A stable, namespaced capability string (PRD-0002 §5a), e.g. brief:approve.\nServer-resolved and opaque to the FE — never derived from a role client-side.

\n", "kind": 193 }, + { + "name": "ChangeRequestMsg", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type | literal type | literal type | literal type | literal type", + "file": "src/app/registratie/domain/change-request.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, + { + "name": "ChangeRequestState", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type | literal type", + "file": "src/app/registratie/domain/change-request.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

The change-request (adreswijziging) form as one tagged union — the SAME idiom\nas the wizards, just single-step. draft/errors exist only while Editing;\nSubmitting/Submitted/Failed carry the parsed Valid. Illegal states (submitting\nan invalid draft, a success screen with errors) are unrepresentable.

\n", + "kind": 193 + }, { "name": "Correspondentie", "ctype": "miscellaneous", @@ -30017,17 +30039,6 @@ "description": "

Rich text as a serialisable value, not opaque HTML.

\n

A block is a node tree. Because a placeholder is a first-class NODE (not a\n{{token}} substring hidden inside a string), highlighting it, inserting it,\nand linting it are all pure functions over data — no regex over markup. This\nis the whole reason the letter feature stays in the "impossible states" style:\nthe value the app holds is always well-shaped, and the imperative editor is\nquarantined behind one component that converts to/from this tree.

\n", "kind": 193 }, - { - "name": "Msg", - "ctype": "miscellaneous", - "subtype": "typealias", - "rawtype": "literal type | literal type | literal type | literal type | literal type | literal type | literal type", - "file": "src/app/registratie/domain/change-request.machine.ts", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "kind": 193 - }, { "name": "PassageScope", "ctype": "miscellaneous", @@ -30171,17 +30182,6 @@ "description": "", "kind": 193 }, - { - "name": "State", - "ctype": "miscellaneous", - "subtype": "typealias", - "rawtype": "literal type | literal type | literal type | literal type", - "file": "src/app/registratie/domain/change-request.machine.ts", - "deprecated": false, - "deprecationMessage": "", - "description": "

The change-request (adreswijziging) form as one tagged union — the SAME idiom\nas the wizards, just single-step. draft/errors exist only while Editing;\nSubmitting/Submitted/Failed carry the parsed Valid. Illegal states (submitting\nan invalid draft, a success screen with errors) are unrepresentable.

\n", - "kind": 193 - }, { "name": "StatusTag", "ctype": "miscellaneous", @@ -30652,18 +30652,6 @@ "defaultValue": "{\n info: $localize`:@@alert.icon.info:Informatie`,\n ok: $localize`:@@alert.icon.ok:Gelukt`,\n warning: $localize`:@@alert.icon.warning:Waarschuwing`,\n error: $localize`:@@alert.icon.error:Foutmelding`,\n}" } ], - "src/app/brief/domain/brief.machine.ts": [ - { - "name": "initial", - "ctype": "miscellaneous", - "subtype": "variable", - "file": "src/app/brief/domain/brief.machine.ts", - "deprecated": false, - "deprecationMessage": "", - "type": "BriefState", - "defaultValue": "{ tag: 'loading' }" - } - ], "src/app/herregistratie/domain/herregistratie.machine.ts": [ { "name": "initial", @@ -30712,6 +30700,18 @@ "description": "

The fixed step list. Number of steps never changes; questions reveal inline.

\n" } ], + "src/app/brief/domain/brief.machine.ts": [ + { + "name": "initial", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/brief/domain/brief.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "BriefState", + "defaultValue": "{ tag: 'loading' }" + } + ], "src/app/registratie/domain/change-request.machine.ts": [ { "name": "initial", @@ -30720,7 +30720,7 @@ "file": "src/app/registratie/domain/change-request.machine.ts", "deprecated": false, "deprecationMessage": "", - "type": "State", + "type": "ChangeRequestState", "defaultValue": "{\n tag: 'Editing',\n draft: { straat: '', postcode: '', woonplaats: '' },\n errors: {},\n}" } ], @@ -36931,22 +36931,22 @@ "args": [ { "name": "s", - "type": "State", + "type": "ChangeRequestState", "deprecated": false, "deprecationMessage": "" }, { "name": "m", - "type": "Msg", + "type": "ChangeRequestMsg", "deprecated": false, "deprecationMessage": "" } ], - "returnType": "State", + "returnType": "ChangeRequestState", "jsdoctags": [ { "name": "s", - "type": "State", + "type": "ChangeRequestState", "deprecated": false, "deprecationMessage": "", "tagName": { @@ -36955,7 +36955,7 @@ }, { "name": "m", - "type": "Msg", + "type": "ChangeRequestMsg", "deprecated": false, "deprecationMessage": "", "tagName": { @@ -37621,6 +37621,41 @@ "kind": 193 } ], + "src/app/registratie/domain/change-request.machine.ts": [ + { + "name": "ChangeRequestMsg", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type | literal type | literal type | literal type | literal type", + "file": "src/app/registratie/domain/change-request.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, + { + "name": "ChangeRequestState", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "literal type | literal type | literal type | literal type", + "file": "src/app/registratie/domain/change-request.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "

The change-request (adreswijziging) form as one tagged union — the SAME idiom\nas the wizards, just single-step. draft/errors exist only while Editing;\nSubmitting/Submitted/Failed carry the parsed Valid. Illegal states (submitting\nan invalid draft, a success screen with errors) are unrepresentable.

\n", + "kind": 193 + }, + { + "name": "Errors", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "Partial>", + "file": "src/app/registratie/domain/change-request.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 184 + } + ], "src/app/shared/upload/upload.machine.ts": [ { "name": "DeliveryChannel", @@ -37815,41 +37850,6 @@ "kind": 193 } ], - "src/app/registratie/domain/change-request.machine.ts": [ - { - "name": "Errors", - "ctype": "miscellaneous", - "subtype": "typealias", - "rawtype": "Partial>", - "file": "src/app/registratie/domain/change-request.machine.ts", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "kind": 184 - }, - { - "name": "Msg", - "ctype": "miscellaneous", - "subtype": "typealias", - "rawtype": "literal type | literal type | literal type | literal type | literal type | literal type | literal type", - "file": "src/app/registratie/domain/change-request.machine.ts", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "kind": 193 - }, - { - "name": "State", - "ctype": "miscellaneous", - "subtype": "typealias", - "rawtype": "literal type | literal type | literal type | literal type", - "file": "src/app/registratie/domain/change-request.machine.ts", - "deprecated": false, - "deprecationMessage": "", - "description": "

The change-request (adreswijziging) form as one tagged union — the SAME idiom\nas the wizards, just single-step. draft/errors exist only while Editing;\nSubmitting/Submitted/Failed carry the parsed Valid. Illegal states (submitting\nan invalid draft, a success screen with errors) are unrepresentable.

\n", - "kind": 193 - } - ], "src/app/shared/kernel/rich-text.ts": [ { "name": "Mark", @@ -39787,6 +39787,26 @@ "coverageCount": "0/1", "status": "low" }, + { + "filePath": "src/app/registratie/domain/change-request.machine.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "ChangeRequestMsg", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/registratie/domain/change-request.machine.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "ChangeRequestState", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, { "filePath": "src/app/registratie/domain/change-request.machine.ts", "type": "type alias", @@ -39797,26 +39817,6 @@ "coverageCount": "0/1", "status": "low" }, - { - "filePath": "src/app/registratie/domain/change-request.machine.ts", - "type": "type alias", - "linktype": "miscellaneous", - "linksubtype": "typealias", - "name": "Msg", - "coveragePercent": 0, - "coverageCount": "0/1", - "status": "low" - }, - { - "filePath": "src/app/registratie/domain/change-request.machine.ts", - "type": "type alias", - "linktype": "miscellaneous", - "linksubtype": "typealias", - "name": "State", - "coveragePercent": 100, - "coverageCount": "1/1", - "status": "very-good" - }, { "filePath": "src/app/registratie/domain/person.ts", "type": "interface", diff --git a/src/app/registratie/domain/change-request.machine.spec.ts b/src/app/registratie/domain/change-request.machine.spec.ts index 09db9b4..5d3a0b9 100644 --- a/src/app/registratie/domain/change-request.machine.spec.ts +++ b/src/app/registratie/domain/change-request.machine.spec.ts @@ -1,9 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { State, reduce, initial } from './change-request.machine'; +import { ChangeRequestState, reduce, initial } from './change-request.machine'; const editingWith = ( draft: Partial<{ straat: string; postcode: string; woonplaats: string }>, -): State => ({ +): ChangeRequestState => ({ tag: 'Editing', draft: { straat: '', postcode: '', woonplaats: '', ...draft }, errors: {}, @@ -13,13 +13,13 @@ describe('change-request reduce', () => { it('SetField updates the draft while editing', () => { const s = reduce(initial, { tag: 'SetField', key: 'straat', value: 'Lange Voorhout 9' }); expect(s.tag).toBe('Editing'); - expect((s as Extract).draft.straat).toBe('Lange Voorhout 9'); + expect((s as Extract).draft.straat).toBe('Lange Voorhout 9'); }); it('Submit with an invalid draft stays Editing and reports field errors', () => { const s = reduce(editingWith({ straat: '', postcode: 'nope' }), { tag: 'Submit' }); expect(s.tag).toBe('Editing'); - const errors = (s as Extract).errors; + const errors = (s as Extract).errors; expect(errors.straat).toBeTruthy(); expect(errors.postcode).toBeTruthy(); }); @@ -29,7 +29,7 @@ describe('change-request reduce', () => { tag: 'Submit', }); expect(s.tag).toBe('Submitting'); - expect((s as Extract).data.postcode).toBe('2514 EA'); + expect((s as Extract).data.postcode).toBe('2514 EA'); }); it('confirms and fails only from Submitting; Retry re-submits a failure', () => { diff --git a/src/app/registratie/domain/change-request.machine.ts b/src/app/registratie/domain/change-request.machine.ts index c33d0c9..8a954e8 100644 --- a/src/app/registratie/domain/change-request.machine.ts +++ b/src/app/registratie/domain/change-request.machine.ts @@ -23,13 +23,13 @@ export type Errors = Partial>; * Submitting/Submitted/Failed carry the parsed `Valid`. Illegal states (submitting * an invalid draft, a success screen with errors) are unrepresentable. */ -export type State = +export type ChangeRequestState = | { tag: 'Editing'; draft: Draft; errors: Errors } | { tag: 'Submitting'; data: Valid } | { tag: 'Submitted'; data: Valid; referentie: string } | { tag: 'Failed'; data: Valid; error: string }; -export const initial: State = { +export const initial: ChangeRequestState = { tag: 'Editing', draft: { straat: '', postcode: '', woonplaats: '' }, errors: {}, @@ -51,16 +51,16 @@ function validate(draft: Draft): Result { return { ok: false, error: errors }; } -export type Msg = +export type ChangeRequestMsg = | { tag: 'SetField'; key: keyof Draft; value: string } | { tag: 'Submit' } | { tag: 'Retry' } | { tag: 'SubmitConfirmed'; referentie: string } | { tag: 'SubmitFailed'; error: string } | { tag: 'Reset' } - | { tag: 'Seed'; state: State }; // mount a specific state (stories/tests) + | { tag: 'Seed'; state: ChangeRequestState }; // mount a specific state (stories/tests) -export function reduce(s: State, m: Msg): State { +export function reduce(s: ChangeRequestState, m: ChangeRequestMsg): ChangeRequestState { switch (m.tag) { case 'SetField': return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s; diff --git a/src/app/registratie/ui/change-request-form/change-request-form.component.ts b/src/app/registratie/ui/change-request-form/change-request-form.component.ts index 9dba895..548b0a1 100644 --- a/src/app/registratie/ui/change-request-form/change-request-form.component.ts +++ b/src/app/registratie/ui/change-request-form/change-request-form.component.ts @@ -10,7 +10,7 @@ import { } from '@registratie/ui/address-fields/address-fields.component'; import { createStore } from '@shared/application/store'; import { whenTag } from '@shared/kernel/fp'; -import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine'; +import { ChangeRequestState, ChangeRequestMsg, initial, reduce } from '@registratie/domain/change-request.machine'; import { createSubmitChangeRequest } from '@registratie/application/submit-change-request'; /** @@ -70,10 +70,10 @@ export class ChangeRequestFormComponent { // adapter); the UI holds only this bound command. Field initializer = injection // context, like createStore below. private submit = createSubmitChangeRequest(); - private store = createStore(initial, reduce); + private store = createStore(initial, reduce); /** Optional seed so Storybook / tests can mount any state directly. */ - seed = input(initial); + seed = input(initial); readonly state = this.store.model; protected dispatch = this.store.dispatch; diff --git a/src/docs/machines.mdx b/src/docs/machines.mdx new file mode 100644 index 0000000..0d378a5 --- /dev/null +++ b/src/docs/machines.mdx @@ -0,0 +1,100 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# State machines (The Elm Architecture, in Angular) + +Every form or wizard with validation or submission in this app is wired the **same +way**: one Model, one Msg union, one pure `reduce`, one command per side effect. Pick any +one — `herregistratie.machine.ts` is the fullest worked example — and the shape +transfers everywhere else. + +## Model / Msg / reduce + +```ts +// Model — everything the UI needs to render, as ONE tagged union +export type WizardState = { tag: 'step1'; draft: Draft } | { tag: 'step2'; valid: Valid } | …; + +// Msg — every way the Model is allowed to change +export type WizardMsg = { tag: 'FieldChanged'; field: string; value: string } | { tag: 'NextStep' } | …; + +// reduce — PURE: (current, message) -> next. No I/O, no Date.now(), no randomness. +export function reduce(s: WizardState, m: WizardMsg): WizardState { … } +``` + +Because the whole state is one value, a bug reproduces from a message log; because +`reduce` is pure, every transition is a one-line assertion in a spec — no `TestBed`, no +mocked HTTP, just `expect(reduce(state, msg)).toEqual(next)`. + +## Commands: side effects stay OUT of the reducer + +`reduce` only ever answers "what is the new state" — it never calls `fetch`. A +**command** (an `application/submit-*.ts` file, or a store method) does the I/O, then +dispatches a message describing the outcome: + +```ts +// command = "go do it, then say what happened" — reduce never sees the HTTP call itself +async function submit(store: Store) { + const r = await adapter.submit(toDto(store.model())); + store.dispatch(r.ok ? { tag: 'SubmitConfirmed', referentie: r.value } : { tag: 'SubmitFailed', error: r.error }); +} +``` + +This is also how a machine receives **server-owned config** without becoming aware of +HTTP: `intake.machine.ts`'s scholing threshold has an offline fallback +(`SCHOLING_THRESHOLD_DEFAULT`) baked into the model, and a plain `SetPolicy` message +that overwrites it once the real value arrives — the machine doesn't know or care that +the value came from a `resource()` fetch. + +## `createStore`: the one wiring idiom + +```ts +private store = createStore(initial, reduce); +readonly model = this.store.model; // Signal — template reads this +dispatch = this.store.dispatch; // template calls this, on click/input/etc — never mutates +``` + +A page or component **never** hand-rolls `signal(initialModel)` plus its own local +`dispatch` function that calls `reduce` inline — that's the same idea reinvented with a +worse name, and it's the thing a newcomer copies if two idioms are visible side by side. +Wire every machine through `createStore`, full stop. + +`dispatch` uses `model.update(…)`, not `model.set(reduce(model(), msg))` — the latter +reads `model()` *inside* the call, which means an `effect()` that both reads `model` and +calls `dispatch` would subscribe to its own write and livelock. `.update()`'s callback +receives the current value directly, untracked. + +## Naming + +- A top-level machine's types are **context-prefixed**: `ChangeRequestState`, + `ChangeRequestMsg`, `WizardState`, `WizardMsg` — never bare `State`/`Msg`. A bare name + reads fine in the one file that defines it and then collides (or forces an import + alias) the moment two machines are open side by side. +- A top-level machine exports `initial` (the starting Model) and `reduce` — unprefixed, + since the file/module already disambiguates them at the import site + (`import { initial, reduce } from './herregistratie.machine'`). +- A **composable sub-machine** — one embedded *inside* a parent Model, like + `upload.machine.ts`'s upload-widget state living inside the registratie wizard's own + Model — keeps **prefixed value exports** instead: `initialUpload`, `reduceUpload`. + The parent machine already imports several machines' `initial`/`reduce`; prefixing the + sub-machine's exports avoids a wall of `as` import aliases at the composition site. + +## Derive, don't store + +If a value can be computed from the Model, it is **not** a field on the Model. The +wizard's visible steps are `visibleSteps(answers)`, a pure function of the current +answers — not a `visibleSteps: Step[]` field someone has to remember to keep in sync +every time an answer changes. The reflex: before adding a field, ask "could this just be +a function of what I already have?" + +## Where RemoteData fits in + +A machine owns the **domain** lifecycle of what it holds once it exists (draft → +submitted → approved, in the brief's case). It should generally *not* also own the +**fetch** lifecycle (loading/failed) for the initial GET that produces it — that's a +generic concern `RemoteData` already models once, consistently, across the app (see +[Foundations/RemoteData & Async](?path=/docs/foundations-remotedata-async--docs)). Where +a machine's own state happens to have `loading`/`failed` tags that purely mirror that +fetch, project them onto a `RemoteData` at the store layer for `` to render +(`BriefStore.remoteData` is the worked example) rather than teaching every consumer to +hand-roll a `@switch` over the machine's own tags.